// Package bootstrap composes application dependencies into a managed host // with deterministic lifecycle behavior. Construction validates all inputs // and rejects invalid configurations before any side effect. Run starts // components in declared order; Close stops them in reverse order. // // The module is side-effect free until Run is called. Every error from // construction or lifecycle is preserved so callers can inspect exactly // what failed and why. package bootstrap import ( "context" "errors" "fmt" "path/filepath" "reflect" "strings" "time" "iop/apps/agent/internal/clientprocess" "iop/apps/agent/internal/host" "iop/apps/agent/internal/localcontrol" "iop/apps/agent/internal/projectlog" "iop/apps/agent/internal/taskloop" "iop/packages/go/agentconfig" "iop/packages/go/agentstate" "iop/packages/go/agenttask" "iop/packages/go/agentworkspace" ) // Module owns the validated, composed application lifecycle. It wraps a // host.Host and delegates Start/Stop to it after validating that every // dependency is well-formed. Construction never starts or stops anything. type Module struct { host *host.Host } // NewModule validates the provided components and builds a Module. It // rejects nil components and components whose resolved names collide. // No side effects occur at construction time. func NewModule(components ...host.Component) (*Module, error) { if len(components) == 0 { return nil, fmt.Errorf("bootstrap: at least one component is required") } seen := make(map[string]struct{}, len(components)) validated := make([]host.Component, 0, len(components)) for i, component := range components { if isNilComponent(component) { return nil, fmt.Errorf("bootstrap: component at index %d is nil", i) } name := resolveComponentName(component, i) if _, duplicate := seen[name]; duplicate { return nil, fmt.Errorf("%w %q", ErrDuplicateName, name) } seen[name] = struct{}{} validated = append(validated, resolvedComponent{ Component: component, name: name, }) } h := host.NewHost(validated...) return &Module{host: h}, nil } // resolveComponentName returns the component's Name if it implements // host.Namer, otherwise a synthetic fallback based on its position. func resolveComponentName(c host.Component, idx int) string { if namer, ok := c.(host.Namer); ok { name := namer.Name() if name != "" { return name } } return fmt.Sprintf("component-%s", strings.Repeat("x", idx+1)) } // Run starts every managed component in declared order. On the first // failure it rolls back already-started components in reverse and returns // the combined error. The host must not have been previously run. func (m *Module) Run(ctx context.Context) error { return m.host.Start(ctx) } // Close stops every started component in reverse order. It preserves // every individual error. Repeated calls return nil once the host has // fully stopped. func (m *Module) Close(ctx context.Context) error { return m.host.Stop(ctx) } // Status returns a snapshot of the host's current lifecycle state. func (m *Module) Status() host.Status { return m.host.Status() } // resolvedComponent wraps a host.Component with a validated, non-empty // name that is preserved through host construction. It implements // host.Component and host.Namer so the host consumes the exact validated // identity without re-resolving. type resolvedComponent struct { host.Component name string } // Name returns the captured resolved name. func (r resolvedComponent) Name() string { return r.name } // isNilComponent reports whether c is a nil interface value or a typed-nil // pointer/interface/map/slice/chan/func. It never calls a method on c. func isNilComponent(c host.Component) bool { if c == nil { return true } v := reflect.ValueOf(c) switch v.Kind() { case reflect.Ptr, reflect.Interface, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func: return v.IsNil() } return false } // ErrDuplicateName is returned by NewModule when two components resolve // to the same name. var ErrDuplicateName = errors.New("bootstrap: duplicate component name") // DaemonOptions configures optional dependencies for production composition. type DaemonOptions struct { ProcessBackend clientprocess.ProcessBackend TaskCatalog agentconfig.Catalog TaskProvider agenttask.ProviderInvoker TaskReviewExecutor taskloop.ReviewExecutor TaskValidator agentworkspace.ValidationFunc TaskQuotaObserver taskloop.QuotaObserver } // DaemonOption modifies DaemonOptions. type DaemonOption func(*DaemonOptions) // WithProcessBackend supplies a custom ProcessBackend for client process management. func WithProcessBackend(backend clientprocess.ProcessBackend) DaemonOption { return func(o *DaemonOptions) { if backend != nil { o.ProcessBackend = backend } } } // WithTaskCatalog supplies the immutable provider catalog used by the // authoritative task runtime. Production callers must pass the same catalog // that was validated by the CLI entry point. func WithTaskCatalog(catalog agentconfig.Catalog) DaemonOption { return func(o *DaemonOptions) { o.TaskCatalog = catalog } } // WithTaskRuntimePorts supplies test-owned runtime ports without changing the // production catalog-backed defaults. The bootstrap package is internal to // apps/agent, so this seam cannot become an external daemon API. func WithTaskRuntimePorts( provider agenttask.ProviderInvoker, reviewExecutor taskloop.ReviewExecutor, validator agentworkspace.ValidationFunc, quotaObserver taskloop.QuotaObserver, ) DaemonOption { return func(o *DaemonOptions) { o.TaskProvider = provider o.TaskReviewExecutor = reviewExecutor o.TaskValidator = validator o.TaskQuotaObserver = quotaObserver } } // DaemonModule wraps Module and exposes component references for testing and inspection. type DaemonModule struct { *Module StateStore *agentstate.Store TaskRuntime *taskloop.Runtime ClientManager *clientprocess.Manager ProjectStores map[string]*projectlog.Store ControlServer *localcontrol.Server Ledger *localcontrol.Ledger } // NewDaemonModule constructs one authoritative task runtime together with the // project-log, client-process, and local-control adapters over one state store // and immutable config snapshot. func NewDaemonModule( ctx context.Context, snapshot agentconfig.RuntimeSnapshot, opts ...DaemonOption, ) (*DaemonModule, error) { cfg := snapshot.Config() if cfg.Device.StateRoot == "" { return nil, fmt.Errorf("bootstrap: device state_root is required") } dOpts := DaemonOptions{} for _, opt := range opts { opt(&dOpts) } statePath := filepath.Join(cfg.Device.StateRoot, "state.json") stateStore, err := agentstate.NewStore(statePath) if err != nil { return nil, fmt.Errorf("bootstrap: create state store: %w", err) } taskCatalog := dOpts.TaskCatalog if taskCatalog.Version == "" { return nil, errors.New("bootstrap: task provider catalog is required") } taskValidator := dOpts.TaskValidator if taskValidator == nil { taskValidator = taskloop.DefaultValidator() } taskRuntime, err := taskloop.New(taskloop.Config{ Snapshot: snapshot, Catalog: taskCatalog, StateStore: stateStore, OwnerID: "iop-agent-daemon", Provider: dOpts.TaskProvider, ReviewExecutor: dOpts.TaskReviewExecutor, Validator: taskValidator, QuotaObserver: dOpts.TaskQuotaObserver, }) if err != nil { return nil, fmt.Errorf("bootstrap: create task runtime: %w", err) } mgrOpts := []clientprocess.Option{} if dOpts.ProcessBackend != nil { mgrOpts = append(mgrOpts, clientprocess.WithProcessBackend(dOpts.ProcessBackend)) } manager, err := clientprocess.NewManager(ctx, cfg.Clients, stateStore, mgrOpts...) if err != nil { return nil, fmt.Errorf("bootstrap: create client process manager: %w", err) } projectStores := taskRuntime.ProjectStores() ledger, err := localcontrol.NewLedger(ctx, stateStore, "iop-agent-daemon", 0) if err != nil { _ = manager.Close(ctx) return nil, fmt.Errorf("bootstrap: create local control ledger: %w", err) } clientOps, err := localcontrol.NewClientOperations(manager, ledger) if err != nil { _ = manager.Close(ctx) return nil, fmt.Errorf("bootstrap: create client operations: %w", err) } bridge := &daemonStateBridge{ snapshot: snapshot, manager: manager, task: taskRuntime, } service, err := localcontrol.NewService(bridge, bridge, ledger) if err != nil { _ = manager.Close(ctx) return nil, fmt.Errorf("bootstrap: create local control service: %w", err) } service.WithClientOperations(clientOps) server, err := localcontrol.NewServer(localcontrol.ServerConfig{ StateRoot: cfg.Device.StateRoot, }, service) if err != nil { _ = manager.Close(ctx) return nil, fmt.Errorf("bootstrap: create local control server: %w", err) } pLogComp := &projectLogAdapter{stores: projectStores} taskComp, err := taskloop.NewComponent(taskRuntime, 250*time.Millisecond) if err != nil { _ = manager.Close(ctx) return nil, fmt.Errorf("bootstrap: create task runtime component: %w", err) } cProcComp := &clientProcessAdapter{manager: manager} lCtrlComp := &localControlAdapter{server: server} m, err := NewModule(pLogComp, taskComp, cProcComp, lCtrlComp) if err != nil { _ = manager.Close(ctx) return nil, err } return &DaemonModule{ Module: m, StateStore: stateStore, TaskRuntime: taskRuntime, ClientManager: manager, ProjectStores: projectStores, ControlServer: server, Ledger: ledger, }, nil } type projectLogAdapter struct { stores map[string]*projectlog.Store } func (p *projectLogAdapter) Name() string { return "project-log" } func (p *projectLogAdapter) Start(ctx context.Context) error { for _, store := range p.stores { if err := store.Reconcile(ctx); err != nil { return fmt.Errorf("projectlog reconcile: %w", err) } } return nil } func (p *projectLogAdapter) Stop(ctx context.Context) error { return nil } type clientProcessAdapter struct { manager *clientprocess.Manager } func (c *clientProcessAdapter) Name() string { return "client-process" } func (c *clientProcessAdapter) Start(ctx context.Context) error { if err := c.manager.StartConfigured(ctx); err != nil { return errors.Join(err, c.manager.Close(context.WithoutCancel(ctx))) } return nil } func (c *clientProcessAdapter) Stop(ctx context.Context) error { return c.manager.Close(ctx) } type localControlAdapter struct { server *localcontrol.Server } func (l *localControlAdapter) Name() string { return "local-control" } func (l *localControlAdapter) Start(ctx context.Context) error { return l.server.Start(ctx) } func (l *localControlAdapter) Stop(ctx context.Context) error { return l.server.Stop() } type daemonStateBridge struct { snapshot agentconfig.RuntimeSnapshot manager *clientprocess.Manager task *taskloop.Runtime } func (b *daemonStateBridge) RuntimeStatus(ctx context.Context) (localcontrol.StatusSnapshot, error) { return localcontrol.StatusSnapshot{ SubjectID: "daemon", State: "running", Summary: "iop-agent daemon active", }, nil } func (b *daemonStateBridge) ProjectStatus(ctx context.Context, projectID string) (localcontrol.StatusSnapshot, error) { view, err := b.task.ProjectStatus(ctx, projectID) if err != nil { return localcontrol.StatusSnapshot{}, err } return projectSnapshot(view), nil } func (b *daemonStateBridge) OverlayStatus(ctx context.Context, projectID, workUnitID string) (localcontrol.StatusSnapshot, error) { view, work, err := b.workStatus(ctx, projectID, workUnitID) if err != nil { return localcontrol.StatusSnapshot{}, err } return localcontrol.StatusSnapshot{ SubjectID: workUnitID, State: string(work.State), Summary: work.Overlay, Entries: []localcontrol.StatusEntry{{ Kind: "project", SubjectID: projectID, State: string(view.Status), }}, }, nil } func (b *daemonStateBridge) IntegrationStatus(ctx context.Context, projectID, workUnitID string) (localcontrol.StatusSnapshot, error) { view, work, err := b.workStatus(ctx, projectID, workUnitID) if err != nil { return localcontrol.StatusSnapshot{}, err } return localcontrol.StatusSnapshot{ SubjectID: workUnitID, State: string(work.State), Summary: work.Integration, Entries: []localcontrol.StatusEntry{{ Kind: "project", SubjectID: projectID, State: string(view.Status), }}, }, nil } func (b *daemonStateBridge) BlockerList(ctx context.Context, projectID string) (localcontrol.StatusSnapshot, error) { view, err := b.task.ProjectStatus(ctx, projectID) if err != nil { return localcontrol.StatusSnapshot{}, err } snapshot := localcontrol.StatusSnapshot{ SubjectID: projectID, State: string(view.Status), Summary: fmt.Sprintf("%d blocker(s)", len(view.Blockers)), } for _, blocker := range view.Blockers { snapshot.Entries = append(snapshot.Entries, localcontrol.StatusEntry{ Kind: "blocker", SubjectID: string(blocker.Code), State: "blocked", Summary: blocker.Message, }) } return snapshot, nil } func (b *daemonStateBridge) ProcessStatus(ctx context.Context, clientKind string) (localcontrol.StatusSnapshot, error) { if b.manager == nil { return localcontrol.StatusSnapshot{ SubjectID: clientKind, State: "unknown", Summary: "process manager unavailable", }, nil } rec, err := b.manager.Status(clientprocess.ClientKind(clientKind)) if err != nil { return localcontrol.StatusSnapshot{}, err } return localcontrol.StatusSnapshot{ SubjectID: clientKind, State: string(rec.State), Summary: rec.LastResult, }, nil } func (b *daemonStateBridge) StartProject(ctx context.Context, cmd localcontrol.ProjectCommand) (localcontrol.MutationResult, error) { if err := b.prepareProjectCommand(ctx, cmd); err != nil { return localcontrol.MutationResult{}, err } view, err := b.task.StartProject(ctx, cmd.ProjectID) return mutationResult(view), err } func (b *daemonStateBridge) StopProject(ctx context.Context, cmd localcontrol.ProjectCommand) (localcontrol.MutationResult, error) { if err := b.verifyProjectIdentity(ctx, cmd); err != nil { return localcontrol.MutationResult{}, err } view, err := b.task.StopProject(ctx, cmd.ProjectID) return mutationResult(view), err } func (b *daemonStateBridge) ResumeProject(ctx context.Context, cmd localcontrol.ProjectCommand) (localcontrol.MutationResult, error) { if err := b.prepareProjectCommand(ctx, cmd); err != nil { return localcontrol.MutationResult{}, err } view, err := b.task.ResumeProject(ctx, cmd.ProjectID) return mutationResult(view), err } func (b *daemonStateBridge) prepareProjectCommand( ctx context.Context, cmd localcontrol.ProjectCommand, ) error { if err := b.verifyProjectIdentity(ctx, cmd); err != nil { return err } if cmd.MilestoneID != "" { return b.task.SelectMilestone(ctx, cmd.ProjectID, cmd.MilestoneID) } return nil } func (b *daemonStateBridge) verifyProjectIdentity( ctx context.Context, cmd localcontrol.ProjectCommand, ) error { if b.task == nil { return errors.New("bootstrap: authoritative task runtime is missing") } registration, ok := b.snapshot.Project(cmd.ProjectID) if !ok || !registration.Enabled { return fmt.Errorf("project %s is not registered", cmd.ProjectID) } if cmd.WorkspaceID == "" { return nil } snapshot, err := b.task.WorkflowSnapshot(ctx, cmd.ProjectID) if err != nil { return err } if cmd.WorkspaceID != string(snapshot.WorkspaceID) { return errors.New("bootstrap: project command workspace identity mismatch") } return nil } func (b *daemonStateBridge) workStatus( ctx context.Context, projectID, workUnitID string, ) (taskloop.ProjectView, taskloop.WorkView, error) { view, err := b.task.ProjectStatus(ctx, projectID) if err != nil { return taskloop.ProjectView{}, taskloop.WorkView{}, err } for _, work := range view.Works { if work.WorkUnitID == workUnitID { return view, work, nil } } return taskloop.ProjectView{}, taskloop.WorkView{}, fmt.Errorf( "project %s has no work unit %s", projectID, workUnitID, ) } func projectSnapshot(view taskloop.ProjectView) localcontrol.StatusSnapshot { snapshot := localcontrol.StatusSnapshot{ SubjectID: view.ProjectID, State: string(view.Status), Summary: "authoritative task runtime state", } for _, work := range view.Works { snapshot.Entries = append(snapshot.Entries, localcontrol.StatusEntry{ Kind: "work", SubjectID: work.WorkUnitID, State: string(work.State), Summary: work.Integration, }) } return snapshot } func mutationResult(view taskloop.ProjectView) localcontrol.MutationResult { return localcontrol.MutationResult{ SubjectID: view.ProjectID, State: string(view.Status), Summary: "authoritative task runtime mutation committed", } }