From 8779c88d79a3528ee154f40e155d3da18a5ff90b Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 20 Sep 2026 07:06:19 +0800 Subject: [PATCH] feat(platform): show which startup step is running, with progress and ETA (#3813) Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio --- .../runtime/lifecycle/manager_lifecycle.go | 18 + ...anager_lifecycle_recovery_deadline_test.go | 8 + .../manager_recovery_sessions_step_test.go | 239 +++++++++ .../handlers/stats_read_availability_test.go | 2 + .../internal/backendapp/canvas_edit_test.go | 52 +- .../internal/backendapp/e2e_startup_page.go | 33 ++ .../backendapp/e2e_startup_page_test.go | 93 ++++ .../internal/backendapp/health_test.go | 45 ++ apps/backend/internal/backendapp/helpers.go | 35 +- .../backend/internal/backendapp/httpserver.go | 21 +- .../internal/backendapp/httpserver_test.go | 2 +- apps/backend/internal/backendapp/main.go | 62 ++- .../internal/backendapp/orchestrator.go | 7 +- .../backendapp/persistence_middleware_test.go | 5 + .../backendapp/plugins_version_test.go | 4 +- apps/backend/internal/backendapp/services.go | 56 ++- .../internal/backendapp/startup_order_test.go | 19 + .../internal/backendapp/startup_page.go | 464 +++++++++++++++++ .../internal/backendapp/startup_page_test.go | 262 ++++++++++ apps/backend/internal/backendapp/storage.go | 76 +-- .../backendapp/storage_maintenance.go | 51 +- .../internal/backendapp/storage_sweep_test.go | 263 ++++++++++ .../internal/i18n/accept_language_test.go | 86 ++++ apps/backend/internal/i18n/i18n.go | 40 +- apps/backend/internal/i18n/locales/en.json | 38 ++ .../backend/internal/i18n/locales/pseudo.json | 38 ++ apps/backend/internal/i18n/locales/pt-pt.json | 38 ++ apps/backend/internal/i18n/locales/zh-cn.json | 38 ++ apps/backend/internal/i18n/locales/zh-hk.json | 38 ++ apps/backend/internal/i18n/locales/zh-tw.json | 38 ++ apps/backend/internal/launcher/health.go | 89 +++- .../launcher/health_startup_line_test.go | 275 ++++++++++ .../internal/office/retention/sweep.go | 14 +- .../persistence/backup_progress_test.go | 121 +++++ apps/backend/internal/persistence/provider.go | 58 +++ .../persistence/requiredstores/catalog.go | 108 ++-- .../persistence/requiredstores/health_test.go | 15 +- .../persistence/requiredstores/sweep_test.go | 42 ++ .../persistence/requiredstores/tracker.go | 29 ++ .../requiredstores/tracker_test.go | 17 +- .../internal/persistence/testmain_test.go | 16 + apps/backend/internal/startup/duration.go | 25 + .../backend/internal/startup/duration_test.go | 30 ++ apps/backend/internal/startup/progress.go | 47 +- .../startup/registry_completeness_test.go | 208 ++++++++ .../internal/startup/snapshot_wire_test.go | 87 ++++ apps/backend/internal/startup/step.go | 180 +++++++ .../internal/startup/step_lifecycle.go | 338 +++++++++++++ .../internal/startup/step_lifecycle_test.go | 376 ++++++++++++++ .../backend/internal/startup/step_snapshot.go | 115 +++++ .../internal/startup/step_snapshot_test.go | 200 ++++++++ .../system/persistence/handler_test.go | 5 +- .../system/system_database_path_test.go | 3 + .../task/repository/sqlite/base_migrations.go | 16 +- ...ase_migrations_repository_identity_test.go | 2 +- .../sqlite/base_migrations_steps_test.go | 144 ++++++ .../task/repository/sqlite/base_schema.go | 2 +- .../clarification_resolutions_retired_test.go | 7 +- .../sqlite/conversation_upgrade_large_test.go | 3 +- .../sqlite/document_plan_postgres_test.go | 4 +- .../execution_profile_persistence_test.go | 4 +- .../sqlite/executor_localpid_test.go | 2 +- .../sqlite/git_snapshots_postgres_test.go | 4 +- .../sqlite/plan_comment_postgres_test.go | 4 +- .../repository/sqlite/plan_comment_test.go | 4 +- .../task/repository/sqlite/plan_test.go | 4 +- .../repository/sqlite/plan_version_test.go | 4 +- .../repository/sqlite/postgres_schema_test.go | 14 +- .../repository/sqlite/prompt_index_test.go | 2 +- .../sqlite/repository_entity_test.go | 12 +- ...epository_set_base_branch_postgres_test.go | 4 +- .../sqlite/repository_set_base_branch_test.go | 4 +- .../sqlite/session_commits_migration_test.go | 4 +- .../sqlite/slack_retirement_test.go | 7 +- .../repository/sqlite/startup_cost_test.go | 4 +- ...entries_marker_positions_migration_test.go | 2 +- ..._entries_marker_positions_postgres_test.go | 2 +- .../sqlite/step_stamp_migration_test.go | 5 +- .../sqlite/step_transitions_migration_test.go | 5 +- .../sqlite/subagent_context_backfill.go | 9 +- .../sqlite/subagent_context_migration_test.go | 41 +- .../sqlite/subagent_context_postgres_test.go | 6 +- .../sqlite/task_external_id_migration_test.go | 5 +- ...repository_branch_policy_migration_test.go | 4 +- .../task_status_summary_postgres_test.go | 2 +- .../sqlite/task_status_summary_test.go | 4 +- .../sqlite/workspace_folder_test.go | 4 +- ...branch_template_migration_postgres_test.go | 6 +- ...worktree_branch_template_migration_test.go | 8 +- .../testutil/storeconformance/suite_test.go | 7 +- apps/backend/internal/webapp/accept.go | 107 ++++ apps/backend/internal/webapp/accept_test.go | 39 ++ .../system/restart-progress-dialog.test.tsx | 151 ++++++ .../system/restart-progress-dialog.tsx | 60 +++ .../layout/mobile-startup-progress.spec.ts | 58 +++ .../restart-progress-startup-detail.spec.ts | 184 +++++++ .../web/e2e/tests/layout/startup-page.spec.ts | 116 +++++ .../domains/system/use-kandev-restart.test.ts | 28 +- .../domains/system/use-kandev-restart.ts | 11 - .../system/use-startup-progress.test.ts | 193 ++++++++ .../domains/system/use-startup-progress.ts | 108 ++++ apps/web/lib/api/client.test.ts | 18 + apps/web/lib/api/client.ts | 10 +- .../web/lib/startup-progress/duration.test.ts | 17 + apps/web/lib/startup-progress/duration.ts | 25 + apps/web/lib/startup-progress/format.test.ts | 118 +++++ apps/web/lib/startup-progress/format.ts | 66 +++ apps/web/lib/startup-progress/types.test.ts | 44 ++ apps/web/lib/startup-progress/types.ts | 123 +++++ apps/web/src/locales/en/startup.json | 40 ++ apps/web/src/locales/pseudo/startup.json | 40 ++ apps/web/src/locales/pt-pt/_verbatim.json | 2 + apps/web/src/locales/pt-pt/startup.json | 40 ++ apps/web/src/locales/zh-cn/startup.json | 40 ++ apps/web/src/locales/zh-hk/startup.json | 40 ++ apps/web/src/locales/zh-tw/startup.json | 40 ++ .../plans/startup-progress-visibility/plan.md | 149 ++++++ .../task-01-step-registry-and-surfaces.md | 172 +++++++ ...task-02-retire-deleted-step-identifiers.md | 101 ++++ docs/specs/platform/README.md | 2 + .../requirements/startup-lifecycle.md | 2 +- .../startup-progress-visibility.md | 321 ++++++++++++ .../system-design/startup-lifecycle.md | 7 +- .../startup-progress-visibility.md | 468 ++++++++++++++++++ 124 files changed, 7527 insertions(+), 347 deletions(-) create mode 100644 apps/backend/internal/agent/runtime/lifecycle/manager_recovery_sessions_step_test.go create mode 100644 apps/backend/internal/backendapp/e2e_startup_page.go create mode 100644 apps/backend/internal/backendapp/e2e_startup_page_test.go create mode 100644 apps/backend/internal/backendapp/startup_page.go create mode 100644 apps/backend/internal/backendapp/startup_page_test.go create mode 100644 apps/backend/internal/backendapp/storage_sweep_test.go create mode 100644 apps/backend/internal/i18n/accept_language_test.go create mode 100644 apps/backend/internal/launcher/health_startup_line_test.go create mode 100644 apps/backend/internal/persistence/backup_progress_test.go create mode 100644 apps/backend/internal/persistence/requiredstores/sweep_test.go create mode 100644 apps/backend/internal/persistence/testmain_test.go create mode 100644 apps/backend/internal/startup/duration.go create mode 100644 apps/backend/internal/startup/duration_test.go create mode 100644 apps/backend/internal/startup/registry_completeness_test.go create mode 100644 apps/backend/internal/startup/snapshot_wire_test.go create mode 100644 apps/backend/internal/startup/step.go create mode 100644 apps/backend/internal/startup/step_lifecycle.go create mode 100644 apps/backend/internal/startup/step_lifecycle_test.go create mode 100644 apps/backend/internal/startup/step_snapshot.go create mode 100644 apps/backend/internal/startup/step_snapshot_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/base_migrations_steps_test.go create mode 100644 apps/backend/internal/webapp/accept.go create mode 100644 apps/backend/internal/webapp/accept_test.go create mode 100644 apps/web/components/settings/system/restart-progress-dialog.test.tsx create mode 100644 apps/web/e2e/tests/layout/mobile-startup-progress.spec.ts create mode 100644 apps/web/e2e/tests/layout/restart-progress-startup-detail.spec.ts create mode 100644 apps/web/e2e/tests/layout/startup-page.spec.ts create mode 100644 apps/web/hooks/domains/system/use-startup-progress.test.ts create mode 100644 apps/web/hooks/domains/system/use-startup-progress.ts create mode 100644 apps/web/lib/startup-progress/duration.test.ts create mode 100644 apps/web/lib/startup-progress/duration.ts create mode 100644 apps/web/lib/startup-progress/format.test.ts create mode 100644 apps/web/lib/startup-progress/format.ts create mode 100644 apps/web/lib/startup-progress/types.test.ts create mode 100644 apps/web/lib/startup-progress/types.ts create mode 100644 apps/web/src/locales/en/startup.json create mode 100644 apps/web/src/locales/pseudo/startup.json create mode 100644 apps/web/src/locales/pt-pt/startup.json create mode 100644 apps/web/src/locales/zh-cn/startup.json create mode 100644 apps/web/src/locales/zh-hk/startup.json create mode 100644 apps/web/src/locales/zh-tw/startup.json create mode 100644 docs/plans/startup-progress-visibility/plan.md create mode 100644 docs/plans/startup-progress-visibility/task-01-step-registry-and-surfaces.md create mode 100644 docs/plans/startup-progress-visibility/task-02-retire-deleted-step-identifiers.md create mode 100644 docs/specs/platform/requirements/startup-progress-visibility.md create mode 100644 docs/specs/platform/system-design/startup-progress-visibility.md diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go index 8e72a2eef4..a2f530d487 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/agent/executor" "github.com/kandev/kandev/internal/agentctl/tracing" agentctltypes "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/startup" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -52,6 +53,7 @@ func (m *Manager) Start(ctx context.Context) error { // Read the live standalone recovery-inventory records (startup step 3, // AC-EXECUTORS-SURVIVAL-002.8) before recovery contacts any control // server, and hand them to every runtime's RecoverInstances unchanged. + startup.BeginStep(ctx, startup.StepSessionsRecovery) records, listErr := m.ListLiveStandaloneExecutorsRunning(ctx) m.runRecoveryErr = listErr if listErr != nil { @@ -69,6 +71,11 @@ func (m *Manager) Start(ctx context.Context) error { m.logger.Error("skipping recovery: live standalone recovery-inventory records could not be read, so no live instance can be correlated to a session", zap.Error(listErr)) records = nil + // The corpus is unknown, not zero: a failed read is not the same as + // an empty inventory, so this activation is locked opaque (never + // promoted to counted) rather than reporting SetTotal(0), which + // would claim a known-empty corpus. + startup.Degrade(ctx, startup.StepSessionsRecovery) } // Take a recovery guard for every named session except a confirmed @@ -95,6 +102,9 @@ func (m *Manager) Start(ctx context.Context) error { // (ListLiveStandaloneExecutorsRunning), and StandaloneExecutor is the // only backend that reads them, so this narrows nothing else. records = recoverableRecords(records, guardedSessions) + if listErr == nil { + startup.SetTotal(ctx, startup.StepSessionsRecovery, int64(len(records))) + } // AC-EXECUTORS-SURVIVAL-003.7: bound this pass's // adoption+enumeration+reconstruction work with a single deadline, clocked @@ -132,6 +142,7 @@ func (m *Manager) Start(ctx context.Context) error { zap.String("instance_id", ri.InstanceID), zap.String("session_id", ri.SessionID)) m.dispatchUnreconstructableStop(&stopWG, ri) + startup.Advance(ctx, startup.StepSessionsRecovery, 1) continue } execution := &AgentExecution{ @@ -183,6 +194,7 @@ func (m *Manager) Start(ctx context.Context) error { zap.String("instance_id", execution.ID), zap.String("session_id", execution.SessionID)) m.dispatchUnreconstructableStop(&stopWG, ri) + startup.Advance(ctx, startup.StepSessionsRecovery, 1) continue } // A recovered instance is resuming a live provider session (its @@ -200,6 +212,7 @@ func (m *Manager) Start(ctx context.Context) error { zap.String("session_id", execution.SessionID), zap.Error(err)) m.dispatchUnreconstructableStop(&stopWG, ri) + startup.Advance(ctx, startup.StepSessionsRecovery, 1) continue } // AC-EXECUTORS-SURVIVAL-002.14: Office profile identity is a new key @@ -225,6 +238,7 @@ func (m *Manager) Start(ctx context.Context) error { zap.String("agent_profile_id", execution.AgentProfileID), zap.Error(err)) m.dispatchUnreconstructableStop(&stopWG, ri) + startup.Advance(ctx, startup.StepSessionsRecovery, 1) continue } // AC-EXECUTORS-SURVIVAL-004.2/004.5: retrieve this instance's @@ -239,6 +253,7 @@ func (m *Manager) Start(ctx context.Context) error { zap.String("instance_id", execution.ID), zap.String("session_id", execution.SessionID)) m.dispatchUnreconstructableStop(&stopWG, ri) + startup.Advance(ctx, startup.StepSessionsRecovery, 1) continue } // Create trace span for the recovered session @@ -268,6 +283,7 @@ func (m *Manager) Start(ctx context.Context) error { } execution.EndSessionSpan() initSpan.End() + startup.Advance(ctx, startup.StepSessionsRecovery, 1) continue } m.setRuntimeInterest(execution.SessionID, true) @@ -320,6 +336,7 @@ func (m *Manager) Start(ctx context.Context) error { go m.streamManager.ReconnectAll(execution) initSpan.End() + startup.Advance(ctx, startup.StepSessionsRecovery, 1) } m.logger.Info("recovered executions", zap.Int("count", len(recovered))) } @@ -333,6 +350,7 @@ func (m *Manager) Start(ctx context.Context) error { // before returning) so Start remains synchronous overall and // ReleaseAllExceptRetained below never races a still-resolving stop. stopWG.Wait() + startup.EndStep(ctx, startup.StepSessionsRecovery) // Recovery is synchronous above: by this point every guarded session's // outcome (re-tracked or not) is already decided, so every guard still diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_recovery_deadline_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_recovery_deadline_test.go index e619d6cd58..3a35f1edeb 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_recovery_deadline_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_recovery_deadline_test.go @@ -3,6 +3,7 @@ package lifecycle import ( "context" "errors" + "sync" "testing" "time" @@ -20,6 +21,9 @@ type deadlineRecoveringExecutor struct { name executor.Name recovered []*ExecutorInstance stopWithRetryCalls []string + stopStarted chan struct{} + allowStop chan struct{} + stopStartOnce sync.Once } func (e *deadlineRecoveringExecutor) Name() executor.Name { return e.name } @@ -39,6 +43,10 @@ func (e *deadlineRecoveringExecutor) ShouldApplyPreferredShell() bool func (e *deadlineRecoveringExecutor) IsAlwaysResumable() bool { return false } func (e *deadlineRecoveringExecutor) stopWithRetry(_ context.Context, instanceID string) error { e.stopWithRetryCalls = append(e.stopWithRetryCalls, instanceID) + if e.stopStarted != nil { + e.stopStartOnce.Do(func() { close(e.stopStarted) }) + <-e.allowStop + } return nil } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_sessions_step_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_sessions_step_test.go new file mode 100644 index 0000000000..b9ce8ea2f6 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_recovery_sessions_step_test.go @@ -0,0 +1,239 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" + + "github.com/kandev/kandev/internal/agent/executor" + agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/startup" + "github.com/kandev/kandev/internal/task/models" +) + +// newObservedSessionsRecoveryReporter builds a startup.Reporter whose own +// logger is independent of the Manager's -- Manager.Start's +// startup.BeginStep/Advance/Degrade/EndStep calls all go through the +// context-carried Reporter, not m.logger, so observing it needs no changes +// to how the Manager under test is constructed. +func newObservedSessionsRecoveryReporter(t *testing.T) (*startup.Reporter, *observer.ObservedLogs) { + t.Helper() + core, logs := observer.New(zap.InfoLevel) + log, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatal(err) + } + return startup.New(log), logs +} + +// completedStepFields returns the ContextMap of the last "Startup step +// completed" entry for wantStep, or fails the test if there is none. +func completedStepFields(t *testing.T, logs *observer.ObservedLogs, wantStep startup.StepID) map[string]interface{} { + t.Helper() + var fields map[string]interface{} + for _, entry := range logs.All() { + if entry.Message != "Startup step completed" { + continue + } + if entry.ContextMap()["step"] != string(wantStep) { + continue + } + fields = entry.ContextMap() + } + if fields == nil { + t.Fatalf("no \"Startup step completed\" entry for step %q", wantStep) + } + return fields +} + +// TestManagerStartRecoverySessionsStepReportsCountedProgress covers the +// sessions.recovery half of AC-PLATFORM-STARTUP-PROGRESS-005.2/.8 on the +// ordinary (successful inventory read) path: the step totals from the +// post-recoverableRecords record count, and the consumer loop's Advance(1) +// call fires even on a record that is immediately refused (empty TaskID), +// since that record was still consumed from the corpus that SetTotal +// counted. +func TestManagerStartRecoverySessionsStepReportsCountedProgress(t *testing.T) { + log := newTestLogger() + registry := NewExecutorRegistry(log) + backend := &MockExecutor{ + name: executor.NameStandalone, + recoverInstances: []*ExecutorInstance{{ + InstanceID: "instance-1", + SessionID: "session-1", + TaskID: "", // refused immediately (AC-EXECUTORS-SURVIVAL-002.4), but still one unit of progress + RuntimeName: executor.NameStandalone, + }}, + } + registry.Register(backend) + + mgr := NewManager(newTestRegistry(), &MockEventBus{}, registry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + t.Cleanup(func() { _ = mgr.Stop() }) + mgr.SetExecutorRunningWriter(&listingWriter{rows: []*models.ExecutorRunning{{SessionID: "session-1"}}}) + + reporter, logs := newObservedSessionsRecoveryReporter(t) + reporter.Set(startup.RecoveringSessions) + ctx := startup.WithReporter(context.Background(), reporter) + + if err := mgr.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + + if snap := reporter.Snapshot(); snap.Step != nil { + t.Fatalf("Step after Start = %+v, want nil (step closed)", snap.Step) + } + + fields := completedStepFields(t, logs, startup.StepSessionsRecovery) + if got := fields["measure"]; got != "counted" { + t.Fatalf("measure = %v, want \"counted\": a successful non-empty inventory read must promote past opaque", got) + } + if got, ok := fields["done"].(int64); !ok || got != 1 { + t.Fatalf("done = %v, want 1", fields["done"]) + } + if got, ok := fields["total"].(int64); !ok || got != 1 { + t.Fatalf("total = %v, want 1", fields["total"]) + } +} + +// TestManagerStartRecoverySessionsStepReportsZeroTotalOnEmptyInventory +// covers the successful-but-empty boundary: a SUCCESSFUL read returning no +// records is authoritative (TestManagerStartEmptyRecoveryInventoryStillStopsOrphans +// pins the orphan-stop side of this same scenario), so SetTotal(0) reports a +// known-empty corpus rather than the unreadable path's Degrade. +func TestManagerStartRecoverySessionsStepReportsZeroTotalOnEmptyInventory(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "instance-1", SessionID: "session-1"}, + } + + mgr := newLivenessTestManager(t, control) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + mgr.SetExecutorRunningWriter(&listingWriter{rows: []*models.ExecutorRunning{}}) + mgr.SetRecoveryDeadline(5 * time.Second) + + reporter, logs := newObservedSessionsRecoveryReporter(t) + reporter.Set(startup.RecoveringSessions) + ctx := startup.WithReporter(context.Background(), reporter) + + if err := mgr.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + + for _, entry := range logs.All() { + if entry.Message == "Startup step warning" && entry.ContextMap()["step"] == string(startup.StepSessionsRecovery) && entry.ContextMap()["condition"] == "degrade" { + t.Fatalf("unexpected degrade warning on an authoritative empty inventory: %+v", entry.ContextMap()) + } + } + fields := completedStepFields(t, logs, startup.StepSessionsRecovery) + if got := fields["measure"]; got != "opaque" { + t.Fatalf("measure = %v, want \"opaque\": SetTotal(0) seals the activation opaque", got) + } + if got, ok := fields["total"].(int64); !ok || got != 0 { + t.Fatalf("total = %v, want 0", fields["total"]) + } +} + +func TestManagerStartKeepsRecoveryStepUntilStopWorkersFinish(t *testing.T) { + log := newTestLogger() + execRegistry := NewExecutorRegistry(log) + backend := &deadlineRecoveringExecutor{ + name: executor.NameStandalone, + stopStarted: make(chan struct{}), + allowStop: make(chan struct{}), + recovered: []*ExecutorInstance{{ + InstanceID: "exec-1", + TaskID: "task-1", + SessionID: "session-1", + RuntimeName: executor.NameStandalone, + StandaloneInstanceID: "standalone-1", + }}, + } + execRegistry.Register(backend) + + mgr := NewManager(newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, + ExecutorFallbackWarn, "", log) + cleanupManagerStopCh(t, mgr) + mgr.SetExecutorRunningWriter(&listingWriter{rows: []*models.ExecutorRunning{{SessionID: "session-1"}}}) + mgr.SetRecoveryDeadlineStart(time.Now().Add(-time.Hour)) + mgr.SetRecoveryDeadline(time.Millisecond) + + reporter, _ := newObservedSessionsRecoveryReporter(t) + reporter.Set(startup.RecoveringSessions) + ctx := startup.WithReporter(context.Background(), reporter) + done := make(chan error, 1) + go func() { done <- mgr.Start(ctx) }() + + select { + case <-backend.stopStarted: + case <-time.After(2 * time.Second): + t.Fatal("recovery stop worker did not start") + } + + if snap := reporter.Snapshot(); snap.Step == nil { + t.Fatal("recovery step ended before the stop worker finished") + } + + close(backend.allowStop) + if err := <-done; err != nil { + t.Fatalf("Start() error = %v", err) + } + if snap := reporter.Snapshot(); snap.Step != nil { + t.Fatalf("Step after Start = %+v, want nil (step closed)", snap.Step) + } +} + +// TestManagerStartRecoverySessionsStepLocksOpaqueOnUnreadableInventory +// covers the AC-EXECUTORS-SURVIVAL-002.6/002.12 unreadable-inventory path +// this same file's sibling manager_recovery_inventory_unknown_test.go pins +// the orphan-stop behavior for: the corpus is unknown, not empty, so the +// step never promotes past opaque and Degrade logs a "degrade" warning +// distinguishing this boot from a genuinely empty one. +func TestManagerStartRecoverySessionsStepLocksOpaqueOnUnreadableInventory(t *testing.T) { + control := newStandaloneControlServer(t, true) + control.listInstances = []*agentctlclient.InstanceInfo{ + {ID: "instance-1", SessionID: "session-1"}, + } + + mgr := newLivenessTestManager(t, control) + t.Cleanup(func() { _ = mgr.Stop() }) + registerRecoveryTestAgentProfile(t, mgr) + mgr.SetExecutorRunningWriter(&listingWriter{err: errors.New("database unavailable")}) + mgr.SetRecoveryDeadline(5 * time.Second) + + reporter, logs := newObservedSessionsRecoveryReporter(t) + reporter.Set(startup.RecoveringSessions) + ctx := startup.WithReporter(context.Background(), reporter) + + if err := mgr.Start(ctx); err != nil { + t.Fatalf("Start() error = %v", err) + } + + var sawDegradeWarning bool + for _, entry := range logs.All() { + if entry.Message != "Startup step warning" { + continue + } + if entry.ContextMap()["step"] == string(startup.StepSessionsRecovery) && entry.ContextMap()["condition"] == "degrade" { + sawDegradeWarning = true + } + } + if !sawDegradeWarning { + t.Fatal("expected a \"Startup step warning\" entry with condition=degrade for an unreadable inventory") + } + + fields := completedStepFields(t, logs, startup.StepSessionsRecovery) + if got := fields["measure"]; got != "opaque" { + t.Fatalf("measure = %v, want \"opaque\": an unreadable inventory must never promote past opaque", got) + } + if got, ok := fields["total"].(int64); !ok || got != 0 { + t.Fatalf("total = %v, want 0 (SetTotal is never called on this path)", fields["total"]) + } +} diff --git a/apps/backend/internal/analytics/handlers/stats_read_availability_test.go b/apps/backend/internal/analytics/handlers/stats_read_availability_test.go index 206fcfa69c..5bab7219f2 100644 --- a/apps/backend/internal/analytics/handlers/stats_read_availability_test.go +++ b/apps/backend/internal/analytics/handlers/stats_read_availability_test.go @@ -14,6 +14,7 @@ import ( analyticsservice "github.com/kandev/kandev/internal/analytics/service" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/persistence/requiredstores" + "github.com/kandev/kandev/internal/startup" ) const availabilityWorkspaceID = statsReferenceWorkspaceID @@ -125,6 +126,7 @@ func newAnalyticsAvailabilityHealth(t *testing.T, fixture statsReferenceFixture) ID: "analytics", OwnerPackage: "internal/analytics/repository", RequiredTables: []string{"tasks"}, + Sweep: startup.StepStoresRepositories, }}) if err != nil { t.Fatalf("NewTracker failed: %v", err) diff --git a/apps/backend/internal/backendapp/canvas_edit_test.go b/apps/backend/internal/backendapp/canvas_edit_test.go index 8fd9604371..8a6ebb7c0a 100644 --- a/apps/backend/internal/backendapp/canvas_edit_test.go +++ b/apps/backend/internal/backendapp/canvas_edit_test.go @@ -220,32 +220,32 @@ func stringContains(value, want string) bool { func newCanvasEditTestDependencies() (*fakeCanvasEditTaskStore, *fakeCanvasEditCanvasStore, *fakeCanvasEditReleaseStore, *fakeCanvasEditArtifactStore) { return &fakeCanvasEditTaskStore{ - workspace: &models.Workspace{ - ID: "workspace-a", - DefaultAgentProfileID: stringPointer("profile-default"), - DefaultExecutorID: stringPointer("executor-default"), - }, - }, &fakeCanvasEditCanvasStore{canvas: &canvas.Canvas{ - ID: "canvas-a", - PluginInstanceID: "instance-a", - WorkspaceID: "workspace-a", - ScopeKind: canvas.ScopeWorkspace, - Status: instances.StatusActive, - ActiveReleaseID: "release-a", - ActiveReleaseStatus: instances.ValidationValid, - EffectiveGrants: []canvas.GrantProjection{{ - PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: instances.ScopeWorkspace, - }}, - }}, &fakeCanvasEditReleaseStore{release: instances.Release{ - ID: "release-a", - InstanceID: "instance-a", - PackageDigest: "digest-a", - ArtifactPath: "releases/digest-a", - ValidationStatus: instances.ValidationValid, - }}, &fakeCanvasEditArtifactStore{files: map[string][]byte{ - "manifest.yaml": []byte("manifest"), - "index.html": []byte("index"), - }} + workspace: &models.Workspace{ + ID: "workspace-a", + DefaultAgentProfileID: stringPointer("profile-default"), + DefaultExecutorID: stringPointer("executor-default"), + }, + }, &fakeCanvasEditCanvasStore{canvas: &canvas.Canvas{ + ID: "canvas-a", + PluginInstanceID: "instance-a", + WorkspaceID: "workspace-a", + ScopeKind: canvas.ScopeWorkspace, + Status: instances.StatusActive, + ActiveReleaseID: "release-a", + ActiveReleaseStatus: instances.ValidationValid, + EffectiveGrants: []canvas.GrantProjection{{ + PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: instances.ScopeWorkspace, + }}, + }}, &fakeCanvasEditReleaseStore{release: instances.Release{ + ID: "release-a", + InstanceID: "instance-a", + PackageDigest: "digest-a", + ArtifactPath: "releases/digest-a", + ValidationStatus: instances.ValidationValid, + }}, &fakeCanvasEditArtifactStore{files: map[string][]byte{ + "manifest.yaml": []byte("manifest"), + "index.html": []byte("index"), + }} } func stringPointer(value string) *string { return &value } diff --git a/apps/backend/internal/backendapp/e2e_startup_page.go b/apps/backend/internal/backendapp/e2e_startup_page.go new file mode 100644 index 0000000000..190ad9d651 --- /dev/null +++ b/apps/backend/internal/backendapp/e2e_startup_page.go @@ -0,0 +1,33 @@ +package backendapp + +import ( + "net/http" + "os" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/startup" +) + +// registerE2EStartupPageFixtureRoute exposes writeStartupPage, the same +// renderer newBootstrapHandler uses while the real router is still coming +// up, as a fixture endpoint. The bootstrap window closes as soon as this +// process finishes starting, so a browser test has no other way to reach +// that markup and embedded poll script against a caller-supplied snapshot. +// Gated the same way as registerE2EResetRoutes. +func registerE2EStartupPageFixtureRoute(router *gin.Engine, log *logger.Logger) { + mockMode := os.Getenv("KANDEV_MOCK_AGENT") + if mockMode != "true" && mockMode != "only" { + return + } + router.POST("/api/v1/e2e/startup-page-fixture", func(c *gin.Context) { + var snap startup.Snapshot + if err := c.ShouldBindJSON(&snap); err != nil { + c.JSON(http.StatusBadRequest, gin.H{errKey: "invalid startup snapshot"}) + return + } + writeStartupPage(c.Writer, c.Request, snap) + }) + log.Info("registered E2E startup page fixture route (test-only)") +} diff --git a/apps/backend/internal/backendapp/e2e_startup_page_test.go b/apps/backend/internal/backendapp/e2e_startup_page_test.go new file mode 100644 index 0000000000..4574d11e2f --- /dev/null +++ b/apps/backend/internal/backendapp/e2e_startup_page_test.go @@ -0,0 +1,93 @@ +package backendapp + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +func newE2EStartupPageFixtureRouter(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger.NewFromZap: %v", err) + } + router := gin.New() + registerE2EStartupPageFixtureRoute(router, log) + return router +} + +func TestE2EStartupPageFixtureRendersRealTemplate(t *testing.T) { + t.Setenv("KANDEV_MOCK_AGENT", "only") + router := newE2EStartupPageFixtureRouter(t) + + body := strings.NewReader(`{ + "phase": "applying_migrations", + "boot": 1, + "seq": 1, + "elapsed_ms": 12000, + "phase_elapsed_ms": 12000, + "step": { + "id": "stores.repositories", + "label_key": "startup.step.stores_repositories", + "measure": "counted", + "unit": "stores", + "elapsed_ms": 12000, + "done": 400, + "total": 1000, + "eta_ms": 30000, + "stalled": false + } + }`) + req := httptest.NewRequest("POST", "/api/v1/e2e/startup-page-fixture", body) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != 503 { + t.Fatalf("status = %d, want 503", resp.Code) + } + html := resp.Body.String() + if !strings.Contains(html, `id="startup-data"`) { + t.Fatalf("response does not contain the embedded poll script's data island: %s", html) + } + if !strings.Contains(html, "400 of 1000") { + t.Fatalf("response does not render the step's counted progress: %s", html) + } +} + +func TestE2EStartupPageFixtureRejectsUnparseableBody(t *testing.T) { + t.Setenv("KANDEV_MOCK_AGENT", "only") + router := newE2EStartupPageFixtureRouter(t) + + req := httptest.NewRequest("POST", "/api/v1/e2e/startup-page-fixture", strings.NewReader(`not json`)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != 400 { + t.Fatalf("status = %d, want 400", resp.Code) + } +} + +func TestE2EStartupPageFixtureRouteHiddenOutsideMockAgent(t *testing.T) { + t.Setenv("KANDEV_MOCK_AGENT", "") + router := newE2EStartupPageFixtureRouter(t) + + req := httptest.NewRequest("POST", "/api/v1/e2e/startup-page-fixture", strings.NewReader(`{}`)) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != 404 { + t.Fatalf("status = %d, want 404 when KANDEV_MOCK_AGENT is unset", resp.Code) + } +} diff --git a/apps/backend/internal/backendapp/health_test.go b/apps/backend/internal/backendapp/health_test.go index b460e3cb66..401baecc15 100644 --- a/apps/backend/internal/backendapp/health_test.go +++ b/apps/backend/internal/backendapp/health_test.go @@ -11,6 +11,7 @@ import ( authhttpmw "github.com/kandev/kandev/internal/auth/httpmw" "github.com/kandev/kandev/internal/persistence/requiredstores" + "github.com/kandev/kandev/internal/startup" "github.com/kandev/kandev/internal/system/info" ) @@ -113,11 +114,55 @@ func TestReadyHandlerBodyShapesByReadiness(t *testing.T) { } } +// TestReadyHandlerIncludesStartupSnapshot covers the AC-PLATFORM-STARTUP- +// PROGRESS-005 requirement that /ready carry the startup snapshot in both +// its starting 503 and its ready 200 bodies, never 404, whenever a +// *startup.Reporter is wired into routeParams. +func TestReadyHandlerIncludesStartupSnapshot(t *testing.T) { + gin.SetMode(gin.TestMode) + reporter := startup.New(testLogger(t)) + reporter.Set(startup.RecoveringSessions) + + setReadyForTest(t, false) + router := gin.New() + router.GET("/ready", readyHandler(routeParams{version: "1.2.3", progress: reporter})) + + startingRec := httptest.NewRecorder() + router.ServeHTTP(startingRec, httptest.NewRequest(http.MethodGet, "/ready", nil)) + var startingBody map[string]interface{} + if err := json.Unmarshal(startingRec.Body.Bytes(), &startingBody); err != nil { + t.Fatalf("decode starting body: %v", err) + } + startingSnap, ok := startingBody["startup"].(map[string]interface{}) + if !ok { + t.Fatalf("starting body missing \"startup\" snapshot: %#v", startingBody) + } + if startingSnap["phase"] != string(startup.RecoveringSessions) { + t.Fatalf("starting startup.phase = %v, want %v", startingSnap["phase"], startup.RecoveringSessions) + } + + setReadyForTest(t, true) + readyRec := httptest.NewRecorder() + router.ServeHTTP(readyRec, httptest.NewRequest(http.MethodGet, "/ready", nil)) + var readyBody map[string]interface{} + if err := json.Unmarshal(readyRec.Body.Bytes(), &readyBody); err != nil { + t.Fatalf("decode ready body: %v", err) + } + readySnap, ok := readyBody["startup"].(map[string]interface{}) + if !ok { + t.Fatalf("ready body missing \"startup\" snapshot: %#v", readyBody) + } + if readySnap["phase"] != string(startup.RecoveringSessions) { + t.Fatalf("ready startup.phase = %v, want %v", readySnap["phase"], startup.RecoveringSessions) + } +} + func TestReadyHandlerReportsPersistenceFailure(t *testing.T) { gin.SetMode(gin.TestMode) setReadyForTest(t, true) tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"}, + Sweep: startup.StepStoresRepositories, }}) if err != nil { t.Fatalf("NewTracker: %v", err) diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 50fa5514f5..588fe23650 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -84,6 +84,7 @@ import ( "github.com/kandev/kandev/internal/sentry" spriteshandlers "github.com/kandev/kandev/internal/sprites" sshhandlers "github.com/kandev/kandev/internal/ssh" + "github.com/kandev/kandev/internal/startup" systemsvc "github.com/kandev/kandev/internal/system" "github.com/kandev/kandev/internal/system/storage/tempartifacts" taskdto "github.com/kandev/kandev/internal/task/dto" @@ -729,6 +730,7 @@ type routeParams struct { homeDir string interimSettingsInterlockToken string log *logger.Logger + progress *startup.Reporter } // registerRoutes sets up all HTTP and WebSocket routes on the given router. @@ -1015,29 +1017,27 @@ func healthHandler(p routeParams) gin.HandlerFunc { func readyHandler(p routeParams) gin.HandlerFunc { version := resolveVersion(p) return func(c *gin.Context) { + body := gin.H{ + serviceFieldKey: kandevName, + versionFieldKey: version, + } + if p.progress != nil { + body["startup"] = p.progress.Snapshot() + } if !ready.Load() { - c.JSON(http.StatusServiceUnavailable, gin.H{ - statusKey: startingStatus, - serviceFieldKey: kandevName, - versionFieldKey: version, - }) + body[statusKey] = startingStatus + c.JSON(http.StatusServiceUnavailable, body) return } if p.persistenceHealth != nil && !p.persistenceHealth.Healthy() { - c.JSON(http.StatusServiceUnavailable, gin.H{ - statusKey: startingStatus, - serviceFieldKey: kandevName, - versionFieldKey: version, - "reason": "persistence", - "store_ids": p.persistenceHealth.UnhealthyStoreIDs(), - }) + body[statusKey] = startingStatus + body["reason"] = "persistence" + body["store_ids"] = p.persistenceHealth.UnhealthyStoreIDs() + c.JSON(http.StatusServiceUnavailable, body) return } - c.JSON(http.StatusOK, gin.H{ - statusKey: "ok", - serviceFieldKey: kandevName, - versionFieldKey: version, - }) + body[statusKey] = "ok" + c.JSON(http.StatusOK, body) } } @@ -1598,6 +1598,7 @@ func registerSecondaryRoutes( registerE2EResetRoutes( p.router, p.taskRepo, p.taskSvc, automationSvc, p.services.GitHub, p.services.GitLab, p.eventBus, p.log, ) + registerE2EStartupPageFixtureRoute(p.router, p.log) if officetestharness.Enabled() { var officeAgentSvc *officeagents.AgentService diff --git a/apps/backend/internal/backendapp/httpserver.go b/apps/backend/internal/backendapp/httpserver.go index decdd8ee9d..5d2067848b 100644 --- a/apps/backend/internal/backendapp/httpserver.go +++ b/apps/backend/internal/backendapp/httpserver.go @@ -13,6 +13,7 @@ import ( "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/startup" + "github.com/kandev/kandev/internal/webapp" "go.uber.org/zap" ) @@ -53,11 +54,16 @@ func (hs *handlerSwitch) ServeHTTP(w http.ResponseWriter, r *http.Request) { // token header, if configured) so the launcher's liveness probe // (internal/launcher/health.go) succeeds regardless of startup progress — // making liveness depend on readiness here would bring back the crash loop -// this handler exists to fix. Every other path returns a deterministic 503 -// with a parseable "starting" body instead of hanging, resetting, or 404ing. -// GET /health's body is identical to healthHandler's in helpers.go (status -// "ok", service, mode, version) since /health is a pure liveness probe and -// its shape must not depend on startup progress. +// this handler exists to fix. GET /health's body is identical to +// healthHandler's in helpers.go (status "ok", service, mode, version) since +// /health is a pure liveness probe and its shape must not depend on startup +// progress. +// +// A GET on an application route (per webapp.IsSPARoute, excluding /ready +// itself — see AC-PLATFORM-STARTUP-PROGRESS-003.3) whose Accept header +// prefers HTML gets the server-rendered startup page instead of the +// machine-readable body. Every other request gets a deterministic 503 with +// a parseable "starting" body instead of hanging, resetting, or 404ing. func newBootstrapHandler(version string, reporters ...*startup.Reporter) http.Handler { progress := startup.New(nil) if len(reporters) > 0 { @@ -65,6 +71,11 @@ func newBootstrapHandler(version string, reporters ...*startup.Reporter) http.Ha } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.URL.Path != healthRoutePath { + if r.Method == http.MethodGet && r.URL.Path != readyRoutePath && + webapp.IsSPARoute(r.URL.Path) && webapp.PrefersHTML(r.Header.Get("Accept")) { + writeStartupPage(w, r, progress.Snapshot()) + return + } body := map[string]any{ statusKey: startingStatus, serviceFieldKey: kandevName, diff --git a/apps/backend/internal/backendapp/httpserver_test.go b/apps/backend/internal/backendapp/httpserver_test.go index f688361849..3edf27a60b 100644 --- a/apps/backend/internal/backendapp/httpserver_test.go +++ b/apps/backend/internal/backendapp/httpserver_test.go @@ -22,7 +22,7 @@ func TestBuildHTTPServerAbortsWhenInterlockTokenGenerationFails(t *testing.T) { newInterimSettingsInterlockToken = func() (string, error) { return "", wantErr } t.Cleanup(func() { newInterimSettingsInterlockToken = original }) - server, err := buildHTTPServer(&config.Config{}, testLogger(t), nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + server, err := buildHTTPServer(&config.Config{}, testLogger(t), nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) if !errors.Is(err, wantErr) { t.Fatalf("buildHTTPServer error = %v, want %v", err, wantErr) } diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 495da44aaf..600fbbf08a 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -138,6 +138,7 @@ import ( // System pages (status / database / backups / logs / updates / about) systemsvc "github.com/kandev/kandev/internal/system" "github.com/kandev/kandev/internal/system/sessioncapacity" + storagepkg "github.com/kandev/kandev/internal/system/storage" "github.com/kandev/kandev/internal/system/storage/tempartifacts" // Database @@ -716,7 +717,7 @@ func startAgentInfrastructure( log.Info("Initializing Orchestrator...") sessionCapacityEnvironment := sessioncapacity.ReadEnvironment() - orchestratorSvc, msgCreator, err := provideOrchestrator(cfg, log, dbPool, eventBus, repos.Task, services.Task, services.User, + orchestratorSvc, msgCreator, err := provideOrchestrator(ctx, cfg, log, dbPool, eventBus, repos.Task, services.Task, services.User, lifecycleMgr, agentRegistry, services.Workflow, userSecretStore, repoCloner, services.Prompts, services.GitHub, services.GitCredentials, repos.SystemSettings, sessionCapacityEnvironment, repos.RequiredStores) if err != nil { @@ -892,7 +893,7 @@ func startAgentInfrastructure( // the ledger's foreign keys require them present at CREATE TABLE time // on PostgreSQL. services.Task satisfies delivery.CheckoutResolver. _, deliveryCleanup, deliveryErr := delivery.Provide(dbPool.Writer(), dbPool.Reader(), services.Task, log) - if recordErr := recordRequiredStore(repos.RequiredStores, "delivery", deliveryErr); recordErr != nil { + if recordErr := recordRequiredStore(ctx, repos.RequiredStores, "delivery", deliveryErr); recordErr != nil { log.Error("delivery ledger initialization failed", zap.Error(recordErr)) return false } @@ -908,6 +909,27 @@ func startAgentInfrastructure( // in-memory bus while Service.Start is still doing its startup reconciliation. // Service.Start calls Watcher.Start again; the watcher is idempotent and keeps // these subscriptions. + // + // storage is stores.services's last admission chronologically, so it has + // to happen here, immediately before the phase transition and + // lifecycleMgr.Start below (which is what actually runs + // sessions.recovery's BeginStep/Advance/EndStep sequence): its old + // position deep inside startGatewayAndServe ran chronologically after + // sessions.recovery had already begun, so BeginStep force-closed + // stores.services early and every admission after that point -- + // including storage's own -- silently no-op'd. + storageStore, err := provideStorageStore(ctx, dbPool, repos.RequiredStores) + if err != nil { + log.Error("Failed to initialize storage store", zap.Error(err)) + return false + } + + // The phase transition must happen here, immediately before + // lifecycleMgr.Start below (which is what actually runs + // sessions.recovery's BeginStep/Advance/EndStep sequence) rather than + // later at HTTP listener bind time in startGatewayAndServe: that point + // runs well after this recovery work has already completed. + startup.SetPhase(ctx, startup.RecoveringSessions) if err := orchestratorSvc.StartEventWatcher(ctx); err != nil { log.Error("Failed to start orchestrator event watcher for agent recovery", zap.Error(err)) return false @@ -920,7 +942,7 @@ func startAgentInfrastructure( return startGatewayAndServe(ctx, cfg, log, eventBus, agentRuntimeAvailability, dbPool, repos, services, agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, repoCloner, agentctlBinaryPath, - sessionCapacityEnvironment, func(fn func() error) { addRuntimeCleanup(fn) }, runCleanups, cancelWorkers, + sessionCapacityEnvironment, storageStore, func(fn func() error) { addRuntimeCleanup(fn) }, runCleanups, cancelWorkers, restoreCleanups, databaseQuiesce) } @@ -985,6 +1007,7 @@ func startGatewayAndServe( repoCloner *repoclone.Cloner, agentctlBinaryPath string, sessionCapacityEnvironment sessioncapacity.Environment, + storageStore *storagepkg.Store, addCleanup func(func() error), runCleanups func(), cancelWorkers context.CancelFunc, @@ -1073,7 +1096,7 @@ func startGatewayAndServe( bootstrap := ctx.Value(bootstrapContextKey{}).(*bootstrapRuntime) handler, server, listeners := bootstrap.handler, bootstrap.server, bootstrap.listeners - bindListeners := func() error { startup.SetPhase(ctx, startup.RecoveringSessions); return ctx.Err() } + bindListeners := func() error { return ctx.Err() } if err := startOrchestratorAndAutomationConsumers( bindListeners, @@ -1219,7 +1242,7 @@ func startGatewayAndServe( cfg, dbPool, systemSvc.Jobs, eventBus, lifecycleMgr, services.WorktreeMgr, services.Task, log, func(message string, err error) { log.Error(message, zap.Error(err)) }, - repos.RequiredStores, repos.SystemSettings, + storageStore, repos.SystemSettings, ) if err != nil { log.Error("Failed to initialize storage maintenance", zap.Error(err)) @@ -1326,7 +1349,7 @@ func startGatewayAndServe( builtServer, err := buildHTTPServer(cfg, log, gateway, repos, services, agentSettingsController, lifecycleMgr, eventBus, orchestratorSvc, notificationCtrl, msgCreator, agentRegistry, hostUtilityMgr, addCleanup, repoCloner, systemSvc, storageComposition.workspaceRestorer, - storageComposition.tempArtifacts, dbPool, agentRuntimeAvailability, persistenceHealth) + storageComposition.tempArtifacts, dbPool, agentRuntimeAvailability, startup.FromContext(ctx), persistenceHealth) if err != nil { log.Error("Failed to build HTTP server", zap.Error(err)) closeBoundListeners(server, listeners, log) @@ -1352,23 +1375,30 @@ func startGatewayAndServe( return false } - // Flip readiness before swapping in the fully wired router — see - // publishReadiness for why the order matters and - // TestPublishReadinessFlipsReadyBeforeSwappingHandler for the regression - // test pinning it. if !bootstrap.beginReadinessPublication(ctx) { return false } - publishReadiness(func() { - ready.Store(true) - bootstrap.ready.Store(true) - }, func() { handler.Store(builtServer.Handler) }) - startup.SetPhase(ctx, startup.Ready) + markStartupReady(func() { startup.SetPhase(ctx, startup.Ready) }, func() { + publishReadiness(func() { + ready.Store(true) + bootstrap.ready.Store(true) + }, func() { handler.Store(builtServer.Handler) }) + }) + awaitShutdown(ctx, server, listeners, scheduling, orchestratorSvc, lifecycleMgr, runCleanups, log) return true } +// markStartupReady transitions the startup phase to Ready and then publishes +// readiness, in that order — never the reverse. A GET /ready request racing +// the two must never observe a successful status against a snapshot that +// still reports an earlier phase (AC-PLATFORM-STARTUP-PROGRESS-002.3). +func markStartupReady(setPhaseReady func(), publish func()) { + setPhaseReady() + publish() +} + // publishReadiness flips readiness and then swaps in the fully wired router, // in that order — never the reverse. A request landing between the two must // never observe the real router while ready is still false: readyHandler @@ -2568,6 +2598,7 @@ func buildHTTPServer( temporaryArtifacts *tempartifacts.Registry, dbPool *db.Pool, agentRuntimeAvailability *agentctlclient.Availability, + progress *startup.Reporter, persistenceHealth ...*requiredstores.Health, ) (*http.Server, error) { gin.SetMode(gin.ReleaseMode) @@ -2699,6 +2730,7 @@ func buildHTTPServer( homeDir: cfg.ResolvedHomeDir(), interimSettingsInterlockToken: interimSettingsInterlockToken, log: log, + progress: progress, }) // Addr is intentionally left unset: bind addresses are resolved from diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index 85553a8214..be42eb4373 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -58,6 +58,7 @@ const ( const defaultEventNamespace = "default" func provideOrchestrator( + ctx context.Context, cfg *config.Config, log *logger.Logger, pool *db.Pool, @@ -116,7 +117,7 @@ func provideOrchestrator( queueRepo, err := messagequeue.NewSQLiteRepository(pool.Writer(), pool.Reader()) if len(trackers) > 0 && trackers[0] != nil { - if recordErr := recordRequiredStore(trackers[0], "message-queue", err); recordErr != nil { + if recordErr := recordRequiredStore(ctx, trackers[0], "message-queue", err); recordErr != nil { return nil, nil, fmt.Errorf("message queue store: %w", recordErr) } } @@ -1221,10 +1222,6 @@ func (a *repositoryResolverAdapter) persistDetectedDefaultBranch( // later write succeeds — and this call site retries with the same // detected value on every future invocation, so a rejection driven by // validation (as opposed to a transient DB error) will repeat forever. - // Review round 3, finding #4: this used to log at Warn and nothing - // else, making that permanent degradation invisible. Error level plus - // the dedicated counter make it observable the same way ancestry/write - // failures already are in internal/delivery/metrics.go. if _, err := a.taskSvc.UpdateRepository(ctx, repo.ID, &taskservice.UpdateRepositoryRequest{ DefaultBranch: &detected, }); err != nil { diff --git a/apps/backend/internal/backendapp/persistence_middleware_test.go b/apps/backend/internal/backendapp/persistence_middleware_test.go index 39f782aa12..8e2a04649a 100644 --- a/apps/backend/internal/backendapp/persistence_middleware_test.go +++ b/apps/backend/internal/backendapp/persistence_middleware_test.go @@ -21,6 +21,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/persistence/requiredstores" + "github.com/kandev/kandev/internal/startup" "github.com/kandev/kandev/internal/system/maintenance" userstore "github.com/kandev/kandev/internal/user/store" ) @@ -40,6 +41,7 @@ func TestPersistenceMiddlewareRemainsAvailableDuringManagedMaintenance(t *testin pool := db.NewPool(conn, conn) tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"}, + Sweep: startup.StepStoresRepositories, }}) if err != nil { t.Fatalf("NewTracker: %v", err) @@ -98,6 +100,7 @@ func TestRequiredPersistenceMiddlewareBlocksStatefulTrafficButAllowsDiagnostics( gin.SetMode(gin.TestMode) tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"}, + Sweep: startup.StepStoresRepositories, }}) if err != nil { t.Fatalf("NewTracker: %v", err) @@ -150,6 +153,7 @@ func TestRequiredPersistenceMiddlewareRunsBeforeAuthentication(t *testing.T) { gin.SetMode(gin.TestMode) tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"}, + Sweep: startup.StepStoresRepositories, }}) if err != nil { t.Fatalf("NewTracker: %v", err) @@ -217,6 +221,7 @@ func TestRequiredPersistenceMiddlewarePrecedesDatabaseBackedAuthentication(t *te tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"}, + Sweep: startup.StepStoresRepositories, }}) if err != nil { t.Fatalf("NewTracker: %v", err) diff --git a/apps/backend/internal/backendapp/plugins_version_test.go b/apps/backend/internal/backendapp/plugins_version_test.go index 51877b70b3..363a7757dd 100644 --- a/apps/backend/internal/backendapp/plugins_version_test.go +++ b/apps/backend/internal/backendapp/plugins_version_test.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/persistence/requiredstores" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/startup" ) // TestProvideServicesWiresPluginsKandevVersion pins the production wiring of @@ -50,12 +51,13 @@ func TestRequiredStoreBootstrapCompleteness(t *testing.T) { func TestRequiredStoreFailure(t *testing.T) { tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks"}, + Sweep: startup.StepStoresRepositories, }}) if err != nil { t.Fatalf("NewTracker: %v", err) } constructorErr := errors.New("schema constructor failed") - got := recordRequiredStore(tracker, "task", constructorErr) + got := recordRequiredStore(context.Background(), tracker, "task", constructorErr) if !errors.Is(got, constructorErr) { t.Fatalf("recordRequiredStore() error = %v, want %v", got, constructorErr) } diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index cd008d29b1..11ce464c1d 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -95,7 +95,7 @@ func provideServices(ctx context.Context, cfg *config.Config, log *logger.Logger agentSettingsController.SetSecretStore(userSecretStore) agentSettingsController.SetManagedRuntimeSelectionStore(managedRuntimeSelections) - core, err := initCoreTaskServices(cfg, repos, dbPool, eventBus, agentRegistry, storeTracker, log) + core, err := initCoreTaskServices(ctx, cfg, repos, dbPool, eventBus, agentRegistry, storeTracker, log) if err != nil { return nil, nil, err } @@ -103,7 +103,7 @@ func provideServices(ctx context.Context, cfg *config.Config, log *logger.Logger wireTaskWorkflowCrossReferences(taskSvc, workflowSvc, userSecretStore, repos, log) - providers, err := initThirdPartyProviders(cfg, dbPool, eventBus, repos, storeTracker, taskSvc, promptSvc, workflowSvc, log) + providers, err := initThirdPartyProviders(ctx, cfg, dbPool, eventBus, repos, storeTracker, taskSvc, promptSvc, workflowSvc, log) if err != nil { return nil, nil, err } @@ -193,6 +193,7 @@ type coreTaskServices struct { } func initCoreTaskServices( + ctx context.Context, cfg *config.Config, repos *Repositories, dbPool *db.Pool, @@ -212,13 +213,13 @@ func initCoreTaskServices( _, ok := agentRegistry.GetInferenceAgent(agentID) return ok })) - dynamicResolver, dynamicBindingResolver, err := initDynamicRuntimeResolver(context.Background(), repos, cfg, log) + dynamicResolver, dynamicBindingResolver, err := initDynamicRuntimeResolver(ctx, repos, cfg, log) if err != nil { return nil, err } utilitySvc.SetExecutionProfileResolver(dynamicResolver) workflowSvc := workflowservice.NewService(repos.Workflow, log) - pendingActionProjectionEpoch, err := repos.Task.NextPendingActionProjectionEpoch(context.Background()) + pendingActionProjectionEpoch, err := repos.Task.NextPendingActionProjectionEpoch(ctx) if err != nil { return nil, fmt.Errorf("allocate pending-action projection epoch: %w", err) } @@ -269,7 +270,7 @@ func initCoreTaskServices( // Workspace membership needs to resolve colleague names and reject // disabled or unknown accounts before writing a row. taskSvc.SetUserDirectory(newUserDirectoryAdapter(repos.UserAccounts)) - orgSvc, unitSvc, err := initOrgAndUnitServices(cfg, dbPool, repos, storeTracker, taskSvc, log) + orgSvc, unitSvc, err := initOrgAndUnitServices(ctx, cfg, dbPool, repos, storeTracker, taskSvc, log) if err != nil { return nil, err } @@ -356,6 +357,7 @@ func initDynamicRuntimeResolver( // services, runs the one-time tenancy migration that creates the default // organization, and wires the unit tree back into the task and org services. func initOrgAndUnitServices( + ctx context.Context, cfg *config.Config, dbPool *db.Pool, repos *Repositories, @@ -367,19 +369,19 @@ func initOrgAndUnitServices( // company name to borrow, and an operator renames it in one click. const defaultOrgName = "Default organization" orgSvc, orgErr := buildOrgService(cfg, dbPool, repos, taskSvc, log) - if recordErr := recordRequiredStore(storeTracker, "organizations", orgErr); recordErr != nil { + if recordErr := recordRequiredStore(ctx, storeTracker, "organizations", orgErr); recordErr != nil { return nil, nil, fmt.Errorf("initialize organizations: %w", recordErr) } // The tenancy migration runs once at boot: it creates the default // organization and puts every pre-tenancy user and workspace into it. - if _, err := orgSvc.EnsureDefaultOrg(context.Background(), defaultOrgName); err != nil { + if _, err := orgSvc.EnsureDefaultOrg(ctx, defaultOrgName); err != nil { return nil, nil, fmt.Errorf("organization migration: %w", err) } // The unit tree is built after the tenancy migration, which is what stamps // organization ids: placing a workspace before it knows its organization // would put it under the wrong root. unitSvc, unitErr := buildOrgUnitService(dbPool, repos.Task, repos.UserAccounts, log) - if recordErr := recordRequiredStore(storeTracker, "organization-units", unitErr); recordErr != nil { + if recordErr := recordRequiredStore(ctx, storeTracker, "organization-units", unitErr); recordErr != nil { return nil, nil, fmt.Errorf("initialize organization units: %w", recordErr) } taskSvc.SetUnitPlacer(unitSvc) @@ -429,17 +431,17 @@ func initIntegrationWiring( _ = pluginsCleanup() } }() - canvasSvc, canvasDistributionSvc, err := initCanvasWiring(cfg, dbPool, eventBus, storeTracker, taskSvc, pluginsSvc, version, log) + canvasSvc, canvasDistributionSvc, err := initCanvasWiring(ctx, cfg, dbPool, eventBus, storeTracker, taskSvc, pluginsSvc, version, log) if err != nil { return nil, err } gitCredentialBroker, shareHTTP, err := initGitCredentialAndRemoteWiring( - cfg, dbPool, repos, storeTracker, taskSvc, githubSvc, pluginsSvc, version, log, + ctx, cfg, dbPool, repos, storeTracker, taskSvc, githubSvc, pluginsSvc, version, log, ) if err != nil { return nil, err } - automationComponents, err := initAutomationWiring(dbPool, eventBus, githubSvc, storeTracker, taskSvc, workflowSvc, repos, log) + automationComponents, err := initAutomationWiring(ctx, dbPool, eventBus, githubSvc, storeTracker, taskSvc, workflowSvc, repos, log) if err != nil { return nil, err } @@ -525,6 +527,7 @@ type thirdPartyProviders struct { } func initThirdPartyProviders( + ctx context.Context, cfg *config.Config, dbPool *db.Pool, eventBus bus.EventBus, @@ -536,7 +539,7 @@ func initThirdPartyProviders( log *logger.Logger, ) (*thirdPartyProviders, error) { githubSvc, _, githubErr := initGitHubServiceRequired(cfg, dbPool, eventBus, repos.Secrets, log) - if recordErr := recordRequiredStore(storeTracker, "github", githubErr); recordErr != nil { + if recordErr := recordRequiredStore(ctx, storeTracker, "github", githubErr); recordErr != nil { return nil, fmt.Errorf("initialize github: %w", recordErr) } if githubSvc != nil { @@ -549,7 +552,7 @@ func initThirdPartyProviders( } } gitlabSvc, gitlabCleanup, gitlabErr := initGitLabServiceRequiredWithSettings(repos.SystemSettings, dbPool, eventBus, repos.Secrets, log) - if recordErr := recordRequiredStore(storeTracker, "gitlab", gitlabErr); recordErr != nil { + if recordErr := recordRequiredStore(ctx, storeTracker, "gitlab", gitlabErr); recordErr != nil { return nil, fmt.Errorf("initialize gitlab: %w", recordErr) } if gitlabSvc != nil { @@ -557,7 +560,7 @@ func initThirdPartyProviders( gitlabSvc.SetComparisonTargetObserver(taskSvc) } azureDevOpsSvc, _, azureDevOpsErr := initAzureDevOpsServiceRequired(dbPool, eventBus, repos.Secrets, log) - if recordErr := recordRequiredStore(storeTracker, "azure-devops", azureDevOpsErr); recordErr != nil { + if recordErr := recordRequiredStore(ctx, storeTracker, "azure-devops", azureDevOpsErr); recordErr != nil { return nil, fmt.Errorf("initialize azure devops: %w", recordErr) } if azureDevOpsSvc != nil { @@ -565,19 +568,19 @@ func initThirdPartyProviders( azureDevOpsSvc.SetWorkspaceAuthorizer(taskSvc.AuthorizeWorkspaceAccess) } jiraSvc, _, jiraErr := initJiraServiceRequired(dbPool, eventBus, repos.Secrets, log) - if recordErr := recordRequiredStore(storeTracker, "jira", jiraErr); recordErr != nil { + if recordErr := recordRequiredStore(ctx, storeTracker, "jira", jiraErr); recordErr != nil { return nil, fmt.Errorf("initialize jira: %w", recordErr) } linearSvc, _, linearErr := initLinearServiceRequired(dbPool, eventBus, repos.Secrets, log) - if recordErr := recordRequiredStore(storeTracker, "linear", linearErr); recordErr != nil { + if recordErr := recordRequiredStore(ctx, storeTracker, "linear", linearErr); recordErr != nil { return nil, fmt.Errorf("initialize linear: %w", recordErr) } sentrySvc, _, sentryErr := initSentryServiceRequired(dbPool, eventBus, repos.Secrets, log) - if recordErr := recordRequiredStore(storeTracker, "sentry", sentryErr); recordErr != nil { + if recordErr := recordRequiredStore(ctx, storeTracker, "sentry", sentryErr); recordErr != nil { return nil, fmt.Errorf("initialize sentry: %w", recordErr) } workflowSyncSvc, _, workflowSyncErr := initWorkflowSyncServiceRequired(dbPool, githubSvc, gitlabSvc, workflowSvc, taskSvc, log) - if recordErr := recordRequiredStore(storeTracker, "workflow-sync", workflowSyncErr); recordErr != nil { + if recordErr := recordRequiredStore(ctx, storeTracker, "workflow-sync", workflowSyncErr); recordErr != nil { return nil, fmt.Errorf("initialize workflow sync: %w", recordErr) } return &thirdPartyProviders{ @@ -605,7 +608,7 @@ func initPluginsWiring( log *logger.Logger, ) (*plugins.Service, func() error, *taskservice.AgentConversationService, error) { pluginsSvc, pluginsCleanup, pluginStoreErrors := initPluginsServiceRequired(ctx, cfg, dbPool, eventBus, repos.Secrets, log) - if recordErr := recordPluginStores(storeTracker, pluginStoreErrors); recordErr != nil { + if recordErr := recordPluginStores(ctx, storeTracker, pluginStoreErrors); recordErr != nil { if pluginsCleanup != nil { _ = pluginsCleanup() } @@ -642,6 +645,7 @@ func initPluginsWiring( // initCanvasWiring constructs the canvas service and, when the canvases // feature is enabled, the canvas distribution service that depends on it. func initCanvasWiring( + ctx context.Context, cfg *config.Config, dbPool *db.Pool, eventBus bus.EventBus, @@ -652,7 +656,7 @@ func initCanvasWiring( log *logger.Logger, ) (*canvasservice.Service, *canvasservice.DistributionService, error) { canvasRepo, canvasErr := canvasservice.NewRepository(dbPool) - if recordErr := recordRequiredStore(storeTracker, "canvas", canvasErr); recordErr != nil { + if recordErr := recordRequiredStore(ctx, storeTracker, "canvas", canvasErr); recordErr != nil { return nil, nil, fmt.Errorf("initialize canvas: %w", recordErr) } canvasSvc, err := initCanvasServiceWithRepository(cfg.Features.Canvases, canvasRepo, eventBus, pluginsSvc, taskSvc, log) @@ -685,6 +689,7 @@ func initCanvasWiring( // initAutomationWiring constructs the automation service stack and wires its // cross-service lookups back into the task and workflow services. func initAutomationWiring( + ctx context.Context, dbPool *db.Pool, eventBus bus.EventBus, githubSvc *github.Service, @@ -695,7 +700,7 @@ func initAutomationWiring( log *logger.Logger, ) (*automation.Components, error) { automationComponents, automationErr := automation.Provide(dbPool.Writer(), dbPool.Reader(), eventBus, githubSvc, log) - if recordErr := recordRequiredStore(storeTracker, "automation", automationErr); recordErr != nil { + if recordErr := recordRequiredStore(ctx, storeTracker, "automation", automationErr); recordErr != nil { return nil, fmt.Errorf("initialize automation: %w", recordErr) } if automationComponents == nil { @@ -733,6 +738,7 @@ func initAutomationWiring( // and task-share HTTP handlers, then wires code-host branch listing, PR // resolution, and fresh-workspace defaults into the task service. func initGitCredentialAndRemoteWiring( + ctx context.Context, cfg *config.Config, dbPool *db.Pool, repos *Repositories, @@ -751,11 +757,11 @@ func initGitCredentialAndRemoteWiring( githubSvc.SetCredentialBroker(github.NewCredentialBrokerFromBroker(gitCredentialBroker)) } shareHTTP, _, shareErr := initShareHandlersRequired(dbPool, repos.Task, taskSvc, githubSvc, log, version) - if recordErr := recordRequiredStore(storeTracker, "task-share", shareErr); recordErr != nil { + if recordErr := recordRequiredStore(ctx, storeTracker, "task-share", shareErr); recordErr != nil { return nil, nil, fmt.Errorf("initialize task share: %w", recordErr) } _, officeConfigErr := officeconfigsync.NewStore(dbPool.Writer(), dbPool.Reader()) - if recordErr := recordRequiredStore(storeTracker, "office-config-sync", officeConfigErr); recordErr != nil { + if recordErr := recordRequiredStore(ctx, storeTracker, "office-config-sync", officeConfigErr); recordErr != nil { return nil, nil, fmt.Errorf("initialize office config sync: %w", recordErr) } @@ -1725,7 +1731,7 @@ func initPluginsServiceRequired( return plugins.ProvideWithStoreErrors(cfg, dbPool, secretadapter.New(secretsStore), eventBus, log) } -func recordPluginStores(tracker *requiredstores.Tracker, initErrors plugins.StoreInitErrors) error { +func recordPluginStores(ctx context.Context, tracker *requiredstores.Tracker, initErrors plugins.StoreInitErrors) error { var failures []error for _, id := range []string{ "plugin-instances", @@ -1735,7 +1741,7 @@ func recordPluginStores(tracker *requiredstores.Tracker, initErrors plugins.Stor "plugin-instance-state", "plugin-user-state", } { - if err := recordRequiredStore(tracker, id, initErrors[id]); err != nil { + if err := recordRequiredStore(ctx, tracker, id, initErrors[id]); err != nil { failures = append(failures, err) } } diff --git a/apps/backend/internal/backendapp/startup_order_test.go b/apps/backend/internal/backendapp/startup_order_test.go index 8a3f95a5eb..5605324009 100644 --- a/apps/backend/internal/backendapp/startup_order_test.go +++ b/apps/backend/internal/backendapp/startup_order_test.go @@ -95,6 +95,25 @@ func TestPublishReadinessFlipsReadyBeforeSwappingHandler(t *testing.T) { } } +// TestMarkStartupReadySetsPhaseBeforePublishingReadiness is the regression +// test for AC-PLATFORM-STARTUP-PROGRESS-002.3: a GET /ready request racing +// startGatewayAndServe's completion must never observe a successful status +// against a snapshot that still reports an earlier phase, so the phase must +// reach Ready before the readiness flag flips, never the reverse. +func TestMarkStartupReadySetsPhaseBeforePublishingReadiness(t *testing.T) { + var order []string + + markStartupReady( + func() { order = append(order, "phase") }, + func() { order = append(order, "publish") }, + ) + + want := []string{"phase", "publish"} + if !reflect.DeepEqual(order, want) { + t.Fatalf("markStartupReady order = %v, want %v", order, want) + } +} + func TestShouldLogStartupOrchestratorErrorSkipsExpectedShutdownErrors(t *testing.T) { for _, test := range []struct { name string diff --git a/apps/backend/internal/backendapp/startup_page.go b/apps/backend/internal/backendapp/startup_page.go new file mode 100644 index 0000000000..566ecdd754 --- /dev/null +++ b/apps/backend/internal/backendapp/startup_page.go @@ -0,0 +1,464 @@ +package backendapp + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "net/http" + "strings" + + "github.com/kandev/kandev/internal/i18n" + "github.com/kandev/kandev/internal/startup" +) + +// writeStartupPage renders the self-contained HTML startup page +// (AC-PLATFORM-STARTUP-PROGRESS-003.1) directly from the in-process +// reporter snapshot. It embeds no bundle: markup, styling, and the polling +// script that keeps the page live all live in startupPageTemplate. The +// caller (newBootstrapHandler) has already decided the request qualifies +// per AC-003.3. +func writeStartupPage(w http.ResponseWriter, r *http.Request, snap startup.Snapshot) { + locale := i18n.FromRequest(r) + view := buildStartupPageView(locale, snap) + + var buf bytes.Buffer + if err := startupPageTemplate.Execute(&buf, view); err != nil { + writeBootstrapJSON(w, http.StatusServiceUnavailable, map[string]any{ + statusKey: startingStatus, + serviceFieldKey: kandevName, + }) + return + } + + // AC-PLATFORM-STARTUP-PROGRESS-003.12: no-store so no intermediary keeps + // serving this page after startup completes. Status stays 503 throughout, + // matching every other bootstrap-handler branch. + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write(buf.Bytes()) +} + +// startupStepView is the rendered form of one StepSnapshot, resolved for a +// single locale so the template stays free of measure-branching logic. +type startupStepView struct { + Label string + Progress string + ETA string + HasETA bool + HasBar bool + PercentDoneStr string + Stalled bool + StalledText string +} + +// startupPageView is the html/template data for startupPageTemplate. +type startupPageView struct { + Locale string + Title string + PhaseLabel string + Elapsed string + HasStep bool + Step startupStepView + DataJSON template.JS +} + +func buildStartupPageView(locale string, snap startup.Snapshot) startupPageView { + view := startupPageView{ + Locale: locale, + Title: i18n.T(locale, "startup.page.title"), + PhaseLabel: i18n.T(locale, "startup.phase."+string(snap.Phase)), + Elapsed: formatElapsed(locale, snap.ElapsedMS), + } + if snap.Step != nil { + view.HasStep = true + view.Step = buildStepView(locale, snap.Step) + } + view.DataJSON = marshalStartupData(locale, snap) + return view +} + +func buildStepView(locale string, step *startup.StepSnapshot) startupStepView { + view := startupStepView{Label: i18n.T(locale, step.LabelKey)} + switch step.Measure { + case startup.MeasureOpaque: + view.Progress = i18n.T(locale, "startup.page.progressUnavailable") + case startup.MeasureCounting: + view.Progress = countingProgressText(locale, step) + case startup.MeasureCounted: + applyCountedProgress(locale, step, &view) + } + if step.Stalled && step.SinceAdvanceMS != nil { + view.Stalled = true + view.StalledText = i18n.Tf(locale, "startup.page.stalled", map[string]any{tfVarDuration: formatDuration(locale, *step.SinceAdvanceMS)}) + } + return view +} + +// Tf variable names, factored out because the i18n placeholder names +// ("{{count}}", "{{total}}", ...) that the catalog strings interpolate +// against must match these literals exactly. +const ( + tfVarCount = "count" + tfVarTotal = "total" + tfVarDone = "done" + tfVarUnit = "unit" + tfVarDuration = "duration" +) + +func countingProgressText(locale string, step *startup.StepSnapshot) string { + done := stepDone(step) + unit := i18n.Tf(locale, "startup.unit."+string(step.Unit), map[string]any{tfVarCount: done}) + return i18n.Tf(locale, "startup.page.countingProgress", map[string]any{tfVarDone: done, tfVarUnit: unit}) +} + +func applyCountedProgress(locale string, step *startup.StepSnapshot, view *startupStepView) { + done, total := stepDone(step), stepTotal(step) + unit := i18n.Tf(locale, "startup.unit."+string(step.Unit), map[string]any{tfVarCount: total}) + view.Progress = i18n.Tf(locale, "startup.page.countedProgress", map[string]any{ + tfVarDone: done, tfVarTotal: total, tfVarUnit: unit, + }) + if total > 0 { + view.HasBar = true + view.PercentDoneStr = fmt.Sprintf("%.2f", percentDone(done, total)) + } + if step.ETAMS != nil { + view.HasETA = true + view.ETA = i18n.Tf(locale, "startup.page.eta", map[string]any{tfVarDuration: formatDuration(locale, *step.ETAMS)}) + } +} + +func stepDone(step *startup.StepSnapshot) int64 { + if step.Done == nil { + return 0 + } + return *step.Done +} + +func stepTotal(step *startup.StepSnapshot) int64 { + if step.Total == nil { + return 0 + } + return *step.Total +} + +func percentDone(done, total int64) float64 { + pct := float64(done) / float64(total) * 100 + switch { + case pct < 0: + return 0 + case pct > 100: + return 100 + default: + return pct + } +} + +// formatDuration mirrors startup.EstimateComponents's rounding rule +// (AC-PLATFORM-STARTUP-PROGRESS-003.10): whole seconds rounded up with a +// floor of one second at or below 60000 ms, whole minutes rounded up above +// that. The embedded poll script (startupPageScript) re-implements the same +// rule in JavaScript for subsequent reads, since the page has no bundle to +// share Go code with. +func formatDuration(locale string, ms int64) string { + value, minutes := startup.EstimateComponents(ms) + key := "startup.duration.seconds" + if minutes { + key = "startup.duration.minutes" + } + return i18n.Tf(locale, key, map[string]any{tfVarCount: value}) +} + +func formatElapsed(locale string, ms int64) string { + return i18n.Tf(locale, "startup.page.elapsed", map[string]any{tfVarDuration: formatDuration(locale, ms)}) +} + +// startupPageKeys lists every catalog key the page's initial render or its +// client-side re-poll can need. Keeping it explicit (rather than embedding +// the whole catalog) means the translations island carries only what this +// page uses. +func startupPageKeys() []string { + keys := []string{ + "startup.page.title", "startup.page.waiting", "startup.page.lastKnown", + "startup.page.progressUnavailable", "startup.page.countedProgress", + "startup.page.countingProgress", "startup.page.elapsed", "startup.page.eta", + "startup.page.stalled", + "startup.phase.opening_database", "startup.phase.backing_up_database", + "startup.phase.applying_migrations", "startup.phase.initializing_services", + "startup.phase.recovering_sessions", "startup.phase.ready", + "startup.duration.seconds_one", "startup.duration.seconds_other", + "startup.duration.minutes_one", "startup.duration.minutes_other", + } + for _, unit := range []startup.Unit{ + startup.UnitRows, startup.UnitMessages, startup.UnitTurns, + startup.UnitSessions, startup.UnitBytes, startup.UnitStores, + } { + keys = append(keys, "startup.unit."+string(unit)+"_one", "startup.unit."+string(unit)+"_other") + } + for _, spec := range startup.Registry() { + keys = append(keys, spec.LabelKey) + } + return keys +} + +func translationsFor(locale string) map[string]string { + out := make(map[string]string, len(startupPageKeys())) + for _, key := range startupPageKeys() { + out[key] = i18n.T(locale, key) + } + return out +} + +// marshalStartupData embeds the resolved translations plus the initial +// snapshot as a JSON island the poll script reads on load, so every +// subsequent /ready read can be rendered client-side without another +// server round trip for markup. The " + + + + + +{{.Title}} + + + +
+

{{.Title}}

+
+

{{.PhaseLabel}}

+

{{.Elapsed}}

+

{{.Step.Label}}

+

{{.Step.Progress}}

+

{{.Step.ETA}}

+
+

{{.Step.StalledText}}

+ +
+
+ + + + +` + +// startupPageScript keeps the page live after the initial server render. +// It polls /ready on the AC-PLATFORM-STARTUP-PROGRESS-003.2 cadence — one +// request in flight, abandoned after 5000 ms, any tick skipped while one is +// outstanding — and never gives up while the page is open. It reloads once +// the polled snapshot itself reports the ready phase (not merely a +// successful HTTP status, since AC-PLATFORM-STARTUP-PROGRESS-002.3 allows a +// post-startup degraded response that still carries a ready snapshot). +const startupPageScript = ` +(function () { + "use strict"; + var POLL_MS = 1000; + var TIMEOUT_MS = 5000; + var dataEl = document.getElementById("startup-data"); + var data = JSON.parse(dataEl.textContent); + var t = data.translations; + var inFlight = false; + + var phases = { + opening_database: true, + backing_up_database: true, + applying_migrations: true, + initializing_services: true, + recovering_sessions: true, + ready: true + }; + var measures = { opaque: true, counting: true, counted: true }; + var units = { rows: true, messages: true, turns: true, sessions: true, bytes: true, stores: true }; + + function nonNegativeInteger(value) { + return typeof value === "number" && isFinite(value) && value >= 0 && Math.floor(value) === value; + } + + function nonNegativeNumber(value) { + return typeof value === "number" && isFinite(value) && value >= 0; + } + + function validStartupStep(step) { + if (!step || typeof step !== "object" || + typeof step.id !== "string" || !step.id || + typeof step.label_key !== "string" || !step.label_key || + !measures[step.measure] || !units[step.unit] || + !nonNegativeInteger(step.elapsed_ms) || typeof step.stalled !== "boolean") { + return false; + } + for (var i = 0; i < ["done", "total", "eta_ms", "since_advance_ms"].length; i++) { + var key = ["done", "total", "eta_ms", "since_advance_ms"][i]; + if (step[key] !== undefined && !nonNegativeInteger(step[key])) return false; + } + return step.rate_per_second === undefined || nonNegativeNumber(step.rate_per_second); + } + + function validStartupSnapshot(snapshot) { + if (!snapshot || typeof snapshot !== "object" || !phases[snapshot.phase] || + !nonNegativeInteger(snapshot.boot) || !nonNegativeInteger(snapshot.seq) || + !nonNegativeInteger(snapshot.elapsed_ms) || !nonNegativeInteger(snapshot.phase_elapsed_ms)) { + return false; + } + return snapshot.step === undefined || validStartupStep(snapshot.step); + } + + function el(id) { return document.getElementById(id); } + function setText(id, text) { var e = el(id); if (e) e.textContent = text || ""; } + function toggle(id, show) { var e = el(id); if (e) e.classList.toggle("hidden", !show); } + + function interpolate(message, vars) { + if (!message) return ""; + return message.replace(/\{\{(\w+)\}\}/g, function (_, name) { + return Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : ""; + }); + } + + function pluralLookup(base, count) { + var key = base + (count === 1 ? "_one" : "_other"); + return t[key] || t[base] || ""; + } + + function ceilDiv(numerator, denominator) { + if (numerator <= 0) return 0; + return Math.floor((numerator + denominator - 1) / denominator); + } + + function estimateComponents(ms) { + if (ms <= 60000) { + var seconds = ceilDiv(ms, 1000); + if (seconds < 1) seconds = 1; + return { value: seconds, minutes: false }; + } + return { value: ceilDiv(ms, 60000), minutes: true }; + } + + function durationLabel(ms) { + var c = estimateComponents(ms); + var key = c.minutes ? "startup.duration.minutes" : "startup.duration.seconds"; + return interpolate(pluralLookup(key, c.value), { count: c.value }); + } + + function unitLabel(unit, count) { + return pluralLookup("startup.unit." + unit, count); + } + + function progressText(step) { + if (step.measure === "opaque") return t["startup.page.progressUnavailable"]; + if (step.measure === "counting") { + var done = step.done || 0; + return interpolate(t["startup.page.countingProgress"], { done: done, unit: unitLabel(step.unit, done) }); + } + var counted = step.done || 0; + var total = step.total || 0; + return interpolate(t["startup.page.countedProgress"], { done: counted, total: total, unit: unitLabel(step.unit, total) }); + } + + function renderStep(step) { + toggle("startup-step-label", !!step); + toggle("startup-progress", !!step); + toggle("startup-eta", false); + toggle("startup-bar", false); + toggle("startup-stalled", false); + if (!step) return; + setText("startup-step-label", t[step.label_key]); + setText("startup-progress", progressText(step)); + if (step.measure === "counted" && step.eta_ms != null) { + setText("startup-eta", interpolate(t["startup.page.eta"], { duration: durationLabel(step.eta_ms) })); + toggle("startup-eta", true); + } + if (step.measure === "counted" && step.total) { + var pct = Math.max(0, Math.min(100, (step.done || 0) / step.total * 100)); + var fill = el("startup-bar-fill"); + if (fill) fill.style.width = pct + "%"; + toggle("startup-bar", true); + } + if (step.stalled && step.since_advance_ms != null) { + setText("startup-stalled", interpolate(t["startup.page.stalled"], { duration: durationLabel(step.since_advance_ms) })); + toggle("startup-stalled", true); + } + } + + function render(snapshot) { + setText("startup-phase", t["startup.phase." + snapshot.phase]); + setText("startup-elapsed", interpolate(t["startup.page.elapsed"], { duration: durationLabel(snapshot.elapsed_ms) })); + renderStep(snapshot.step || null); + } + + function markLastKnown(isLastKnown) { + setText("startup-last-known", isLastKnown ? t["startup.page.lastKnown"] : ""); + toggle("startup-last-known", isLastKnown); + } + + function handleBody(ok, body) { + var snapshot = body && body.startup; + if (!validStartupSnapshot(snapshot)) { + // AC-PLATFORM-STARTUP-PROGRESS-002.6: no parseable snapshot means the + // status code is authoritative. + if (ok) { window.location.reload(); return; } + markLastKnown(true); + return; + } + markLastKnown(false); + if (snapshot.phase === "ready") { window.location.reload(); return; } + render(snapshot); + } + + function poll() { + if (inFlight) return; + inFlight = true; + var controller = ("AbortController" in window) ? new AbortController() : null; + var timeoutId = controller ? setTimeout(function () { controller.abort(); }, TIMEOUT_MS) : null; + fetch("/ready", { cache: "no-store", signal: controller ? controller.signal : undefined }) + .then(function (res) { + return res + .json() + .then(function (body) { return { ok: res.ok, body: body }; }) + .catch(function () { return { ok: res.ok, body: null }; }); + }) + .then(function (result) { + if (timeoutId) clearTimeout(timeoutId); + inFlight = false; + handleBody(result.ok, result.body); + }) + .catch(function () { + if (timeoutId) clearTimeout(timeoutId); + inFlight = false; + markLastKnown(true); + }); + } + + setInterval(poll, POLL_MS); +})(); +` diff --git a/apps/backend/internal/backendapp/startup_page_test.go b/apps/backend/internal/backendapp/startup_page_test.go new file mode 100644 index 0000000000..fd4cca4072 --- /dev/null +++ b/apps/backend/internal/backendapp/startup_page_test.go @@ -0,0 +1,262 @@ +package backendapp + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/kandev/kandev/internal/startup" +) + +func int64Ptr(v int64) *int64 { return &v } + +// @covers AC-PLATFORM-STARTUP-PROGRESS-003.13 +func TestBuildStartupPageViewNoStep(t *testing.T) { + view := buildStartupPageView("en", startup.Snapshot{Phase: startup.ApplyingMigrations, ElapsedMS: 4200}) + if view.HasStep { + t.Fatal("no-step snapshot must not render a step") + } + if view.PhaseLabel != "Applying migrations" { + t.Fatalf("phase label = %q, want %q", view.PhaseLabel, "Applying migrations") + } + if view.Elapsed != "5 seconds elapsed" { + t.Fatalf("elapsed = %q, want the rounded-up elapsed string", view.Elapsed) + } +} + +func TestBuildStartupPageViewOpaqueStep(t *testing.T) { + snap := startup.Snapshot{ + Phase: startup.ApplyingMigrations, + Step: &startup.StepSnapshot{ + LabelKey: "startup.step.prompt_seq", + Measure: startup.MeasureOpaque, + Unit: startup.UnitRows, + }, + } + view := buildStartupPageView("en", snap) + if !view.HasStep { + t.Fatal("want a step") + } + if view.Step.Label != "Prompt sequence numbers" { + t.Fatalf("step label = %q", view.Step.Label) + } + if view.Step.Progress != "Progress cannot be measured for this step." { + t.Fatalf("opaque progress = %q", view.Step.Progress) + } + if view.Step.HasBar || view.Step.HasETA { + t.Fatal("opaque step must render no bar and no ETA") + } +} + +func TestBuildStartupPageViewCountingStep(t *testing.T) { + snap := startup.Snapshot{ + Phase: startup.BackingUpDatabase, + Step: &startup.StepSnapshot{ + LabelKey: "startup.step.database_backup", + Measure: startup.MeasureCounting, + Unit: startup.UnitBytes, + Done: int64Ptr(1), + }, + } + view := buildStartupPageView("en", snap) + if view.Step.Progress != "1 byte" { + t.Fatalf("counting progress = %q, want singular unit", view.Step.Progress) + } + if view.Step.HasBar { + t.Fatal("counting step (no total) must render no bar") + } +} + +func TestBuildStartupPageViewCountedStepWithETA(t *testing.T) { + snap := startup.Snapshot{ + Phase: startup.ApplyingMigrations, + Step: &startup.StepSnapshot{ + LabelKey: "startup.step.stores_repositories", + Measure: startup.MeasureCounted, + Unit: startup.UnitTurns, + Done: int64Ptr(5), + Total: int64Ptr(10), + ETAMS: int64Ptr(30000), + }, + } + view := buildStartupPageView("en", snap) + if view.Step.Progress != "5 of 10 turns" { + t.Fatalf("counted progress = %q, want plural unit from total", view.Step.Progress) + } + if !view.Step.HasBar || view.Step.PercentDoneStr != "50.00" { + t.Fatalf("bar = %v %q, want 50.00%%", view.Step.HasBar, view.Step.PercentDoneStr) + } + if !view.Step.HasETA || view.Step.ETA != "About 30 seconds remaining" { + t.Fatalf("eta = %v %q", view.Step.HasETA, view.Step.ETA) + } +} + +// @covers AC-PLATFORM-STARTUP-PROGRESS-004.6 +func TestBuildStartupPageViewStalledStep(t *testing.T) { + snap := startup.Snapshot{ + Phase: startup.ApplyingMigrations, + Step: &startup.StepSnapshot{ + LabelKey: "startup.step.stores_repositories", + Measure: startup.MeasureCounted, + Unit: startup.UnitTurns, + Done: int64Ptr(5), + Total: int64Ptr(10), + SinceAdvanceMS: int64Ptr(125000), + Stalled: true, + }, + } + view := buildStartupPageView("en", snap) + if !view.Step.Stalled { + t.Fatal("want the step view to report stalled") + } + if view.Step.StalledText != "Stalled for 3 minutes" { + t.Fatalf("stalled text = %q, want the rounded-up duration", view.Step.StalledText) + } +} + +// @covers AC-PLATFORM-STARTUP-PROGRESS-004.5 +func TestBuildStartupPageViewRunningStepHasNoStalledText(t *testing.T) { + snap := startup.Snapshot{ + Phase: startup.ApplyingMigrations, + Step: &startup.StepSnapshot{ + LabelKey: "startup.step.stores_repositories", + Measure: startup.MeasureCounted, + Unit: startup.UnitTurns, + Done: int64Ptr(5), + Total: int64Ptr(10), + SinceAdvanceMS: int64Ptr(500), + Stalled: false, + }, + } + view := buildStartupPageView("en", snap) + if view.Step.Stalled || view.Step.StalledText != "" { + t.Fatalf("running step must render no stalled state, got Stalled=%v StalledText=%q", view.Step.Stalled, view.Step.StalledText) + } +} + +func TestBuildStartupPageViewCountedStepSingularTotal(t *testing.T) { + snap := startup.Snapshot{ + Phase: startup.ApplyingMigrations, + Step: &startup.StepSnapshot{ + LabelKey: "startup.step.stores_repositories", + Measure: startup.MeasureCounted, + Unit: startup.UnitTurns, + Done: int64Ptr(1), + Total: int64Ptr(1), + }, + } + view := buildStartupPageView("en", snap) + if view.Step.Progress != "1 of 1 turn" { + t.Fatalf("counted progress = %q, want singular unit from total=1", view.Step.Progress) + } +} + +func TestWriteStartupPageHeadersAndStatus(t *testing.T) { + w := httptest.NewRecorder() + writeStartupPage(w, httptest.NewRequest("GET", "/", nil), startup.Snapshot{Phase: startup.OpeningDatabase}) + + if w.Code != 503 { + t.Fatalf("status = %d, want 503", w.Code) + } + if got := w.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("Cache-Control = %q, want no-store", got) + } + if got := w.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") { + t.Fatalf("Content-Type = %q, want text/html", got) + } + body := w.Body.String() + if !strings.Contains(body, "Opening database") { + t.Fatalf("body missing phase label: %s", body) + } + if !strings.Contains(body, `role="status" aria-live="polite"`) { + t.Fatal("body missing the live region required by AC-PLATFORM-STARTUP-PROGRESS-003.11") + } + if !strings.Contains(body, "prefers-reduced-motion") { + t.Fatal("body missing the reduced-motion accommodation required by AC-PLATFORM-STARTUP-PROGRESS-003.11") + } + + dataStart := strings.Index(body, `") + if dataEnd < 0 { + t.Fatal("unterminated startup-data script") + } + var island struct { + Translations map[string]string `json:"translations"` + Snapshot startup.Snapshot `json:"snapshot"` + } + if err := json.Unmarshal([]byte(body[dataStart:dataStart+dataEnd]), &island); err != nil { + t.Fatalf("startup-data island did not parse as JSON: %v", err) + } + if island.Snapshot.Phase != startup.OpeningDatabase { + t.Fatalf("embedded snapshot phase = %q, want %q", island.Snapshot.Phase, startup.OpeningDatabase) + } + if island.Translations["startup.phase.opening_database"] != "Opening database" { + t.Fatalf("embedded translations missing phase key, got %#v", island.Translations) + } +} + +// @covers AC-PLATFORM-STARTUP-PROGRESS-003.3 +func TestNewBootstrapHandlerServesStartupPageForHTMLPreferringSPARequest(t *testing.T) { + handler := newBootstrapHandler("test") + req := httptest.NewRequest("GET", "/tasks", nil) + req.Header.Set("Accept", "text/html") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if got := w.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") { + t.Fatalf("Content-Type = %q, want text/html for an HTML-preferring SPA route request", got) + } +} + +func TestNewBootstrapHandlerKeepsJSONForReadyEvenWhenHTMLPreferred(t *testing.T) { + handler := newBootstrapHandler("test") + req := httptest.NewRequest("GET", "/ready", nil) + req.Header.Set("Accept", "text/html") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if got := w.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") { + t.Fatalf("Content-Type = %q, want JSON: /ready must keep answering the snapshot even when HTML is preferred", got) + } +} + +func TestNewBootstrapHandlerKeepsJSONForNonSPARoute(t *testing.T) { + handler := newBootstrapHandler("test") + req := httptest.NewRequest("GET", "/api/v1/features", nil) + req.Header.Set("Accept", "text/html") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if got := w.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") { + t.Fatalf("Content-Type = %q, want JSON for a non-application route", got) + } +} + +func TestNewBootstrapHandlerKeepsJSONWhenJSONPreferred(t *testing.T) { + handler := newBootstrapHandler("test") + req := httptest.NewRequest("GET", "/tasks", nil) + req.Header.Set("Accept", "application/json") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if got := w.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") { + t.Fatalf("Content-Type = %q, want JSON when the client prefers it", got) + } +} + +func TestNewBootstrapHandlerKeepsJSONForNonGETMethod(t *testing.T) { + handler := newBootstrapHandler("test") + req := httptest.NewRequest("POST", "/tasks", nil) + req.Header.Set("Accept", "text/html") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if got := w.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") { + t.Fatalf("Content-Type = %q, want JSON for a non-GET method", got) + } +} diff --git a/apps/backend/internal/backendapp/storage.go b/apps/backend/internal/backendapp/storage.go index b16ebc209e..2016ee5eee 100644 --- a/apps/backend/internal/backendapp/storage.go +++ b/apps/backend/internal/backendapp/storage.go @@ -61,35 +61,45 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo cleanups = append(cleanups, cleanup) startup.SetPhase(ctx, startup.ApplyingMigrations) writer, reader := pool.Writer(), pool.Reader() - if err := checkStartupContext(ctx, "schema metadata"); err != nil { - return nil, nil, nil, err - } - if err := recordRequiredStore(tracker, "schema-meta", nil); err != nil { - return nil, nil, nil, err - } - if err := checkStartupContext(ctx, "task repository"); err != nil { return nil, nil, nil, err } - taskRepoImpl, cleanup, err := repository.ProvideContext(ctx, writer, reader, log) - if err := recordRequiredStore(tracker, "task", err); err != nil { + // repository.ProvideContext's own migrations and backfills open and + // close several of their own steps under this same ApplyingMigrations + // phase (journal turns/messages, prompt_seq, message_timestamps, + // subagent_context). It must finish before the sweep step below opens: + // steps must never overlap, so any of those steps beginning while + // stores.repositories was already active would silently end the sweep + // and turn every later recordRequiredStore call in this function into a + // no-op against a step that is no longer current. + taskRepoImpl, cleanup, taskRepoErr := repository.ProvideContext(ctx, writer, reader, log) + cleanups = append(cleanups, cleanup) + + startup.BeginStep(ctx, startup.StepStoresRepositories) + startup.SetTotal(ctx, startup.StepStoresRepositories, int64(tracker.SweepTotal(startup.StepStoresRepositories))) + if err := checkStartupContext(ctx, "schema metadata"); err != nil { + return nil, nil, nil, err + } + if err := recordRequiredStore(ctx, tracker, "schema-meta", nil); err != nil { + return nil, nil, nil, err + } + if err := recordRequiredStore(ctx, tracker, "task", taskRepoErr); err != nil { return nil, nil, nil, fmt.Errorf("task store: %w", err) } - cleanups = append(cleanups, cleanup) // Workflow repo must be initialized before analytics repo because // analytics creates indexes on the workflow_steps table. if err := checkStartupContext(ctx, "workflow repository"); err != nil { return nil, nil, nil, err } workflowRepo, err := workflowrepository.NewWithDB(writer, reader, log) - if err := recordRequiredStore(tracker, "workflow", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "workflow", err); err != nil { return nil, nil, nil, fmt.Errorf("workflow store: %w", err) } if err := checkStartupContext(ctx, "analytics repository"); err != nil { return nil, nil, nil, err } analyticsRepo, cleanup, err := analyticsrepository.Provide(writer, reader) - if err := recordRequiredStore(tracker, "analytics", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "analytics", err); err != nil { return nil, nil, nil, fmt.Errorf("analytics store: %w", err) } cleanups = append(cleanups, cleanup) @@ -97,7 +107,7 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo return nil, nil, nil, err } agentSettingsRepo, cleanup, err := settingsstore.Provide(writer, reader, log) - if err := recordRequiredStore(tracker, "agent-settings", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "agent-settings", err); err != nil { return nil, nil, nil, fmt.Errorf("agent settings store: %w", err) } cleanups = append(cleanups, cleanup) @@ -114,7 +124,7 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo return nil, nil, nil, err } officeRepo, officeCleanup, err := office.Provide(writer, reader, log) - if err := recordRequiredStore(tracker, "office", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "office", err); err != nil { return nil, nil, nil, fmt.Errorf("office repo: %w", err) } cleanups = append(cleanups, officeCleanup) @@ -122,21 +132,21 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo return nil, nil, nil, err } terminalRepoImpl, err := terminalrepo.NewWithDB(writer, reader, log) - if err := recordRequiredStore(tracker, "terminal", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "terminal", err); err != nil { return nil, nil, nil, fmt.Errorf("terminal repo: %w", err) } if err := checkStartupContext(ctx, "quick terminal repository"); err != nil { return nil, nil, nil, err } quickTerminalRepoImpl, err := quickterminalrepository.NewWithDB(writer, reader) - if err := recordRequiredStore(tracker, "quick-terminal", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "quick-terminal", err); err != nil { return nil, nil, nil, fmt.Errorf("quick terminal repo: %w", err) } if err := checkStartupContext(ctx, "runtime flags repository"); err != nil { return nil, nil, nil, err } runtimeFlagsStore, err := runtimeflags.NewSQLiteStore(writer, reader) - if err := recordRequiredStore(tracker, "runtime-flags", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "runtime-flags", err); err != nil { return nil, nil, nil, fmt.Errorf("runtime flags store: %w", err) } @@ -144,7 +154,7 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo return nil, nil, nil, err } authRepo, err := authstore.New(writer, reader) - if err := recordRequiredStore(tracker, "auth", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "auth", err); err != nil { return nil, nil, nil, fmt.Errorf("auth store: %w", err) } if err := checkStartupContext(ctx, "secret repository"); err != nil { @@ -158,7 +168,7 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo return nil, nil, nil, err } secretStore, cleanup, err := secrets.Provide(writer, reader, masterKeyProvider) - if err := recordRequiredStore(tracker, "secrets", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "secrets", err); err != nil { return nil, nil, nil, fmt.Errorf("secret store: %w", err) } cleanups = append(cleanups, cleanup) @@ -167,14 +177,14 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo return nil, nil, nil, err } systemSettings, err := systemsettings.NewStore(pool) - if err := recordRequiredStore(tracker, "system-settings", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "system-settings", err); err != nil { return nil, nil, nil, fmt.Errorf("system settings store: %w", err) } if err := checkStartupContext(ctx, "hostname repository"); err != nil { return nil, nil, nil, err } hostnameCache, err := hostnames.NewStore(writer, reader) - if err := recordRequiredStore(tracker, "auth-hostnames", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "auth-hostnames", err); err != nil { return nil, nil, nil, fmt.Errorf("hostname cache store: %w", err) } @@ -182,7 +192,7 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo return nil, nil, nil, err } telemetryStore, err := telemetrycontract.NewWithDB(writer, reader) - if err := recordRequiredStore(tracker, "telemetry-contract", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "telemetry-contract", err); err != nil { return nil, nil, nil, fmt.Errorf("telemetry contract store: %w", err) } activateTelemetryContracts(ctx, telemetryStore, log) @@ -212,7 +222,10 @@ func provideRepositories(ctx context.Context, cfg *config.Config, log *logger.Lo return nil, nil, nil, err } initialized = true + startup.EndStep(ctx, startup.StepStoresRepositories) startup.SetPhase(ctx, startup.InitializingServices) + startup.BeginStep(ctx, startup.StepStoresServices) + startup.SetTotal(ctx, startup.StepStoresServices, int64(tracker.SweepTotal(startup.StepStoresServices))) return pool, repos, cleanups, nil } @@ -246,7 +259,7 @@ func provideSupportRepos(ctx context.Context, writer, reader *sqlx.DB, tracker * } userRepo, cleanup, err := userstore.Provide(writer, reader) cleanups = append(cleanups, cleanup) - if err := recordRequiredStore(tracker, "user", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "user", err); err != nil { return repos, cleanups, err } repos.user = userRepo @@ -258,7 +271,7 @@ func provideSupportRepos(ctx context.Context, writer, reader *sqlx.DB, tracker * } notificationRepo, cleanup, err := notificationstore.Provide(ctx, writer, reader) cleanups = append(cleanups, cleanup) - if err := recordRequiredStore(tracker, "notification", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "notification", err); err != nil { return repos, cleanups, err } repos.notification = notificationRepo @@ -268,7 +281,7 @@ func provideSupportRepos(ctx context.Context, writer, reader *sqlx.DB, tracker * } editorRepo, cleanup, err := editorstore.Provide(writer, reader) cleanups = append(cleanups, cleanup) - if err := recordRequiredStore(tracker, "editor", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "editor", err); err != nil { return repos, cleanups, err } repos.editor = editorRepo @@ -278,7 +291,7 @@ func provideSupportRepos(ctx context.Context, writer, reader *sqlx.DB, tracker * } promptRepo, cleanup, err := promptstore.Provide(writer, reader) cleanups = append(cleanups, cleanup) - if err := recordRequiredStore(tracker, "prompts", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "prompts", err); err != nil { return repos, cleanups, err } repos.prompts = promptRepo @@ -288,7 +301,7 @@ func provideSupportRepos(ctx context.Context, writer, reader *sqlx.DB, tracker * } utilityRepo, cleanup, err := utilitystore.Provide(writer, reader) cleanups = append(cleanups, cleanup) - if err := recordRequiredStore(tracker, "utility", err); err != nil { + if err := recordRequiredStore(ctx, tracker, "utility", err); err != nil { return repos, cleanups, err } repos.utility = utilityRepo @@ -297,14 +310,19 @@ func provideSupportRepos(ctx context.Context, writer, reader *sqlx.DB, tracker * } // recordRequiredStore records a constructor result before the process can -// expose readiness. Initialization failures remain fatal to the caller. -func recordRequiredStore(tracker *requiredstores.Tracker, id string, initErr error) error { +// expose readiness. Initialization failures remain fatal to the caller. A +// successful admission advances the store-admission sweep step its catalog +// descriptor names, by one. +func recordRequiredStore(ctx context.Context, tracker *requiredstores.Tracker, id string, initErr error) error { if trackerErr := tracker.Record(id, initErr); trackerErr != nil { return trackerErr } if initErr != nil { return fmt.Errorf("required store %q: %w", id, initErr) } + if sweep, ok := tracker.DescriptorSweep(id); ok { + startup.Advance(ctx, sweep, 1) + } return nil } diff --git a/apps/backend/internal/backendapp/storage_maintenance.go b/apps/backend/internal/backendapp/storage_maintenance.go index 9adcb451d8..711399c474 100644 --- a/apps/backend/internal/backendapp/storage_maintenance.go +++ b/apps/backend/internal/backendapp/storage_maintenance.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/persistence/requiredstores" + "github.com/kandev/kandev/internal/startup" "github.com/kandev/kandev/internal/system/jobs" systemmetrics "github.com/kandev/kandev/internal/system/metrics" systemsettings "github.com/kandev/kandev/internal/system/settings" @@ -58,20 +59,29 @@ type storageDependencies struct { providers []storagepkg.CleanupProvider } -func provideStorageComposition( - cfg *config.Config, +// provideStorageStore constructs the storage system's persistence store and +// closes stores.services's required-store sweep. storage is stores. +// services's last admission chronologically (after services.go, +// orchestrator.go, and main.go's delivery), so this is where the sweep step +// closes. Callers must run this before sessions.recovery's BeginStep: steps +// must never overlap, and BeginStep unconditionally ends whatever step is +// currently active, which would silently truncate the sweep. +func provideStorageStore( + ctx context.Context, pool *db.Pool, - tracker *jobs.Tracker, - eventBus bus.EventBus, - lifecycleMgr *lifecycle.Manager, - worktreeMgr *worktree.Manager, - taskSvc *taskservice.Service, - log *logger.Logger, - logError func(string, error), -) (*storageComposition, error) { - return provideStorageCompositionWithDependencies( - cfg, pool, tracker, eventBus, lifecycleMgr, worktreeMgr, taskSvc, log, logError, nil, nil, - ) + requiredTracker *requiredstores.Tracker, +) (*storagepkg.Store, error) { + store, err := storagepkg.NewStore(pool) + if requiredTracker != nil { + if recordErr := recordRequiredStore(ctx, requiredTracker, "storage", err); recordErr != nil { + return nil, fmt.Errorf("initialize storage store: %w", recordErr) + } + startup.EndStep(ctx, startup.StepStoresServices) + } + if err != nil { + return nil, fmt.Errorf("initialize storage store: %w", err) + } + return store, nil } func provideStorageCompositionWithDependencies( @@ -84,11 +94,11 @@ func provideStorageCompositionWithDependencies( taskSvc *taskservice.Service, log *logger.Logger, logError func(string, error), - requiredTracker *requiredstores.Tracker, + store *storagepkg.Store, providedSettings *systemsettings.Store, ) (*storageComposition, error) { dependencies, err := prepareStorageDependencies( - cfg, pool, requiredTracker, eventBus, lifecycleMgr, worktreeMgr, taskSvc, log, logError, providedSettings, + cfg, pool, store, eventBus, lifecycleMgr, worktreeMgr, taskSvc, log, logError, providedSettings, ) if err != nil { return nil, err @@ -136,7 +146,7 @@ func provideStorageCompositionWithDependencies( func prepareStorageDependencies( cfg *config.Config, pool *db.Pool, - requiredTracker *requiredstores.Tracker, + store *storagepkg.Store, eventBus bus.EventBus, lifecycleMgr *lifecycle.Manager, worktreeMgr *worktree.Manager, @@ -154,15 +164,6 @@ func prepareStorageDependencies( return nil, fmt.Errorf("initialize storage settings: %w", err) } settings := storagepkg.NewSettingsStore(rawSettings) - store, err := storagepkg.NewStore(pool) - if requiredTracker != nil { - if recordErr := recordRequiredStore(requiredTracker, "storage", err); recordErr != nil { - return nil, fmt.Errorf("initialize storage store: %w", recordErr) - } - } - if err != nil { - return nil, fmt.Errorf("initialize storage store: %w", err) - } tempArtifacts := tempartifacts.NewRegistry(tempartifacts.Config{Store: store, TempRoot: os.TempDir()}) if err := tempArtifacts.Reconcile(context.Background()); err != nil { logError("reconcile temporary artifact registry", err) diff --git a/apps/backend/internal/backendapp/storage_sweep_test.go b/apps/backend/internal/backendapp/storage_sweep_test.go new file mode 100644 index 0000000000..fc17ac3122 --- /dev/null +++ b/apps/backend/internal/backendapp/storage_sweep_test.go @@ -0,0 +1,263 @@ +package backendapp + +import ( + "context" + "errors" + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/persistence/requiredstores" + "github.com/kandev/kandev/internal/startup" +) + +// TestRecordRequiredStoreAdvancesItsDescriptorsSweep is the unit-level half +// of AC-PLATFORM-STARTUP-PROGRESS-005.7 ("recordRequiredStore advances the +// sweep its descriptor names, by one"): a successful admission advances the +// active sweep step by exactly one, and a failed admission - fatal to the +// caller - advances nothing. +func TestRecordRequiredStoreAdvancesItsDescriptorsSweep(t *testing.T) { + tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{ + {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, Sweep: startup.StepStoresRepositories}, + {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}, Sweep: startup.StepStoresRepositories}, + }) + if err != nil { + t.Fatalf("NewTracker: %v", err) + } + reporter := startup.New(nil) + ctx := startup.WithReporter(context.Background(), reporter) + reporter.BeginStep(startup.StepStoresRepositories) + reporter.SetTotal(startup.StepStoresRepositories, 2) + + if err := recordRequiredStore(ctx, tracker, "first", nil); err != nil { + t.Fatalf("recordRequiredStore(first): %v", err) + } + if done := *reporter.Snapshot().Step.Done; done != 1 { + t.Fatalf("Done after first admission = %d, want 1", done) + } + + failErr := errors.New("boom") + if err := recordRequiredStore(ctx, tracker, "second", failErr); err == nil { + t.Fatal("recordRequiredStore(second, failErr) = nil error, want failErr") + } + if done := *reporter.Snapshot().Step.Done; done != 1 { + t.Fatalf("Done after failed admission = %d, want unchanged at 1", done) + } +} + +// TestProvideRepositoriesReportsStoresRepositoriesSweep covers the +// stores.repositories half of AC-PLATFORM-STARTUP-PROGRESS-005.2/.7: every +// admission in provideRepositories (including the five inside +// provideSupportRepos) advances the sweep step its catalog descriptor names, +// and the sweep closes and hands off to stores.services at the same +// storage.go:215 boundary that already switches the phase - this is +// completeness test 4 (declared Phase matches the real call site) for these +// two steps, exercised end to end rather than centrally. +func TestProvideRepositoriesReportsStoresRepositoriesSweep(t *testing.T) { + cfg := &config.Config{ + HomeDir: t.TempDir(), + Database: config.DatabaseConfig{Driver: "sqlite"}, + } + reporter := startup.New(nil) + ctx := startup.WithReporter(context.Background(), reporter) + + _, _, cleanups, err := provideRepositories(ctx, cfg, newTestLogger(), "test") + if err != nil { + t.Fatalf("provideRepositories: %v", err) + } + t.Cleanup(func() { + for i := len(cleanups) - 1; i >= 0; i-- { + if cleanups[i] != nil { + _ = cleanups[i]() + } + } + }) + + wantRepositoriesTotal := int64(requiredstoresSweepTotal(t, startup.StepStoresRepositories)) + wantServicesTotal := int64(requiredstoresSweepTotal(t, startup.StepStoresServices)) + + snap := reporter.Snapshot() + if snap.Phase != startup.InitializingServices { + t.Fatalf("Phase = %q, want %q", snap.Phase, startup.InitializingServices) + } + if snap.Step == nil { + t.Fatal("Step = nil, want stores.services active after provideRepositories returns") + } + if snap.Step.ID != startup.StepStoresServices { + t.Fatalf("Step.ID = %q, want %q (stores.repositories must have closed at the InitializingServices boundary)", snap.Step.ID, startup.StepStoresServices) + } + if snap.Step.Done == nil || *snap.Step.Done != 0 { + t.Fatalf("Step.Done = %v, want 0 (stores.services has admitted nothing yet)", snap.Step.Done) + } + if snap.Step.Total == nil || *snap.Step.Total != wantServicesTotal { + t.Fatalf("Step.Total = %v, want %d", snap.Step.Total, wantServicesTotal) + } + if wantRepositoriesTotal != 19 { + t.Fatalf("stores.repositories catalog total = %d, want 19 (sanity check on the fixture)", wantRepositoriesTotal) + } +} + +// TestProvideRepositoriesStoresRepositoriesSweepReachesFullTotal covers the +// interleaving between the stores.repositories sweep and +// repository.ProvideContext's own migrations/backfills, which open and close +// several finer-grained steps (journal turns/messages, prompt_seq, +// message_timestamps, subagent_context) under the same ApplyingMigrations +// phase. On a fresh database (first-ever boot) every one of those backfill +// steps actually opens, so if stores.repositories's own BeginStep/EndStep +// window still overlapped theirs, its later recordRequiredStore admissions +// would silently no-op against a step that had already been ended out from +// under it, and it would never log "Startup step completed" at all. This +// asserts the sweep both logs its completion and reaches done == total == +// 19, proving every admission after the nested steps still landed. +func TestProvideRepositoriesStoresRepositoriesSweepReachesFullTotal(t *testing.T) { + cfg := &config.Config{ + HomeDir: t.TempDir(), + Database: config.DatabaseConfig{Driver: "sqlite"}, + } + core, logs := observer.New(zapcore.InfoLevel) + log, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("observer logger: %v", err) + } + reporter := startup.New(log) + ctx := startup.WithReporter(context.Background(), reporter) + + _, _, cleanups, err := provideRepositories(ctx, cfg, log, "test") + if err != nil { + t.Fatalf("provideRepositories: %v", err) + } + t.Cleanup(func() { + for i := len(cleanups) - 1; i >= 0; i-- { + if cleanups[i] != nil { + _ = cleanups[i]() + } + } + }) + + var sawNestedStep bool + var completedFields map[string]interface{} + for _, entry := range logs.All() { + fields := entry.ContextMap() + if entry.Message == "Startup step started" && fields["step"] != string(startup.StepStoresRepositories) { + sawNestedStep = true + } + if entry.Message == "Startup step completed" && fields["step"] == string(startup.StepStoresRepositories) { + completedFields = fields + } + } + if !sawNestedStep { + t.Fatal("no nested step opened during provideRepositories on a fresh database; this test no longer exercises the interleaving it targets") + } + if completedFields == nil { + t.Fatal(`no "Startup step completed" entry for stores.repositories: EndStep silently no-op'd against a step already ended by a nested BeginStep`) + } + wantTotal := int64(requiredstoresSweepTotal(t, startup.StepStoresRepositories)) + done, _ := completedFields["done"].(int64) + total, _ := completedFields["total"].(int64) + if done != wantTotal || total != wantTotal { + t.Fatalf("stores.repositories completed with done=%v total=%v, want both %d", done, total, wantTotal) + } +} + +// TestStoresServicesSweepReachesFullTotalAfterStorageAdmission is the +// stores.services sibling of +// TestProvideRepositoriesStoresRepositoriesSweepReachesFullTotal. storage is +// stores.services's last admission chronologically, constructed by +// provideStorageStore. Production runs provideStorageStore (and the plugins +// journal mirror sync that must follow it) immediately before +// sessions.recovery's BeginStep specifically so that BeginStep never finds +// stores.services still open: were the order reversed, BeginStep would +// force-close stores.services early and every admission recorded up to that +// point -- including storage's own -- would be silently lost. This asserts +// the sweep both logs its completion and reaches done == total == 22 when +// admitted in that order, with no interleaving step in between. +func TestStoresServicesSweepReachesFullTotalAfterStorageAdmission(t *testing.T) { + cfg := &config.Config{ + HomeDir: t.TempDir(), + Database: config.DatabaseConfig{Driver: "sqlite"}, + } + core, logs := observer.New(zapcore.InfoLevel) + log, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("observer logger: %v", err) + } + reporter := startup.New(log) + ctx := startup.WithReporter(context.Background(), reporter) + + pool, repos, cleanups, err := provideRepositories(ctx, cfg, log, "test") + if err != nil { + t.Fatalf("provideRepositories: %v", err) + } + t.Cleanup(func() { + for i := len(cleanups) - 1; i >= 0; i-- { + if cleanups[i] != nil { + _ = cleanups[i]() + } + } + }) + + agentRegistry, registryCleanup, err := registry.Provide(log) + if err != nil { + t.Fatalf("registry.Provide: %v", err) + } + t.Cleanup(func() { + if registryCleanup != nil { + _ = registryCleanup() + } + }) + + services, _, err := provideServices(ctx, cfg, log, repos, pool, bus.NewMemoryEventBus(log), agentRegistry, "test") + if err != nil { + t.Fatalf("provideServices: %v", err) + } + if services.PluginsCleanup != nil { + t.Cleanup(func() { _ = services.PluginsCleanup() }) + } + + // message-queue and delivery are recorded in production by + // provideOrchestrator and startAgentInfrastructure respectively; record + // them the same way here without standing up the full orchestrator and + // lifecycle manager. + if err := recordRequiredStore(ctx, repos.RequiredStores, "message-queue", nil); err != nil { + t.Fatalf("recordRequiredStore(message-queue): %v", err) + } + if err := recordRequiredStore(ctx, repos.RequiredStores, "delivery", nil); err != nil { + t.Fatalf("recordRequiredStore(delivery): %v", err) + } + + if _, err := provideStorageStore(ctx, pool, repos.RequiredStores); err != nil { + t.Fatalf("provideStorageStore: %v", err) + } + + var completedFields map[string]interface{} + for _, entry := range logs.All() { + fields := entry.ContextMap() + if entry.Message == "Startup step completed" && fields["step"] == string(startup.StepStoresServices) { + completedFields = fields + } + } + if completedFields == nil { + t.Fatal(`no "Startup step completed" entry for stores.services: EndStep silently no-op'd against a step already ended`) + } + wantTotal := int64(requiredstoresSweepTotal(t, startup.StepStoresServices)) + done, _ := completedFields["done"].(int64) + total, _ := completedFields["total"].(int64) + if done != wantTotal || total != wantTotal { + t.Fatalf("stores.services completed with done=%v total=%v, want both %d", done, total, wantTotal) + } +} + +func requiredstoresSweepTotal(t *testing.T, sweep startup.StepID) int { + t.Helper() + tracker, err := requiredstores.NewCatalogTracker() + if err != nil { + t.Fatalf("NewCatalogTracker: %v", err) + } + return tracker.SweepTotal(sweep) +} diff --git a/apps/backend/internal/i18n/accept_language_test.go b/apps/backend/internal/i18n/accept_language_test.go new file mode 100644 index 0000000000..76cb98da9a --- /dev/null +++ b/apps/backend/internal/i18n/accept_language_test.go @@ -0,0 +1,86 @@ +package i18n + +import ( + "net/http/httptest" + "testing" +) + +func TestParseAcceptLanguageExcludesUnparseableQValue(t *testing.T) { + t.Parallel() + got := parseAcceptLanguage("fr;q=not-a-number, en;q=0.5") + if len(got) != 1 || got[0] != "en" { + t.Fatalf("parseAcceptLanguage = %v, want only [en]: an unparseable q must exclude the tag, not promote it to 1.0", got) + } +} + +func TestParseAcceptLanguageExcludesZeroQValue(t *testing.T) { + t.Parallel() + got := parseAcceptLanguage("fr;q=0, en;q=0.5") + if len(got) != 1 || got[0] != "en" { + t.Fatalf("parseAcceptLanguage = %v, want only [en]: q=0 means not acceptable per RFC 7231", got) + } +} + +func TestParseAcceptLanguageExcludesNegativeQValue(t *testing.T) { + t.Parallel() + got := parseAcceptLanguage("fr;q=-1, en;q=0.5") + if len(got) != 1 || got[0] != "en" { + t.Fatalf("parseAcceptLanguage = %v, want only [en]", got) + } +} + +func TestParseAcceptLanguageExcludesQValueAboveOne(t *testing.T) { + t.Parallel() + got := parseAcceptLanguage("zh-cn;q=2, en;q=1") + if len(got) != 1 || got[0] != "en" { + t.Fatalf("parseAcceptLanguage = %v, want only [en]: q>1 is outside HTTP quality's [0,1] range and must not outrank a valid entry", got) + } +} + +func TestParseAcceptLanguageExcludesTrailingGarbageQValue(t *testing.T) { + t.Parallel() + got := parseAcceptLanguage("fr;q=1abc, en;q=0.5") + if len(got) != 1 || got[0] != "en" { + t.Fatalf("parseAcceptLanguage = %v, want only [en]: a numeric prefix followed by garbage must not parse as valid", got) + } +} + +func TestParseAcceptLanguageExcludesNonFiniteQValue(t *testing.T) { + t.Parallel() + got := parseAcceptLanguage("fr;q=NaN, en;q=0.5") + if len(got) != 1 || got[0] != "en" { + t.Fatalf("parseAcceptLanguage = %v, want only [en]: NaN must not enter the priority sort", got) + } +} + +func TestParseAcceptLanguageOrdersDescendingByQValue(t *testing.T) { + t.Parallel() + got := parseAcceptLanguage("en;q=0.3, zh-cn;q=0.9, pt-pt;q=0.6") + want := []string{"zh-cn", "pt-pt", "en"} + if len(got) != len(want) { + t.Fatalf("parseAcceptLanguage = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("parseAcceptLanguage = %v, want %v", got, want) + } + } +} + +func TestParseAcceptLanguageDefaultsMissingQTo1(t *testing.T) { + t.Parallel() + got := parseAcceptLanguage("en, zh-cn;q=0.5") + want := []string{"en", "zh-cn"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("parseAcceptLanguage = %v, want %v", got, want) + } +} + +func TestFromRequestFallsThroughAMalformedQValueToTheNextTag(t *testing.T) { + t.Parallel() + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Accept-Language", "fr;q=bogus, zh-cn;q=0.8") + if got := FromRequest(r); got != "zh-cn" { + t.Fatalf("FromRequest = %q, want %q: a malformed q must not win priority", got, "zh-cn") + } +} diff --git a/apps/backend/internal/i18n/i18n.go b/apps/backend/internal/i18n/i18n.go index 1cf18a2611..168becc2c3 100644 --- a/apps/backend/internal/i18n/i18n.go +++ b/apps/backend/internal/i18n/i18n.go @@ -24,7 +24,9 @@ import ( "embed" "encoding/json" "fmt" + "math" "net/http" + "strconv" "strings" "sync" ) @@ -120,7 +122,9 @@ func FromRequest(r *http.Request) string { } // parseAcceptLanguage returns the header's tags in descending q-value order. -// Malformed entries are skipped rather than failing the request. +// Malformed entries, and any entry whose q-value is unparseable or not +// positive, are excluded rather than falling through to the request's +// default acceptability of 1.0. func parseAcceptLanguage(header string) []string { if header == "" { return nil @@ -136,15 +140,9 @@ func parseAcceptLanguage(header string) []string { if tag == "" { continue } - q := 1.0 - for _, field := range fields[1:] { - field = strings.TrimSpace(field) - if !strings.HasPrefix(field, "q=") { - continue - } - if _, err := fmt.Sscanf(field, "q=%f", &q); err != nil { - q = 1.0 - } + q, ok := acceptLanguageQuality(fields[1:]) + if !ok || q <= 0 { + continue } items = append(items, weighted{tag: tag, q: q}) } @@ -161,6 +159,28 @@ func parseAcceptLanguage(header string) []string { return tags } +// acceptLanguageQuality reads the q parameter from one Accept-Language +// field's ";"-separated parameters, defaulting to 1.0 when none is present. +// It reports ok=false for a q parameter that is present but unparseable, not +// a finite number, or outside HTTP quality's [0,1] range, so the caller +// excludes the tag instead of promoting a malformed value to the highest +// priority or letting it outrank well-formed entries. +func acceptLanguageQuality(params []string) (float64, bool) { + q := 1.0 + for _, field := range params { + field = strings.TrimSpace(field) + if !strings.HasPrefix(field, "q=") { + continue + } + parsed, err := strconv.ParseFloat(strings.TrimPrefix(field, "q="), 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed < 0 || parsed > 1 { + return 0, false + } + q = parsed + } + return q, true +} + // T returns the message for key in locale, falling back to DefaultLocale and // finally to the key itself. A missing key therefore degrades to // visible-but-wrong rather than blank — the same contract as the frontend. diff --git a/apps/backend/internal/i18n/locales/en.json b/apps/backend/internal/i18n/locales/en.json index 9a9af9d161..6d61a845e1 100644 --- a/apps/backend/internal/i18n/locales/en.json +++ b/apps/backend/internal/i18n/locales/en.json @@ -30,6 +30,44 @@ "share.toolOutput": "Tool output", "share.toolOutputTruncated": "Tool output (truncated)", "share.untitledTask": "Untitled task", + "startup.duration.minutes_one": "{{count}} minute", + "startup.duration.minutes_other": "{{count}} minutes", + "startup.duration.seconds_one": "{{count}} second", + "startup.duration.seconds_other": "{{count}} seconds", + "startup.page.countedProgress": "{{done}} of {{total}} {{unit}}", + "startup.page.countingProgress": "{{done}} {{unit}}", + "startup.page.elapsed": "{{duration}} elapsed", + "startup.page.eta": "About {{duration}} remaining", + "startup.page.lastKnown": "Last known", + "startup.page.progressUnavailable": "Progress cannot be measured for this step.", + "startup.page.stalled": "Stalled for {{duration}}", + "startup.page.title": "Kandev is starting", + "startup.page.waiting": "Kandev is starting and not yet reachable.", + "startup.phase.applying_migrations": "Applying migrations", + "startup.phase.backing_up_database": "Backing up database", + "startup.phase.initializing_services": "Initializing services", + "startup.phase.opening_database": "Opening database", + "startup.phase.ready": "Ready", + "startup.phase.recovering_sessions": "Recovering sessions", + "startup.step.database_backup": "Database backup", + "startup.step.message_timestamps": "Message timestamps", + "startup.step.prompt_seq": "Prompt sequence numbers", + "startup.step.session_recovery": "Session recovery", + "startup.step.stores_repositories": "Repository stores", + "startup.step.stores_services": "Service stores", + "startup.step.subagent_context": "Subagent context", + "startup.unit.bytes_one": "byte", + "startup.unit.bytes_other": "bytes", + "startup.unit.messages_one": "message", + "startup.unit.messages_other": "messages", + "startup.unit.rows_one": "row", + "startup.unit.rows_other": "rows", + "startup.unit.sessions_one": "session", + "startup.unit.sessions_other": "sessions", + "startup.unit.stores_one": "store", + "startup.unit.stores_other": "stores", + "startup.unit.turns_one": "turn", + "startup.unit.turns_other": "turns", "webapp.assetReadFailed": "Could not read a web asset.", "webapp.devServerUnavailable": "The Vite dev server is unavailable.", "webapp.shellUnavailable": "The Kandev web app is unavailable." diff --git a/apps/backend/internal/i18n/locales/pseudo.json b/apps/backend/internal/i18n/locales/pseudo.json index 3db213e0a7..84f1905e1d 100644 --- a/apps/backend/internal/i18n/locales/pseudo.json +++ b/apps/backend/internal/i18n/locales/pseudo.json @@ -30,6 +30,44 @@ "share.toolOutput": "Ţōōĺ ōũţƥũţ", "share.toolOutputTruncated": "Ţōōĺ ōũţƥũţ (ţŕũńćàţēď)", "share.untitledTask": "Ũńţĩţĺēď ţàśķ", + "startup.duration.minutes_one": "{{count}} ḿĩńũţē", + "startup.duration.minutes_other": "{{count}} ḿĩńũţēś", + "startup.duration.seconds_one": "{{count}} śēćōńď", + "startup.duration.seconds_other": "{{count}} śēćōńďś", + "startup.page.countedProgress": "{{done}} ōƒ {{total}} {{unit}}", + "startup.page.countingProgress": "{{done}} {{unit}}", + "startup.page.elapsed": "{{duration}} ēĺàƥśēď", + "startup.page.eta": "Àƀōũţ {{duration}} ŕēḿàĩńĩńĝ", + "startup.page.lastKnown": "Ĺàśţ ķńōŵń", + "startup.page.progressUnavailable": "Ƥŕōĝŕēśś ćàńńōţ ƀē ḿēàśũŕēď ƒōŕ ţĥĩś śţēƥ.", + "startup.page.stalled": "Śţàĺĺēď ƒōŕ {{duration}}", + "startup.page.title": "Ķàńďēv ĩś śţàŕţĩńĝ", + "startup.page.waiting": "Ķàńďēv ĩś śţàŕţĩńĝ àńď ńōţ ŷēţ ŕēàćĥàƀĺē.", + "startup.phase.applying_migrations": "Àƥƥĺŷĩńĝ ḿĩĝŕàţĩōńś", + "startup.phase.backing_up_database": "Ɓàćķĩńĝ ũƥ ďàţàƀàśē", + "startup.phase.initializing_services": "Ĩńĩţĩàĺĩźĩńĝ śēŕvĩćēś", + "startup.phase.opening_database": "Ōƥēńĩńĝ ďàţàƀàśē", + "startup.phase.ready": "Ŕēàďŷ", + "startup.phase.recovering_sessions": "Ŕēćōvēŕĩńĝ śēśśĩōńś", + "startup.step.database_backup": "Ďàţàƀàśē ƀàćķũƥ", + "startup.step.message_timestamps": "Ḿēśśàĝē ţĩḿēśţàḿƥś", + "startup.step.prompt_seq": "Ƥŕōḿƥţ śēqũēńćē ńũḿƀēŕś", + "startup.step.session_recovery": "Śēśśĩōń ŕēćōvēŕŷ", + "startup.step.stores_repositories": "Ŕēƥōśĩţōŕŷ śţōŕēś", + "startup.step.stores_services": "Śēŕvĩćē śţōŕēś", + "startup.step.subagent_context": "Śũƀàĝēńţ ćōńţēxţ", + "startup.unit.bytes_one": "ƀŷţē", + "startup.unit.bytes_other": "ƀŷţēś", + "startup.unit.messages_one": "ḿēśśàĝē", + "startup.unit.messages_other": "ḿēśśàĝēś", + "startup.unit.rows_one": "ŕōŵ", + "startup.unit.rows_other": "ŕōŵś", + "startup.unit.sessions_one": "śēśśĩōń", + "startup.unit.sessions_other": "śēśśĩōńś", + "startup.unit.stores_one": "śţōŕē", + "startup.unit.stores_other": "śţōŕēś", + "startup.unit.turns_one": "ţũŕń", + "startup.unit.turns_other": "ţũŕńś", "webapp.assetReadFailed": "Ćōũĺď ńōţ ŕēàď à ŵēƀ àśśēţ.", "webapp.devServerUnavailable": "Ţĥē Vĩţē ďēv śēŕvēŕ ĩś ũńàvàĩĺàƀĺē.", "webapp.shellUnavailable": "Ţĥē Ķàńďēv ŵēƀ àƥƥ ĩś ũńàvàĩĺàƀĺē." diff --git a/apps/backend/internal/i18n/locales/pt-pt.json b/apps/backend/internal/i18n/locales/pt-pt.json index 8ec4626928..64704c40a3 100644 --- a/apps/backend/internal/i18n/locales/pt-pt.json +++ b/apps/backend/internal/i18n/locales/pt-pt.json @@ -30,6 +30,44 @@ "share.toolOutput": "Saída da ferramenta", "share.toolOutputTruncated": "Saída da ferramenta (truncada)", "share.untitledTask": "Tarefa sem título", + "startup.duration.minutes_one": "{{count}} minuto", + "startup.duration.minutes_other": "{{count}} minutos", + "startup.duration.seconds_one": "{{count}} segundo", + "startup.duration.seconds_other": "{{count}} segundos", + "startup.page.countedProgress": "{{done}} de {{total}} {{unit}}", + "startup.page.countingProgress": "{{done}} {{unit}}", + "startup.page.elapsed": "Tempo decorrido: {{duration}}", + "startup.page.eta": "Tempo restante aproximado: {{duration}}", + "startup.page.lastKnown": "Último valor conhecido", + "startup.page.progressUnavailable": "Não é possível medir o progresso deste passo.", + "startup.page.stalled": "Parado há {{duration}}", + "startup.page.title": "O Kandev está a iniciar", + "startup.page.waiting": "O Kandev está a iniciar e ainda não está acessível.", + "startup.phase.applying_migrations": "A aplicar migrações", + "startup.phase.backing_up_database": "A fazer cópia de segurança da base de dados", + "startup.phase.initializing_services": "A inicializar serviços", + "startup.phase.opening_database": "A abrir a base de dados", + "startup.phase.ready": "Pronto", + "startup.phase.recovering_sessions": "A recuperar sessões", + "startup.step.database_backup": "Cópia de segurança da base de dados", + "startup.step.message_timestamps": "Carimbos de data/hora das mensagens", + "startup.step.prompt_seq": "Números de sequência de prompts", + "startup.step.session_recovery": "Recuperação de sessões", + "startup.step.stores_repositories": "Armazenamentos de repositórios", + "startup.step.stores_services": "Armazenamentos de serviços", + "startup.step.subagent_context": "Contexto de subagentes", + "startup.unit.bytes_one": "byte", + "startup.unit.bytes_other": "bytes", + "startup.unit.messages_one": "mensagem", + "startup.unit.messages_other": "mensagens", + "startup.unit.rows_one": "linha", + "startup.unit.rows_other": "linhas", + "startup.unit.sessions_one": "sessão", + "startup.unit.sessions_other": "sessões", + "startup.unit.stores_one": "armazenamento", + "startup.unit.stores_other": "armazenamentos", + "startup.unit.turns_one": "turno", + "startup.unit.turns_other": "turnos", "webapp.assetReadFailed": "Não foi possível ler um recurso web.", "webapp.devServerUnavailable": "O servidor de desenvolvimento do Vite não está disponível.", "webapp.shellUnavailable": "A aplicação web do Kandev não está disponível." diff --git a/apps/backend/internal/i18n/locales/zh-cn.json b/apps/backend/internal/i18n/locales/zh-cn.json index cda9c0900e..ceffa1ccee 100644 --- a/apps/backend/internal/i18n/locales/zh-cn.json +++ b/apps/backend/internal/i18n/locales/zh-cn.json @@ -30,6 +30,44 @@ "share.toolOutput": "工具输出", "share.toolOutputTruncated": "工具输出(已截断)", "share.untitledTask": "未命名任务", + "startup.duration.minutes_one": "{{count}} 分钟", + "startup.duration.minutes_other": "{{count}} 分钟", + "startup.duration.seconds_one": "{{count}} 秒", + "startup.duration.seconds_other": "{{count}} 秒", + "startup.page.countedProgress": "{{done}} / {{total}} {{unit}}", + "startup.page.countingProgress": "{{done}} {{unit}}", + "startup.page.elapsed": "已用时 {{duration}}", + "startup.page.eta": "预计还需 {{duration}}", + "startup.page.lastKnown": "最后已知状态", + "startup.page.progressUnavailable": "无法测量此步骤的进度。", + "startup.page.stalled": "已停滞 {{duration}}", + "startup.page.title": "Kandev 正在启动", + "startup.page.waiting": "Kandev 正在启动,尚无法访问。", + "startup.phase.applying_migrations": "正在应用迁移", + "startup.phase.backing_up_database": "正在备份数据库", + "startup.phase.initializing_services": "正在初始化服务", + "startup.phase.opening_database": "正在打开数据库", + "startup.phase.ready": "就绪", + "startup.phase.recovering_sessions": "正在恢复会话", + "startup.step.database_backup": "数据库备份", + "startup.step.message_timestamps": "消息时间戳", + "startup.step.prompt_seq": "提示序列号", + "startup.step.session_recovery": "会话恢复", + "startup.step.stores_repositories": "仓库存储", + "startup.step.stores_services": "服务存储", + "startup.step.subagent_context": "子智能体上下文", + "startup.unit.bytes_one": "字节", + "startup.unit.bytes_other": "字节", + "startup.unit.messages_one": "条消息", + "startup.unit.messages_other": "条消息", + "startup.unit.rows_one": "行", + "startup.unit.rows_other": "行", + "startup.unit.sessions_one": "个会话", + "startup.unit.sessions_other": "个会话", + "startup.unit.stores_one": "个存储", + "startup.unit.stores_other": "个存储", + "startup.unit.turns_one": "个轮次", + "startup.unit.turns_other": "个轮次", "webapp.assetReadFailed": "无法读取网页资源。", "webapp.devServerUnavailable": "Vite 开发服务器不可用。", "webapp.shellUnavailable": "Kandev Web 应用不可用。" diff --git a/apps/backend/internal/i18n/locales/zh-hk.json b/apps/backend/internal/i18n/locales/zh-hk.json index f2570d8285..2ac9045dd5 100644 --- a/apps/backend/internal/i18n/locales/zh-hk.json +++ b/apps/backend/internal/i18n/locales/zh-hk.json @@ -30,6 +30,44 @@ "share.toolOutput": "工具輸出", "share.toolOutputTruncated": "工具輸出(已截斷)", "share.untitledTask": "未命名任務", + "startup.duration.minutes_one": "{{count}} 分鐘", + "startup.duration.minutes_other": "{{count}} 分鐘", + "startup.duration.seconds_one": "{{count}} 秒", + "startup.duration.seconds_other": "{{count}} 秒", + "startup.page.countedProgress": "{{done}} / {{total}} {{unit}}", + "startup.page.countingProgress": "{{done}} {{unit}}", + "startup.page.elapsed": "已用時 {{duration}}", + "startup.page.eta": "預計還需 {{duration}}", + "startup.page.lastKnown": "最後已知狀態", + "startup.page.progressUnavailable": "無法測量此步驟的進度。", + "startup.page.stalled": "已停滯 {{duration}}", + "startup.page.title": "Kandev 正在啓動", + "startup.page.waiting": "Kandev 正在啓動,尚無法存取。", + "startup.phase.applying_migrations": "正在應用遷移", + "startup.phase.backing_up_database": "正在備份數據庫", + "startup.phase.initializing_services": "正在初始化服務", + "startup.phase.opening_database": "正在開啟數據庫", + "startup.phase.ready": "就緒", + "startup.phase.recovering_sessions": "正在恢復工作階段", + "startup.step.database_backup": "數據庫備份", + "startup.step.message_timestamps": "訊息時間戳", + "startup.step.prompt_seq": "提示序列號", + "startup.step.session_recovery": "工作階段恢復", + "startup.step.stores_repositories": "儲存庫存儲", + "startup.step.stores_services": "服務存儲", + "startup.step.subagent_context": "子代理程式上下文", + "startup.unit.bytes_one": "字節", + "startup.unit.bytes_other": "字節", + "startup.unit.messages_one": "條訊息", + "startup.unit.messages_other": "條訊息", + "startup.unit.rows_one": "行", + "startup.unit.rows_other": "行", + "startup.unit.sessions_one": "個工作階段", + "startup.unit.sessions_other": "個工作階段", + "startup.unit.stores_one": "個存儲", + "startup.unit.stores_other": "個存儲", + "startup.unit.turns_one": "個輪次", + "startup.unit.turns_other": "個輪次", "webapp.assetReadFailed": "無法讀取網頁資源。", "webapp.devServerUnavailable": "Vite 開發伺服器不可用。", "webapp.shellUnavailable": "Kandev Web 應用不可用。" diff --git a/apps/backend/internal/i18n/locales/zh-tw.json b/apps/backend/internal/i18n/locales/zh-tw.json index 3b7e108529..94349bb6f5 100644 --- a/apps/backend/internal/i18n/locales/zh-tw.json +++ b/apps/backend/internal/i18n/locales/zh-tw.json @@ -30,6 +30,44 @@ "share.toolOutput": "工具輸出", "share.toolOutputTruncated": "工具輸出(已截斷)", "share.untitledTask": "未命名任務", + "startup.duration.minutes_one": "{{count}} 分鐘", + "startup.duration.minutes_other": "{{count}} 分鐘", + "startup.duration.seconds_one": "{{count}} 秒", + "startup.duration.seconds_other": "{{count}} 秒", + "startup.page.countedProgress": "{{done}} / {{total}} {{unit}}", + "startup.page.countingProgress": "{{done}} {{unit}}", + "startup.page.elapsed": "已用時 {{duration}}", + "startup.page.eta": "預計還需 {{duration}}", + "startup.page.lastKnown": "最後已知狀態", + "startup.page.progressUnavailable": "無法測量此步驟的進度。", + "startup.page.stalled": "已停滯 {{duration}}", + "startup.page.title": "Kandev 正在啟動", + "startup.page.waiting": "Kandev 正在啟動,尚無法存取。", + "startup.phase.applying_migrations": "正在應用遷移", + "startup.phase.backing_up_database": "正在備份資料庫", + "startup.phase.initializing_services": "正在初始化服務", + "startup.phase.opening_database": "正在開啟資料庫", + "startup.phase.ready": "就緒", + "startup.phase.recovering_sessions": "正在恢復工作階段", + "startup.step.database_backup": "資料庫備份", + "startup.step.message_timestamps": "訊息時間戳", + "startup.step.prompt_seq": "提示序列號", + "startup.step.session_recovery": "工作階段恢復", + "startup.step.stores_repositories": "儲存庫儲存", + "startup.step.stores_services": "服務儲存", + "startup.step.subagent_context": "子代理程式上下文", + "startup.unit.bytes_one": "位元組", + "startup.unit.bytes_other": "位元組", + "startup.unit.messages_one": "條訊息", + "startup.unit.messages_other": "條訊息", + "startup.unit.rows_one": "行", + "startup.unit.rows_other": "行", + "startup.unit.sessions_one": "個工作階段", + "startup.unit.sessions_other": "個工作階段", + "startup.unit.stores_one": "個儲存", + "startup.unit.stores_other": "個儲存", + "startup.unit.turns_one": "個輪次", + "startup.unit.turns_other": "個輪次", "webapp.assetReadFailed": "無法讀取網頁資源。", "webapp.devServerUnavailable": "Vite 開發伺服器不可用。", "webapp.shellUnavailable": "Kandev Web 應用不可用。" diff --git a/apps/backend/internal/launcher/health.go b/apps/backend/internal/launcher/health.go index af77ab127f..0257682c95 100644 --- a/apps/backend/internal/launcher/health.go +++ b/apps/backend/internal/launcher/health.go @@ -15,6 +15,7 @@ import ( "time" "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/i18n" "github.com/kandev/kandev/internal/startup" ) @@ -23,6 +24,12 @@ const ( healthProbeTimeout = 2 * time.Second ) +// startupProgressReportFloor is the maximum interval between AC-PLATFORM- +// STARTUP-PROGRESS-003.5 progress lines when neither phase, sequence number, +// nor stall state has changed. A package-level var (not const) so tests can +// override it instead of waiting out 15 real seconds. +var startupProgressReportFloor = 15 * time.Second + // healthProbeClient bounds every individual health request. http.DefaultClient // has no timeout, so a backend that accepts the connection but never answers // would otherwise block the launcher forever. Launcher readiness is local to @@ -362,27 +369,38 @@ func safeProbeError(err error) string { // in flight. Do not fold this into waitForHealth's timeout — that would // recreate the crash loop docs/specs/startup-listener-before-recovery/spec.md // exists to fix. +// +// Per AC-PLATFORM-STARTUP-PROGRESS-003.5, a line is written on a phase +// change, a sequence-number change (every step start/end), or a stall-state +// transition, and otherwise at most once per startupProgressReportFloor. func waitForReady(ctx context.Context, baseURL string, proc childState) error { readyURL := baseURL + "/ready" var last startup.Snapshot + var lastStalled bool var reported time.Time exitError := func(code int) error { - if label := last.Phase.Label(); label != "" { - return fmt.Errorf("backend exited (code %d) during %s (elapsed %.1fs); check backend logs for the initialization error", code, label, float64(last.ElapsedMS)/1000) + label := last.Phase.Label() + if label == "" { + return fmt.Errorf("backend exited (code %d) before it reported ready; check backend logs for the initialization error", code) } - return fmt.Errorf("backend exited (code %d) before it reported ready; check backend logs for the initialization error", code) + if last.Step != nil { + label = fmt.Sprintf("%s (%s)", label, i18n.T(i18n.DefaultLocale, last.Step.LabelKey)) + } + return fmt.Errorf("backend exited (code %d) during %s (elapsed %.1fs); check backend logs for the initialization error", code, label, float64(last.ElapsedMS)/1000) } for ctx.Err() == nil { if exited, code := proc.Exited(); exited { return exitError(code) } - isReady, snapshot := probeReadyStatus(ctx, readyURL) + isReady, snapshot := probeReadyStatusFn(ctx, readyURL) if snapshot.Phase.Label() != "" { - if snapshot.Phase != last.Phase || time.Since(reported) >= 15*time.Second { - fmt.Fprintf(os.Stderr, "[kandev] %s (elapsed %.1fs; phase %.1fs)\n", snapshot.Phase.Label(), float64(snapshot.ElapsedMS)/1000, float64(snapshot.PhaseElapsedMS)/1000) + stalled := snapshot.Step != nil && snapshot.Step.Stalled + if snapshot.Phase != last.Phase || snapshot.Seq != last.Seq || stalled != lastStalled || time.Since(reported) >= startupProgressReportFloor { + fmt.Fprintf(os.Stderr, "[kandev] %s\n", formatStartupLine(snapshot)) reported = time.Now() } last = snapshot + lastStalled = stalled } if isReady { if err := ctx.Err(); err != nil { @@ -401,6 +419,60 @@ func waitForReady(ctx context.Context, baseURL string, proc childState) error { return fmt.Errorf("backend readiness wait canceled at %s: %w", readyURL, ctx.Err()) } +// formatStartupLine renders one AC-PLATFORM-STARTUP-PROGRESS-003.5 progress +// line: phase, elapsed time, and — when a step is active — its name plus +// whichever of done, total, rate, and estimate the snapshot carries, rather +// than only for a `counted` step, since a `counting` step (for example +// database.backup) carries a done count and a rate with no total. An opaque +// step states plainly that it cannot report progress +// (AC-PLATFORM-STARTUP-PROGRESS-001.5) instead of a bar at any position, and +// a stalled step states how long it has gone without progress +// (AC-PLATFORM-STARTUP-PROGRESS-004.6). +func formatStartupLine(snapshot startup.Snapshot) string { + line := fmt.Sprintf("%s (elapsed %.1fs; phase %.1fs)", snapshot.Phase.Label(), float64(snapshot.ElapsedMS)/1000, float64(snapshot.PhaseElapsedMS)/1000) + step := snapshot.Step + if step == nil { + return line + } + name := i18n.T(i18n.DefaultLocale, step.LabelKey) + if step.Measure == startup.MeasureOpaque { + return fmt.Sprintf("%s: %s (progress not available)", line, name) + } + detail := name + switch { + case step.Done != nil && step.Total != nil: + detail += fmt.Sprintf(": %d/%d", *step.Done, *step.Total) + case step.Done != nil: + detail += fmt.Sprintf(": %d", *step.Done) + } + if step.RatePerSecond != nil { + detail += fmt.Sprintf(" (%.1f/s)", *step.RatePerSecond) + } + if step.ETAMS != nil { + detail += fmt.Sprintf(", ~%s remaining", formatDurationEstimate(*step.ETAMS)) + } + if step.Stalled && step.SinceAdvanceMS != nil { + detail += fmt.Sprintf(", stalled for %s", formatDurationEstimate(*step.SinceAdvanceMS)) + } + return fmt.Sprintf("%s: %s", line, detail) +} + +// formatDurationEstimate renders a millisecond duration per +// AC-PLATFORM-STARTUP-PROGRESS-003.10: 60000ms or less renders in whole +// seconds rounded up with a floor of one second, anything longer in whole +// minutes rounded up, never sub-second. +func formatDurationEstimate(ms int64) string { + if ms <= 60000 { + seconds := (ms + 999) / 1000 + if seconds < 1 { + seconds = 1 + } + return fmt.Sprintf("%ds", seconds) + } + minutes := (ms + 59999) / 60000 + return fmt.Sprintf("%dm", minutes) +} + // probeReady reports whether a single readiness request returned 2xx. Unlike // probeHealth, it does not check the desktop health token: readyHandler never // sets X-Kandev-Desktop-Health-Token (that header is /health-only, see @@ -410,6 +482,11 @@ func probeReady(ctx context.Context, readyURL string) bool { return ready } +// probeReadyStatusFn is a seam for waitForReady's polling loop, overridden in +// tests that need a synctest-safe stub: real HTTP I/O sits outside the +// synctest fake-time bubble and would prevent synctest.Wait from settling. +var probeReadyStatusFn = probeReadyStatus + func probeReadyStatus(ctx context.Context, readyURL string) (bool, startup.Snapshot) { var body struct { Startup startup.Snapshot `json:"startup"` diff --git a/apps/backend/internal/launcher/health_startup_line_test.go b/apps/backend/internal/launcher/health_startup_line_test.go new file mode 100644 index 0000000000..b26497b315 --- /dev/null +++ b/apps/backend/internal/launcher/health_startup_line_test.go @@ -0,0 +1,275 @@ +package launcher + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/kandev/kandev/internal/startup" +) + +// TestFormatDurationEstimate covers AC-PLATFORM-STARTUP-PROGRESS-003.10: 60s +// or less renders in whole seconds rounded up with a floor of one second, a +// longer duration renders in whole minutes rounded up, never sub-second. +func TestFormatDurationEstimate(t *testing.T) { + cases := []struct { + ms int64 + want string + }{ + {ms: 1, want: "1s"}, + {ms: 999, want: "1s"}, + {ms: 1000, want: "1s"}, + {ms: 1001, want: "2s"}, + {ms: 60000, want: "60s"}, + {ms: 60001, want: "2m"}, + {ms: 90000, want: "2m"}, + {ms: 120000, want: "2m"}, + } + for _, tc := range cases { + if got := formatDurationEstimate(tc.ms); got != tc.want { + t.Errorf("formatDurationEstimate(%d) = %q, want %q", tc.ms, got, tc.want) + } + } +} + +// TestFormatStartupLineNoStep covers AC-PLATFORM-STARTUP-PROGRESS-003.13- +// adjacent behavior for the launcher: with no active step the line carries +// only phase and elapsed time. +func TestFormatStartupLineNoStep(t *testing.T) { + snapshot := startup.Snapshot{Phase: startup.OpeningDatabase, ElapsedMS: 1500, PhaseElapsedMS: 500} + got := formatStartupLine(snapshot) + want := "Opening database (elapsed 1.5s; phase 0.5s)" + if got != want { + t.Fatalf("formatStartupLine() = %q, want %q", got, want) + } +} + +// TestFormatStartupLineOpaqueStepStatesProgressUnavailable covers F74/ +// AC-PLATFORM-STARTUP-PROGRESS-001.5: an opaque step never carries done or +// total, and the launcher line states plainly that it cannot report +// progress rather than showing a bar at any position. +func TestFormatStartupLineOpaqueStepStatesProgressUnavailable(t *testing.T) { + snapshot := startup.Snapshot{ + Phase: startup.InitializingServices, ElapsedMS: 2000, PhaseElapsedMS: 1000, + Step: &startup.StepSnapshot{LabelKey: "startup.step.prompt_seq", Measure: startup.MeasureOpaque}, + } + got := formatStartupLine(snapshot) + want := "Initializing services (elapsed 2.0s; phase 1.0s): Prompt sequence numbers (progress not available)" + if got != want { + t.Fatalf("formatStartupLine() = %q, want %q", got, want) + } +} + +// TestFormatStartupLineCountingStepWithNoTotal covers the design's +// database.backup example: a `counting` step carries a done count and a +// rate with no total, and the line must gain both rather than waiting for a +// `counted` step. +func TestFormatStartupLineCountingStepWithNoTotal(t *testing.T) { + done := int64(42) + rate := 3.5 + snapshot := startup.Snapshot{ + Phase: startup.BackingUpDatabase, ElapsedMS: 5000, PhaseElapsedMS: 5000, + Step: &startup.StepSnapshot{ + LabelKey: "startup.step.database_backup", Measure: startup.MeasureCounting, + Done: &done, RatePerSecond: &rate, + }, + } + got := formatStartupLine(snapshot) + want := "Backing up database (elapsed 5.0s; phase 5.0s): Database backup: 42 (3.5/s)" + if got != want { + t.Fatalf("formatStartupLine() = %q, want %q", got, want) + } +} + +// TestFormatStartupLineCountedStepWithEstimate covers the full counted case: +// done, total, rate, and estimate all present. +func TestFormatStartupLineCountedStepWithEstimate(t *testing.T) { + done, total := int64(12), int64(50) + rate := 2.0 + eta := int64(19000) + snapshot := startup.Snapshot{ + Phase: startup.RecoveringSessions, ElapsedMS: 6000, PhaseElapsedMS: 6000, + Step: &startup.StepSnapshot{ + LabelKey: "startup.step.session_recovery", Measure: startup.MeasureCounted, + Done: &done, Total: &total, RatePerSecond: &rate, ETAMS: &eta, + }, + } + got := formatStartupLine(snapshot) + want := "Recovering sessions (elapsed 6.0s; phase 6.0s): Session recovery: 12/50 (2.0/s), ~19s remaining" + if got != want { + t.Fatalf("formatStartupLine() = %q, want %q", got, want) + } +} + +// TestFormatStartupLineStalledStepStatesDuration covers F75/ +// AC-PLATFORM-STARTUP-PROGRESS-004.6: a stalled step states how long it has +// gone without progress. +func TestFormatStartupLineStalledStepStatesDuration(t *testing.T) { + done, total := int64(3), int64(10) + sinceAdvance := int64(125000) + snapshot := startup.Snapshot{ + Phase: startup.RecoveringSessions, ElapsedMS: 130000, PhaseElapsedMS: 130000, + Step: &startup.StepSnapshot{ + LabelKey: "startup.step.session_recovery", Measure: startup.MeasureCounted, + Done: &done, Total: &total, SinceAdvanceMS: &sinceAdvance, Stalled: true, + }, + } + got := formatStartupLine(snapshot) + want := "Recovering sessions (elapsed 130.0s; phase 130.0s): Session recovery: 3/10, stalled for 3m" + if got != want { + t.Fatalf("formatStartupLine() = %q, want %q", got, want) + } +} + +// TestFormatStartupLineNonStalledStepOmitsStallStatement is the negative +// case for the same AC: a step short of the stall threshold prints no +// stall statement at all. +func TestFormatStartupLineNonStalledStepOmitsStallStatement(t *testing.T) { + done, total := int64(3), int64(10) + sinceAdvance := int64(500) + snapshot := startup.Snapshot{ + Phase: startup.RecoveringSessions, ElapsedMS: 1000, PhaseElapsedMS: 1000, + Step: &startup.StepSnapshot{ + LabelKey: "startup.step.session_recovery", Measure: startup.MeasureCounted, + Done: &done, Total: &total, SinceAdvanceMS: &sinceAdvance, Stalled: false, + }, + } + got := formatStartupLine(snapshot) + if strings.Contains(got, "stalled") { + t.Fatalf("formatStartupLine() = %q, want no stall statement below the threshold", got) + } +} + +// TestReadinessExitNamesLastObservedStep covers AC-PLATFORM-STARTUP- +// PROGRESS-003.6: when the backend exits during startup, the launcher names +// the last observed step as well as the last observed phase. +func TestReadinessExitNamesLastObservedStep(t *testing.T) { + var child toggledChild + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"status":"starting","startup":{"phase":"recovering_sessions","elapsed_ms":1234,"phase_elapsed_ms":1000,"step":{"id":"sessions.recovery","label_key":"startup.step.session_recovery","measure":"counted","unit":"sessions","elapsed_ms":1000,"done":3,"total":10,"stalled":false}}}`)) + child.code.Store(3) + child.exited.Store(true) + })) + defer srv.Close() + err := waitForReady(context.Background(), srv.URL, &child) + if err == nil || !strings.Contains(err.Error(), "Recovering sessions") { + t.Fatalf("missing startup phase in error: %v", err) + } + if !strings.Contains(err.Error(), "Session recovery") { + t.Fatalf("error = %v, want it to name the last observed step", err) + } +} + +// TestWaitForReadyReprintsOnReportFloorWithoutOtherChanges covers +// AC-PLATFORM-STARTUP-PROGRESS-003.5's floor trigger: with phase, seq, and +// stall state all unchanged, a line is written again only once +// startupProgressReportFloor has elapsed since the last one, and not on +// every intervening poll. Stubs probeReadyStatusFn rather than using a real +// httptest.Server: real HTTP I/O sits outside synctest's fake-time bubble +// and would prevent the 15-second floor from fast-forwarding (see +// internal/system/updates/service_test.go's TestService_StartPoller_ +// TicksEvery6h for the same convention). +func TestWaitForReadyReprintsOnReportFloorWithoutOtherChanges(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + oldProbe := probeReadyStatusFn + t.Cleanup(func() { probeReadyStatusFn = oldProbe }) + + start := time.Now() + probeReadyStatusFn = func(context.Context, string) (bool, startup.Snapshot) { + if time.Since(start) >= 40*time.Second { + return true, startup.Snapshot{} + } + return false, startup.Snapshot{Phase: startup.RecoveringSessions, Seq: 1, ElapsedMS: 1, PhaseElapsedMS: 1} + } + + var err error + output := captureStderr(t, func() { + err = waitForReady(context.Background(), "http://probe.invalid", fakeChild{}) + }) + if err != nil { + t.Fatalf("waitForReady() = %v, want nil", err) + } + lines := strings.Count(output, "Recovering sessions") + if lines != 3 { + t.Fatalf("stderr output = %q, want exactly 3 progress lines (initial + two floor reprints at 15s and 30s), got %d", output, lines) + } + }) +} + +// captureStderr swaps os.Stderr for the duration of fn and returns what was +// written to it. The launcher test package never runs tests in parallel, so +// this global swap is safe. +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + read, write, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() = %v", err) + } + oldStderr := os.Stderr + os.Stderr = write + defer func() { os.Stderr = oldStderr }() + + fn() + if err := write.Close(); err != nil { + t.Fatalf("close stderr writer: %v", err) + } + var output bytes.Buffer + if _, err := io.Copy(&output, read); err != nil { + t.Fatalf("read captured stderr: %v", err) + } + if err := read.Close(); err != nil { + t.Fatalf("close stderr reader: %v", err) + } + return output.String() +} + +// TestWaitForReadyReprintsOnSeqChangeWithoutPhaseChange covers +// AC-PLATFORM-STARTUP-PROGRESS-003.5's sequence-number trigger: a step +// beginning or ending bumps `seq` without necessarily changing phase, and +// that alone must produce an immediate line rather than waiting for the +// 15-second floor or a phase change. +func TestWaitForReadyReprintsOnSeqChangeWithoutPhaseChange(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls++ + switch calls { + case 1: + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"startup":{"phase":"recovering_sessions","seq":1,"elapsed_ms":1,"phase_elapsed_ms":1}}`)) + case 2: + // Same phase, same seq: within the 15s floor, no reprint expected. + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"startup":{"phase":"recovering_sessions","seq":1,"elapsed_ms":2,"phase_elapsed_ms":2}}`)) + case 3: + // seq bumped (a step ended) with no phase change: must reprint. + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"startup":{"phase":"recovering_sessions","seq":2,"elapsed_ms":3,"phase_elapsed_ms":3}}`)) + default: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + } + })) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + var err error + output := captureStderr(t, func() { + err = waitForReady(ctx, srv.URL, fakeChild{}) + }) + if err != nil { + t.Fatalf("waitForReady() = %v, want nil", err) + } + lines := strings.Count(output, "Recovering sessions") + if lines != 2 { + t.Fatalf("stderr output = %q, want exactly 2 progress lines (initial + seq change), got %d", output, lines) + } +} diff --git a/apps/backend/internal/office/retention/sweep.go b/apps/backend/internal/office/retention/sweep.go index c8e69a8c34..9c972d19cf 100644 --- a/apps/backend/internal/office/retention/sweep.go +++ b/apps/backend/internal/office/retention/sweep.go @@ -299,13 +299,13 @@ func (s *Sweeper) sweepRuns(ctx context.Context, q queryer, cfg TableSettings, n }}, satelliteResults{} } return SweptTableResult{TableSweepResult: TableSweepResult{ - Deleted: result.RunsDeleted, - Backlog: eligible > int64(batchLimit), - }}, satelliteResults{ - RunEvents: TableSweepResult{Deleted: result.RunEventsDeleted}, - RouteAttempts: TableSweepResult{Deleted: result.RouteAttemptsDeleted}, - RunSkills: TableSweepResult{Deleted: result.RunSkillsDeleted}, - } + Deleted: result.RunsDeleted, + Backlog: eligible > int64(batchLimit), + }}, satelliteResults{ + RunEvents: TableSweepResult{Deleted: result.RunEventsDeleted}, + RouteAttempts: TableSweepResult{Deleted: result.RouteAttemptsDeleted}, + RunSkills: TableSweepResult{Deleted: result.RunSkillsDeleted}, + } } // retentionCutoff turns a table's configured window into the instant a diff --git a/apps/backend/internal/persistence/backup_progress_test.go b/apps/backend/internal/persistence/backup_progress_test.go new file mode 100644 index 0000000000..49c6c5cf1c --- /dev/null +++ b/apps/backend/internal/persistence/backup_progress_test.go @@ -0,0 +1,121 @@ +package persistence + +import ( + "context" + "os" + "path/filepath" + "testing" + "testing/synctest" + "time" + + "github.com/kandev/kandev/internal/startup" +) + +func newBackupStepContext() (context.Context, *startup.Reporter) { + r := startup.New(nil) + ctx := startup.WithReporter(context.Background(), r) + startup.BeginStep(ctx, startup.StepDatabaseBackup) + return ctx, r +} + +func TestReportBackupProgressAdvancesOnSizeIncrease(t *testing.T) { + ctx, r := newBackupStepContext() + path := filepath.Join(t.TempDir(), "staging.db") + if err := os.WriteFile(path, make([]byte, 100), 0o600); err != nil { + t.Fatalf("write staged file: %v", err) + } + + lastSize, degraded := reportBackupProgress(ctx, path, 0, false) + if lastSize != 100 { + t.Fatalf("lastSize = %d, want 100", lastSize) + } + if degraded { + t.Fatal("degraded = true, want false") + } + + snap := r.Snapshot() + if snap.Step == nil || snap.Step.Done == nil || *snap.Step.Done != 100 { + t.Fatalf("Step = %+v, want Done=100", snap.Step) + } + + if err := os.WriteFile(path, make([]byte, 100), 0o600); err != nil { + t.Fatalf("rewrite staged file: %v", err) + } + lastSize, degraded = reportBackupProgress(ctx, path, lastSize, degraded) + if lastSize != 100 { + t.Fatalf("lastSize after unchanged size = %d, want 100 (no regression, no duplicate advance)", lastSize) + } + if degraded { + t.Fatal("degraded = true after unchanged size, want false") + } +} + +func TestReportBackupProgressIgnoresMissingFile(t *testing.T) { + ctx, r := newBackupStepContext() + path := filepath.Join(t.TempDir(), "not-yet-created.db") + + lastSize, degraded := reportBackupProgress(ctx, path, 0, false) + if lastSize != 0 { + t.Fatalf("lastSize = %d, want 0 (VACUUM INTO has not created the file yet)", lastSize) + } + if degraded { + t.Fatal("degraded = true on ENOENT, want false: not yet started is not a measurement failure") + } + if snap := r.Snapshot(); snap.Step == nil || snap.Step.Done == nil || *snap.Step.Done != 0 { + t.Fatalf("Step = %+v, want Done=0 (no advance recorded)", r.Snapshot().Step) + } +} + +func TestReportBackupProgressDegradesOnceOnRealStatError(t *testing.T) { + ctx, _ := newBackupStepContext() + // Stat-ing a path under a regular file (not a directory) fails with + // ENOTDIR, which os.IsNotExist reports as false - a genuine stat error + // distinct from "VACUUM INTO has not created the file yet". + regularFile := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(regularFile, []byte("x"), 0o600); err != nil { + t.Fatalf("write regular file: %v", err) + } + path := filepath.Join(regularFile, "staging.db") + + lastSize, degraded := reportBackupProgress(ctx, path, 0, false) + if !degraded { + t.Fatal("degraded = false after a real stat error, want true") + } + if lastSize != 0 { + t.Fatalf("lastSize = %d, want 0", lastSize) + } + + // A second tick against the same failing path must not degrade again + // (Degrade only warns once per activation; this call proves the helper + // itself does not re-invoke it once already degraded). + lastSize, degraded = reportBackupProgress(ctx, path, lastSize, degraded) + if !degraded { + t.Fatal("degraded flag reset itself, want it to stay true") + } + if lastSize != 0 { + t.Fatalf("lastSize = %d, want 0", lastSize) + } +} + +func TestTrackBackupProgressReportsGrowthAndStopsCleanly(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + ctx, r := newBackupStepContext() + path := filepath.Join(t.TempDir(), "staging.db") + + stop := trackBackupProgress(ctx, path) + + if err := os.WriteFile(path, make([]byte, 42), 0o600); err != nil { + t.Fatalf("write staged file: %v", err) + } + time.Sleep(backupProgressTickInterval) + synctest.Wait() + + snap := r.Snapshot() + if snap.Step == nil || snap.Step.Done == nil || *snap.Step.Done != 42 { + t.Fatalf("Step = %+v, want Done=42 after one tick", snap.Step) + } + + stop() + synctest.Wait() + }) +} diff --git a/apps/backend/internal/persistence/provider.go b/apps/backend/internal/persistence/provider.go index eb011bcab1..b1322b0dc3 100644 --- a/apps/backend/internal/persistence/provider.go +++ b/apps/backend/internal/persistence/provider.go @@ -105,7 +105,9 @@ func provideSQLite(ctx context.Context, cfg *config.Config, log *logger.Logger, _ = pool.Close() return nil, nil, fmt.Errorf("create backup dir: %w", err) } + startup.BeginStep(ctx, startup.StepDatabaseBackup) path, size, err := createPreMigrationBackup(ctx, writer, backupDir, storedVersion) + startup.EndStep(ctx, startup.StepDatabaseBackup) if err != nil { _ = pool.Close() return nil, nil, err @@ -150,7 +152,9 @@ func createPreMigrationBackup(ctx context.Context, writer *sqlx.DB, backupDir, s defer func() { _ = os.RemoveAll(stagingDir) }() stagedPath := filepath.Join(stagingDir, filepath.Base(path)) + stopTracking := trackBackupProgress(ctx, stagedPath) size, err := snapshotSQLiteContext(ctx, writer, stagedPath) + stopTracking() if err != nil { return "", 0, fmt.Errorf("pre-migration backup failed: %w", err) } @@ -172,6 +176,60 @@ func createPreMigrationBackup(ctx context.Context, writer *sqlx.DB, backupDir, s return path, size, nil } +// backupProgressTickInterval is how often trackBackupProgress stats the +// staging file. VACUUM INTO gives no row-level progress, so this is the only +// signal the counting step has. +const backupProgressTickInterval = 250 * time.Millisecond + +// trackBackupProgress polls stagedPath's size while a VACUUM INTO writes it +// and reports the increase to the database.backup step. VACUUM INTO creates +// the file only once it starts writing, so ENOENT before that point is +// expected and not a measurement failure; any other stat error degrades the +// step once, not on every tick. The returned stop function cancels the +// goroutine and waits for it to exit before returning. +func trackBackupProgress(ctx context.Context, stagedPath string) func() { + tickerCtx, cancel := context.WithCancel(ctx) + stopped := make(chan struct{}) + go func() { + defer close(stopped) + ticker := time.NewTicker(backupProgressTickInterval) + defer ticker.Stop() + var lastSize int64 + var degraded bool + for { + select { + case <-tickerCtx.Done(): + return + case <-ticker.C: + lastSize, degraded = reportBackupProgress(ctx, stagedPath, lastSize, degraded) + } + } + }() + return func() { + cancel() + <-stopped + } +} + +// reportBackupProgress stats stagedPath once and advances the step by the +// size increase since lastSize, returning the updated size and degraded +// state for the next tick. +func reportBackupProgress(ctx context.Context, stagedPath string, lastSize int64, degraded bool) (int64, bool) { + info, err := os.Stat(stagedPath) + if err != nil { + if !os.IsNotExist(err) && !degraded { + startup.Degrade(ctx, startup.StepDatabaseBackup) + degraded = true + } + return lastSize, degraded + } + if delta := info.Size() - lastSize; delta > 0 { + startup.Advance(ctx, startup.StepDatabaseBackup, delta) + lastSize = info.Size() + } + return lastSize, degraded +} + func providePostgres(cfg *config.Config, log *logger.Logger) (*db.Pool, func() error, error) { dsn := cfg.Database.DSN() dbConn, err := db.OpenPostgres(dsn, cfg.Database.MaxConns, cfg.Database.MinConns) diff --git a/apps/backend/internal/persistence/requiredstores/catalog.go b/apps/backend/internal/persistence/requiredstores/catalog.go index 2a215a823b..4d058e0aed 100644 --- a/apps/backend/internal/persistence/requiredstores/catalog.go +++ b/apps/backend/internal/persistence/requiredstores/catalog.go @@ -5,6 +5,8 @@ package requiredstores import ( "fmt" "strings" + + "github.com/kandev/kandev/internal/startup" ) // Capability identifies a SQL behavior that a store conformance adapter must @@ -25,6 +27,19 @@ var validCapabilities = map[Capability]struct{}{ CapabilityTransaction: {}, } +// Store IDs referenced from multiple catalog entries, either as their own ID +// or as another entry's DependsOn, kept as constants so the two never drift. +const ( + storeIDSchemaMeta = "schema-meta" + storeIDTask = "task" + storeIDWorkflow = "workflow" + storeIDAgentSettings = "agent-settings" + storeIDUser = "user" + storeIDPluginInstances = "plugin-instances" + + pkgPluginsState = "internal/plugins/state" +) + // Descriptor is the immutable metadata for one built-in SQL schema owner. // RequiredTables are the table names used by runtime persistence probes. type Descriptor struct { @@ -33,53 +48,61 @@ type Descriptor struct { RequiredTables []string DependsOn []string Capabilities []Capability + + // Sweep names which store-admission startup step (stores.repositories or + // stores.services) claims this entry's `recordRequiredStore` call. Runtime + // admission order decides the owner, not file or definition line, so this + // is the single declared source both sweeps' totals read at BeginStep and + // what the catalog/registry completeness check asserts is total and + // non-overlapping. + Sweep startup.StepID } // catalog is ordered in the same dependency order used by backend startup. // Keep entries for stores whose feature is disabled: their schema remains part // of the database contract and must be initialized before readiness. var catalog = []Descriptor{ - {ID: "schema-meta", OwnerPackage: "internal/persistence", RequiredTables: []string{"kandev_meta"}}, - {ID: "task", OwnerPackage: "internal/task/repository/sqlite", RequiredTables: []string{"workspaces", "tasks", "task_workflow_session_bindings"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, - {ID: "workflow", OwnerPackage: "internal/workflow/repository", RequiredTables: []string{"workflow_templates", "workflow_steps"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, - {ID: "analytics", OwnerPackage: "internal/analytics/repository", RequiredTables: []string{"tasks"}, DependsOn: []string{"task", "workflow"}, Capabilities: []Capability{CapabilityTimestamp}}, - {ID: "agent-settings", OwnerPackage: "internal/agent/settings/store", RequiredTables: []string{"agents", "agent_profiles"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "user", OwnerPackage: "internal/user/store", RequiredTables: []string{"users"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "notification", OwnerPackage: "internal/notifications/store", RequiredTables: []string{"notification_providers"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "editor", OwnerPackage: "internal/editors/store", RequiredTables: []string{"editors"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, - {ID: "prompts", OwnerPackage: "internal/prompts/store", RequiredTables: []string{"custom_prompts"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, - {ID: "utility", OwnerPackage: "internal/utility/store", RequiredTables: []string{"utility_agents"}, DependsOn: []string{"agent-settings"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "office", OwnerPackage: "internal/office/repository/sqlite", RequiredTables: []string{"office_projects", "runs"}, DependsOn: []string{"task", "agent-settings"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, - {ID: "terminal", OwnerPackage: "internal/terminal/repository", RequiredTables: []string{"user_terminals"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "quick-terminal", OwnerPackage: "internal/quickterminal/repository", RequiredTables: []string{"quick_terminal_tabs"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, - {ID: "runtime-flags", OwnerPackage: "internal/runtimeflags", RequiredTables: []string{"runtime_flag_overrides"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "auth", OwnerPackage: "internal/auth/store", RequiredTables: []string{"auth_identities", "auth_sessions"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, - {ID: "secrets", OwnerPackage: "internal/secrets", RequiredTables: []string{"secrets"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, - {ID: "system-settings", OwnerPackage: "internal/system/settings", RequiredTables: []string{"settings"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, - {ID: "auth-hostnames", OwnerPackage: "internal/auth/hostnames", RequiredTables: []string{"auth_hostname_cache"}, DependsOn: []string{"auth"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, - {ID: "organizations", OwnerPackage: "internal/org", RequiredTables: []string{"orgs"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "organization-units", OwnerPackage: "internal/orgunit", RequiredTables: []string{"org_units"}, DependsOn: []string{"organizations"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, - {ID: "message-queue", OwnerPackage: "internal/orchestrator/messagequeue", RequiredTables: []string{"queued_messages", "queue_admission_receipts"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, - {ID: "task-share", OwnerPackage: "internal/task/share", RequiredTables: []string{"task_shares"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, - {ID: "telemetry-contract", OwnerPackage: "internal/telemetrycontract", RequiredTables: []string{"telemetry_activations"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}}, - {ID: "delivery", OwnerPackage: "internal/delivery", RequiredTables: []string{"task_delivery_ledger"}, DependsOn: []string{"task"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, - {ID: "storage", OwnerPackage: "internal/system/storage", RequiredTables: []string{"storage_maintenance_runs", "storage_quarantine_entries"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, - {ID: "plugin-instances", OwnerPackage: "internal/plugins/instances", RequiredTables: []string{"plugin_instances"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "plugin-marketplace", OwnerPackage: "internal/plugins/marketplace", RequiredTables: []string{"plugin_marketplace_source"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "plugin-settings", OwnerPackage: "internal/plugins", RequiredTables: []string{"plugin_settings"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "plugin-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_state"}, DependsOn: []string{"schema-meta"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "plugin-instance-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_instance_state"}, DependsOn: []string{"plugin-instances"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "plugin-user-state", OwnerPackage: "internal/plugins/state", RequiredTables: []string{"plugin_user_state"}, DependsOn: []string{"user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "canvas", OwnerPackage: "internal/canvas", RequiredTables: []string{"canvas_lifecycle_metadata", "canvas_creation_authority", "canvas_install_receipts"}, DependsOn: []string{"plugin-instances"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, - {ID: "github", OwnerPackage: "internal/github", RequiredTables: []string{"github_pr_watches"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "gitlab", OwnerPackage: "internal/gitlab", RequiredTables: []string{"gitlab_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "jira", OwnerPackage: "internal/jira", RequiredTables: []string{"jira_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "linear", OwnerPackage: "internal/linear", RequiredTables: []string{"linear_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "sentry", OwnerPackage: "internal/sentry", RequiredTables: []string{"sentry_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "azure-devops", OwnerPackage: "internal/azuredevops", RequiredTables: []string{"azure_devops_configs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "workflow-sync", OwnerPackage: "internal/workflowsync", RequiredTables: []string{"workflow_sync_configs"}, DependsOn: []string{"workflow"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "office-config-sync", OwnerPackage: "internal/office/configsync", RequiredTables: []string{"office_config_sync_configs"}, DependsOn: []string{"office"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}}, - {ID: "automation", OwnerPackage: "internal/automation", RequiredTables: []string{"automations", "automation_runs"}, DependsOn: []string{"task", "user"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}}, + {ID: storeIDSchemaMeta, OwnerPackage: "internal/persistence", RequiredTables: []string{"kandev_meta"}, Sweep: startup.StepStoresRepositories}, + {ID: storeIDTask, OwnerPackage: "internal/task/repository/sqlite", RequiredTables: []string{"workspaces", "tasks", "task_workflow_session_bindings"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}, Sweep: startup.StepStoresRepositories}, + {ID: storeIDWorkflow, OwnerPackage: "internal/workflow/repository", RequiredTables: []string{"workflow_templates", "workflow_steps"}, DependsOn: []string{storeIDTask}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}, Sweep: startup.StepStoresRepositories}, + {ID: "analytics", OwnerPackage: "internal/analytics/repository", RequiredTables: []string{"tasks"}, DependsOn: []string{storeIDTask, storeIDWorkflow}, Capabilities: []Capability{CapabilityTimestamp}, Sweep: startup.StepStoresRepositories}, + {ID: storeIDAgentSettings, OwnerPackage: "internal/agent/settings/store", RequiredTables: []string{"agents", "agent_profiles"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, + {ID: storeIDUser, OwnerPackage: "internal/user/store", RequiredTables: []string{"users"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, + {ID: "notification", OwnerPackage: "internal/notifications/store", RequiredTables: []string{"notification_providers"}, DependsOn: []string{storeIDUser}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, + {ID: "editor", OwnerPackage: "internal/editors/store", RequiredTables: []string{"editors"}, DependsOn: []string{storeIDUser}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, + {ID: "prompts", OwnerPackage: "internal/prompts/store", RequiredTables: []string{"custom_prompts"}, DependsOn: []string{storeIDUser}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, + {ID: "utility", OwnerPackage: "internal/utility/store", RequiredTables: []string{"utility_agents"}, DependsOn: []string{storeIDAgentSettings}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, + {ID: "office", OwnerPackage: "internal/office/repository/sqlite", RequiredTables: []string{"office_projects", "runs"}, DependsOn: []string{storeIDTask, storeIDAgentSettings}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}, Sweep: startup.StepStoresRepositories}, + {ID: "terminal", OwnerPackage: "internal/terminal/repository", RequiredTables: []string{"user_terminals"}, DependsOn: []string{storeIDUser}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, + {ID: "quick-terminal", OwnerPackage: "internal/quickterminal/repository", RequiredTables: []string{"quick_terminal_tabs"}, DependsOn: []string{storeIDUser}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, + {ID: "runtime-flags", OwnerPackage: "internal/runtimeflags", RequiredTables: []string{"runtime_flag_overrides"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, + {ID: "auth", OwnerPackage: "internal/auth/store", RequiredTables: []string{"auth_identities", "auth_sessions"}, DependsOn: []string{storeIDUser}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}, Sweep: startup.StepStoresRepositories}, + {ID: "secrets", OwnerPackage: "internal/secrets", RequiredTables: []string{"secrets"}, DependsOn: []string{storeIDTask}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}, Sweep: startup.StepStoresRepositories}, + {ID: "system-settings", OwnerPackage: "internal/system/settings", RequiredTables: []string{"settings"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, + {ID: "auth-hostnames", OwnerPackage: "internal/auth/hostnames", RequiredTables: []string{"auth_hostname_cache"}, DependsOn: []string{"auth"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, + {ID: "organizations", OwnerPackage: "internal/org", RequiredTables: []string{"orgs"}, DependsOn: []string{storeIDUser}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "organization-units", OwnerPackage: "internal/orgunit", RequiredTables: []string{"org_units"}, DependsOn: []string{"organizations"}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "message-queue", OwnerPackage: "internal/orchestrator/messagequeue", RequiredTables: []string{"queued_messages", "queue_admission_receipts"}, DependsOn: []string{storeIDTask}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}, Sweep: startup.StepStoresServices}, + {ID: "task-share", OwnerPackage: "internal/task/share", RequiredTables: []string{"task_shares"}, DependsOn: []string{storeIDTask}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "telemetry-contract", OwnerPackage: "internal/telemetrycontract", RequiredTables: []string{"telemetry_activations"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresRepositories}, + {ID: "delivery", OwnerPackage: "internal/delivery", RequiredTables: []string{"task_delivery_ledger"}, DependsOn: []string{storeIDTask}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}, Sweep: startup.StepStoresServices}, + {ID: "storage", OwnerPackage: "internal/system/storage", RequiredTables: []string{"storage_maintenance_runs", "storage_quarantine_entries"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}, Sweep: startup.StepStoresServices}, + {ID: storeIDPluginInstances, OwnerPackage: "internal/plugins/instances", RequiredTables: []string{"plugin_instances"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "plugin-marketplace", OwnerPackage: "internal/plugins/marketplace", RequiredTables: []string{"plugin_marketplace_source"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "plugin-settings", OwnerPackage: "internal/plugins", RequiredTables: []string{"plugin_settings"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "plugin-state", OwnerPackage: pkgPluginsState, RequiredTables: []string{"plugin_state"}, DependsOn: []string{storeIDSchemaMeta}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "plugin-instance-state", OwnerPackage: pkgPluginsState, RequiredTables: []string{"plugin_instance_state"}, DependsOn: []string{storeIDPluginInstances}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "plugin-user-state", OwnerPackage: pkgPluginsState, RequiredTables: []string{"plugin_user_state"}, DependsOn: []string{storeIDUser}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "canvas", OwnerPackage: "internal/canvas", RequiredTables: []string{"canvas_lifecycle_metadata", "canvas_creation_authority", "canvas_install_receipts"}, DependsOn: []string{storeIDPluginInstances}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}, Sweep: startup.StepStoresServices}, + {ID: "github", OwnerPackage: "internal/github", RequiredTables: []string{"github_pr_watches"}, DependsOn: []string{storeIDTask, storeIDUser}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "gitlab", OwnerPackage: "internal/gitlab", RequiredTables: []string{"gitlab_configs"}, DependsOn: []string{storeIDTask, storeIDUser}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "jira", OwnerPackage: "internal/jira", RequiredTables: []string{"jira_configs"}, DependsOn: []string{storeIDTask, storeIDUser}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "linear", OwnerPackage: "internal/linear", RequiredTables: []string{"linear_configs"}, DependsOn: []string{storeIDTask, storeIDUser}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "sentry", OwnerPackage: "internal/sentry", RequiredTables: []string{"sentry_configs"}, DependsOn: []string{storeIDTask, storeIDUser}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "azure-devops", OwnerPackage: "internal/azuredevops", RequiredTables: []string{"azure_devops_configs"}, DependsOn: []string{storeIDTask, storeIDUser}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "workflow-sync", OwnerPackage: "internal/workflowsync", RequiredTables: []string{"workflow_sync_configs"}, DependsOn: []string{storeIDWorkflow}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "office-config-sync", OwnerPackage: "internal/office/configsync", RequiredTables: []string{"office_config_sync_configs"}, DependsOn: []string{"office"}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict}, Sweep: startup.StepStoresServices}, + {ID: "automation", OwnerPackage: "internal/automation", RequiredTables: []string{"automations", "automation_runs"}, DependsOn: []string{storeIDTask, storeIDUser}, Capabilities: []Capability{CapabilityBoolean, CapabilityTimestamp, CapabilityConflict, CapabilityTransaction}, Sweep: startup.StepStoresServices}, } // Catalog returns a deep copy of the authoritative store catalog. @@ -113,6 +136,9 @@ func ValidateCatalog(descriptors []Descriptor) error { if len(descriptor.RequiredTables) == 0 { return fmt.Errorf("catalog entry %q has no required tables", descriptor.ID) } + if descriptor.Sweep != startup.StepStoresRepositories && descriptor.Sweep != startup.StepStoresServices { + return fmt.Errorf("catalog entry %q has invalid sweep %q", descriptor.ID, descriptor.Sweep) + } if err := validateNames(descriptor); err != nil { return err } diff --git a/apps/backend/internal/persistence/requiredstores/health_test.go b/apps/backend/internal/persistence/requiredstores/health_test.go index 3d63eafb25..7bc29cdda6 100644 --- a/apps/backend/internal/persistence/requiredstores/health_test.go +++ b/apps/backend/internal/persistence/requiredstores/health_test.go @@ -10,6 +10,7 @@ import ( _ "github.com/mattn/go-sqlite3" "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/startup" "github.com/kandev/kandev/internal/system/maintenance" ) @@ -43,7 +44,7 @@ func holdWriter(t *testing.T, conn *sqlx.DB) *sqlx.Tx { func TestRuntimeHealthDefersDuringMaintenance(t *testing.T) { conn, pool, tracker, health := newSQLiteHealthFixture(t, []Descriptor{{ - ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, + ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, Sweep: startup.StepStoresRepositories, }}) if _, err := conn.Exec("CREATE TABLE first (id TEXT PRIMARY KEY)"); err != nil { t.Fatalf("create first table: %v", err) @@ -80,8 +81,8 @@ func TestRuntimeHealthDefersDuringMaintenance(t *testing.T) { func TestRuntimeHealthDeferralPreservesMixedStoreStates(t *testing.T) { conn, pool, tracker, health := newSQLiteHealthFixture(t, []Descriptor{ - {ID: "healthy", OwnerPackage: "owner/healthy", RequiredTables: []string{"healthy"}}, - {ID: "unhealthy", OwnerPackage: "owner/unhealthy", RequiredTables: []string{"unhealthy"}}, + {ID: "healthy", OwnerPackage: "owner/healthy", RequiredTables: []string{"healthy"}, Sweep: startup.StepStoresRepositories}, + {ID: "unhealthy", OwnerPackage: "owner/unhealthy", RequiredTables: []string{"unhealthy"}, Sweep: startup.StepStoresRepositories}, }) if _, err := conn.Exec("CREATE TABLE healthy (id TEXT PRIMARY KEY)"); err != nil { t.Fatalf("create healthy table: %v", err) @@ -118,7 +119,7 @@ func TestRuntimeHealthDeferralPreservesMixedStoreStates(t *testing.T) { func TestStartupHealthDoesNotDefer(t *testing.T) { conn, pool, tracker, health := newSQLiteHealthFixture(t, []Descriptor{{ - ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, + ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, Sweep: startup.StepStoresRepositories, }}) if _, err := conn.Exec("CREATE TABLE first (id TEXT PRIMARY KEY)"); err != nil { t.Fatalf("create first table: %v", err) @@ -160,7 +161,7 @@ func TestStartupHealthDoesNotDefer(t *testing.T) { func TestRuntimeHealthResumesAfterMaintenance(t *testing.T) { conn, pool, tracker, health := newSQLiteHealthFixture(t, []Descriptor{{ - ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, + ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, Sweep: startup.StepStoresRepositories, }}) if _, err := conn.Exec("CREATE TABLE first (id TEXT PRIMARY KEY)"); err != nil { t.Fatalf("create first table: %v", err) @@ -231,7 +232,7 @@ func TestHealthCheckMarksMissingTableUnhealthyAndRecovers(t *testing.T) { } tracker, err := NewTracker([]Descriptor{{ - ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, + ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, Sweep: startup.StepStoresRepositories, }}) if err != nil { t.Fatalf("NewTracker: %v", err) @@ -278,7 +279,7 @@ func TestProbeTablesHonorsContextDeadline(t *testing.T) { } tracker, err := NewTracker([]Descriptor{{ - ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, + ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, Sweep: startup.StepStoresRepositories, }}) if err != nil { t.Fatalf("NewTracker: %v", err) diff --git a/apps/backend/internal/persistence/requiredstores/sweep_test.go b/apps/backend/internal/persistence/requiredstores/sweep_test.go new file mode 100644 index 0000000000..d552e64d56 --- /dev/null +++ b/apps/backend/internal/persistence/requiredstores/sweep_test.go @@ -0,0 +1,42 @@ +package requiredstores + +import ( + "testing" + + "github.com/kandev/kandev/internal/startup" +) + +// TestCatalogSweepAssignmentIsCompleteAndNonOverlapping is completeness test 1 +// from docs/specs/platform/system-design/startup-progress-visibility.md: +// every requiredstores.Catalog() entry is claimed by exactly one store- +// admission sweep step (AC-PLATFORM-STARTUP-PROGRESS-005.2). A Descriptor's +// Sweep field can only ever hold one value, so "exactly one" reduces to +// "a valid one"; this test also locks in the current runtime-order split +// (19 stores.repositories admissions in storage.go, 22 stores.services +// admissions elsewhere) so a future catalog entry silently landing on the +// wrong side of storage.go:215 fails loudly instead of only at startup. +func TestCatalogSweepAssignmentIsCompleteAndNonOverlapping(t *testing.T) { + catalog := Catalog() + + var repositories, services int + for _, descriptor := range catalog { + switch descriptor.Sweep { + case startup.StepStoresRepositories: + repositories++ + case startup.StepStoresServices: + services++ + default: + t.Errorf("catalog entry %q has sweep %q, want one of stores.repositories/stores.services", descriptor.ID, descriptor.Sweep) + } + } + + if total := repositories + services; total != len(catalog) { + t.Fatalf("sweep-claimed entries = %d, want %d (every catalog entry claimed exactly once)", total, len(catalog)) + } + if repositories != 19 { + t.Errorf("stores.repositories claims %d catalog entries, want 19", repositories) + } + if services != 22 { + t.Errorf("stores.services claims %d catalog entries, want 22", services) + } +} diff --git a/apps/backend/internal/persistence/requiredstores/tracker.go b/apps/backend/internal/persistence/requiredstores/tracker.go index a09eae5de5..6ebe793184 100644 --- a/apps/backend/internal/persistence/requiredstores/tracker.go +++ b/apps/backend/internal/persistence/requiredstores/tracker.go @@ -4,6 +4,8 @@ import ( "fmt" "sync" "time" + + "github.com/kandev/kandev/internal/startup" ) // State is the initialization or probe state of a required store. @@ -250,6 +252,33 @@ func (t *Tracker) Status(id string) (Status, bool) { return status, true } +// DescriptorSweep returns the store-admission sweep step that claims id, per +// its catalog descriptor. A sweep cannot infer its own share at runtime, so +// this is the declared source recordRequiredStore advances against. +func (t *Tracker) DescriptorSweep(id string) (startup.StepID, bool) { + t.mu.RLock() + defer t.mu.RUnlock() + index, exists := t.byID[id] + if !exists { + return "", false + } + return t.catalog[index].Sweep, true +} + +// SweepTotal returns how many catalog entries claim sweep. This is the +// sweep's total, read once at BeginStep. +func (t *Tracker) SweepTotal(sweep startup.StepID) int { + t.mu.RLock() + defer t.mu.RUnlock() + total := 0 + for _, descriptor := range t.catalog { + if descriptor.Sweep == sweep { + total++ + } + } + return total +} + func statusFor(descriptor Descriptor, state State) Status { return Status{ ID: descriptor.ID, diff --git a/apps/backend/internal/persistence/requiredstores/tracker_test.go b/apps/backend/internal/persistence/requiredstores/tracker_test.go index 060f548ffc..e2055d339f 100644 --- a/apps/backend/internal/persistence/requiredstores/tracker_test.go +++ b/apps/backend/internal/persistence/requiredstores/tracker_test.go @@ -1,14 +1,15 @@ package requiredstores import ( + "github.com/kandev/kandev/internal/startup" "strings" "testing" ) func TestTrackerRejectsUnknownDuplicateAndOutOfOrderResults(t *testing.T) { tracker, err := NewTracker([]Descriptor{ - {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}}, - {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}, DependsOn: []string{"first"}}, + {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, Sweep: startup.StepStoresRepositories}, + {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}, DependsOn: []string{"first"}, Sweep: startup.StepStoresRepositories}, }) if err != nil { t.Fatalf("NewTracker() error = %v", err) @@ -24,8 +25,8 @@ func TestTrackerRejectsUnknownDuplicateAndOutOfOrderResults(t *testing.T) { func TestTrackerSnapshotUsesCatalogOrderAndDetectsFailures(t *testing.T) { tracker, err := NewTracker([]Descriptor{ - {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}}, - {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}, DependsOn: []string{"first"}}, + {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, Sweep: startup.StepStoresRepositories}, + {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}, DependsOn: []string{"first"}, Sweep: startup.StepStoresRepositories}, }) if err != nil { t.Fatalf("NewTracker() error = %v", err) @@ -54,8 +55,8 @@ func TestTrackerSnapshotUsesCatalogOrderAndDetectsFailures(t *testing.T) { func TestTrackerDetectsMissingResults(t *testing.T) { tracker, err := NewTracker([]Descriptor{ - {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}}, - {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}}, + {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, Sweep: startup.StepStoresRepositories}, + {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}, Sweep: startup.StepStoresRepositories}, }) if err != nil { t.Fatalf("NewTracker() error = %v", err) @@ -70,8 +71,8 @@ func TestTrackerDetectsMissingResults(t *testing.T) { func TestTrackerProbeStateRecoversAndReportsUnavailableStores(t *testing.T) { tracker, err := NewTracker([]Descriptor{ - {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}}, - {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}}, + {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, Sweep: startup.StepStoresRepositories}, + {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}, Sweep: startup.StepStoresRepositories}, }) if err != nil { t.Fatalf("NewTracker() error = %v", err) diff --git a/apps/backend/internal/persistence/testmain_test.go b/apps/backend/internal/persistence/testmain_test.go new file mode 100644 index 0000000000..01196fa214 --- /dev/null +++ b/apps/backend/internal/persistence/testmain_test.go @@ -0,0 +1,16 @@ +package persistence + +import ( + "testing" + + "go.uber.org/goleak" +) + +// TestMain wraps the persistence package's test suite in goleak so the +// backup-progress ticker goroutine (trackBackupProgress) is verified to +// join on stop. Matches the goleak convention already used by +// internal/gateway/websocket, agentctl/server/process, and the runtime +// lifecycle package. +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/apps/backend/internal/startup/duration.go b/apps/backend/internal/startup/duration.go new file mode 100644 index 0000000000..6aca5ebfa6 --- /dev/null +++ b/apps/backend/internal/startup/duration.go @@ -0,0 +1,25 @@ +package startup + +// EstimateComponents converts an ETA in milliseconds into the whole count and +// unit AC-PLATFORM-STARTUP-PROGRESS-003.10 requires a rendering surface to +// use: 60000 ms or less rounds up to whole seconds with a floor of one second +// while work remains; anything longer rounds up to whole minutes. Never +// sub-second. Both the Go-rendered startup page and the web dialog mirror +// this rule so the two surfaces never disagree on the same snapshot. +func EstimateComponents(ms int64) (value int64, minutes bool) { + if ms <= 60000 { + seconds := ceilDiv(ms, 1000) + if seconds < 1 { + seconds = 1 + } + return seconds, false + } + return ceilDiv(ms, 60000), true +} + +func ceilDiv(numerator, denominator int64) int64 { + if numerator <= 0 { + return 0 + } + return (numerator + denominator - 1) / denominator +} diff --git a/apps/backend/internal/startup/duration_test.go b/apps/backend/internal/startup/duration_test.go new file mode 100644 index 0000000000..5534a0f62a --- /dev/null +++ b/apps/backend/internal/startup/duration_test.go @@ -0,0 +1,30 @@ +package startup + +import "testing" + +func TestEstimateComponents(t *testing.T) { + cases := []struct { + name string + ms int64 + wantValue int64 + wantMinutes bool + }{ + {"zero floors to one second", 0, 1, false}, + {"sub-second floors to one second", 250, 1, false}, + {"exact second stays whole", 1000, 1, false}, + {"partial second rounds up", 1001, 2, false}, + {"boundary of 60000ms renders in seconds", 60000, 60, false}, + {"just over the boundary renders in minutes", 60001, 2, true}, + {"exact minute stays whole", 120000, 2, true}, + {"partial minute rounds up", 120001, 3, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + value, minutes := EstimateComponents(tc.ms) + if value != tc.wantValue || minutes != tc.wantMinutes { + t.Fatalf("EstimateComponents(%d) = (%d, %v), want (%d, %v)", + tc.ms, value, minutes, tc.wantValue, tc.wantMinutes) + } + }) + } +} diff --git a/apps/backend/internal/startup/progress.go b/apps/backend/internal/startup/progress.go index a1bba3058b..fbd1a1a86f 100644 --- a/apps/backend/internal/startup/progress.go +++ b/apps/backend/internal/startup/progress.go @@ -3,6 +3,8 @@ package startup import ( "context" + cryptorand "crypto/rand" + "encoding/binary" "sync" "time" @@ -42,9 +44,12 @@ func (p Phase) Label() string { } type Snapshot struct { - Phase Phase `json:"phase"` - ElapsedMS int64 `json:"elapsed_ms"` - PhaseElapsedMS int64 `json:"phase_elapsed_ms"` + Phase Phase `json:"phase"` + Boot int64 `json:"boot"` + Seq uint64 `json:"seq"` + ElapsedMS int64 `json:"elapsed_ms"` + PhaseElapsedMS int64 `json:"phase_elapsed_ms"` + Step *StepSnapshot `json:"step,omitempty"` } type Reporter struct { @@ -52,13 +57,34 @@ type Reporter struct { started, phaseStarted time.Time phase Phase log *logger.Logger + + boot int64 + seq uint64 + active *stepActivation + unregisteredWarned map[StepID]bool + mismatchWarned map[mismatchKey]bool } type contextKey struct{} func New(log *logger.Logger) *Reporter { now := time.Now() - return &Reporter{started: now, phaseStarted: now, phase: OpeningDatabase, log: log} + return &Reporter{started: now, phaseStarted: now, phase: OpeningDatabase, log: log, boot: newBoot()} +} + +// newBoot draws a positive, non-guessable, per-process identifier below 2^53 +// so every value decodes exactly when a consumer reads JSON numbers as +// doubles. crypto/rand.Read never errors on the Go version this module +// requires, which is what lets New keep its error-free signature. +func newBoot() int64 { + var buf [8]byte + for { + _, _ = cryptorand.Read(buf[:]) + v := int64(binary.BigEndian.Uint64(buf[:]) & ((1 << 53) - 1)) + if v != 0 { + return v + } + } } func WithReporter(ctx context.Context, r *Reporter) context.Context { return context.WithValue(ctx, contextKey{}, r) @@ -75,7 +101,18 @@ func SetPhase(ctx context.Context, phase Phase) { func (r *Reporter) Snapshot() Snapshot { r.mu.Lock() defer r.mu.Unlock() - return Snapshot{Phase: r.phase, ElapsedMS: time.Since(r.started).Milliseconds(), PhaseElapsedMS: time.Since(r.phaseStarted).Milliseconds()} + now := time.Now() + snap := Snapshot{ + Phase: r.phase, + Boot: r.boot, + Seq: r.seq, + ElapsedMS: now.Sub(r.started).Milliseconds(), + PhaseElapsedMS: now.Sub(r.phaseStarted).Milliseconds(), + } + if r.active != nil { + snap.Step = r.active.snapshot(now) + } + return snap } func (r *Reporter) Set(phase Phase) { r.mu.Lock() diff --git a/apps/backend/internal/startup/registry_completeness_test.go b/apps/backend/internal/startup/registry_completeness_test.go new file mode 100644 index 0000000000..8a05a824f4 --- /dev/null +++ b/apps/backend/internal/startup/registry_completeness_test.go @@ -0,0 +1,208 @@ +package startup + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/kandev/kandev/internal/i18n" +) + +// TestRegisteredIdentifiersMatchAC005_3 is completeness test 2: the registered +// identifier set equals AC-PLATFORM-STARTUP-PROGRESS-005.3's list exactly, in +// both directions. +func TestRegisteredIdentifiersMatchAC005_3(t *testing.T) { + want := map[StepID]bool{ + StepDatabaseBackup: true, + StepStoresRepositories: true, + StepStoresServices: true, + StepPromptSeqBackfill: true, + StepMessageTimestampsBackfill: true, + StepSubagentContextBackfill: true, + StepSessionsRecovery: true, + } + + got := map[StepID]bool{} + for _, spec := range Registry() { + got[spec.ID] = true + } + + for id := range want { + if !got[id] { + t.Errorf("AC-005.3 identifier %q is not registered", id) + } + } + for id := range got { + if !want[id] { + t.Errorf("registered identifier %q is absent from the AC-005.3 list", id) + } + } +} + +// TestEveryLabelKeyResolvesInEveryLocale is completeness test 3: every +// LabelKey resolves in every supported backend locale and every supported web +// locale. The backend half asserts the key is defined in the `en` catalog; +// i18n's own TestCatalogsHaveMatchingKeys already asserts every other backend +// locale carries an identical key set, so that transitively covers pt-pt, +// zh-cn, zh-tw, zh-hk, and pseudo. The web half reads the `startup` namespace's +// `en` catalog directly (the LabelKey with its leading "startup." stripped, +// matching the web resolution convention `t(key, { ns: "startup" })`); the +// repo's own i18n:check gate transitively covers the other web locales the +// same way. +func TestEveryLabelKeyResolvesInEveryLocale(t *testing.T) { + backendKeys := map[string]bool{} + for _, key := range i18n.Keys() { + backendKeys[key] = true + } + webCatalog := readWebStartupCatalog(t) + + for _, spec := range Registry() { + if !backendKeys[spec.LabelKey] { + t.Errorf("step %q label key %q is missing from the backend en catalog", spec.ID, spec.LabelKey) + } + webKey := strings.TrimPrefix(spec.LabelKey, "startup.") + if _, ok := webCatalog[webKey]; !ok { + t.Errorf("step %q label key %q (web key %q) is missing from apps/web/src/locales/en/startup.json", spec.ID, spec.LabelKey, webKey) + } + } +} + +func readWebStartupCatalog(t *testing.T) map[string]string { + t.Helper() + _, sourceFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate startup package") + } + // internal/startup -> internal -> backend -> apps -> repo root + repoRoot := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "..") + path := filepath.Join(repoRoot, "apps", "web", "src", "locales", "en", "startup.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read web startup catalog: %v", err) + } + catalog := map[string]string{} + if err := json.Unmarshal(data, &catalog); err != nil { + t.Fatalf("parse web startup catalog: %v", err) + } + return catalog +} + +// TestRegisteredStepsDeclarePhaseMatchesCallSite is completeness test 4: +// each step's declared Phase equals the phase active at its production +// BeginStep call site (AC-PLATFORM-STARTUP-PROGRESS-005.7). The right-hand +// side of callSitePhase is derived from each call site's governing SetPhase +// transition, not copied from the registry, so a call site moving to a +// different phase without a matching registry update (or vice versa) fails +// here instead of only warning silently at runtime. +func TestRegisteredStepsDeclarePhaseMatchesCallSite(t *testing.T) { + callSitePhase := map[StepID]Phase{ + // persistence/provider.go:101 sets backing_up_database immediately + // before :108's BeginStep. + StepDatabaseBackup: BackingUpDatabase, + // backendapp/storage.go:62 sets applying_migrations immediately + // before :63's BeginStep. + StepStoresRepositories: ApplyingMigrations, + // backendapp/storage.go:218 sets initializing_services immediately + // before :219's BeginStep. + StepStoresServices: InitializingServices, + // task/repository/sqlite/base_migrations.go:211/:477 and + // subagent_context_backfill.go:68, reached from the same + // runMigrations call chain during repository construction. + StepMessageTimestampsBackfill: ApplyingMigrations, + StepPromptSeqBackfill: ApplyingMigrations, + StepSubagentContextBackfill: ApplyingMigrations, + // agent/runtime/lifecycle/manager_lifecycle.go:56, inside + // Manager.Start, called from backendapp/main.go's + // startAgentInfrastructure immediately after that function's own + // SetPhase(RecoveringSessions) transition (not the later + // bindListeners closure in startGatewayAndServe, which runs after + // recovery has already completed). + StepSessionsRecovery: RecoveringSessions, + } + + for _, spec := range Registry() { + want, ok := callSitePhase[spec.ID] + if !ok { + t.Errorf("step %q has no call-site phase recorded in this test's table", spec.ID) + continue + } + if spec.Phase != want { + t.Errorf("step %q declares Phase=%q but its call site runs under %q", spec.ID, spec.Phase, want) + } + } + if len(callSitePhase) != len(Registry()) { + t.Errorf("call-site phase table has %d entries, want %d (one per registered step)", len(callSitePhase), len(Registry())) + } +} + +// TestRetiredIdentifiersCannotBeReRegistered is completeness test 5: the three +// identifiers retired with their work by 2eee90d38 are recorded in the +// append-only retired list, are absent from the registry, and panic through +// init()'s own guard if re-registered (AC-PLATFORM-STARTUP-PROGRESS-005.6). +func TestRetiredIdentifiersCannotBeReRegistered(t *testing.T) { + wantRetired := []StepID{ + "task.journal.turns.backfill", + "task.journal.messages.backfill", + "plugins.session_events.mirror", + } + + if len(retiredStepIDs) != len(wantRetired) { + t.Errorf("retiredStepIDs has %d entries, want %d: %v", len(retiredStepIDs), len(wantRetired), retiredStepIDs) + } + + registered := map[StepID]bool{} + for _, spec := range Registry() { + registered[spec.ID] = true + } + + for _, id := range wantRetired { + if !IsRetired(id) { + t.Errorf("IsRetired(%q) = false, want true", id) + } + if registered[id] { + t.Errorf("retired identifier %q is still registered", id) + } + if !panicsOnRegistration(id) { + t.Errorf("re-registering retired identifier %q did not panic", id) + } + } +} + +// panicsOnRegistration reports whether init()'s production guard rejects a +// registry that reuses id. It calls assertNoRetiredRegistrations rather than +// re-implementing the check, so the test cannot drift from the guard. +func panicsOnRegistration(id StepID) (panicked bool) { + defer func() { + if recover() != nil { + panicked = true + } + }() + assertNoRetiredRegistrations([]StepSpec{{ID: id}}) + return false +} + +// TestRegistryDeclarationsMatchMeasure is completeness test 6: a counting or +// counted entry declares a non-empty Corpus; an opaque entry declares none of +// Corpus/Outstanding/Order; a non-empty Outstanding requires Measure==counted. +func TestRegistryDeclarationsMatchMeasure(t *testing.T) { + for _, spec := range Registry() { + switch spec.Measure { + case MeasureOpaque: + if spec.Corpus != "" || spec.Outstanding != "" || len(spec.Order) != 0 { + t.Errorf("step %q is opaque but declares Corpus=%q Outstanding=%q Order=%v", spec.ID, spec.Corpus, spec.Outstanding, spec.Order) + } + case MeasureCounting, MeasureCounted: + if spec.Corpus == "" { + t.Errorf("step %q has measure %q but declares no Corpus", spec.ID, spec.Measure) + } + default: + t.Errorf("step %q has unknown measure %q", spec.ID, spec.Measure) + } + if spec.Outstanding != "" && spec.Measure != MeasureCounted { + t.Errorf("step %q declares Outstanding but measure is %q, want counted", spec.ID, spec.Measure) + } + } +} diff --git a/apps/backend/internal/startup/snapshot_wire_test.go b/apps/backend/internal/startup/snapshot_wire_test.go new file mode 100644 index 0000000000..16d315136f --- /dev/null +++ b/apps/backend/internal/startup/snapshot_wire_test.go @@ -0,0 +1,87 @@ +package startup + +import ( + "encoding/json" + "testing" +) + +func TestSnapshotBootIsStableAndPositive(t *testing.T) { + r := New(nil) + first := r.Snapshot().Boot + second := r.Snapshot().Boot + if first != second { + t.Fatalf("boot changed within one process: %d -> %d", first, second) + } + if first <= 0 || first >= (1<<53) { + t.Fatalf("boot = %d, want a positive integer below 2^53", first) + } +} + +func TestSnapshotSeqStartsAtZeroAndIncrementsPerStepTransition(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + if got := r.Snapshot().Seq; got != 0 { + t.Fatalf("seq = %d, want 0 before any step transition", got) + } + r.BeginStep(StepStoresRepositories) + if got := r.Snapshot().Seq; got != 1 { + t.Fatalf("seq = %d, want 1 after one BeginStep", got) + } + r.EndStep(StepStoresRepositories) + if got := r.Snapshot().Seq; got != 2 { + t.Fatalf("seq = %d, want 2 after one EndStep", got) + } +} + +func TestSnapshotSeqUnaffectedByPhaseTransitions(t *testing.T) { + r := New(nil) + r.Set(BackingUpDatabase) + r.Set(ApplyingMigrations) + if got := r.Snapshot().Seq; got != 0 { + t.Fatalf("seq = %d, want 0: phase transitions alone must not move it", got) + } +} + +func TestSnapshotJSONOmitsStepWhenNoneActive(t *testing.T) { + r := New(nil) + raw, err := json.Marshal(r.Snapshot()) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded map[string]json.RawMessage + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if _, present := decoded["step"]; present { + t.Fatalf("step must be omitted entirely with none active, got %s", raw) + } + for _, field := range []string{"phase", "boot", "seq", "elapsed_ms", "phase_elapsed_ms"} { + if _, present := decoded[field]; !present { + t.Fatalf("expected top-level field %q in %s", field, raw) + } + } +} + +func TestStepSnapshotJSONFieldPresenceByMeasure(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 100) + r.Advance(StepStoresRepositories, 10) + + raw, err := json.Marshal(r.Snapshot()) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded struct { + Step map[string]json.RawMessage `json:"step"` + } + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, field := range []string{"id", "label_key", "measure", "unit", "elapsed_ms", "done", "total", "rate_per_second", "since_advance_ms", "stalled"} { + if _, present := decoded.Step[field]; !present { + t.Fatalf("counted, advanced step should carry %q, got %s", field, raw) + } + } +} diff --git a/apps/backend/internal/startup/step.go b/apps/backend/internal/startup/step.go new file mode 100644 index 0000000000..3e55811982 --- /dev/null +++ b/apps/backend/internal/startup/step.go @@ -0,0 +1,180 @@ +package startup + +// StepID identifies a registered, named unit of startup work. The registry is +// the only source of valid identifiers (AC-PLATFORM-STARTUP-PROGRESS-005.1). +type StepID string + +const ( + StepDatabaseBackup StepID = "database.backup" + StepStoresRepositories StepID = "stores.repositories" + StepStoresServices StepID = "stores.services" + StepPromptSeqBackfill StepID = "task.prompt_seq.backfill" + StepMessageTimestampsBackfill StepID = "task.message_timestamps.backfill" + StepSubagentContextBackfill StepID = "task.subagent_context.backfill" + StepSessionsRecovery StepID = "sessions.recovery" +) + +// Measure is the ladder a step's reported progress can occupy: counted (done +// and total both known) > counting (done known, no total) > opaque (neither). +type Measure string + +const ( + MeasureOpaque Measure = "opaque" + MeasureCounting Measure = "counting" + MeasureCounted Measure = "counted" +) + +// Unit names the noun a step's count is measured in. It is an enum, not a +// free string, so the localized noun and its plural belong to the rendering +// surface rather than the wire payload. +type Unit string + +const ( + UnitRows Unit = "rows" + UnitMessages Unit = "messages" + UnitTurns Unit = "turns" + UnitSessions Unit = "sessions" + UnitBytes Unit = "bytes" + UnitStores Unit = "stores" +) + +// Dialect is a database backend a step can apply under. +type Dialect string + +const ( + DialectSQLite Dialect = "sqlite" + DialectPostgres Dialect = "postgres" +) + +var bothDialects = []Dialect{DialectSQLite, DialectPostgres} + +// StepSpec is a registry entry declaring everything about one step that does +// not vary between activations. +type StepSpec struct { + ID StepID + Phase Phase + Measure Measure + Unit Unit + LabelKey string + Dialects []Dialect + + // Corpus names the record set a reported count covers. Empty when Measure + // is opaque. + Corpus string + // Outstanding names the predicate selecting work still outstanding. + // Non-empty only for a step that seeds its done count from durable state. + Outstanding string + // Order lists the loop query's existing ordering and tiebreak columns, + // outermost first. Empty when the loop walks no query-ordered row set. + Order []string +} + +// registry is the complete, exhaustive set of registered steps +// (AC-PLATFORM-STARTUP-PROGRESS-005.3). +var registry = []StepSpec{ + { + ID: StepDatabaseBackup, + Phase: BackingUpDatabase, + Measure: MeasureCounting, + Unit: UnitBytes, + LabelKey: "startup.step.database_backup", + Dialects: []Dialect{DialectSQLite}, + Corpus: "staging file size in bytes", + }, + { + ID: StepStoresRepositories, + Phase: ApplyingMigrations, + Measure: MeasureCounted, + Unit: UnitStores, + LabelKey: "startup.step.stores_repositories", + Dialects: bothDialects, + Corpus: "catalog entries whose descriptor names this sweep", + }, + { + ID: StepStoresServices, + Phase: InitializingServices, + Measure: MeasureCounted, + Unit: UnitStores, + LabelKey: "startup.step.stores_services", + Dialects: bothDialects, + Corpus: "catalog entries whose descriptor names this sweep", + }, + { + ID: StepPromptSeqBackfill, + Phase: ApplyingMigrations, + Measure: MeasureOpaque, + Unit: UnitRows, + LabelKey: "startup.step.prompt_seq", + Dialects: bothDialects, + }, + { + ID: StepMessageTimestampsBackfill, + Phase: ApplyingMigrations, + Measure: MeasureOpaque, + Unit: UnitRows, + LabelKey: "startup.step.message_timestamps", + Dialects: bothDialects, + }, + { + ID: StepSubagentContextBackfill, + Phase: ApplyingMigrations, + Measure: MeasureOpaque, + Unit: UnitRows, + LabelKey: "startup.step.subagent_context", + Dialects: bothDialects, + }, + { + ID: StepSessionsRecovery, + Phase: RecoveringSessions, + Measure: MeasureCounted, + Unit: UnitSessions, + LabelKey: "startup.step.session_recovery", + Dialects: bothDialects, + Corpus: "the executors_running record set read before the sweep", + }, +} + +// retiredStepIDs is append-only: a removed identifier is listed here forever +// and can never be re-registered (AC-PLATFORM-STARTUP-PROGRESS-005.6). +var retiredStepIDs = map[StepID]struct{}{ + "task.journal.turns.backfill": {}, + "task.journal.messages.backfill": {}, + "plugins.session_events.mirror": {}, +} + +func init() { + assertNoRetiredRegistrations(registry) +} + +// assertNoRetiredRegistrations panics if any spec reuses a retired identifier +// (AC-PLATFORM-STARTUP-PROGRESS-005.6). +func assertNoRetiredRegistrations(specs []StepSpec) { + for _, spec := range specs { + if _, retired := retiredStepIDs[spec.ID]; retired { + panic("startup: registered step reuses a retired identifier: " + string(spec.ID)) + } + } +} + +// Registry returns a copy of the complete step registry. +func Registry() []StepSpec { + out := make([]StepSpec, len(registry)) + copy(out, registry) + return out +} + +// IsRetired reports whether id was once registered and has since been +// retired, and so must never be reused. +func IsRetired(id StepID) bool { + _, retired := retiredStepIDs[id] + return retired +} + +func lookupStep(id StepID) (StepSpec, bool) { + for _, spec := range registry { + if spec.ID == id { + return spec, true + } + } + return StepSpec{}, false +} diff --git a/apps/backend/internal/startup/step_lifecycle.go b/apps/backend/internal/startup/step_lifecycle.go new file mode 100644 index 0000000000..224ef0794c --- /dev/null +++ b/apps/backend/internal/startup/step_lifecycle.go @@ -0,0 +1,338 @@ +package startup + +import ( + "context" + "math" + "time" + + "go.uber.org/zap" +) + +const ( + stallThresholdMS = 120000 + rateWindow = 30 * time.Second + maxETAMs = 86400000 + maxRateSamples = 64 +) + +type rateSample struct { + at time.Time + delta int64 +} + +// stepActivation is the mutable state of one BeginStep..EndStep lifetime. A +// fresh one is created on every BeginStep, so nothing here survives past the +// next activation of the same step. +type stepActivation struct { + spec StepSpec + startedAt time.Time + + measure Measure + promotionBlocked bool + + hasTotal bool + total int64 + done int64 + anchorDone int64 + + hasAdvancedOnce bool + lastAdvanceAt time.Time + samples []rateSample + + warned map[string]bool +} + +// BeginStep ends the currently active step, if any, and opens id as the new +// active step, as a single sequence-number transition. A call naming the +// already-active step is a no-op. A call naming an unregistered identifier +// opens no step and records one warning. +func (r *Reporter) BeginStep(id StepID) { + r.mu.Lock() + defer r.mu.Unlock() + spec, ok := lookupStep(id) + if !ok { + r.warnUnregisteredLocked(id) + return + } + if r.active != nil && r.active.spec.ID == id { + return + } + r.endActiveLocked() + + a := &stepActivation{spec: spec, startedAt: time.Now(), measure: MeasureOpaque, warned: map[string]bool{}} + if spec.Measure == MeasureCounting { + a.measure = MeasureCounting + } + r.active = a + r.seq++ + if spec.Phase != r.phase { + r.warnActivationLocked(a, "phase_mismatch") + } + if r.log != nil { + r.log.Info("Startup step started", zap.String("step", string(id)), zap.String("measure", string(a.measure))) + } +} + +// EndStep closes the active step if it is the one named. A call naming a +// step that is not active changes nothing. +func (r *Reporter) EndStep(id StepID) { + r.mu.Lock() + defer r.mu.Unlock() + if r.active == nil || r.active.spec.ID != id { + return + } + r.endActiveLocked() + r.seq++ +} + +func (r *Reporter) endActiveLocked() { + a := r.active + if a == nil { + return + } + if r.log != nil { + r.log.Info("Startup step completed", + zap.String("step", string(a.spec.ID)), zap.String("measure", string(a.measure)), + zap.Int64("done", a.done), zap.Int64("total", a.total)) + if a.measure == MeasureCounted && a.hasTotal && a.done < a.total { + r.log.Warn("Startup step ended below its total", + zap.String("step", string(a.spec.ID)), zap.Int64("done", a.done), zap.Int64("total", a.total)) + } + } + r.active = nil +} + +// SeedDone assigns the active step's done count outright from durably +// applied work, without appending a rate sample or touching the +// last-advance clock. Rejected once the total is set or the step has +// advanced. +func (r *Reporter) SeedDone(id StepID, done int64) { + r.mu.Lock() + defer r.mu.Unlock() + a := r.active + if a == nil || a.spec.ID != id { + r.warnMismatchLocked(id, "seed_done") + return + } + if done < 0 { + r.warnActivationLocked(a, "seed_done_negative") + return + } + if a.hasTotal || a.hasAdvancedOnce { + r.warnActivationLocked(a, "seed_done_rejected") + return + } + if done == a.done { + return + } + a.done = done + a.anchorDone = done +} + +// SetTotal accepts the active step's total, the single promotion point for a +// step whose registry entry declares counted. A total of zero seals the +// activation opaque; any other accepted total promotes it. +func (r *Reporter) SetTotal(id StepID, total int64) { + r.mu.Lock() + defer r.mu.Unlock() + a := r.active + if a == nil || a.spec.ID != id { + r.warnMismatchLocked(id, "set_total") + return + } + if total < 0 { + r.warnActivationLocked(a, "set_total_negative") + return + } + if a.hasTotal { + if total != a.total { + r.warnActivationLocked(a, "set_total_rejected") + } + return + } + a.hasTotal = true + a.total = total + if a.done > total { + a.done = total + } + if total == 0 { + a.promotionBlocked = true + return + } + if !a.promotionBlocked && a.spec.Measure == MeasureCounted { + a.measure = MeasureCounted + } +} + +// Advance adds delta to the active step's done count, saturating at the +// total when one is known. Only the actual increase - never the requested +// delta - resets the stall clock and feeds the rate sample. +func (r *Reporter) Advance(id StepID, delta int64) { + r.mu.Lock() + defer r.mu.Unlock() + a := r.active + if a == nil || a.spec.ID != id { + r.warnMismatchLocked(id, "advance") + return + } + if delta <= 0 { + return + } + newDone := saturatingAdd(a.done, delta) + if a.hasTotal && newDone > a.total { + newDone = a.total + r.warnActivationLocked(a, "advance_exceeds_total") + } + increase := newDone - a.done + a.done = newDone + if increase <= 0 { + return + } + now := time.Now() + a.hasAdvancedOnce = true + a.lastAdvanceAt = now + samples, droppedDelta := appendSample(a.samples, rateSample{at: now, delta: increase}) + a.samples = samples + a.anchorDone += droppedDelta +} + +// Degrade moves the active step's measure down one rung (counted->counting, +// counting->opaque) and permanently blocks re-promotion for the rest of this +// activation, including when it is called while already opaque. +func (r *Reporter) Degrade(id StepID) { + r.mu.Lock() + defer r.mu.Unlock() + a := r.active + if a == nil || a.spec.ID != id { + r.warnMismatchLocked(id, "degrade") + return + } + switch a.measure { + case MeasureCounted: + a.measure = MeasureCounting + case MeasureCounting: + a.measure = MeasureOpaque + case MeasureOpaque: + // already at the floor; only the latch below changes. + } + a.promotionBlocked = true + r.warnActivationLocked(a, "degrade") +} + +func (r *Reporter) warnActivationLocked(a *stepActivation, condition string) { + if a.warned[condition] { + return + } + a.warned[condition] = true + if r.log != nil { + r.log.Warn("Startup step warning", zap.String("step", string(a.spec.ID)), zap.String("condition", condition)) + } +} + +func (r *Reporter) warnUnregisteredLocked(id StepID) { + if r.unregisteredWarned == nil { + r.unregisteredWarned = map[StepID]bool{} + } + if r.unregisteredWarned[id] { + return + } + r.unregisteredWarned[id] = true + if r.log != nil { + r.log.Warn("Startup step begin for unregistered identifier", zap.String("step", string(id))) + } +} + +// mismatchKey dedupes a "called for a step that isn't active" warning per +// (target step, operation): a caller bug of this shape typically repeats on +// every record in a loop, and only the first occurrence is informative. +type mismatchKey struct { + id StepID + op string +} + +func (r *Reporter) warnMismatchLocked(id StepID, op string) { + if r.mismatchWarned == nil { + r.mismatchWarned = map[mismatchKey]bool{} + } + key := mismatchKey{id: id, op: op} + if r.mismatchWarned[key] { + return + } + r.mismatchWarned[key] = true + if r.log != nil { + var active StepID + if r.active != nil { + active = r.active.spec.ID + } + r.log.Warn("Startup step call for a step that is not active", + zap.String("step", string(id)), zap.String("op", op), zap.String("active_step", string(active))) + } +} + +func saturatingAdd(a, b int64) int64 { + sum := a + b + if sum < a { + return math.MaxInt64 + } + return sum +} + +// appendSample appends s to samples, trimming from the front once the ring +// exceeds maxRateSamples. It also returns the sum of the trimmed samples' +// deltas so the caller can fold it into anchorDone: rateWindowStart's +// cumulative-sum walk assumes anchorDone is the done value immediately +// before the first sample still in the slice, and silently dropping old +// samples without that adjustment understates the window's baseline, +// overstating the rate it produces. +func appendSample(samples []rateSample, s rateSample) ([]rateSample, int64) { + samples = append(samples, s) + var droppedDelta int64 + if len(samples) > maxRateSamples { + trimCount := len(samples) - maxRateSamples + for _, dropped := range samples[:trimCount] { + droppedDelta += dropped.delta + } + samples = samples[trimCount:] + } + return samples, droppedDelta +} + +// Package-level helpers follow the existing SetPhase pattern: a no-op when +// ctx carries no reporter, which keeps every call site safe in tests and +// embedded use. + +func BeginStep(ctx context.Context, id StepID) { + if r := FromContext(ctx); r != nil { + r.BeginStep(id) + } +} + +func EndStep(ctx context.Context, id StepID) { + if r := FromContext(ctx); r != nil { + r.EndStep(id) + } +} + +func SeedDone(ctx context.Context, id StepID, done int64) { + if r := FromContext(ctx); r != nil { + r.SeedDone(id, done) + } +} + +func SetTotal(ctx context.Context, id StepID, total int64) { + if r := FromContext(ctx); r != nil { + r.SetTotal(id, total) + } +} + +func Advance(ctx context.Context, id StepID, delta int64) { + if r := FromContext(ctx); r != nil { + r.Advance(id, delta) + } +} + +func Degrade(ctx context.Context, id StepID) { + if r := FromContext(ctx); r != nil { + r.Degrade(id) + } +} diff --git a/apps/backend/internal/startup/step_lifecycle_test.go b/apps/backend/internal/startup/step_lifecycle_test.go new file mode 100644 index 0000000000..5d28f6fd7b --- /dev/null +++ b/apps/backend/internal/startup/step_lifecycle_test.go @@ -0,0 +1,376 @@ +package startup + +import ( + "context" + "testing" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" + + "github.com/kandev/kandev/internal/common/logger" +) + +func TestBeginStepOpensRegisteredStepOpaqueOrCounting(t *testing.T) { + r := New(nil) + r.Set(BackingUpDatabase) + r.BeginStep(StepDatabaseBackup) + snap := r.Snapshot() + if snap.Step == nil { + t.Fatal("expected an active step") + } + if snap.Step.ID != StepDatabaseBackup { + t.Fatalf("step id = %q, want %q", snap.Step.ID, StepDatabaseBackup) + } + if snap.Step.Measure != MeasureCounting { + t.Fatalf("database.backup should open counting, got %q", snap.Step.Measure) + } + + r2 := New(nil) + r2.Set(ApplyingMigrations) + r2.BeginStep(StepStoresRepositories) + snap2 := r2.Snapshot() + if snap2.Step.Measure != MeasureOpaque { + t.Fatalf("counted-declared step should open opaque, got %q", snap2.Step.Measure) + } +} + +func TestBeginStepUnregisteredIdentifierOpensNoStep(t *testing.T) { + r := New(nil) + seqBefore := r.Snapshot().Seq + r.BeginStep(StepID("not.a.real.step")) + snap := r.Snapshot() + if snap.Step != nil { + t.Fatal("unregistered identifier must not open a step") + } + if snap.Seq != seqBefore { + t.Fatalf("seq changed on unregistered begin: before=%d after=%d", seqBefore, snap.Seq) + } +} + +func TestBeginStepAlreadyActiveIsNoOp(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SeedDone(StepStoresRepositories, 5) + r.SetTotal(StepStoresRepositories, 50) + seqAfterFirst := r.Snapshot().Seq + + r.BeginStep(StepStoresRepositories) + snap := r.Snapshot() + if snap.Seq != seqAfterFirst { + t.Fatalf("re-beginning the active step changed seq: %d -> %d", seqAfterFirst, snap.Seq) + } + if *snap.Step.Done != 5 { + t.Fatalf("re-beginning the active step reset counts: done=%d", *snap.Step.Done) + } +} + +func TestBeginStepEndsPriorAsOneTransition(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + seq0 := r.Snapshot().Seq + + r.BeginStep(StepPromptSeqBackfill) + snap := r.Snapshot() + if snap.Seq != seq0+1 { + t.Fatalf("switching active steps should increment seq by exactly one, got %d -> %d", seq0, snap.Seq) + } + if snap.Step.ID != StepPromptSeqBackfill { + t.Fatalf("active step = %q, want %q", snap.Step.ID, StepPromptSeqBackfill) + } +} + +func TestEndStepClosesOnlyTheActiveStep(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + seq0 := r.Snapshot().Seq + + r.EndStep(StepStoresServices) // not active: no-op + if r.Snapshot().Seq != seq0 { + t.Fatal("ending a non-active step must not change seq") + } + if r.Snapshot().Step == nil { + t.Fatal("ending a non-active step must not close the active one") + } + + r.EndStep(StepStoresRepositories) + snap := r.Snapshot() + if snap.Seq != seq0+1 { + t.Fatalf("ending the active step should increment seq by one, got %d -> %d", seq0, snap.Seq) + } + if snap.Step != nil { + t.Fatal("snapshot must retain no step after EndStep") + } +} + +func TestSeedDoneAssignsUntilTotalOrAdvance(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + + r.SeedDone(StepStoresRepositories, -1) // rejected; still opaque, done unexposed + if got := r.Snapshot().Step; got.Measure != MeasureOpaque { + t.Fatalf("negative seed must be rejected without promoting, measure = %q", got.Measure) + } + + r.SeedDone(StepStoresRepositories, 10) + r.SetTotal(StepStoresRepositories, 100) // promotes: done should reflect the seed + if *r.Snapshot().Step.Done != 10 { + t.Fatalf("done = %d, want 10", *r.Snapshot().Step.Done) + } + + r.SeedDone(StepStoresRepositories, 20) // rejected: total already set + if *r.Snapshot().Step.Done != 10 { + t.Fatalf("seed after total must be rejected, done = %d", *r.Snapshot().Step.Done) + } +} + +func TestSeedDoneRejectedAfterAdvance(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 100) + r.Advance(StepStoresRepositories, 5) + + r.SeedDone(StepStoresRepositories, 50) + if *r.Snapshot().Step.Done != 5 { + t.Fatalf("seed after advance must be rejected, done = %d", *r.Snapshot().Step.Done) + } +} + +func TestSetTotalPromotesAndClampsSeededDone(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SeedDone(StepStoresRepositories, 90) + + r.SetTotal(StepStoresRepositories, -1) + if r.Snapshot().Step.Measure != MeasureOpaque { + t.Fatal("negative total must be rejected without promoting") + } + + r.SetTotal(StepStoresRepositories, 50) // below the seeded done: clamp + snap := r.Snapshot().Step + if snap.Measure != MeasureCounted { + t.Fatalf("measure = %q, want counted", snap.Measure) + } + if *snap.Done != 50 { + t.Fatalf("seeded done should clamp to total, got %d", *snap.Done) + } + if *snap.Total != 50 { + t.Fatalf("total = %d, want 50", *snap.Total) + } +} + +func TestSetTotalZeroSealsOpaqueForActivation(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 0) + if snap := r.Snapshot().Step; snap.Measure != MeasureOpaque { + t.Fatalf("total of zero must seal the step opaque, got %q", snap.Measure) + } + r.SetTotal(StepStoresRepositories, 10) // later different total must not revive it + if snap := r.Snapshot().Step; snap.Measure != MeasureOpaque { + t.Fatalf("a later total must not revive a zero-total step, got %q", snap.Measure) + } +} + +func TestSetTotalRejectsADifferentTotalOnceAccepted(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 100) + r.SetTotal(StepStoresRepositories, 100) // equal: no-op, no warning + r.SetTotal(StepStoresRepositories, 200) // different: rejected + if *r.Snapshot().Step.Total != 100 { + t.Fatalf("total = %d, want 100 (unchanged)", *r.Snapshot().Step.Total) + } +} + +func TestAdvanceClampsToTotalAndSaturates(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 10) + + r.Advance(StepStoresRepositories, 0) // no-op + if *r.Snapshot().Step.Done != 0 { + t.Fatal("advance of zero must be a no-op") + } + r.Advance(StepStoresRepositories, -5) // no-op + if *r.Snapshot().Step.Done != 0 { + t.Fatal("negative advance must be a no-op") + } + r.Advance(StepStoresRepositories, 999) + if *r.Snapshot().Step.Done != 10 { + t.Fatalf("advance exceeding total must clamp, done = %d", *r.Snapshot().Step.Done) + } +} + +func TestAdvanceOnNonActiveIdentifierIsNoOp(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 10) + r.Advance(StepStoresServices, 5) // not the active step + if *r.Snapshot().Step.Done != 0 { + t.Fatal("advance naming a non-active identifier must change nothing") + } +} + +// TestMismatchOnNonActiveStepWarnsOncePerOperation covers +// SeedDone/SetTotal/Advance/Degrade calls naming a step that isn't active: +// without a warning these are silent no-ops, leaving an operator no way to +// learn a caller has drifted from the reporter's real active step. Each +// mismatched call must log exactly one warning per (step, operation) pair, +// not once per call. +func TestMismatchOnNonActiveStepWarnsOncePerOperation(t *testing.T) { + core, observed := observer.New(zap.InfoLevel) + log, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("observer logger: %v", err) + } + r := New(log) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + + r.SeedDone(StepStoresServices, 5) + r.SeedDone(StepStoresServices, 5) // repeat: must not log again + r.SetTotal(StepStoresServices, 10) + r.Advance(StepStoresServices, 1) + r.Degrade(StepStoresServices) + + wantOps := map[string]bool{"seed_done": false, "set_total": false, "advance": false, "degrade": false} + var mismatchCount int + for _, entry := range observed.All() { + if entry.Message != "Startup step call for a step that is not active" { + continue + } + mismatchCount++ + fields := entry.ContextMap() + if fields["step"] != string(StepStoresServices) { + t.Fatalf("warning step = %v, want %q", fields["step"], StepStoresServices) + } + if fields["active_step"] != string(StepStoresRepositories) { + t.Fatalf("warning active_step = %v, want %q", fields["active_step"], StepStoresRepositories) + } + op, _ := fields["op"].(string) + if _, known := wantOps[op]; !known { + t.Fatalf("unexpected op %q in mismatch warning", op) + } + wantOps[op] = true + } + if mismatchCount != 4 { + t.Fatalf("mismatch warning count = %d, want 4 (one per operation, repeat suppressed)", mismatchCount) + } + for op, seen := range wantOps { + if !seen { + t.Fatalf("no mismatch warning logged for op %q", op) + } + } +} + +func TestDegradeMovesDownOneRungAndNeverBack(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + if r.Snapshot().Step.Measure != MeasureOpaque { + t.Fatal("precondition: step should open opaque") + } + r.SetTotal(StepStoresRepositories, 10) // promotes to counted + r.Degrade(StepStoresRepositories) + if got := r.Snapshot().Step.Measure; got != MeasureCounting { + t.Fatalf("measure after one degrade = %q, want counting", got) + } + r.Degrade(StepStoresRepositories) + if got := r.Snapshot().Step.Measure; got != MeasureOpaque { + t.Fatalf("measure after two degrades = %q, want opaque", got) + } + r.SetTotal(StepStoresRepositories, 20) // must never re-promote this activation + if got := r.Snapshot().Step.Measure; got != MeasureOpaque { + t.Fatalf("measure after SetTotal post-degrade = %q, want opaque (no re-promotion)", got) + } +} + +func TestDegradeBeforeSetTotalBlocksLaterPromotion(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.Degrade(StepStoresRepositories) // fails counter query before total is known + r.SetTotal(StepStoresRepositories, 100) + if got := r.Snapshot().Step.Measure; got != MeasureOpaque { + t.Fatalf("measure = %q, want opaque: a pre-total degrade must block promotion", got) + } +} + +func TestSnapshotHasNoStepAfterEndStep(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.EndStep(StepStoresRepositories) + if r.Snapshot().Step != nil { + t.Fatal("snapshot must not retain an earlier step's identifier") + } +} + +// TestRateSampleTrimFoldsDeltaIntoAnchorDone covers the rate-sample ring: +// once it exceeds maxRateSamples, appendSample drops the oldest entries. If +// that drop left anchorDone stale, rateWindowStart's cumulative-sum walk +// would understate "done at the window's older edge" by exactly the dropped +// samples' total delta, overstating the rate reported for a fast sweep that +// advances more than maxRateSamples times within one 30s window (e.g. +// storage.go's required-store sweeps). +func TestRateSampleTrimFoldsDeltaIntoAnchorDone(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 1000) + + const advances = maxRateSamples + 36 + for i := 0; i < advances; i++ { + r.Advance(StepStoresRepositories, 1) + } + + if got := len(r.active.samples); got != maxRateSamples { + t.Fatalf("retained samples = %d, want %d (ring capped)", got, maxRateSamples) + } + wantDropped := int64(advances - maxRateSamples) + if r.active.anchorDone != wantDropped { + t.Fatalf("anchorDone = %d, want %d (the %d trimmed samples' folded delta)", r.active.anchorDone, wantDropped, wantDropped) + } + + _, windowDone := r.active.rateWindowStart(time.Now()) + if windowDone != wantDropped { + t.Fatalf("rateWindowStart done = %d, want %d: a stale anchorDone understates the window baseline and overstates the rate", windowDone, wantDropped) + } +} + +func TestPackageLevelHelpersAreNoOpsWithoutReporter(t *testing.T) { + ctx := context.Background() // no reporter attached + BeginStep(ctx, StepStoresRepositories) + SeedDone(ctx, StepStoresRepositories, 1) + SetTotal(ctx, StepStoresRepositories, 1) + Advance(ctx, StepStoresRepositories, 1) + Degrade(ctx, StepStoresRepositories) + EndStep(ctx, StepStoresRepositories) // must not panic +} + +func TestPackageLevelHelpersDelegateToReporter(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + ctx := WithReporter(context.Background(), r) + BeginStep(ctx, StepStoresRepositories) + SetTotal(ctx, StepStoresRepositories, 10) + Advance(ctx, StepStoresRepositories, 3) + if *r.Snapshot().Step.Done != 3 { + t.Fatalf("done = %d, want 3", *r.Snapshot().Step.Done) + } + EndStep(ctx, StepStoresRepositories) + if r.Snapshot().Step != nil { + t.Fatal("expected no active step after EndStep") + } +} diff --git a/apps/backend/internal/startup/step_snapshot.go b/apps/backend/internal/startup/step_snapshot.go new file mode 100644 index 0000000000..610fc4585b --- /dev/null +++ b/apps/backend/internal/startup/step_snapshot.go @@ -0,0 +1,115 @@ +package startup + +import "time" + +// StepSnapshot is the wire shape of the currently active step. Fields are +// pointers so an absent value (per the measure's boundary rules) is omitted +// from JSON rather than rendered as a misleading zero. +type StepSnapshot struct { + ID StepID `json:"id"` + LabelKey string `json:"label_key"` + Measure Measure `json:"measure"` + Unit Unit `json:"unit"` + ElapsedMS int64 `json:"elapsed_ms"` + Done *int64 `json:"done,omitempty"` + Total *int64 `json:"total,omitempty"` + RatePerSecond *float64 `json:"rate_per_second,omitempty"` + ETAMS *int64 `json:"eta_ms,omitempty"` + SinceAdvanceMS *int64 `json:"since_advance_ms,omitempty"` + Stalled bool `json:"stalled"` +} + +func (a *stepActivation) snapshot(now time.Time) *StepSnapshot { + s := &StepSnapshot{ + ID: a.spec.ID, + LabelKey: a.spec.LabelKey, + Measure: a.measure, + Unit: a.spec.Unit, + ElapsedMS: now.Sub(a.startedAt).Milliseconds(), + } + if a.measure == MeasureOpaque { + return s + } + + done := a.done + s.Done = &done + if a.measure == MeasureCounted && a.hasTotal { + total := a.total + s.Total = &total + } + + sinceAdvance := a.sinceAdvanceMS(now) + s.SinceAdvanceMS = &sinceAdvance + s.Stalled = sinceAdvance >= stallThresholdMS + + if a.hasAdvancedOnce { + rate := a.rate(now) + s.RatePerSecond = &rate + if rate > 0 && a.measure == MeasureCounted && a.hasTotal { + if eta, ok := a.etaMS(rate); ok { + s.ETAMS = &eta + } + } + } + return s +} + +func (a *stepActivation) sinceAdvanceMS(now time.Time) int64 { + base := a.startedAt + if a.hasAdvancedOnce { + base = a.lastAdvanceAt + } + return now.Sub(base).Milliseconds() +} + +func (a *stepActivation) etaMS(rate float64) (int64, bool) { + remaining := a.total - a.done + if remaining < 0 { + remaining = 0 + } + ms := float64(remaining) / rate * 1000 + if ms > maxETAMs { + return 0, false + } + return int64(ms), true +} + +// rate is the mean units-per-second increase over a trailing 30 s window, +// falling back to the step's start below two samples so a burst of very +// early advances cannot spike the estimate. +func (a *stepActivation) rate(now time.Time) float64 { + windowStart, windowDone := a.rateWindowStart(now) + elapsed := now.Sub(windowStart) + if elapsed < time.Millisecond { + elapsed = time.Millisecond + } + delta := a.done - windowDone + if delta < 0 { + delta = 0 + } + return float64(delta) / elapsed.Seconds() +} + +// rateWindowStart returns the time and cumulative done count at the older +// edge of the rate window: the oldest recorded sample no older than 30 s, +// or the step's start when fewer than two samples have ever been recorded. +// The returned timestamp is the point immediately before the first sample in +// the window, paired with the cumulative count at that point. When every +// sample is older than the window, it returns "now" paired with the current +// done count, which yields a zero rate rather than reviving a stale burst. +func (a *stepActivation) rateWindowStart(now time.Time) (time.Time, int64) { + if len(a.samples) < 2 { + return a.startedAt, a.anchorDone + } + cutoff := now.Add(-rateWindow) + cumulative := a.anchorDone + previousAt := a.startedAt + for _, s := range a.samples { + if !s.at.Before(cutoff) { + return previousAt, cumulative + } + cumulative += s.delta + previousAt = s.at + } + return now, a.done +} diff --git a/apps/backend/internal/startup/step_snapshot_test.go b/apps/backend/internal/startup/step_snapshot_test.go new file mode 100644 index 0000000000..6d5ee57239 --- /dev/null +++ b/apps/backend/internal/startup/step_snapshot_test.go @@ -0,0 +1,200 @@ +package startup + +import ( + "testing" + "time" +) + +func TestOpaqueStepOmitsCountsRateAndSinceAdvance(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepPromptSeqBackfill) // registry declares opaque + snap := r.Snapshot().Step + if snap.Done != nil || snap.Total != nil || snap.RatePerSecond != nil || + snap.ETAMS != nil || snap.SinceAdvanceMS != nil { + t.Fatalf("opaque step must omit every numeric field, got %#v", snap) + } + if snap.Stalled { + t.Fatal("an opaque step must never report stalled") + } +} + +func TestCountingStepCarriesDoneNoTotal(t *testing.T) { + r := New(nil) + r.Set(BackingUpDatabase) + r.BeginStep(StepDatabaseBackup) + r.Advance(StepDatabaseBackup, 1024) + snap := r.Snapshot().Step + if snap.Done == nil || *snap.Done != 1024 { + t.Fatalf("counting step should carry done=1024, got %v", snap.Done) + } + if snap.Total != nil { + t.Fatal("counting step must omit total") + } + if snap.ETAMS != nil { + t.Fatal("a step with no total must never carry an ETA") + } +} + +func TestCountedStepCarriesDoneAndTotal(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 10) + r.Advance(StepStoresRepositories, 4) + snap := r.Snapshot().Step + if snap.Done == nil || *snap.Done != 4 { + t.Fatalf("done = %v, want 4", snap.Done) + } + if snap.Total == nil || *snap.Total != 10 { + t.Fatalf("total = %v, want 10", snap.Total) + } +} + +func TestRateOmittedBeforeFirstAdvance(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 10) + snap := r.Snapshot().Step + if snap.RatePerSecond != nil { + t.Fatal("rate must be absent before the step has advanced at least once") + } + if snap.SinceAdvanceMS == nil { + t.Fatal("since_advance_ms must be present for a counted step even before its first advance") + } +} + +func TestRatePresentAfterAdvanceEvenIfDecayedToZero(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 100) + r.Advance(StepStoresRepositories, 1) + + // Force the only sample outside the 30s window and force the >=2-samples + // branch by injecting a second, older synthetic sample directly. + a := r.active + a.samples = []rateSample{ + {at: time.Now().Add(-90 * time.Second), delta: 1}, + {at: time.Now().Add(-40 * time.Second), delta: 1}, + } + a.done = 2 + snap := r.Snapshot().Step + if snap.RatePerSecond == nil { + t.Fatal("rate must remain present (even at zero) once the step has advanced") + } + if *snap.RatePerSecond != 0 { + t.Fatalf("rate should have decayed to zero with no samples inside the window, got %v", *snap.RatePerSecond) + } + if snap.ETAMS != nil { + t.Fatal("a zero rate must never carry an ETA") + } +} + +func TestRateFallsBackToStepStartBelowTwoSamples(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 1000) + r.active.startedAt = time.Now().Add(-10 * time.Second) + r.Advance(StepStoresRepositories, 50) // exactly one sample + + rate := r.Snapshot().Step.RatePerSecond + if rate == nil { + t.Fatal("expected a rate with one sample recorded") + } + // ~50 units over ~10s from the step's start, not from the single sample's + // own (near-zero) age. + if *rate <= 0 || *rate > 10 { + t.Fatalf("rate = %v, expected roughly 5/s using step start as the older point", *rate) + } +} + +func TestRateWindowUsesThePointBeforeTheFirstInWindowSample(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + now := time.Now() + r.active.startedAt = now.Add(-50 * time.Second) + r.active.samples = []rateSample{ + {at: now.Add(-40 * time.Second), delta: 100}, + {at: now.Add(-10 * time.Second), delta: 10}, + } + r.active.done = 110 + + rate := r.active.rate(now) + // The in-window sample contributes ten units over the interval from the + // preceding sample, rather than being divided by the near-zero age of its + // own timestamp. + if rate < 0.24 || rate > 0.26 { + t.Fatalf("rate = %v, want about 0.25 units/s", rate) + } +} + +func TestETAOmittedAboveOneDay(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 1_000_000_000) + r.active.startedAt = time.Now().Add(-10 * time.Second) + r.Advance(StepStoresRepositories, 1) // a tiny rate projects far beyond a day + + snap := r.Snapshot().Step + if snap.ETAMS != nil { + t.Fatalf("eta_ms must be omitted above 86400000ms, got %v", *snap.ETAMS) + } +} + +func TestStallDetection(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 100) + r.Advance(StepStoresRepositories, 1) + + r.active.lastAdvanceAt = time.Now().Add(-(stallThresholdMS + 1) * time.Millisecond) + snap := r.Snapshot().Step + if !snap.Stalled { + t.Fatal("expected stalled=true once since_advance_ms crosses the threshold") + } + + r.Advance(StepStoresRepositories, 1) // real progress clears the stall + if r.Snapshot().Step.Stalled { + t.Fatal("an advance that increases done must clear stalled immediately") + } +} + +func TestStallClockUnaffectedBySeedNoOpOrForeignAdvance(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 10) + r.active.startedAt = time.Now().Add(-500 * time.Millisecond) + before := *r.Snapshot().Step.SinceAdvanceMS + + r.SeedDone(StepStoresRepositories, 0) // rejected: total already set + r.Advance(StepStoresServices, 5) // foreign identifier + r.Advance(StepStoresRepositories, 0) // zero advance: no-op + + after := *r.Snapshot().Step.SinceAdvanceMS + if after < before { + t.Fatalf("since_advance_ms must not have reset: before=%d after=%d", before, after) + } +} + +func TestAdvanceExceedingTotalOnlyCountsRealIncreaseAsASample(t *testing.T) { + r := New(nil) + r.Set(ApplyingMigrations) + r.BeginStep(StepStoresRepositories) + r.SetTotal(StepStoresRepositories, 10) + r.Advance(StepStoresRepositories, 5) + r.Advance(StepStoresRepositories, 999) // clamps to 10; actual increase is 5 + + if got := len(r.active.samples); got != 2 { + t.Fatalf("expected 2 recorded samples, got %d", got) + } + if r.active.samples[1].delta != 5 { + t.Fatalf("second sample delta = %d, want 5 (the actual increase, not the request)", r.active.samples[1].delta) + } +} diff --git a/apps/backend/internal/system/persistence/handler_test.go b/apps/backend/internal/system/persistence/handler_test.go index ffb7d250cf..cf1cfab531 100644 --- a/apps/backend/internal/system/persistence/handler_test.go +++ b/apps/backend/internal/system/persistence/handler_test.go @@ -8,13 +8,14 @@ import ( "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/persistence/requiredstores" + "github.com/kandev/kandev/internal/startup" ) func TestHandlerReturnsStableRowsWithSanitizedErrors(t *testing.T) { gin.SetMode(gin.TestMode) tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{ - {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}}, - {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}}, + {ID: "first", OwnerPackage: "owner/first", RequiredTables: []string{"first"}, Sweep: startup.StepStoresRepositories}, + {ID: "second", OwnerPackage: "owner/second", RequiredTables: []string{"second"}, Sweep: startup.StepStoresRepositories}, }) if err != nil { t.Fatalf("NewTracker: %v", err) diff --git a/apps/backend/internal/system/system_database_path_test.go b/apps/backend/internal/system/system_database_path_test.go index 5a67be3b3b..5a58daab42 100644 --- a/apps/backend/internal/system/system_database_path_test.go +++ b/apps/backend/internal/system/system_database_path_test.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/persistence/requiredstores" + "github.com/kandev/kandev/internal/startup" "github.com/kandev/kandev/internal/system/jobs" ) @@ -124,6 +125,7 @@ func TestProvideMarksPersistenceUnavailableForFactoryReset(t *testing.T) { tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ ID: "settings", OwnerPackage: "internal/system/settings", RequiredTables: []string{"settings"}, + Sweep: startup.StepStoresRepositories, }}) if err != nil { t.Fatalf("NewTracker: %v", err) @@ -169,6 +171,7 @@ func TestProvideMarksPersistenceUnavailableForRestore(t *testing.T) { tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ ID: "settings", OwnerPackage: "internal/system/settings", RequiredTables: []string{"settings"}, + Sweep: startup.StepStoresRepositories, }}) if err != nil { t.Fatalf("NewTracker: %v", err) diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index 55a3185e0b..9f3a97a089 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -14,6 +14,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/startup" ) // migrateExecutorProfiles adds mcp_policy column and drops is_default from executor_profiles. @@ -53,7 +54,9 @@ func (r *Repository) migrateSessionsAddCostColumns() { } // runMigrations applies idempotent ALTER TABLE migrations for schema evolution. -func (r *Repository) runMigrations() error { +// +//nolint:cyclop,funlen,maintidx // Legacy flat list of ~60 independent idempotent migration steps predating startup-step instrumentation; splitting it is out of scope here. +func (r *Repository) runMigrations(ctx context.Context) error { if err := r.migrateTaskPriorityToTextPostgres(); err != nil { return err } @@ -214,7 +217,9 @@ func (r *Repository) runMigrations() error { // explicitly in CreateMessage/UpdateMessage. The backfill UPDATE is idempotent // (WHERE updated_at IS NULL). r.migrate.Apply("task_session_messages.updated_at", `ALTER TABLE task_session_messages ADD COLUMN updated_at TIMESTAMP`) + startup.BeginStep(ctx, startup.StepMessageTimestampsBackfill) r.migrate.Apply("task_session_messages.updated_at.backfill", `UPDATE task_session_messages SET updated_at = created_at WHERE updated_at IS NULL`) + startup.EndStep(ctx, startup.StepMessageTimestampsBackfill) r.migrate.Apply("idx_messages_session_updated", `CREATE INDEX IF NOT EXISTS idx_messages_session_updated ON task_session_messages(task_session_id, updated_at)`) // task_session_commits gains a uniqueness constraint before its writer @@ -378,7 +383,7 @@ func (r *Repository) runMigrations() error { // table is not part of the supported upgrade path, so there is no // intermediate-shape rebuild to run here. Only the historical-message // backfill belongs in the migration phase. - r.migrateSubagentContextBackfill() + r.migrateSubagentContextBackfill(ctx) // Durable per-session prompt ordinals. prompt_seq is allocated from a // per-session sequence counter inside the create write boundary, so an @@ -397,7 +402,7 @@ func (r *Repository) runMigrations() error { task_session_id TEXT PRIMARY KEY, last_seq INTEGER NOT NULL )`) - if err := r.backfillPromptSeq(); err != nil { + if err := r.backfillPromptSeq(ctx); err != nil { return err } @@ -504,7 +509,7 @@ func (r *Repository) backfillTaskSessionQueueIncarnations() error { // session's sequence counter at its backfilled maximum. Idempotent: user rows // already carrying a nonzero prompt_seq are untouched, and the counter seed // ignores existing rows. -func (r *Repository) backfillPromptSeq() error { +func (r *Repository) backfillPromptSeq(ctx context.Context) error { nmU := dialect.NormalizedMicrosecond(r.db.DriverName(), "u.created_at") nmM := dialect.NormalizedMicrosecond(r.db.DriverName(), "task_session_messages.created_at") update := fmt.Sprintf(` @@ -516,7 +521,8 @@ func (r *Repository) backfillPromptSeq() error { AND (%s < %s OR (%s = %s AND u.id <= task_session_messages.id)) ) WHERE author_type = 'user' AND prompt_seq = 0`, nmU, nmM, nmU, nmM) - ctx := r.migrationContext() + startup.BeginStep(ctx, startup.StepPromptSeqBackfill) + defer startup.EndStep(ctx, startup.StepPromptSeqBackfill) if _, err := r.db.ExecContext(ctx, update); err != nil { return fmt.Errorf("backfill prompt_seq: %w", err) } diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations_repository_identity_test.go b/apps/backend/internal/task/repository/sqlite/base_migrations_repository_identity_test.go index b59441e0cf..c325fc2eef 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations_repository_identity_test.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations_repository_identity_test.go @@ -32,7 +32,7 @@ func TestBaseMigrationsDoNotInferProviderHostFromPartialRepositoryIdentity(t *te t.Fatalf("create repository: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("run migrations: %v", err) } stored, err := repo.GetRepository(context.Background(), repository.ID) diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations_steps_test.go b/apps/backend/internal/task/repository/sqlite/base_migrations_steps_test.go new file mode 100644 index 0000000000..32108965ef --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/base_migrations_steps_test.go @@ -0,0 +1,144 @@ +package sqlite + +import ( + "context" + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/startup" +) + +// TestBackfillPromptSeqReportsOpaqueStep covers the +// task.prompt_seq.backfill half of AC-PLATFORM-STARTUP-PROGRESS-005.2/.8: +// the opaque step brackets the whole statement pair (backfill UPDATE plus +// counter seed) with a start/completed pair, every boot, since the backfill +// itself is already idempotent (WHERE prompt_seq = 0) and has no separate +// runtime skip condition to respect. +func TestBackfillPromptSeqReportsOpaqueStep(t *testing.T) { + repo := newRepoForSessionTests(t) + reporter, logs := newObservedMigrationStepReporter(t) + ctx := startup.WithReporter(context.Background(), reporter) + + if err := repo.backfillPromptSeq(ctx); err != nil { + t.Fatalf("backfillPromptSeq: %v", err) + } + + assertNoPhaseMismatchWarnings(t, logs) + assertOpaqueStepBracket(t, logs, startup.StepPromptSeqBackfill) + if snap := reporter.Snapshot(); snap.Step != nil { + t.Fatalf("Step after backfillPromptSeq = %+v, want nil (step closed)", snap.Step) + } +} + +// TestMessageTimestampsBackfillReportsOpaqueStep covers the +// task.message_timestamps.backfill half of the same ACs: runMigrations +// brackets its single backfill UPDATE the same way, every boot, since the +// statement is already idempotent (WHERE updated_at IS NULL) with no +// runtime skip condition. +func TestMessageTimestampsBackfillReportsOpaqueStep(t *testing.T) { + repo := newRepoForSessionTests(t) + reporter, logs := newObservedMigrationStepReporter(t) + ctx := startup.WithReporter(context.Background(), reporter) + + if err := repo.runMigrations(ctx); err != nil { + t.Fatalf("runMigrations: %v", err) + } + + assertNoPhaseMismatchWarnings(t, logs) + assertOpaqueStepBracket(t, logs, startup.StepMessageTimestampsBackfill) +} + +// TestMigrateSubagentContextBackfillOpensStepOnlyOnFirstBoot covers F78's +// rule for a registered step whose work is conditionally skipped at +// runtime: migrateSubagentContextBackfill's guard means the real +// INSERT...SELECT (and therefore the step) only runs once per installation +// (AC-23a). The first call — the one that actually does the scan — must +// open and close the step; a later call, once both activation keys are +// already written, must never open it at all (matching +// TestInitConversationJournalSchemaSkipsStepsOnceBackfilled's rule for the +// journal steps). +func TestMigrateSubagentContextBackfillOpensStepOnlyOnFirstBoot(t *testing.T) { + repo, _ := newSubagentMigrationTestRepo(t) + + firstReporter, firstLogs := newObservedMigrationStepReporter(t) + firstCtx := startup.WithReporter(context.Background(), firstReporter) + repo.migrateSubagentContextBackfill(firstCtx) + assertNoPhaseMismatchWarnings(t, firstLogs) + assertOpaqueStepBracket(t, firstLogs, startup.StepSubagentContextBackfill) + + secondReporter, secondLogs := newObservedMigrationStepReporter(t) + secondCtx := startup.WithReporter(context.Background(), secondReporter) + repo.migrateSubagentContextBackfill(secondCtx) + for _, entry := range secondLogs.All() { + if entry.Message == "Startup step started" { + t.Fatalf("unexpected step start on an already-activated boot: %+v", entry.ContextMap()) + } + } +} + +// assertOpaqueStepBracket asserts exactly one "Startup step started" and one +// "Startup step completed" entry for wantStep, started before completed. +func assertOpaqueStepBracket(t *testing.T, logs *observer.ObservedLogs, wantStep startup.StepID) { + t.Helper() + var startedAt, completedAt = -1, -1 + var startedCount, completedCount int + for i, entry := range logs.All() { + if entry.ContextMap()["step"] != string(wantStep) { + continue + } + switch entry.Message { + case "Startup step started": + startedCount++ + startedAt = i + case "Startup step completed": + completedCount++ + completedAt = i + } + } + if startedCount != 1 { + t.Fatalf("startup step %q started %d times, want 1", wantStep, startedCount) + } + if completedCount != 1 { + t.Fatalf("startup step %q completed %d times, want 1", wantStep, completedCount) + } + if startedAt == -1 { + t.Fatalf("no \"Startup step started\" entry for step %q", wantStep) + } + if completedAt == -1 { + t.Fatalf("no \"Startup step completed\" entry for step %q", wantStep) + } + if completedAt < startedAt { + t.Fatalf("step %q completed (index %d) before it started (index %d)", wantStep, completedAt, startedAt) + } +} + +// newObservedMigrationStepReporter returns a reporter already in the +// applying_migrations phase, plus the observed log every step assertion in +// this file reads. +func newObservedMigrationStepReporter(t *testing.T) (*startup.Reporter, *observer.ObservedLogs) { + t.Helper() + core, logs := observer.New(zapcore.DebugLevel) + log, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("observer logger: %v", err) + } + reporter := startup.New(log) + reporter.Set(startup.ApplyingMigrations) + return reporter, logs +} + +// assertNoPhaseMismatchWarnings fails if any step opened under a phase other +// than the one its registry entry declares +// (AC-PLATFORM-STARTUP-PROGRESS-005.7). +func assertNoPhaseMismatchWarnings(t *testing.T, logs *observer.ObservedLogs) { + t.Helper() + for _, entry := range logs.All() { + if entry.Message == "Startup step warning" && entry.ContextMap()["condition"] == "phase_mismatch" { + t.Fatalf("unexpected phase_mismatch warning: %+v", entry.ContextMap()) + } + } +} diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 497b066727..08db9d24ea 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -46,7 +46,7 @@ func (r *Repository) initSchemaContext(ctx context.Context) error { r.migrateTaskSessions, r.ensureDefaultWorkspace, r.ensureDefaultExecutorsAndEnvironments, - r.runMigrations, + func() error { return r.runMigrations(ctx) }, r.hideBuiltinWorkflows, r.healBuiltinWorkflowStepFlags, r.healBuiltinWorkflowStepParticipantSeats, diff --git a/apps/backend/internal/task/repository/sqlite/clarification_resolutions_retired_test.go b/apps/backend/internal/task/repository/sqlite/clarification_resolutions_retired_test.go index a27260f9df..30e92b9ed2 100644 --- a/apps/backend/internal/task/repository/sqlite/clarification_resolutions_retired_test.go +++ b/apps/backend/internal/task/repository/sqlite/clarification_resolutions_retired_test.go @@ -1,6 +1,9 @@ package sqlite -import "testing" +import ( + "context" + "testing" +) // clarification_resolutions was a durable-claim table designed during this // feature's spec review but retired before ever shipping in favor of @@ -27,7 +30,7 @@ func TestClarificationResolutionsTableNeverCreated(t *testing.T) { } // Migrations replay on every boot; the second pass must not resurrect it. - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations replay: %v", err) } if err := repo.db.QueryRow( diff --git a/apps/backend/internal/task/repository/sqlite/conversation_upgrade_large_test.go b/apps/backend/internal/task/repository/sqlite/conversation_upgrade_large_test.go index 4a077b01ce..ae6630e63c 100644 --- a/apps/backend/internal/task/repository/sqlite/conversation_upgrade_large_test.go +++ b/apps/backend/internal/task/repository/sqlite/conversation_upgrade_large_test.go @@ -17,6 +17,7 @@ import ( "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/persistence" "github.com/kandev/kandev/internal/persistence/requiredstores" + "github.com/kandev/kandev/internal/startup" "github.com/kandev/kandev/internal/task/models" ) @@ -160,7 +161,7 @@ func seedLargeLegacy(t *testing.T, repo *Repository) { func runLargeUpgradeWriterLoad(t *testing.T, repo *Repository, pool *db.Pool) { t.Helper() - tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks", "task_sessions", "task_session_messages", "task_session_turns", "conversation_session_revisions"}}}) + tracker, err := requiredstores.NewTracker([]requiredstores.Descriptor{{ID: "task", OwnerPackage: "internal/task", RequiredTables: []string{"tasks", "task_sessions", "task_session_messages", "task_session_turns", "conversation_session_revisions"}, Sweep: startup.StepStoresRepositories}}) if err != nil { t.Fatal(err) } diff --git a/apps/backend/internal/task/repository/sqlite/document_plan_postgres_test.go b/apps/backend/internal/task/repository/sqlite/document_plan_postgres_test.go index 09efc11c36..4ade600061 100644 --- a/apps/backend/internal/task/repository/sqlite/document_plan_postgres_test.go +++ b/apps/backend/internal/task/repository/sqlite/document_plan_postgres_test.go @@ -300,10 +300,10 @@ func TestPostgresPlanRevisionWorkflowColumnsReplayMigration(t *testing.T) { t.Fatalf("insert legacy plan revision: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("run legacy plan migrations: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay legacy plan migrations: %v", err) } var writeVersion string diff --git a/apps/backend/internal/task/repository/sqlite/execution_profile_persistence_test.go b/apps/backend/internal/task/repository/sqlite/execution_profile_persistence_test.go index c91b536dec..8949ee4dff 100644 --- a/apps/backend/internal/task/repository/sqlite/execution_profile_persistence_test.go +++ b/apps/backend/internal/task/repository/sqlite/execution_profile_persistence_test.go @@ -70,10 +70,10 @@ func TestExecutionProfileColumnsReplayOnLegacySchema(t *testing.T) { t.Fatalf("drop legacy %s.execution_profile_id: %v", table, err) } } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations on legacy schema: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay runMigrations: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/executor_localpid_test.go b/apps/backend/internal/task/repository/sqlite/executor_localpid_test.go index b709d5f871..9b17bff000 100644 --- a/apps/backend/internal/task/repository/sqlite/executor_localpid_test.go +++ b/apps/backend/internal/task/repository/sqlite/executor_localpid_test.go @@ -288,7 +288,7 @@ func TestExecutorRunningLocalPIDMigrationOnLegacyDB(t *testing.T) { // Re-run migrations, exactly as a boot of the new binary would. The // idempotent ADD COLUMN must re-add local_pid without erroring. - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations on legacy DB: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/git_snapshots_postgres_test.go b/apps/backend/internal/task/repository/sqlite/git_snapshots_postgres_test.go index 047a6bb6a0..cdc1d8c272 100644 --- a/apps/backend/internal/task/repository/sqlite/git_snapshots_postgres_test.go +++ b/apps/backend/internal/task/repository/sqlite/git_snapshots_postgres_test.go @@ -291,7 +291,7 @@ func TestPostgresSessionCommitsDedupeAndActivationMigration(t *testing.T) { t.Fatalf("pre-migration commit rows = %d, want 3", got) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations: %v", err) } @@ -329,7 +329,7 @@ func TestPostgresSessionCommitsDedupeAndActivationMigration(t *testing.T) { // Replay: must not error, must not duplicate the index, and must not // move the activation marker (legacy rows must stay pinned to the // original activation instant, not silently drift on every boot). - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations twice: %v", err) } if got := readCommitCaptureActivatedAt(t, repo); got != activatedAt { diff --git a/apps/backend/internal/task/repository/sqlite/plan_comment_postgres_test.go b/apps/backend/internal/task/repository/sqlite/plan_comment_postgres_test.go index 9937e0ebef..65e35d0658 100644 --- a/apps/backend/internal/task/repository/sqlite/plan_comment_postgres_test.go +++ b/apps/backend/internal/task/repository/sqlite/plan_comment_postgres_test.go @@ -39,10 +39,10 @@ func TestPostgresPlanCommentSchemaReplayAndCRUD(t *testing.T) { if err := repo.initPlansSchema(); err != nil { t.Fatalf("initialize plan schema on legacy database: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("migrate legacy database: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/plan_comment_test.go b/apps/backend/internal/task/repository/sqlite/plan_comment_test.go index edbf151c00..a9076b8dbf 100644 --- a/apps/backend/internal/task/repository/sqlite/plan_comment_test.go +++ b/apps/backend/internal/task/repository/sqlite/plan_comment_test.go @@ -133,10 +133,10 @@ func TestPlanCommentSchemaReplaysOnLegacyDatabase(t *testing.T) { if err := repo.initPlansSchema(); err != nil { t.Fatalf("initialize plan children on legacy schema: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("migrate legacy schema: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/plan_test.go b/apps/backend/internal/task/repository/sqlite/plan_test.go index 2c9c9f0db5..656027402e 100644 --- a/apps/backend/internal/task/repository/sqlite/plan_test.go +++ b/apps/backend/internal/task/repository/sqlite/plan_test.go @@ -652,10 +652,10 @@ func TestPlanRevisionWorkflowColumnsReplayMigration(t *testing.T) { t.Fatalf("insert legacy plan revision: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("run legacy plan migrations: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay legacy plan migrations: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/plan_version_test.go b/apps/backend/internal/task/repository/sqlite/plan_version_test.go index 74547fc42e..23d81e0a7f 100644 --- a/apps/backend/internal/task/repository/sqlite/plan_version_test.go +++ b/apps/backend/internal/task/repository/sqlite/plan_version_test.go @@ -115,7 +115,7 @@ func TestTaskPlanWriteVersionMigrationBackfillsAndReplays(t *testing.T) { if _, err := repo.db.ExecContext(ctx, `ALTER TABLE task_plans DROP COLUMN write_version`); err != nil { t.Fatalf("drop write_version for legacy fixture: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(ctx); err != nil { t.Fatalf("runMigrations(backfill): %v", err) } migrated, err := repo.GetTaskPlan(ctx, plan.TaskID) @@ -126,7 +126,7 @@ func TestTaskPlanWriteVersionMigrationBackfillsAndReplays(t *testing.T) { t.Fatal("migration left legacy plan without a write version") } version := migrated.WriteVersion - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(ctx); err != nil { t.Fatalf("runMigrations(replay): %v", err) } replayed, err := repo.GetTaskPlan(ctx, plan.TaskID) diff --git a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go index 2953be900f..53e728ae04 100644 --- a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go +++ b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go @@ -80,7 +80,7 @@ func TestPostgresExecutorRunningLocalPIDMigration(t *testing.T) { if _, err := db.Exec(`ALTER TABLE executors_running DROP COLUMN local_pid`); err != nil { t.Fatalf("simulate legacy schema (drop local_pid): %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations on legacy postgres DB: %v", err) } @@ -185,10 +185,10 @@ func TestPostgresImproveKandevWorkflowIndexMigration(t *testing.T) { t.Fatalf("seed legacy workflow %q: %v", id, err) } } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("migrate legacy postgres workflow duplicates: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay postgres improve kandev migration: %v", err) } var count int @@ -304,10 +304,10 @@ func TestPostgresExecutionProfileMigration(t *testing.T) { t.Fatalf("drop %s.execution_profile_id: %v", table, err) } } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations on legacy postgres schema: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay postgres migrations: %v", err) } @@ -586,10 +586,10 @@ func TestPostgresRepositorySecretBindingsSchemaReplay(t *testing.T) { if _, err := db.Exec("DROP TABLE repository_secret_bindings"); err != nil { t.Fatalf("drop repository secret bindings table: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay repository secret bindings migration: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay repository secret bindings migration twice: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/prompt_index_test.go b/apps/backend/internal/task/repository/sqlite/prompt_index_test.go index 9337d80cdc..da7a6c3587 100644 --- a/apps/backend/internal/task/repository/sqlite/prompt_index_test.go +++ b/apps/backend/internal/task/repository/sqlite/prompt_index_test.go @@ -41,7 +41,7 @@ func insertPromptRowWithRawTimestamp(t *testing.T, repo *Repository, id, session // serve after migration. func backfillPromptSeqForTest(t *testing.T, repo *Repository) { t.Helper() - if err := repo.backfillPromptSeq(); err != nil { + if err := repo.backfillPromptSeq(context.Background()); err != nil { t.Fatalf("backfill prompt_seq: %v", err) } } diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go index 8b48bbe9ca..75303b89e9 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go @@ -671,10 +671,10 @@ func TestDeleteRepositoryIfNoActiveTaskSessions(t *testing.T) { // failures by design). func TestRunMigrations_Idempotent(t *testing.T) { repo := newRepoForEntityTests(t) - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("second runMigrations call returned error: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("third runMigrations call returned error: %v", err) } } @@ -698,10 +698,10 @@ func TestRunnerProjectionWorkflowStepColumnsReplayMigration(t *testing.T) { t.Fatalf("drop legacy workflow_steps.%s: %v", column.name, err) } } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations on legacy workflow_steps schema: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay runMigrations: %v", err) } @@ -741,10 +741,10 @@ func TestRunnerProjectionParticipantCreatedAtReplayMigration(t *testing.T) { if _, err := repo.db.Exec(`ALTER TABLE workflow_step_participants DROP COLUMN created_at`); err != nil { t.Fatalf("drop legacy workflow_step_participants.created_at: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations on legacy workflow_step_participants schema: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay runMigrations: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/repository_set_base_branch_postgres_test.go b/apps/backend/internal/task/repository/sqlite/repository_set_base_branch_postgres_test.go index 7eb6e92225..81c593b97e 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_set_base_branch_postgres_test.go +++ b/apps/backend/internal/task/repository/sqlite/repository_set_base_branch_postgres_test.go @@ -56,7 +56,7 @@ func TestPostgresRepositorySetBaseBranchMigrationPreservesItems(t *testing.T) { t.Fatalf("insert legacy repository-set item: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("run base-branch migration: %v", err) } assertBaseBranchColumn := func(stage string) { @@ -74,7 +74,7 @@ func TestPostgresRepositorySetBaseBranchMigrationPreservesItems(t *testing.T) { } } assertBaseBranchColumn("first") - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay base-branch migration: %v", err) } assertBaseBranchColumn("replayed") diff --git a/apps/backend/internal/task/repository/sqlite/repository_set_base_branch_test.go b/apps/backend/internal/task/repository/sqlite/repository_set_base_branch_test.go index 433ab7c2b6..7c1d8af397 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_set_base_branch_test.go +++ b/apps/backend/internal/task/repository/sqlite/repository_set_base_branch_test.go @@ -101,10 +101,10 @@ func TestRepositorySetBaseBranchMigrationPreservesLegacyItems(t *testing.T) { t.Fatalf("insert legacy repository-set item: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("run base-branch migration: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay base-branch migration: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/session_commits_migration_test.go b/apps/backend/internal/task/repository/sqlite/session_commits_migration_test.go index 466134a08f..2bc3cd7946 100644 --- a/apps/backend/internal/task/repository/sqlite/session_commits_migration_test.go +++ b/apps/backend/internal/task/repository/sqlite/session_commits_migration_test.go @@ -35,7 +35,7 @@ func TestSessionCommitsDedupeAndActivationMigration(t *testing.T) { t.Fatalf("pre-migration commit rows = %d, want 3", got) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations: %v", err) } @@ -84,7 +84,7 @@ func TestSessionCommitsDedupeAndActivationMigration(t *testing.T) { // Replay: must not error, must not duplicate the index, and must not // move the activation marker (legacy rows must stay pinned to the // original activation instant, not silently drift on every boot). - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations twice: %v", err) } if got := readCommitCaptureActivatedAt(t, repo); got != activatedAt { diff --git a/apps/backend/internal/task/repository/sqlite/slack_retirement_test.go b/apps/backend/internal/task/repository/sqlite/slack_retirement_test.go index 1e6b691232..13c2bc98a3 100644 --- a/apps/backend/internal/task/repository/sqlite/slack_retirement_test.go +++ b/apps/backend/internal/task/repository/sqlite/slack_retirement_test.go @@ -1,6 +1,9 @@ package sqlite -import "testing" +import ( + "context" + "testing" +) // The in-tree Slack integration was replaced by kandev-plugin-slack. Its // `slack_configs` row is merely dead, but its vault entries are live Slack @@ -107,7 +110,7 @@ func TestDropRetiredSlackIntegrationReplays(t *testing.T) { t.Fatalf("dropRetiredSlackIntegration pass %d: %v", i+1, err) } } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations replay: %v", err) } } diff --git a/apps/backend/internal/task/repository/sqlite/startup_cost_test.go b/apps/backend/internal/task/repository/sqlite/startup_cost_test.go index b75f0775be..ce5cc05f8a 100644 --- a/apps/backend/internal/task/repository/sqlite/startup_cost_test.go +++ b/apps/backend/internal/task/repository/sqlite/startup_cost_test.go @@ -137,7 +137,7 @@ func TestStartupMigrationCostsPopulated(t *testing.T) { t.Fatalf("reset prompt sequence counters: %v", err) } promptSeqStarted := time.Now() - if err := replayRepo.backfillPromptSeq(); err != nil { + if err := replayRepo.backfillPromptSeq(context.Background()); err != nil { _ = replayDB.Close() t.Fatalf("measure prompt sequence backfill: %v", err) } @@ -333,7 +333,7 @@ func TestBackfillPromptSeqStopsOnCanceledContext(t *testing.T) { migrate: dbpkg.NewRequiredMigrateLoggerContext(db, nil, ctx), } done := make(chan error, 1) - go func() { done <- repo.backfillPromptSeq() }() + go func() { done <- repo.backfillPromptSeq(ctx) }() select { case <-started: case <-time.After(2 * time.Second): diff --git a/apps/backend/internal/task/repository/sqlite/step_entries_marker_positions_migration_test.go b/apps/backend/internal/task/repository/sqlite/step_entries_marker_positions_migration_test.go index 409430c63f..079186ca30 100644 --- a/apps/backend/internal/task/repository/sqlite/step_entries_marker_positions_migration_test.go +++ b/apps/backend/internal/task/repository/sqlite/step_entries_marker_positions_migration_test.go @@ -30,7 +30,7 @@ func TestMarkerPositionsMigrationOnLegacyDB(t *testing.T) { // Re-run migrations, exactly as a boot of the new binary would. The // idempotent ADD COLUMN must re-add marker_positions without erroring. - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations on legacy DB: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/step_entries_marker_positions_postgres_test.go b/apps/backend/internal/task/repository/sqlite/step_entries_marker_positions_postgres_test.go index 6377f9b17e..b0a66f0fce 100644 --- a/apps/backend/internal/task/repository/sqlite/step_entries_marker_positions_postgres_test.go +++ b/apps/backend/internal/task/repository/sqlite/step_entries_marker_positions_postgres_test.go @@ -46,7 +46,7 @@ func TestPostgresMarkerPositionsMigrationOnLegacyDB(t *testing.T) { if _, err := db.Exec(`ALTER TABLE workflow_step_entries DROP COLUMN marker_positions`); err != nil { t.Fatalf("simulate legacy schema (drop marker_positions): %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations on legacy postgres DB: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/step_stamp_migration_test.go b/apps/backend/internal/task/repository/sqlite/step_stamp_migration_test.go index 1b01383abb..932a5801db 100644 --- a/apps/backend/internal/task/repository/sqlite/step_stamp_migration_test.go +++ b/apps/backend/internal/task/repository/sqlite/step_stamp_migration_test.go @@ -7,6 +7,7 @@ package sqlite // is untouched by boot/replay, and there is no migration step to reverse. import ( + "context" "path/filepath" "testing" "time" @@ -52,10 +53,10 @@ func TestPreActivationTurnIsNotBackfilledWithStepStamp(t *testing.T) { // Replay schema init/migrations twice more, mirroring a boot after // activation. Nothing about this table's rows should change. - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations twice: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/step_transitions_migration_test.go b/apps/backend/internal/task/repository/sqlite/step_transitions_migration_test.go index dc8be5e91b..6ce24eda95 100644 --- a/apps/backend/internal/task/repository/sqlite/step_transitions_migration_test.go +++ b/apps/backend/internal/task/repository/sqlite/step_transitions_migration_test.go @@ -5,6 +5,7 @@ package sqlite // existing task gains no rows, and the schema-init step is replay-safe. import ( + "context" "path/filepath" "testing" "time" @@ -69,10 +70,10 @@ func TestStepTransitionsSchemaCreatesTableAndIsReplaySafe(t *testing.T) { if err := repo.initStepTransitionsSchema(); err != nil { t.Fatalf("replay initStepTransitionsSchema twice: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay runMigrations: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay runMigrations twice: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/subagent_context_backfill.go b/apps/backend/internal/task/repository/sqlite/subagent_context_backfill.go index 3df5718b8f..cf16c50d52 100644 --- a/apps/backend/internal/task/repository/sqlite/subagent_context_backfill.go +++ b/apps/backend/internal/task/repository/sqlite/subagent_context_backfill.go @@ -12,6 +12,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/startup" ) const ( @@ -45,7 +46,7 @@ func (r *Repository) warnSubagentContextMigration(name string, err error) { // both, and having each key's own INSERT use ON CONFLICT DO NOTHING, is what // lets a partial pre-existing pair repair itself on the next boot without a // second mechanism. -func (r *Repository) migrateSubagentContextBackfill() { +func (r *Repository) migrateSubagentContextBackfill(ctx context.Context) { capturePresent, err := r.subagentContextActivationKeyPresent(subagentContextCaptureSinceKey) if err != nil { r.warnSubagentContextMigration(subagentContextBackfillMigrationName+".guard", err) @@ -57,9 +58,15 @@ func (r *Repository) migrateSubagentContextBackfill() { return } if capturePresent && throughPresent { + // Both activation keys are already written: the backfill already + // completed on an earlier boot, so the step is never opened. Opening + // and immediately ending it here would flash a step at the operator + // on every normal boot after the first. return } + startup.BeginStep(ctx, startup.StepSubagentContextBackfill) + defer startup.EndStep(ctx, startup.StepSubagentContextBackfill) if err := r.migrateSubagentContextBackfillUnsafe(); err != nil { r.warnSubagentContextMigration(subagentContextBackfillMigrationName, err) } diff --git a/apps/backend/internal/task/repository/sqlite/subagent_context_migration_test.go b/apps/backend/internal/task/repository/sqlite/subagent_context_migration_test.go index c59d3fd01f..6c50e42d0d 100644 --- a/apps/backend/internal/task/repository/sqlite/subagent_context_migration_test.go +++ b/apps/backend/internal/task/repository/sqlite/subagent_context_migration_test.go @@ -1,6 +1,7 @@ package sqlite import ( + "context" "database/sql" "encoding/json" "path/filepath" @@ -152,7 +153,7 @@ func newSubagentMigrationTestRepo(t *testing.T) (*Repository, *sqlx.DB) { // against an empty database with no messages seeded yet, and claimed the // two activation keys (AC-24) — correct for a genuinely fresh install, // but not what this file's tests want: they seed subagent-shaped - // messages AFTER construction and then call repo.runMigrations() again, + // messages AFTER construction and then call repo.runMigrations(context.Background()) again, // expecting THAT call to be the real (only) backfill run — mirroring a // DB upgrade where historical messages predate this boot. Reset to // "not yet activated" so every test in this file gets that semantics by @@ -212,10 +213,10 @@ func TestSubagentContextSchemaFreshAndReplay(t *testing.T) { t.Fatal("duplicate (task_session_id, tool_call_id) should violate UNIQUE constraint") } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations twice: %v", err) } if count := countSubagentContextRows(t, repo, "session-schema", "tc-schema"); count != 1 { @@ -254,7 +255,7 @@ func TestSubagentContextBackfillAsyncLaunchedStoresNullNotZero(t *testing.T) { createdAt, updatedAt, ) - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations: %v", err) } @@ -327,7 +328,7 @@ func TestSubagentContextBackfillReportedZeroToolUseCountSurvives(t *testing.T) { ts, ts, ) - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations: %v", err) } @@ -354,7 +355,7 @@ func TestSubagentContextBackfillNegativeMetricBecomesNull(t *testing.T) { ts, ts, ) - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations: %v", err) } @@ -384,7 +385,7 @@ func TestSubagentContextBackfillEmptyStringBecomesNull(t *testing.T) { ts, ts, ) - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations: %v", err) } @@ -414,7 +415,7 @@ func TestSubagentContextBackfillMalformedMetadataDoesNotAbort(t *testing.T) { ts, ts, ) - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations must not abort on malformed metadata rows: %v", err) } @@ -440,7 +441,7 @@ func TestSubagentContextBackfillNonTerminalSettledAtNull(t *testing.T) { ts, ts, ) - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations: %v", err) } @@ -485,7 +486,7 @@ func TestSubagentContextBackfillLiveRowWins(t *testing.T) { t.Fatalf("seed pre-existing live row: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations: %v", err) } @@ -528,7 +529,7 @@ func TestSubagentContextBackfillSkipsRowsWithoutIdentity(t *testing.T) { ts, ts, ) - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations: %v", err) } @@ -553,7 +554,7 @@ func TestSubagentContextBackfillNestedParentToolCallID(t *testing.T) { ts, ts, ) - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations: %v", err) } @@ -580,7 +581,7 @@ func TestSubagentContextActivationKeysWrittenOnce(t *testing.T) { seedSubagentMessage(t, repo, "msg-activation-newest", "session-activation", "task-activation", "turn-activation", "tc-activation-newest", "", "completed", map[string]interface{}{"status": "completed"}, newest, newest) - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations: %v", err) } @@ -604,7 +605,7 @@ func TestSubagentContextActivationKeysWrittenOnce(t *testing.T) { t.Fatalf("subagent_context_backfill_through = %v, want newest message created_at %v", parsed, newest) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay runMigrations: %v", err) } captureSinceReplayed, _ := readMetaKey(t, db, "subagent_context_capture_since") @@ -624,7 +625,7 @@ func TestSubagentContextActivationKeysWrittenOnce(t *testing.T) { func TestSubagentContextActivationBackfillThroughEmptyWithNoMessages(t *testing.T) { repo, db := newSubagentMigrationTestRepo(t) - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations: %v", err) } @@ -659,7 +660,7 @@ func TestSubagentContextBackfillDoesNotRescanOnceActivated(t *testing.T) { ts, ts, ) - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("first runMigrations (the real, one-time backfill): %v", err) } if count := countSubagentContextRows(t, repo, "session-once", "tc-once"); count != 1 { @@ -671,7 +672,7 @@ func TestSubagentContextBackfillDoesNotRescanOnceActivated(t *testing.T) { t.Fatalf("delete backfilled row: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("second runMigrations (a later boot): %v", err) } if count := countSubagentContextRows(t, repo, "session-once", "tc-once"); count != 0 { @@ -728,7 +729,7 @@ func TestSubagentContextBackfillStatementFailureLogsWarnWithMigrationName(t *tes t.Fatalf("drop updated_at column: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations must swallow the failure, not return it: %v", err) } @@ -771,7 +772,7 @@ func TestSubagentContextBackfillDoesNotRescanWhenBackfillThroughEmpty(t *testing repo, db := newSubagentMigrationTestRepo(t) seedForMsgTest(t, repo, "task-empty-guard", "session-empty-guard", "turn-empty-guard") - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("first runMigrations (activates with no messages): %v", err) } backfillThrough, ok := readMetaKey(t, db, "subagent_context_backfill_through") @@ -786,7 +787,7 @@ func TestSubagentContextBackfillDoesNotRescanWhenBackfillThroughEmpty(t *testing seedSubagentMessage(t, repo, "msg-empty-guard", "session-empty-guard", "task-empty-guard", "turn-empty-guard", "tc-empty-guard", "", "completed", map[string]interface{}{"status": "completed"}, ts, ts) - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("second runMigrations (a later boot, message now exists): %v", err) } if count := countSubagentContextRows(t, repo, "session-empty-guard", "tc-empty-guard"); count != 0 { diff --git a/apps/backend/internal/task/repository/sqlite/subagent_context_postgres_test.go b/apps/backend/internal/task/repository/sqlite/subagent_context_postgres_test.go index 5a97f079a9..4ed3061c21 100644 --- a/apps/backend/internal/task/repository/sqlite/subagent_context_postgres_test.go +++ b/apps/backend/internal/task/repository/sqlite/subagent_context_postgres_test.go @@ -80,10 +80,10 @@ func TestPostgresSubagentContextSchemaAndBackfill(t *testing.T) { if _, err := db.Exec(`DELETE FROM kandev_meta WHERE key IN ('subagent_context_capture_since', 'subagent_context_backfill_through')`); err != nil { t.Fatalf("reset activation keys: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay runMigrations: %v", err) } @@ -265,7 +265,7 @@ func TestPostgresSubagentContextBackfillJSONHelpers(t *testing.T) { if _, err := db.Exec(`DELETE FROM kandev_meta WHERE key IN ('subagent_context_capture_since', 'subagent_context_backfill_through')`); err != nil { t.Fatalf("reset activation keys: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("runMigrations must not abort on malformed metadata rows: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/task_external_id_migration_test.go b/apps/backend/internal/task/repository/sqlite/task_external_id_migration_test.go index 2017461867..0a9465eea6 100644 --- a/apps/backend/internal/task/repository/sqlite/task_external_id_migration_test.go +++ b/apps/backend/internal/task/repository/sqlite/task_external_id_migration_test.go @@ -1,6 +1,7 @@ package sqlite import ( + "context" "path/filepath" "testing" "time" @@ -63,10 +64,10 @@ func TestTaskExternalIDMigrationAddsColumnsAndIndex(t *testing.T) { t.Fatalf("create task without external_id after migration: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations twice: %v", err) } } diff --git a/apps/backend/internal/task/repository/sqlite/task_repository_branch_policy_migration_test.go b/apps/backend/internal/task/repository/sqlite/task_repository_branch_policy_migration_test.go index 7c8e4902f9..0f39aae940 100644 --- a/apps/backend/internal/task/repository/sqlite/task_repository_branch_policy_migration_test.go +++ b/apps/backend/internal/task/repository/sqlite/task_repository_branch_policy_migration_test.go @@ -46,10 +46,10 @@ func TestTaskRepositoryBranchPolicyMigrationPreservesLegacyRows(t *testing.T) { } } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("migrate legacy task repository schema: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay legacy task repository migration: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/task_status_summary_postgres_test.go b/apps/backend/internal/task/repository/sqlite/task_status_summary_postgres_test.go index ad91f5d755..216b527115 100644 --- a/apps/backend/internal/task/repository/sqlite/task_status_summary_postgres_test.go +++ b/apps/backend/internal/task/repository/sqlite/task_status_summary_postgres_test.go @@ -29,7 +29,7 @@ func TestPostgresTaskStatusSummarySchemaReplay(t *testing.T) { if _, err := db.Exec(`DROP TABLE task_status_summaries`); err != nil { t.Fatalf("drop summary table to simulate legacy schema: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay postgres migrations: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/task_status_summary_test.go b/apps/backend/internal/task/repository/sqlite/task_status_summary_test.go index dac9a15b18..415db9f0d9 100644 --- a/apps/backend/internal/task/repository/sqlite/task_status_summary_test.go +++ b/apps/backend/internal/task/repository/sqlite/task_status_summary_test.go @@ -66,10 +66,10 @@ func TestTaskStatusSummarySchemaReplayAndCascade(t *testing.T) { t.Fatalf("summary rows after task deletion = %d, want 0", count) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations twice: %v", err) } } diff --git a/apps/backend/internal/task/repository/sqlite/workspace_folder_test.go b/apps/backend/internal/task/repository/sqlite/workspace_folder_test.go index c450c92208..ed058a870e 100644 --- a/apps/backend/internal/task/repository/sqlite/workspace_folder_test.go +++ b/apps/backend/internal/task/repository/sqlite/workspace_folder_test.go @@ -201,10 +201,10 @@ func TestTaskWorkspaceFoldersMigrationReplaysAfterLegacyTableIsMissing(t *testin if _, err := repo.DB().Exec(`DROP TABLE task_workspace_folders`); err != nil { t.Fatalf("simulate legacy schema: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("migrate legacy schema: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay workspace-folder migration: %v", err) } var tableName string diff --git a/apps/backend/internal/task/repository/sqlite/worktree_branch_template_migration_postgres_test.go b/apps/backend/internal/task/repository/sqlite/worktree_branch_template_migration_postgres_test.go index af3731af14..88376a0fde 100644 --- a/apps/backend/internal/task/repository/sqlite/worktree_branch_template_migration_postgres_test.go +++ b/apps/backend/internal/task/repository/sqlite/worktree_branch_template_migration_postgres_test.go @@ -28,7 +28,7 @@ func TestPostgresWorktreeBranchTemplateMigration(t *testing.T) { if err := repo.CreateRepository(ctx, custom); err != nil { t.Fatalf("create custom repository: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay custom template migration: %v", err) } assertPostgresWorktreeBranchTemplate(t, db, custom.ID, custom.WorktreeBranchTemplate) @@ -56,10 +56,10 @@ func TestPostgresWorktreeBranchTemplateMigration(t *testing.T) { if _, err := db.Exec(`ALTER TABLE repositories DROP COLUMN worktree_branch_template`); err != nil { t.Fatalf("remove template column from legacy schema: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("migrate legacy postgres schema: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay legacy postgres migration: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/worktree_branch_template_migration_test.go b/apps/backend/internal/task/repository/sqlite/worktree_branch_template_migration_test.go index e4a7d601a1..1aaee6fa79 100644 --- a/apps/backend/internal/task/repository/sqlite/worktree_branch_template_migration_test.go +++ b/apps/backend/internal/task/repository/sqlite/worktree_branch_template_migration_test.go @@ -24,10 +24,10 @@ func TestWorktreeBranchTemplateMigrationPreservesCustomValue(t *testing.T) { t.Fatalf("create repository: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay migrations: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("second replay migrations: %v", err) } @@ -76,10 +76,10 @@ func TestWorktreeBranchTemplateMigrationBackfillsLegacyPrefix(t *testing.T) { if _, err := repo.db.Exec(`ALTER TABLE repositories DROP COLUMN worktree_branch_template`); err != nil { t.Fatalf("remove template column from legacy schema: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("migrate legacy schema: %v", err) } - if err := repo.runMigrations(); err != nil { + if err := repo.runMigrations(context.Background()); err != nil { t.Fatalf("replay legacy migration: %v", err) } diff --git a/apps/backend/internal/testutil/storeconformance/suite_test.go b/apps/backend/internal/testutil/storeconformance/suite_test.go index 4e15c593f1..ecc75dbec4 100644 --- a/apps/backend/internal/testutil/storeconformance/suite_test.go +++ b/apps/backend/internal/testutil/storeconformance/suite_test.go @@ -5,12 +5,14 @@ import ( "testing" "github.com/kandev/kandev/internal/persistence/requiredstores" + "github.com/kandev/kandev/internal/startup" ) func TestValidateAdaptersRejectsMissingCapability(t *testing.T) { catalog := []requiredstores.Descriptor{{ ID: "store", OwnerPackage: "test/store", RequiredTables: []string{"values_table"}, Capabilities: []requiredstores.Capability{requiredstores.CapabilityBoolean}, + Sweep: startup.StepStoresRepositories, }} adapter := Adapter{ ID: "store", @@ -26,7 +28,7 @@ func TestValidateAdaptersRejectsMissingCapability(t *testing.T) { } func TestValidateAdaptersRejectsMissingEngine(t *testing.T) { - catalog := []requiredstores.Descriptor{{ID: "store", OwnerPackage: "test/store", RequiredTables: []string{"values_table"}}} + catalog := []requiredstores.Descriptor{{ID: "store", OwnerPackage: "test/store", RequiredTables: []string{"values_table"}, Sweep: startup.StepStoresRepositories}} adapter := Adapter{ ID: "store", Engines: map[EngineName]EngineAdapter{EngineSQLite: {Fresh: noop, Replay: noop}}, @@ -38,7 +40,7 @@ func TestValidateAdaptersRejectsMissingEngine(t *testing.T) { } func TestRunUsesStableEngineStoreScenarioNames(t *testing.T) { - catalog := []requiredstores.Descriptor{{ID: "store", OwnerPackage: "test/store", RequiredTables: []string{"values_table"}}} + catalog := []requiredstores.Descriptor{{ID: "store", OwnerPackage: "test/store", RequiredTables: []string{"values_table"}, Sweep: startup.StepStoresRepositories}} seen := make(chan string, 3) callback := func(name string) Scenario { return func(s ScenarioContext) error { @@ -75,6 +77,7 @@ func TestCapabilityScenarioUsesDatabase(t *testing.T) { catalog := []requiredstores.Descriptor{{ ID: "store", OwnerPackage: "test/store", RequiredTables: []string{"values_table"}, Capabilities: []requiredstores.Capability{requiredstores.CapabilityTransaction}, + Sweep: startup.StepStoresRepositories, }} adapter := Adapter{ ID: "store", diff --git a/apps/backend/internal/webapp/accept.go b/apps/backend/internal/webapp/accept.go new file mode 100644 index 0000000000..123cb3590b --- /dev/null +++ b/apps/backend/internal/webapp/accept.go @@ -0,0 +1,107 @@ +package webapp + +import ( + "strconv" + "strings" +) + +// mediaRange is one comma-separated entry of a parsed Accept header. +type mediaRange struct { + typ, subtype string + q float64 +} + +// PrefersHTML reports whether header resolves text/html, by the most +// specific matching media range, to a quality strictly greater than the one +// it resolves for application/json (AC-PLATFORM-STARTUP-PROGRESS-003.3). An +// absent or malformed header, or one that does not clear that bar - including +// a bare */* - reports false rather than promoting an unparseable entry. +func PrefersHTML(header string) bool { + ranges := parseAccept(header) + return acceptQuality(ranges, "text", "html") > acceptQuality(ranges, "application", "json") +} + +// parseAccept parses header into its media ranges, dropping any entry whose +// type/subtype or q parameter cannot be parsed rather than defaulting it to +// an acceptable quality. An explicit q=0 range is kept, not dropped: it is +// how a client excludes a specific type while still accepting a broader +// wildcard, and dropping it would let the wildcard's quality leak through +// for the type the client named as unacceptable. +func parseAccept(header string) []mediaRange { + if header == "" { + return nil + } + var ranges []mediaRange + for _, part := range strings.Split(header, ",") { + fields := strings.Split(strings.TrimSpace(part), ";") + typePair := strings.SplitN(strings.TrimSpace(fields[0]), "/", 2) + if len(typePair) != 2 || typePair[0] == "" || typePair[1] == "" { + continue + } + q, ok := acceptQualityParam(fields[1:]) + if !ok { + continue + } + ranges = append(ranges, mediaRange{ + typ: strings.ToLower(strings.TrimSpace(typePair[0])), + subtype: strings.ToLower(strings.TrimSpace(typePair[1])), + q: q, + }) + } + return ranges +} + +// acceptQualityParam reads the q parameter from one Accept field's +// ";"-separated parameters, defaulting to 1.0 when none is present. It +// reports ok=false for a q parameter present but unparseable - including a +// value with trailing garbage after the number, or one outside [0, 1] - so +// the caller excludes the range instead of promoting a malformed value to +// accepted. The parameter name is matched case-insensitively per RFC 7231. +func acceptQualityParam(params []string) (float64, bool) { + q := 1.0 + for _, field := range params { + key, value, found := strings.Cut(strings.TrimSpace(field), "=") + if !found || !strings.EqualFold(strings.TrimSpace(key), "q") { + continue + } + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil || parsed < 0 || parsed > 1 { + return 0, false + } + q = parsed + } + return q, true +} + +// acceptQuality resolves the quality ranges assigns to typ/subtype: the +// q-value of the highest-specificity matching range (exact type/subtype > +// type/* > */*), or 0 when nothing matches. A tie at the same specificity +// resolves to the highest q among the tied ranges. +func acceptQuality(ranges []mediaRange, typ, subtype string) float64 { + bestSpecificity := -1 + var bestQ float64 + for _, r := range ranges { + specificity, ok := matchSpecificity(r, typ, subtype) + if !ok { + continue + } + if specificity > bestSpecificity || (specificity == bestSpecificity && r.q > bestQ) { + bestSpecificity = specificity + bestQ = r.q + } + } + return bestQ +} + +func matchSpecificity(r mediaRange, typ, subtype string) (int, bool) { + switch { + case r.typ == typ && r.subtype == subtype: + return 2, true + case r.typ == typ && r.subtype == "*": + return 1, true + case r.typ == "*" && r.subtype == "*": + return 0, true + default: + return 0, false + } +} diff --git a/apps/backend/internal/webapp/accept_test.go b/apps/backend/internal/webapp/accept_test.go new file mode 100644 index 0000000000..4f4022b954 --- /dev/null +++ b/apps/backend/internal/webapp/accept_test.go @@ -0,0 +1,39 @@ +package webapp + +import "testing" + +func TestPrefersHTML(t *testing.T) { + cases := []struct { + name string + header string + want bool + }{ + {"empty header stays JSON", "", false}, + {"json preferred over html stays JSON", "application/json;q=1, text/html;q=0.1", false}, + {"equal quality stays JSON", "text/html;q=0.5, application/json;q=0.5", false}, + {"bare wildcard stays JSON", "*/*", false}, + {"html only prefers HTML", "text/html", true}, + {"typical browser navigation header prefers HTML", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", true}, + {"specific html wildcard loses to a higher-quality catch-all", "text/*;q=0.9,*/*;q=1.0", false}, + {"exact match beats type wildcard even at lower q", "text/html;q=0.6,text/*;q=0.9", true}, + {"malformed q value ignored", "text/html;q=bogus", false}, + {"malformed media type ignored", "not-a-media-type;q=1", false}, + {"json wildcard beats html when html unlisted", "application/*;q=1", false}, + {"zero quality html excluded", "text/html;q=0", false}, + { + "explicit q=0 exclusion beats a higher-quality wildcard", + "text/html;q=0.5, application/json;q=0, */*;q=1", + true, + }, + {"malformed q with trailing garbage ignored", "text/html;q=.5junk", false}, + {"out-of-range q ignored, does not leak wildcard quality", "text/html;q=2, */*;q=0.5", false}, + {"uppercase Q parameter name is honored", "text/html;Q=0.9", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := PrefersHTML(tc.header); got != tc.want { + t.Fatalf("PrefersHTML(%q) = %v, want %v", tc.header, got, tc.want) + } + }) + } +} diff --git a/apps/web/components/settings/system/restart-progress-dialog.test.tsx b/apps/web/components/settings/system/restart-progress-dialog.test.tsx new file mode 100644 index 0000000000..6cba937b4e --- /dev/null +++ b/apps/web/components/settings/system/restart-progress-dialog.test.tsx @@ -0,0 +1,151 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { StartupSnapshot } from "@/lib/startup-progress/types"; +import { RestartProgressDialog } from "./restart-progress-dialog"; + +const mocks = vi.hoisted(() => ({ fetchJson: vi.fn() })); + +vi.mock("@/lib/api/client", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchJson: mocks.fetchJson }; +}); + +function snapshot(overrides: Partial = {}): StartupSnapshot { + return { + phase: "applying_migrations", + boot: 1, + seq: 1, + elapsed_ms: 5000, + phase_elapsed_ms: 5000, + ...overrides, + }; +} + +beforeEach(() => { + mocks.fetchJson.mockReset(); + // Never resolves by default, so a test that doesn't care about the poll + // outcome sees the pre-first-read "waiting" state deterministically. + mocks.fetchJson.mockReturnValue(new Promise(() => {})); +}); + +afterEach(() => { + cleanup(); +}); + +describe("RestartProgressDialog", () => { + it("renders nothing while idle", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("shows the waiting state before the first /ready read resolves", async () => { + render(); + expect(await screen.findByText("Kandev is starting and not yet reachable.")).not.toBeNull(); + }); + + it("renders phase and elapsed time alone for a snapshot with no active step", async () => { + mocks.fetchJson.mockResolvedValue({ startup: snapshot() }); + + render(); + + expect(await screen.findByText("Applying migrations")).not.toBeNull(); + expect(screen.getByText("5 seconds elapsed")).not.toBeNull(); + expect(screen.queryByRole("progressbar")).toBeNull(); + }); + + it("renders step-level detail from a polled snapshot", async () => { + mocks.fetchJson.mockResolvedValue({ + startup: snapshot({ + step: { + id: "stores.repositories", + label_key: "startup.step.stores_repositories", + measure: "counted", + unit: "turns", + elapsed_ms: 1000, + done: 5, + total: 10, + eta_ms: 30000, + stalled: false, + }, + }), + }); + + render(); + + expect(await screen.findByText("Applying migrations")).not.toBeNull(); + expect(screen.getByText("5 seconds elapsed")).not.toBeNull(); + expect(screen.getByText("Repository stores")).not.toBeNull(); + expect(screen.getByText("5 of 10 turns")).not.toBeNull(); + expect(screen.getByText("About 30 seconds remaining")).not.toBeNull(); + }); + + it("renders stalled-state detail for a step that has not advanced (AC-PLATFORM-STARTUP-PROGRESS-004.6)", async () => { + mocks.fetchJson.mockResolvedValue({ + startup: snapshot({ + step: { + id: "stores.repositories", + label_key: "startup.step.stores_repositories", + measure: "counted", + unit: "turns", + elapsed_ms: 1000, + done: 5, + total: 10, + since_advance_ms: 125000, + stalled: true, + }, + }), + }); + + render(); + + expect(await screen.findByText("5 of 10 turns")).not.toBeNull(); + expect(screen.getByText("Stalled for 3 minutes")).not.toBeNull(); + }); + + it("renders no stalled-state text for a running step", async () => { + mocks.fetchJson.mockResolvedValue({ + startup: snapshot({ + step: { + id: "stores.repositories", + label_key: "startup.step.stores_repositories", + measure: "counted", + unit: "turns", + elapsed_ms: 1000, + done: 5, + total: 10, + since_advance_ms: 500, + stalled: false, + }, + }), + }); + + render(); + + expect(await screen.findByText("5 of 10 turns")).not.toBeNull(); + expect(screen.queryByText(/Stalled for/)).toBeNull(); + }); + + it("shows the last-known note once a read has been lost after an earlier success", async () => { + mocks.fetchJson + .mockResolvedValueOnce({ startup: snapshot() }) + .mockRejectedValue(new TypeError("fetch failed")); + + render(); + await screen.findByText("Applying migrations"); + + await waitFor( + () => { + expect(screen.getByText("Last known")).not.toBeNull(); + }, + { timeout: 2000 }, + ); + }); + + it("does not render startup progress detail outside the restarting phase", () => { + render(); + expect(screen.queryByText("Kandev is starting and not yet reachable.")).toBeNull(); + expect(mocks.fetchJson).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/settings/system/restart-progress-dialog.tsx b/apps/web/components/settings/system/restart-progress-dialog.tsx index 303033f317..f0622c07f9 100644 --- a/apps/web/components/settings/system/restart-progress-dialog.tsx +++ b/apps/web/components/settings/system/restart-progress-dialog.tsx @@ -11,9 +11,21 @@ import { DialogHeader, DialogTitle, } from "@kandev/ui/dialog"; +import { Progress } from "@kandev/ui/progress"; import { Spinner } from "@kandev/ui/spinner"; import { IconAlertTriangle, IconCheck } from "@tabler/icons-react"; import type { KandevRestartPhase } from "@/hooks/domains/system/use-kandev-restart"; +import { useStartupProgress } from "@/hooks/domains/system/use-startup-progress"; +import { + formatElapsed, + formatEta, + formatPhaseLabel, + formatStalled, + formatStepLabel, + formatStepProgress, + percentDone, +} from "@/lib/startup-progress/format"; +import type { StartupSnapshot, StartupStepSnapshot } from "@/lib/startup-progress/types"; type RestartProgressDialogProps = { phase: KandevRestartPhase; @@ -27,6 +39,7 @@ export function RestartProgressDialog({ onDismiss, }: RestartProgressDialogProps) { const { t } = useTranslation(); + const startupProgress = useStartupProgress(phase === "restarting"); if (phase === "idle") return null; const done = phase === "done"; const failed = phase === "error"; @@ -44,6 +57,13 @@ export function RestartProgressDialog({ {restartDescription(phase, errorMessage, t)} + {phase === "restarting" && ( + + )} {(done || failed) && (