package dispatcher import ( "context" "errors" "fmt" "log/slog" "git.ipao.vip/rogee/go-sip/internal/configread" "git.ipao.vip/rogee/go-sip/internal/store" ) // Bootstrap is the one admission boundary for the current read-only // configuration contract. Control consumption remains available even when // opening task admission fails. type Bootstrap struct { Client *configread.Client Store *store.Store DispatcherID string ApplySIP func(context.Context, configread.SIP) error // nil only for isolated Mock; real runtimes apply before verifying VerifySIP func(context.Context, configread.SIP) error Logger *slog.Logger DrainControls func(context.Context) error Cursor *string // memory-only; restart always starts from a full snapshot SIP *configread.SIP // the exact approved snapshot verified on startup Providers *map[string]configread.Provider // boot-approved catalog shared by all tasks } func (b Bootstrap) Run(ctx context.Context) error { if b.Client == nil || b.Store == nil || b.DispatcherID == "" || b.VerifySIP == nil || b.DrainControls == nil { return errors.New("current Dispatcher bootstrap requires config client, durable store, identity, applied SIP verifier, and control drain") } if b.Cursor != nil { *b.Cursor = "" } if b.SIP != nil { *b.SIP = configread.SIP{} } if b.Providers != nil { *b.Providers = nil } if err := b.Store.CloseAdmission(b.DispatcherID); err != nil { return fmt.Errorf("close task admission before bootstrap: %w", err) } sip, err := b.Client.ReadSIP(ctx) if err != nil { return fmt.Errorf("read approved SIP snapshot: %w", err) } if sip.DispatcherID != b.DispatcherID { return errors.New("SIP snapshot belongs to another Dispatcher") } sip, err = b.Store.PrepareSIP(sip) if err != nil { return fmt.Errorf("prepare local SIP generation: %w", err) } applied, _, err := b.Store.SIPState(b.DispatcherID) if err != nil { return err } occupied, err := b.Store.OccupiedCalls(b.DispatcherID) if err != nil { return err } if b.Logger != nil { b.Logger.Info("startup SIP snapshot prepared", "dispatcher_id", b.DispatcherID, "revision", sip.Revision, "applied_revision", applied, "occupied_calls", occupied, "native_apply", b.ApplySIP != nil) } if occupied != 0 && applied != sip.Revision { return store.ErrSIPPending } if b.ApplySIP != nil { if b.Logger != nil { b.Logger.Info("applying startup SIP snapshot", "dispatcher_id", b.DispatcherID, "revision", sip.Revision) } if err := b.ApplySIP(ctx, sip); err != nil { return fmt.Errorf("apply approved SIP revision %d on Agent/Asterisk: %w", sip.Revision, err) } } if err := b.VerifySIP(ctx, sip); err != nil { return fmt.Errorf("verify SIP revision %d loaded by Agent/Asterisk: %w", sip.Revision, err) } if b.Logger != nil { b.Logger.Info("startup SIP snapshot verified", "dispatcher_id", b.DispatcherID, "revision", sip.Revision, "native_apply", b.ApplySIP != nil) } providers, err := b.Client.ReadProviders(ctx) if err != nil { return fmt.Errorf("read boot-approved AI providers: %w", err) } tasks, cursor, err := b.Client.ReadAllTasks(ctx) if err != nil { return fmt.Errorf("retrieve complete assigned task list: %w", err) } for i, task := range tasks { if task.Status == "stopped" { continue } snapshot, err := b.Client.ReadTask(ctx, task.TaskID, task.TenantID, sip, providers) if err != nil { return fmt.Errorf("read assigned task %q: %w", task.TaskID, err) } tasks[i].TaskRevision = snapshot.Task.TaskRevision tasks[i].Status = snapshot.Task.Status if err := validateAISnapshot(snapshot); err != nil { return err } if err := b.Store.SaveSnapshot(snapshot); err != nil { return fmt.Errorf("persist current task %q: %w", task.TaskID, err) } } if err := b.Store.ApplyDiscoverySnapshot(b.DispatcherID, tasks); err != nil { return fmt.Errorf("persist complete current task discovery: %w", err) } if b.SIP != nil { *b.SIP = sip } if b.Providers != nil { *b.Providers = providers } if err := b.DrainControls(ctx); err != nil { return fmt.Errorf("drain assigned Dispatcher control queue: %w", err) } // Even if a newer SIP notification is still pending, keep the verified // snapshot and in-memory cursor so control/results can continue while the // runtime waits with admission closed. No old SIP version is admitted. if b.Cursor != nil { *b.Cursor = cursor } // A sip.config delivered during control drain remains fenced and is // reconciled by the runtime notification loop rather than aborting boot. _, pending, err := b.Store.SIPState(b.DispatcherID) if err != nil { return err } if pending > sip.Revision { return nil } if err := b.Store.MarkReadyForSIP(b.DispatcherID, sip.Revision); err != nil { return fmt.Errorf("open admitted tasks: %w", err) } return nil }