feat(934): typed Stage enum replaces directory-string state model
The state machine's `Stage` enum becomes the source of truth for pipeline
state. Six stages of work land together:
1. Clean wire vocabulary (`coding`, `merge`, `merge_failure`, ...) replaces
legacy directory-style strings (`2_current`, `4_merge`, ...) on the wire.
`Stage::from_dir` accepted both during deployment; new writes always
emit the clean form via `stage_dir_name`. Lexicographic `dir >= "5_done"`
checks in lifecycle.rs become typed `matches!` checks since the new
vocabulary doesn't sort in pipeline order.
2. `crdt_state::write_item` takes typed `&Stage`, serialising via
`stage_dir_name` at the CRDT boundary. `#[cfg(test)] write_item_str`
parses legacy strings for test fixtures.
3. `WorkItem::stage()` returns typed `crdt_state::Stage`; `stage_str()`
is gone from the public API. Projection dispatches on the typed enum.
4. `frozen` becomes an orthogonal CRDT register. `Stage::Frozen` and
`PipelineEvent::Freeze`/`Unfreeze` are removed; `transition_to_frozen`/
`unfrozen` set the flag directly without touching the stage register.
5. Watcher sweep and `tool_update_story`'s `blocked` setter route through
`apply_transition` so the typed transition table validates every
stage change. `update_story` gains a `frozen` field for symmetry.
6. One-shot startup migration rewrites pre-934 directory-style stage
registers (and sets `frozen=true` on items previously at `7_frozen`).
`Stage::from_dir` drops legacy aliases. The db boundary keeps a small
normaliser so callers with legacy strings (MCP, tests) still work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+45
-14
@@ -33,6 +33,33 @@ impl ItemMeta {
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalise a stage string at the db boundary.
|
||||
///
|
||||
/// Accepts the clean post-934 vocabulary (passthrough) and the pre-934
|
||||
/// directory-style strings (`"2_current"`, `"4_merge"`, etc.) by mapping
|
||||
/// them to the clean form before handing off to `Stage::from_dir` (which
|
||||
/// itself only accepts clean form after stage 6). This keeps the public
|
||||
/// db API tolerant for callers that still pass legacy strings while the
|
||||
/// internal type stays strict.
|
||||
fn normalise_stage_str(stage: &str) -> &str {
|
||||
match stage {
|
||||
"0_upcoming" => "upcoming",
|
||||
"1_backlog" => "backlog",
|
||||
"2_current" => "coding",
|
||||
"2_blocked" => "blocked",
|
||||
"3_qa" => "qa",
|
||||
"4_merge" => "merge",
|
||||
"4_merge_failure" => "merge_failure",
|
||||
"5_done" => "done",
|
||||
"6_archived" => "archived",
|
||||
// `7_frozen` has no direct clean equivalent (the variant was
|
||||
// removed in story 934 stage 4). Returning the unmapped string
|
||||
// makes `Stage::from_dir` return None, so the write is logged and
|
||||
// skipped — frozen items should be seeded via the `frozen` flag.
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a pipeline item from in-memory content (no filesystem access).
|
||||
///
|
||||
/// This is the primary write path for the DB-backed pipeline. It updates
|
||||
@@ -52,16 +79,18 @@ pub fn write_item_with_content(story_id: &str, stage: &str, content: &str, meta:
|
||||
write_content(story_id, content);
|
||||
|
||||
// Primary: CRDT ops.
|
||||
let merged_at_ts = if crate::pipeline_state::Stage::from_dir(stage)
|
||||
.is_some_and(|s| matches!(s, crate::pipeline_state::Stage::Done { .. }))
|
||||
{
|
||||
Some(chrono::Utc::now().timestamp() as f64)
|
||||
} else {
|
||||
None
|
||||
let stage = normalise_stage_str(stage);
|
||||
let Some(typed_stage) = crate::pipeline_state::Stage::from_dir(stage) else {
|
||||
crate::slog!(
|
||||
"[db] write_item_with_content: unknown stage '{stage}' for {story_id}; skipping CRDT write"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let merged_at_ts = matches!(typed_stage, crate::pipeline_state::Stage::Done { .. })
|
||||
.then(|| chrono::Utc::now().timestamp() as f64);
|
||||
crate::crdt_state::write_item(
|
||||
story_id,
|
||||
stage,
|
||||
&typed_stage,
|
||||
meta.name.as_deref(),
|
||||
meta.agent.as_deref(),
|
||||
meta.retry_count,
|
||||
@@ -114,16 +143,18 @@ pub fn move_item_stage(
|
||||
// CRDT typed registers — no need to re-derive it from the content body's
|
||||
// YAML front matter on every stage transition. Pass `None` for those
|
||||
// fields so write_item leaves the existing registers untouched.
|
||||
let merged_at_ts = if crate::pipeline_state::Stage::from_dir(new_stage)
|
||||
.is_some_and(|s| matches!(s, crate::pipeline_state::Stage::Done { .. }))
|
||||
{
|
||||
Some(chrono::Utc::now().timestamp() as f64)
|
||||
} else {
|
||||
None
|
||||
let new_stage = normalise_stage_str(new_stage);
|
||||
let Some(typed_stage) = crate::pipeline_state::Stage::from_dir(new_stage) else {
|
||||
crate::slog!(
|
||||
"[db] move_item_stage: unknown stage '{new_stage}' for {story_id}; skipping CRDT write"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let merged_at_ts = matches!(typed_stage, crate::pipeline_state::Stage::Done { .. })
|
||||
.then(|| chrono::Utc::now().timestamp() as f64);
|
||||
crate::crdt_state::write_item(
|
||||
story_id,
|
||||
new_stage,
|
||||
&typed_stage,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
|
||||
Reference in New Issue
Block a user