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 "" escape defends against a
+// catalog string that could otherwise prematurely close the script tag.
+func marshalStartupData(locale string, snap startup.Snapshot) template.JS {
+ payload := map[string]any{
+ "translations": translationsFor(locale),
+ "snapshot": snap,
+ }
+ encoded, err := json.Marshal(payload)
+ if err != nil {
+ encoded = []byte(`{"translations":{},"snapshot":null}`)
+ }
+ return template.JS(strings.ReplaceAll(string(encoded), "", "<\\/")) //nolint:gosec // JSON-only, no HTML
+}
+
+var startupPageTemplate = template.Must(template.New("startup-page").Parse(startupPageTemplateSource))
+
+const startupPageTemplateSource = `
+
+
+
+
+
+{{.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) && (