# current_step_id Implementation Plan > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 将 job 表的运行时步骤标识从 `currentStepIndex`(int) 迁移到 `currentStepId`(string),消除 AdvanceStep 中 stepId↔stepIndex 的冗余转换。 **Architecture:** job 表新增 `currentStepId` 字段,旧字段 `currentStepIndex` 保留但不写入。`AdvanceStep`/`CompleteStep` 改为写入 stepId,`resolveBranch` 直接返回 stepId(无需二次查询转换)。核心读路径新增 `GetRecipeStepByID` 用 stepId 直接查询。scheduler 层 `StepView`/`CandidateTask` 加 `StepID` 字段。 **Tech Stack:** Go 1.25, ent ORM v0.14.5, PostgreSQL **Spec:** `docs/superpowers/specs/2026-05-16-current-step-id-design.md` --- ## File Structure | File | Action | Responsibility | |------|--------|---------------| | `schema/job.go` | Modify | 新增 `currentStepId` 字段 | | `internal/eventloop/dbstate.go` | Modify | 核心方法:`AdvanceStep`/`CompleteStep` 改为 stepId | | `internal/eventloop/loop.go` | Modify | `RuntimeSnapshot` 加 `StepID` | | `internal/eventloop/scheduler_bridge.go` | Modify | `buildJobViews` 传 stepId | | `internal/eventloop/candidate_handlers.go` | Modify | 用 stepId 查询 | | `internal/scheduler/types.go` | Modify | `StepView`/`CandidateTask` 加 `StepID` | | `internal/scheduler/generator.go` | Modify | 填充 `StepID` | | `internal/types/types.go` | Modify | API 类型加 `CurrentStepId` | | `internal/processor/job_processor.go` | Modify | `ReworkJob` 用 stepId | | `internal/processor/interface.go` | Modify | `ReworkJob` 签名变更 | | `cmd/mockrun/main.go` | Modify | `SetCurrentStepId` | | `frontend/src/api/temp-station/index.ts` | Modify | 类型加 `currentStepId` | | `frontend/src/api/dock/index.ts` | Modify | 类型加 `currentStepId` | | `schema/tools/migrate_step_id.go` | Create | 数据迁移 SQL | --- ## Chunk 1: Schema + Migration ### Task 1.1: Add currentStepId field to job schema **Files:** - Modify: `schema/job.go:35` - Create: `schema/tools/migrate_step_id.go` - [ ] **Step 1: Add field to schema** In `schema/job.go`, after line 35 (`field.Int("currentStepIndex")...`): ```go field.String("currentStepId").MaxLen(20).Default("").Comment("当前步骤业务ID(OP10-OP120)"), ``` - [ ] **Step 2: Regenerate ent code** Run: `cd schema && go generate` - [ ] **Step 3: Verify ent generated SetCurrentStepId/CurrentStepId** Run: `rtk grep "SetCurrentStepId" ent/job/` - [ ] **Step 4: Create migration SQL file** Create `schema/tools/migrate_step_id.go`: ```go package tools import ( "context" "log/slog" "hougai/ent" ) func MigrateCurrentStepID(ctx context.Context, client *ent.Client) error { rows, err := client.Job.Update(). Where(func(s *sql.Selector) { s.Where(sql.ExprP("current_step_id = '' OR current_step_id IS NULL")) }). Save(ctx) // Use raw SQL for migration: // UPDATE job SET current_step_id = rs.step_id // FROM recipe_step rs // WHERE job.recipe_id = rs.recipe_id AND job.current_step_index = rs.step_index // AND (job.current_step_id = '' OR job.current_step_id IS NULL); return nil } ``` Wait—ent doesn't support cross-table UPDATE easily. Use raw SQL via ent client: ```go package tools import ( "context" "database/sql" "fmt" "log/slog" "hougai/ent" "hougai/ent/job" ) // MigrateCurrentStepID 将现有 job 的 current_step_index 转换为 current_step_id func MigrateCurrentStepID(ctx context.Context, client *ent.Client) error { db := client.Job.Query().UnderlyingDB() // 先检查是否有未迁移的 job var count int err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM job WHERE current_step_id = '' AND current_step_index > 0`, ).Scan(&count) if err != nil { return fmt.Errorf("migrate currentStepId: count pending: %w", err) } if count == 0 { slog.Info("migrate currentStepId: nothing to migrate") return nil } result, err := db.ExecContext(ctx, ` UPDATE job SET current_step_id = rs.step_id FROM recipe_step rs WHERE job.recipe_id = rs.recipe_id AND job.current_step_index = rs.step_index AND job.current_step_id = '' `) if err != nil { return fmt.Errorf("migrate currentStepId: %w", err) } n, _ := result.RowsAffected() slog.Info("migrate currentStepId: done", "rows", n) return nil } ``` - [ ] **Step 5: Wire migration into EnsureDataInitialized** In `internal/svc/service_context.go`, after the existing migration calls, add: ```go if err := tools.MigrateCurrentStepID(ctx, entClient); err != nil { return nil, fmt.Errorf("migrate currentStepId: %w", err) } ``` - [ ] **Step 6: Commit** ```bash rtk git add schema/job.go schema/tools/migrate_step_id.go ent/ internal/svc/service_context.go rtk git commit -m "feat: add currentStepId field to job schema with migration" ``` --- ## Chunk 2: DBState Core Methods ### Task 2.1: Add stepId-based query methods to DBState **Files:** - Modify: `internal/eventloop/dbstate.go` - [ ] **Step 1: Add GetRecipeStepByID method** After `GetRecipeStep` (~line 268), add: ```go // GetRecipeStepByID 根据 stepId 获取工艺路线中指定步骤的信息 func (d *DBState) GetRecipeStepByID(ctx context.Context, recipeID int, stepID string) (stepIndex int, stepType constants.StepType, resourceType string, toolType string, stepName string, err error) { step, err := d.client.RecipeStep.Query(). Where(recipestep.RecipeIdEQ(recipeID), recipestep.StepIdEQ(stepID)). First(ctx) if err != nil { return 0, "", "", "", "", fmt.Errorf("get recipe %d step %s: %w", recipeID, stepID, err) } return step.StepIndex, step.StepType, step.ResourceType, step.ToolType, step.StepName, nil } ``` - [ ] **Step 2: Add GetStepIDByIndex method** ```go // GetStepIDByIndex 根据 stepIndex 查找 stepId func (d *DBState) GetStepIDByIndex(ctx context.Context, recipeID, stepIndex int) (string, error) { step, err := d.client.RecipeStep.Query(). Where(recipestep.RecipeIdEQ(recipeID), recipestep.StepIndexEQ(stepIndex)). First(ctx) if err != nil { return "", fmt.Errorf("get stepId by index %d: %w", stepIndex, err) } return step.StepId, nil } ``` - [ ] **Step 3: Add getSortedStepIDs method** ```go // getSortedStepIDs 返回按 stepIndex 排序的 stepId 列表 func (d *DBState) getSortedStepIDs(ctx context.Context, recipeID int) ([]string, error) { steps, err := d.client.RecipeStep.Query(). Where(recipestep.RecipeIdEQ(recipeID)). Order(recipestep.ByStepIndex()). All(ctx) if err != nil { return nil, fmt.Errorf("get sorted step IDs for recipe %d: %w", recipeID, err) } ids := make([]string, len(steps)) for i, s := range steps { ids[i] = s.StepId } return ids, nil } ``` - [ ] **Step 4: Commit** ```bash rtk git add internal/eventloop/dbstate.go rtk git commit -m "feat: add GetRecipeStepByID, GetStepIDByIndex, getSortedStepIDs to DBState" ``` ### Task 2.2: Revise resolveBranch to work with stepId **Files:** - Modify: `internal/eventloop/dbstate.go:315-337` - [ ] **Step 1: Create resolveBranch that returns stepId** Replace the existing `resolveBranch`: ```go // resolveBranch 检查 nextStepBranches:遍历分支键,若在 ctxMap 中存在且 truthy,返回目标 stepId。 // 返回 (stepId, true) 表示匹配成功,("", false) 表示无匹配。 func (d *DBState) resolveBranch(ctx context.Context, recipeID int, currentStepID string, ctxMap map[string]any) (string, bool) { step, err := d.client.RecipeStep.Query(). Where(recipestep.RecipeIdEQ(recipeID), recipestep.StepIdEQ(currentStepID)). First(ctx) if err != nil || step.NextStepBranches == nil { return "", false } for key, val := range step.NextStepBranches { if cv, ok := ctxMap[key]; ok && isTruthy(cv) { stepID, ok := val.(string) if !ok { continue } return stepID, true } } return "", false } ``` - [ ] **Step 2: Commit** ```bash rtk git add internal/eventloop/dbstate.go rtk git commit -m "refactor: resolveBranch returns stepId directly instead of stepIndex" ``` ### Task 2.3: Revise AdvanceStep to use stepId **Files:** - Modify: `internal/eventloop/dbstate.go:162-239` - [ ] **Step 1: Rewrite AdvanceStep** ```go // AdvanceStep 推进到下一步:先合并 context → 匹配 nextStepBranches → 否则 nextStepDefault → 否则线性推进。 // 推进后若下一步是 Decision/Judge 则链式内联处理。 // 返回 true 表示已是最后一步(job 已完成)。 func (d *DBState) AdvanceStep(ctx context.Context, jobID int, contextUpdates map[string]any, posType constants.PositionType, posRefID string) (bool, error) { for { jb, err := d.client.Job.Get(ctx, jobID) if err != nil { return false, fmt.Errorf("advance step: job %d not found: %w", jobID, err) } // 合并上下文更新 ctxMap := jb.Context if ctxMap == nil { ctxMap = make(map[string]any) } for k, v := range contextUpdates { ctxMap[k] = v } // 确定下一步 stepId var nextStepID string currentStepID := jb.CurrentStepId // 1. 优先 nextStepBranches(条件跳步) if branchID, matched := d.resolveBranch(ctx, jb.RecipeId, currentStepID, ctxMap); matched { nextStepID = branchID } else { // 2. 其次 nextStepDefault(指定跳步) step, err := d.client.RecipeStep.Query(). Where(recipestep.RecipeIdEQ(jb.RecipeId), recipestep.StepIdEQ(currentStepID)). First(ctx) if err != nil { return false, fmt.Errorf("advance step: query current step %s: %w", currentStepID, err) } if step.NextStepDefault != "" { nextStepID = step.NextStepDefault } else { // 3. 线性推进:当前 stepIndex + 1 → 找对应 stepId nextID, err := d.GetStepIDByIndex(ctx, jb.RecipeId, step.StepIndex+1) if err != nil { return false, fmt.Errorf("advance step: no next step after %s: %w", currentStepID, err) } nextStepID = nextID } } // 是否最后一步:查 nextStepID 对应的 stepIndex 与 maxStepIndex 比较 nextIdx, err := d.GetStepIndexByStepID(ctx, jb.RecipeId, nextStepID) if err != nil { return false, fmt.Errorf("advance step: get index for %s: %w", nextStepID, err) } maxStep, err := d.GetRecipeMaxStepIndex(ctx, jb.RecipeId) if err != nil { return false, fmt.Errorf("advance step: get recipe max for job %d: %w", jobID, err) } isLast := nextIdx > maxStep if isLast { if posType != "" || posRefID != "" { upd := d.client.Job.UpdateOneID(jobID).AddVersion(1) if posType != "" { upd = upd.SetPositionType(posType) } if posRefID != "" { upd = upd.SetPositionRefId(posRefID) } if _, err := upd.Save(ctx); err != nil { return false, err } } return true, d.FinishJob(ctx, jobID, jb.WorkOrderId, constants.JobStatus_Completed) } update := d.client.Job.UpdateOneID(jobID). SetCurrentStepId(nextStepID). SetContext(ctxMap). AddVersion(1) if posType != "" { update = update.SetPositionType(posType) } if posRefID != "" { update = update.SetPositionRefId(posRefID) } if _, err = update.Save(ctx); err != nil { return false, err } // 检查下一步是否可内联处理(Decision/Judge) _, stepType, _, _, _, err := d.GetRecipeStepByID(ctx, jb.RecipeId, nextStepID) if err != nil || (stepType != constants.StepType_Decision && stepType != constants.StepType_Judge) { return false, err } contextUpdates = evaluateDecisionForJob(d, ctx, jb.RecipeId, nextStepID, ctxMap, jb.WorkpieceNo) posType, posRefID = "", "" } } ``` - [ ] **Step 2: Update evaluateDecisionForJob to accept stepId** Change signature from `stepIndex int` to `stepID string`: ```go func evaluateDecisionForJob(d *DBState, ctx context.Context, recipeID int, stepID string, ctxMap map[string]any, workpieceNo string) map[string]any { // Get stepIndex first since GetStepProcessingParams uses stepIndex step, err := d.client.RecipeStep.Query(). Where(recipestep.RecipeIdEQ(recipeID), recipestep.StepIdEQ(stepID)). First(ctx) if err != nil { return nil } params := step.ProcessingParams clone := make(map[string]any, len(ctxMap)+2) for k, v := range ctxMap { clone[k] = v } evaluateDecision(params, clone, workpieceNo) result := make(map[string]any) for k, v := range clone { if _, ok := ctxMap[k]; !ok { result[k] = v } } return result } ``` - [ ] **Step 3: Commit** ```bash rtk git add internal/eventloop/dbstate.go rtk git commit -m "refactor: AdvanceStep uses stepId instead of stepIndex" ``` ### Task 2.4: Revise CompleteStep to use stepId **Files:** - Modify: `internal/eventloop/dbstate.go:34-55` - [ ] **Step 1: Change CompleteStep signature** ```go // CompleteStep 完成当前步骤,推进到下一步 stepId func (d *DBState) CompleteStep(ctx context.Context, jobID int, nextStepID string, contextPatch map[string]any) error { update := d.client.Job.UpdateOneID(jobID). SetCurrentStepId(nextStepID). AddVersion(1) if contextPatch != nil { jb, err := d.client.Job.Get(ctx, jobID) if err != nil { return fmt.Errorf("complete step: get job %d: %w", jobID, err) } ctxMap := jb.Context if ctxMap == nil { ctxMap = make(map[string]any) } for k, v := range contextPatch { ctxMap[k] = v } update = update.SetContext(ctxMap) } _, err := update.Save(ctx) return err } ``` - [ ] **Step 2: Update callers in loop.go** At `loop.go:409`: ```go // Before: l.db.CompleteStep(ctx, jobID, jb.CurrentStepIndex, map[string]any{"inspectionPass": true}) // After: l.db.CompleteStep(ctx, jobID, jb.CurrentStepId, map[string]any{"inspectionPass": true}) ``` At `loop.go:432`: ```go // Before: l.db.CompleteStep(ctx, jobID, job.CurrentStepIndex, map[string]any{"inspectionPass": false}) // After: l.db.CompleteStep(ctx, jobID, job.CurrentStepId, map[string]any{"inspectionPass": false}) ``` - [ ] **Step 3: Commit** ```bash rtk git add internal/eventloop/dbstate.go internal/eventloop/loop.go rtk git commit -m "refactor: CompleteStep uses stepId instead of stepIndex" ``` --- ## Chunk 3: Runtime Snapshot + Scheduler Bridge ### Task 3.1: Add StepID to RuntimeSnapshot and scheduler types **Files:** - Modify: `internal/eventloop/loop.go:65-84` - Modify: `internal/scheduler/types.go:25-32,86-92` - [ ] **Step 1: Add StepID to RuntimeSnapshot** In `internal/eventloop/loop.go`, add after `StepIndex`: ```go type RuntimeSnapshot struct { // ... existing fields ... StepIndex int StepID string // 当前步骤业务ID(OP10-OP120) // ... rest ... } ``` - [ ] **Step 2: Populate StepID in trySchedule** In `scheduler_bridge.go:49-63`, after loading recipe step info: ```go st, rt, tt, sn, sid, err := l.db.GetRecipeStep(ctx, j.RecipeId, j.CurrentStepIndex) if err == nil { snap.StepType = st snap.ResourceType = rt snap.ToolType = tt snap.StepName = sn snap.StepID = sid } ``` Also read from the job's own `CurrentStepId` field as backup: ```go snap.StepID = j.CurrentStepId ``` Note: `GetRecipeStep` already returns `stepId` as the 6th return value. - [ ] **Step 3: Add StepID to scheduler StepView** In `internal/scheduler/types.go`: ```go type StepView struct { Index int StepID string // 业务步骤ID(OP10-OP120) Name string Type constants.StepType ResourceType string ToolType string TargetID int } ``` - [ ] **Step 4: Add StepID to CandidateTask** ```go type CandidateTask struct { JobID int OrderID int StepIndex int StepID string // 业务步骤ID(OP10-OP120) Action RobotAction TargetID int Priority Priority Meta map[string]any } ``` - [ ] **Step 5: Wire StepID in buildJobViews** In `scheduler_bridge.go:220-227`: ```go CurrentStep: scheduler.StepView{ Index: snap.StepIndex, StepID: snap.StepID, // ... rest ... }, ``` - [ ] **Step 6: Wire StepID in generator.go** In `internal/scheduler/generator.go`, each `CandidateTask` creation adds `StepID: step.StepID`: ```go // Example: line 74 CandidateTask{ JobID: j.JobID, OrderID: j.OrderID, StepIndex: step.Index, StepID: step.StepID, Action: ..., // ... } ``` - [ ] **Step 7: Commit** ```bash rtk git add internal/eventloop/loop.go internal/eventloop/scheduler_bridge.go internal/scheduler/types.go internal/scheduler/generator.go rtk git commit -m "feat: add StepID to RuntimeSnapshot, StepView, CandidateTask" ``` ### Task 3.2: Update candidate_handlers to use stepId **Files:** - Modify: `internal/eventloop/candidate_handlers.go` - [ ] **Step 1: Use GetRecipeStepByID** At line 64: ```go // Before: params, err := l.db.GetStepProcessingParams(ctx, jb.RecipeId, jb.CurrentStepIndex) // After: params, err := l.db.GetStepProcessingParams(ctx, jb.RecipeId, jb.CurrentStepId) ``` But `GetStepProcessingParams` takes stepIndex. Change to look up stepIndex from stepId first, or add a stepId version. Since this is the only caller outside AdvanceStep, keep it simple and add a stepId-based version: ```go // In candidate_handlers.go: // Get the step index from the recipe step for processing params lookup _, _, _, _, _, err := l.db.GetRecipeStepByID(ctx, jb.RecipeId, jb.CurrentStepId) // Actually just pass stepId and add stepId support to GetStepProcessingParams ``` Simpler: add `GetStepProcessingParamsByID` to dbstate.go: ```go func (d *DBState) GetStepProcessingParamsByID(ctx context.Context, recipeID int, stepID string) (map[string]any, error) { step, err := d.client.RecipeStep.Query(). Where(recipestep.RecipeIdEQ(recipeID), recipestep.StepIdEQ(stepID)). First(ctx) if err != nil { return nil, fmt.Errorf("get recipe %d step %s processing params: %w", recipeID, stepID, err) } if step.ProcessingParams == nil { return nil, nil } return step.ProcessingParams, nil } ``` - [ ] **Step 2: Commit** ```bash rtk git add internal/eventloop/candidate_handlers.go internal/eventloop/dbstate.go rtk git commit -m "refactor: candidate_handlers uses GetStepProcessingParamsByID" ``` --- ## Chunk 4: Processor + Handler + Types + Mockrun ### Task 4.1: Update API types with CurrentStepId **Files:** - Modify: `internal/types/types.go:352,519,693` - [ ] **Step 1: Add CurrentStepId to all structs** Add `CurrentStepId string \`json:"currentStepId"\`` to each struct that has `CurrentStepIndex`: ```go // Line ~352 (TempStationJobBrief or similar): CurrentStepIndex int `json:"currentStepIndex"` // deprecated CurrentStepId string `json:"currentStepId"` // Line ~519: CurrentStepIndex int `json:"currentStepIndex"` // deprecated CurrentStepId string `json:"currentStepId"` // Line ~693: CurrentStepIndex int `json:"currentStepIndex"` // deprecated CurrentStepId string `json:"currentStepId"` ``` - [ ] **Step 2: Regenerate types** Run: `cd apis && go generate` - [ ] **Step 3: Update handler/logic to populate CurrentStepId** In response builder logic, set `CurrentStepId` from `job.CurrentStepId`. - [ ] **Step 4: Commit** ```bash rtk git add internal/types/types.go apis/ internal/handler/ internal/logic/ rtk git commit -m "feat: add currentStepId to API response types" ``` ### Task 4.2: Update ReworkJob to use stepId **Files:** - Modify: `internal/processor/interface.go:31` - Modify: `internal/processor/job_processor.go:251-260` - Modify: `internal/logic/job/rework_job_logic.go:26` - [ ] **Step 1: Change ReworkJob signature** In `interface.go`: ```go // Before: ReworkJob(ctx context.Context, jobID int, targetStepIndex int) error // After: ReworkJob(ctx context.Context, jobID int, targetStepID string) error ``` - [ ] **Step 2: Update job_processor.go implementation** ```go func (jp *JobProcessor) ReworkJob(ctx context.Context, jobID int, targetStepID string) error { _, err := jp.entClient.Job.UpdateOneID(jobID). SetStatus(constants.JobStatus_Processing). SetCurrentStepId(targetStepID). SetSuspendedReason(""). Save(ctx) if err != nil { return fmt.Errorf("rework job %d to step %s: %w", jobID, targetStepID, err) } slog.Info("job processor: job reworked", "jobId", jobID, "targetStepId", targetStepID) return nil } ``` - [ ] **Step 3: Update stub** In `interface.go`: ```go func (s *StubProcessor) ReworkJob(ctx context.Context, jobID int, targetStepID string) error { return nil } ``` - [ ] **Step 4: Update rework logic/handler** The `ReworkJobReq` already has `TargetStepId string`. Update the logic to pass it directly: ```go func (l *ReworkJobLogic) ReworkJob(req *types.ReworkJobReq) error { return l.svcCtx.JobProcessor.ReworkJob(l.ctx, req.ID, req.TargetStepId) } ``` - [ ] **Step 5: Commit** ```bash rtk git add internal/processor/interface.go internal/processor/job_processor.go internal/logic/job/rework_job_logic.go rtk git commit -m "refactor: ReworkJob uses stepId instead of stepIndex" ``` ### Task 4.3: Update mockrun **Files:** - Modify: `cmd/mockrun/main.go:110` - [ ] **Step 1: Use SetCurrentStepId** ```go // Before: SetCurrentStepIndex(initialStepIdx) // After: SetCurrentStepId(initialStepID) // lookup from recipe by stepIndex ``` Need to query recipe_step to get the stepId for initialStepIdx. Add a helper: ```go stepID := lookupStepID(ctx, entClient, recipe.RecipeID, initialStepIdx) // ... SetCurrentStepId(stepID) ``` - [ ] **Step 2: Commit** ```bash rtk git add cmd/mockrun/main.go rtk git commit -m "refactor: mockrun uses SetCurrentStepId" ``` --- ## Chunk 5: Frontend ### Task 5.1: Add currentStepId to frontend API types **Files:** - Modify: `frontend/src/api/temp-station/index.ts:9` - Modify: `frontend/src/api/dock/index.ts:9` - [ ] **Step 1: Add currentStepId to JobBrief** ```typescript export interface JobBrief { id: number workOrderId: number workpieceNo: string productTypeId: number status: string currentStepIndex: number // deprecated currentStepId: string currentStepName: string } ``` Same change in both files. - [ ] **Step 2: Commit** ```bash rtk git add frontend/src/api/temp-station/index.ts frontend/src/api/dock/index.ts rtk git commit -m "feat: add currentStepId to frontend API types" ``` --- ## Chunk 6: Deprecation Cleanup ### Task 6.1: Mark deprecated methods **Files:** - Modify: `internal/eventloop/dbstate.go` - Modify: `internal/processor/recipe_runtime.go` - [ ] **Step 1: Mark GetNextStepIndex deprecated** Add comment: ```go // Deprecated: use AdvanceStep with stepId instead. nextStepDefault is already a stepId. func (d *DBState) GetNextStepIndex(...) {...} ``` - [ ] **Step 2: Verify no remaining callers of SetCurrentStepIndex** Run: `rtk grep "SetCurrentStepIndex" --files-with-matches` - [ ] **Step 3: Commit** ```bash rtk git add internal/eventloop/dbstate.go rtk git commit -m "chore: mark GetNextStepIndex deprecated" ``` --- ## Verification Checklist - [ ] `go build ./...` passes - [ ] `go vet ./...` passes - [ ] `go test ./internal/eventloop/...` passes - [ ] `go test ./internal/scheduler/...` passes - [ ] `go test ./internal/processor/...` passes - [ ] `cd frontend && pnpm build` passes - [ ] Migration runs successfully on existing database - [ ] No remaining `SetCurrentStepIndex` callers (except in generated ent code)