23 KiB
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")...):
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:
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:
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:
if err := tools.MigrateCurrentStepID(ctx, entClient); err != nil {
return nil, fmt.Errorf("migrate currentStepId: %w", err)
}
- Step 6: Commit
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:
// 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
// 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
// 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
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:
// 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
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
// 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:
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
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
// 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:
// 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:
// 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
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:
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:
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:
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:
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
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:
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:
// Example: line 74
CandidateTask{
JobID: j.JobID,
OrderID: j.OrderID,
StepIndex: step.Index,
StepID: step.StepID,
Action: ...,
// ...
}
- Step 7: Commit
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:
// 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:
// 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:
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
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 hasCurrentStepIndex`:
// 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
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:
// 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
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:
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:
func (l *ReworkJobLogic) ReworkJob(req *types.ReworkJobReq) error {
return l.svcCtx.JobProcessor.ReworkJob(l.ctx, req.ID, req.TargetStepId)
}
- Step 5: Commit
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
// 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:
stepID := lookupStepID(ctx, entClient, recipe.RecipeID, initialStepIdx)
// ...
SetCurrentStepId(stepID)
- Step 2: Commit
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
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
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:
// 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
rtk git add internal/eventloop/dbstate.go
rtk git commit -m "chore: mark GetNextStepIndex deprecated"
Verification Checklist
go build ./...passesgo vet ./...passesgo test ./internal/eventloop/...passesgo test ./internal/scheduler/...passesgo test ./internal/processor/...passescd frontend && pnpm buildpasses- Migration runs successfully on existing database
- No remaining
SetCurrentStepIndexcallers (except in generated ent code)