| 1 | package boot |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | |
| 6 | "reasonix/internal/config" |
| 7 | "reasonix/internal/extension" |
| 8 | "reasonix/internal/extension/dispatch" |
| 9 | "reasonix/internal/extension/sidecar" |
| 10 | "reasonix/internal/extensioncontract" |
| 11 | ) |
| 12 | |
| 13 | // RuntimeReload is previous-generation state for incremental sidecar adoption |
| 14 | // and subgraph-classified rebuild. |
| 15 | type RuntimeReload struct { |
| 16 | // ForceFullRebuild bypasses extension subgraph reuse and refreshes existing |
| 17 | // native sidecars, including linked binaries whose graph metadata is unchanged. |
| 18 | ForceFullRebuild bool |
| 19 | Extensions *sidecar.Manager |
| 20 | Graph *extension.DependencyGraph |
| 21 | Generation uint64 |
| 22 | // Owner is reused across one controller/session rebuild lineage. Cold builds |
| 23 | // leave it nil and receive a fresh isolated owner. |
| 24 | Owner *extension.RuntimeOwner |
| 25 | // PreviousSnapshot/Dispatcher enable CacheHash-stable no-op rebuilds and |
| 26 | // interceptor-only rewire without rediscovering the whole assembly. |
| 27 | PreviousSnapshot *extension.RuntimeSnapshot |
| 28 | PreviousDispatcher *dispatch.Dispatcher |
| 29 | PreviousPlan *extension.RuntimePlan |
| 30 | // ReuseAssembly, when set with a compatible plan, skips skill/command/hook |
| 31 | // rediscovery inside BuildRuntime. |
| 32 | ReuseAssembly *ReusedAssembly |
| 33 | } |
| 34 | |
| 35 | // buildRuntimeGraph constructs the dependency graph for installed native |
| 36 | // runtime packages. Host-provided capabilities (if any) are included as a |
| 37 | // synthetic "host" component so plugin requirements can resolve. |
| 38 | func buildRuntimeGraph(home string, hostProvides []extensioncontract.Capability) (*extension.DependencyGraph, error) { |
| 39 | packages, _ := sidecar.LoadRuntimePackages(home) |
| 40 | comps := make([]extension.ComponentDescriptor, 0, len(packages)+1) |
| 41 | if len(hostProvides) > 0 { |
| 42 | comps = append(comps, extension.ComponentDescriptor{ |
| 43 | ID: "host", |
| 44 | Source: extension.ContributionSource{Scope: extension.ScopeBuiltin, Origin: "host"}, |
| 45 | Provides: hostProvides, |
| 46 | }) |
| 47 | } |
| 48 | for _, item := range packages { |
| 49 | pkg := item.Package |
| 50 | id := extension.ComponentID("plugin/" + pkg.Manifest.Name) |
| 51 | var intercepts []extension.InterceptorPoint |
| 52 | var replaces []extension.Slot |
| 53 | priority := 0 |
| 54 | optional := true |
| 55 | if rt := pkg.Manifest.Runtime; rt != nil { |
| 56 | priority = rt.Priority |
| 57 | optional = !rt.Required |
| 58 | for _, p := range rt.Intercepts { |
| 59 | intercepts = append(intercepts, extension.InterceptorPoint(p)) |
| 60 | } |
| 61 | for _, s := range rt.Replaces { |
| 62 | if slot, err := extension.ParseSlot(s); err == nil { |
| 63 | replaces = append(replaces, slot) |
| 64 | } |
| 65 | } |
| 66 | } |
| 67 | comps = append(comps, extension.ComponentDescriptor{ |
| 68 | ID: id, |
| 69 | Source: extension.ContributionSource{Scope: extension.ScopePlugin, PluginID: pkg.Manifest.Name, Origin: "plugin", Version: pkg.Manifest.Version}, |
| 70 | Requires: pkg.Requires(), |
| 71 | Provides: pkg.ProvidesCapabilities(), |
| 72 | Intercepts: intercepts, |
| 73 | Replaces: replaces, |
| 74 | Priority: priority, |
| 75 | Optional: optional, |
| 76 | }) |
| 77 | } |
| 78 | if len(comps) == 0 { |
| 79 | return &extension.DependencyGraph{ |
| 80 | Components: map[extension.ComponentID]extension.ComponentDescriptor{}, |
| 81 | Edges: map[extension.ComponentID][]extension.ComponentID{}, |
| 82 | Providers: map[string][]extension.ComponentID{}, |
| 83 | }, nil |
| 84 | } |
| 85 | return extension.BuildDependencyGraph(comps) |
| 86 | } |
| 87 | |
| 88 | // attachPlanAndStatus fills BuildResult.Plan and Status from graphs using the |
| 89 | // lifecycle registry so doctor can explain Inactive/Failed components. |
| 90 | func attachPlanAndStatus(res *BuildResult, from *extension.DependencyGraph, to *extension.DependencyGraph, fromGen uint64, previousSnapshot *extension.RuntimeSnapshot) { |
| 91 | if res == nil { |
| 92 | return |
| 93 | } |
| 94 | toGen := uint64(0) |
| 95 | if res.Snapshot != nil { |
| 96 | toGen = res.Snapshot.Generation() |
| 97 | } |
| 98 | plan := extension.DiffRuntimePlan(from, to, fromGen, toGen) |
| 99 | if previousSnapshot != nil && res.Snapshot != nil { |
| 100 | plan.PrefixChanged = previousSnapshot.CacheHash() != res.Snapshot.CacheHash() |
| 101 | } |
| 102 | res.Plan = plan |
| 103 | life := extension.NewLifecycleRegistry(toGen) |
| 104 | status := &extension.RuntimeStatus{ |
| 105 | PublishedGeneration: toGen, |
| 106 | Plan: extension.PlanView(plan), |
| 107 | } |
| 108 | if res.Runtime != nil { |
| 109 | status.Receipts = res.Runtime.Receipts() |
| 110 | } |
| 111 | if to != nil { |
| 112 | for _, id := range to.ActivateOrder() { |
| 113 | life.Ensure(id) |
| 114 | _ = life.Transition(id, extension.ComponentPreparing, "") |
| 115 | // Structured inactive reasons: missing requirements + graph diagnostics. |
| 116 | inactive := false |
| 117 | var diags []string |
| 118 | for _, d := range to.Diagnostics { |
| 119 | if strings.Contains(d, string(id)) { |
| 120 | inactive = true |
| 121 | diags = append(diags, d) |
| 122 | } |
| 123 | } |
| 124 | if desc, ok := to.Components[id]; ok { |
| 125 | for _, req := range desc.Requires { |
| 126 | if req.Optional { |
| 127 | continue |
| 128 | } |
| 129 | if _, found := to.EpochFor(id, req); !found { |
| 130 | inactive = true |
| 131 | diags = append(diags, "missing required capability "+req.Key.String()+" (versionRange="+req.VersionRange+")") |
| 132 | } |
| 133 | } |
| 134 | // Declared provides with no live client → Unavailable diagnostic. |
| 135 | if res.Extensions != nil && strings.HasPrefix(string(id), "plugin/") { |
| 136 | name := sidecar.PluginNameFromComponentID(id) |
| 137 | if name != "" && res.Extensions.Client(name) == nil && len(desc.Provides) > 0 { |
| 138 | inactive = true |
| 139 | for _, cap := range desc.Provides { |
| 140 | diags = append(diags, "capability Unavailable: "+cap.Key.String()+" (runtime client not started)") |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | if inactive { |
| 146 | _ = life.Transition(id, extension.ComponentInactive, strings.Join(diags, "; ")) |
| 147 | } else if res.Extensions != nil && strings.HasPrefix(string(id), "plugin/") { |
| 148 | name := sidecar.PluginNameFromComponentID(id) |
| 149 | if name != "" && res.Extensions.Client(name) == nil { |
| 150 | _ = life.Transition(id, extension.ComponentInactive, "runtime client not started") |
| 151 | } else { |
| 152 | _ = life.Transition(id, extension.ComponentActive, "") |
| 153 | } |
| 154 | } else { |
| 155 | _ = life.Transition(id, extension.ComponentActive, "") |
| 156 | } |
| 157 | } |
| 158 | status.Components = life.All() |
| 159 | } |
| 160 | res.Status = status |
| 161 | res.Lifecycle = life |
| 162 | } |
| 163 | |
| 164 | // planForPreflight builds the RuntimePlan used to adopt Unchanged sidecars. |
| 165 | func planForPreflight(opts Options, toGen uint64) *extension.RuntimePlan { |
| 166 | if opts.Extensions == nil && opts.Graph == nil { |
| 167 | return nil |
| 168 | } |
| 169 | toGraph, err := buildRuntimeGraph(config.ReasonixHomeDir(), nil) |
| 170 | if err != nil { |
| 171 | return nil |
| 172 | } |
| 173 | plan := extension.DiffRuntimePlan(opts.Graph, toGraph, opts.Generation, toGen) |
| 174 | if opts.ForceFullRebuild && opts.Extensions != nil { |
| 175 | // Explicit reloads refresh linked binaries even when graph metadata is unchanged. |
| 176 | // Preflight replaces them beside the live manager; publishing the new |
| 177 | // controller retires the old processes. |
| 178 | plan.RestartUnchangedSidecars = true |
| 179 | } |
| 180 | return plan |
| 181 | } |
| 182 | |
| 183 | // finalizeBuildResult attaches the cold-start RuntimePlan and status, then |
| 184 | // publishes the generation so stale traffic from prior runtimes is dropped. |
| 185 | func finalizeBuildResult(res *BuildResult, publish bool) *BuildResult { |
| 186 | if res == nil { |
| 187 | return nil |
| 188 | } |
| 189 | if graph, err := buildRuntimeGraph(config.ReasonixHomeDir(), nil); err == nil { |
| 190 | attachPlanAndStatus(res, nil, graph, 0, nil) |
| 191 | } |
| 192 | if publish { |
| 193 | publishBuildResult(res) |
| 194 | } |
| 195 | return res |
| 196 | } |
| 197 | |
| 198 | func publishBuildResult(res *BuildResult) { |
| 199 | if res == nil || res.Snapshot == nil { |
| 200 | return |
| 201 | } |
| 202 | gen := res.Snapshot.Generation() |
| 203 | if gen == 0 { |
| 204 | return |
| 205 | } |
| 206 | owner := res.Owner |
| 207 | if owner == nil { |
| 208 | owner = extension.RuntimeOwnerOrDefault(nil) |
| 209 | res.Owner = owner |
| 210 | } |
| 211 | gate := owner.Gate |
| 212 | // Expire any previous drain TTLs before publishing the new generation. |
| 213 | _ = gate.SweepAndForceExpire() |
| 214 | gate.Publish(gen) |
| 215 | // Product path: force-expire old generations after drainTTL so in-flight |
| 216 | // work cannot linger forever after rebuild. |
| 217 | gate.ScheduleDrainWatch() |
| 218 | if res.Controller != nil { |
| 219 | res.Controller.SetRuntimeGeneration(gen) |
| 220 | } |
| 221 | if res.Runtime != nil { |
| 222 | owner.Receipts.IngestScope(res.Runtime.Scope()) |
| 223 | } |
| 224 | if res.Status != nil { |
| 225 | res.Status.PublishedGeneration = gen |
| 226 | } |
| 227 | } |
| 228 |