Files
huskies/server/src/chat/commands/freeze.rs
T
Timmy 69d91d7707 feat(929): delete db/yaml_legacy.rs entirely — CRDT is the sole source of truth
Final 929 sweep: every YAML-shaped helper is gone. No production code
parses or writes YAML front matter anywhere.

Surface removed:
- db/yaml_legacy.rs (FrontMatter/StoryMetadata structs, parse_front_matter,
  set_front_matter_field, yaml_residue marker) — file deleted.
- ItemMeta::from_yaml — deleted; callers pass typed ItemMeta::named(...) or
  ItemMeta::default() and use typed CRDT setters (set_depends_on,
  set_blocked, set_retry_count, set_agent, set_qa_mode, set_review_hold,
  set_item_type, set_epic, set_mergemaster_attempted) for the rest.
- write_coverage_baseline_to_story_file + read_coverage_percent_from_json —
  the coverage_baseline YAML field was write-only (nothing read it back);
  removed along with its caller in agent_tools/lifecycle.rs.
- update_story_in_file's generic `front_matter` HashMap parameter —
  tool_update_story now intercepts every known field name and routes it
  to a typed CRDT setter; unknown keys are rejected with an explicit error
  pointing at the typed setters. The function only takes user_story /
  description sections now.
- All 117 ItemMeta::from_yaml callsites migrated. Where tests previously
  passed a YAML-shaped content blob and relied on the helper to extract
  name/depends_on/blocked/agent/qa, they now pass:
    write_item_with_content(id, stage, content, ItemMeta::named("Foo"))
    crate::crdt_state::set_depends_on(id, &[...])    // when needed
    crate::crdt_state::set_blocked(id, true)         // when needed
    crate::crdt_state::set_agent(id, Some("..."))    // when needed
- write_story_content + write_story_file (test helper) now take an
  explicit `name: Option<&str>` instead of parsing it from content.
- db::ops::move_item_stage stopped re-parsing YAML on every stage
  transition; metadata is read straight from the CRDT view when mirroring
  the row into SQLite.

New CRDT setters added for symmetry:
- crdt_state::set_name (mirrors set_agent — explicit name updates).

cargo fmt --check, clippy --all-targets -- -D warnings, and the
2830-test suite all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:55:25 +01:00

289 lines
10 KiB
Rust

//! Handler for the `freeze` and `unfreeze` commands.
//!
//! `freeze <number>` transitions the story to `Stage::Frozen`, halting pipeline
//! advancement and auto-assign until `unfreeze <number>` restores the prior stage.
use super::CommandContext;
use std::path::Path;
/// Handle the `freeze` command.
///
/// Parses `<number>` from `ctx.args`, locates the work item, and sets
/// `frozen: true` in its front matter.
pub(super) fn handle_freeze(ctx: &CommandContext) -> Option<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Usage: `{} freeze <number>` (e.g. `freeze 42`)",
ctx.services.bot_name
));
}
Some(freeze_by_number(ctx.effective_root(), num_str))
}
/// Core freeze logic: find story by numeric prefix and set `frozen: true`.
///
/// Returns a Markdown-formatted response string suitable for all transports.
pub(crate) fn freeze_by_number(project_root: &Path, story_number: &str) -> String {
let (story_id, _, _, _) =
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
Some(found) => found,
None => {
return format!("No story, bug, or spike with number **{story_number}** found.");
}
};
freeze_by_story_id(&story_id)
}
fn freeze_by_story_id(story_id: &str) -> String {
let story_name = resolve_story_name(story_id);
match crate::service::work_item::freeze::freeze(story_id) {
Ok(crate::service::work_item::FreezeStatus::AlreadyFrozen) => {
format!("**{story_name}** ({story_id}) is already frozen.")
}
Ok(crate::service::work_item::FreezeStatus::Frozen) => format!(
"Frozen **{story_name}** ({story_id}). Pipeline advancement and auto-assign suppressed until unfrozen."
),
Err(e) => format!("Failed to freeze **{story_name}** ({story_id}): {e}"),
}
}
/// Handle the `unfreeze` command.
///
/// Parses `<number>` from `ctx.args`, locates the work item, and clears the
/// `frozen` flag to resume normal pipeline behaviour.
pub(super) fn handle_unfreeze(ctx: &CommandContext) -> Option<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) {
return Some(format!(
"Usage: `{} unfreeze <number>` (e.g. `unfreeze 42`)",
ctx.services.bot_name
));
}
Some(unfreeze_by_number(ctx.effective_root(), num_str))
}
/// Core unfreeze logic: find story by numeric prefix and clear `frozen` flag.
pub(crate) fn unfreeze_by_number(project_root: &Path, story_number: &str) -> String {
let (story_id, _, _, _) =
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
Some(found) => found,
None => {
return format!("No story, bug, or spike with number **{story_number}** found.");
}
};
unfreeze_by_story_id(&story_id)
}
fn unfreeze_by_story_id(story_id: &str) -> String {
let story_name = resolve_story_name(story_id);
match crate::service::work_item::freeze::unfreeze(story_id) {
Ok(crate::service::work_item::UnfreezeStatus::NotFrozen) => {
format!("**{story_name}** ({story_id}) is not frozen. Nothing to unfreeze.")
}
Ok(crate::service::work_item::UnfreezeStatus::Unfrozen) => {
format!("Unfrozen **{story_name}** ({story_id}). Normal pipeline behaviour resumed.")
}
Err(e) => format!("Failed to unfreeze **{story_name}** ({story_id}): {e}"),
}
}
/// Look up the display name for a story from the CRDT name register
/// (story 929 — CRDT is the sole source of story metadata).
///
/// Falls back to `story_id` if no CRDT entry exists.
fn resolve_story_name(story_id: &str) -> String {
crate::crdt_state::read_item(story_id)
.and_then(|w| w.name().map(str::to_string))
.unwrap_or_else(|| story_id.to_string())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use crate::chat::test_helpers::write_story_file;
use super::super::{CommandDispatch, try_handle_command};
fn freeze_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy freeze {args}"))
}
fn unfreeze_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
let services = crate::services::Services::new_test(root.to_path_buf(), "Timmy".to_string());
let room_id = "!test:example.com".to_string();
let dispatch = CommandDispatch {
services: &services,
project_root: &services.project_root,
bot_user_id: "@timmy:homeserver.local",
room_id: &room_id,
};
try_handle_command(&dispatch, &format!("@timmy unfreeze {args}"))
}
#[test]
fn freeze_command_is_registered() {
use super::super::commands;
assert!(
commands().iter().any(|c| c.name == "freeze"),
"freeze command must be in the registry"
);
}
#[test]
fn unfreeze_command_is_registered() {
use super::super::commands;
assert!(
commands().iter().any(|c| c.name == "unfreeze"),
"unfreeze command must be in the registry"
);
}
#[test]
fn freeze_command_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = freeze_cmd_with_root(tmp.path(), "").unwrap();
assert!(
output.contains("Usage"),
"no args should show usage: {output}"
);
}
#[test]
fn unfreeze_command_no_args_returns_usage() {
let tmp = tempfile::TempDir::new().unwrap();
let output = unfreeze_cmd_with_root(tmp.path(), "").unwrap();
assert!(
output.contains("Usage"),
"no args should show usage: {output}"
);
}
#[test]
fn freeze_command_not_found_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
let output = freeze_cmd_with_root(tmp.path(), "9988").unwrap();
assert!(
output.contains("9988") && output.contains("found"),
"not-found message should include number and 'found': {output}"
);
}
#[test]
fn freeze_command_sets_stage_to_frozen() {
let tmp = tempfile::TempDir::new().unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
write_story_file(
tmp.path(),
"2_current",
"9940_story_freezeme.md",
"# Story\n",
Some("Freeze Me"),
);
let output = freeze_cmd_with_root(tmp.path(), "9940").unwrap();
assert!(
output.contains("Frozen") && output.contains("Freeze Me"),
"should confirm freeze with story name: {output}"
);
let item = crate::pipeline_state::read_typed("9940_story_freezeme")
.expect("read_typed should succeed")
.expect("item should be present");
assert!(
item.stage.is_frozen(),
"stage should be Frozen after freeze: {:?}",
item.stage
);
}
#[test]
fn unfreeze_command_restores_prior_stage() {
let tmp = tempfile::TempDir::new().unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
write_story_file(
tmp.path(),
"2_current",
"9941_story_frozen.md",
"# Story\n",
Some("Frozen Story"),
);
// Freeze first.
let freeze_out = freeze_cmd_with_root(tmp.path(), "9941").unwrap();
assert!(
freeze_out.contains("Frozen"),
"should confirm freeze: {freeze_out}"
);
// Now unfreeze.
let output = unfreeze_cmd_with_root(tmp.path(), "9941").unwrap();
assert!(
output.contains("Unfrozen") && output.contains("Frozen Story"),
"should confirm unfreeze with story name: {output}"
);
let item = crate::pipeline_state::read_typed("9941_story_frozen")
.expect("read_typed should succeed")
.expect("item should be present");
assert!(
matches!(item.stage, crate::pipeline_state::Stage::Coding),
"stage should be restored to Coding: {:?}",
item.stage
);
}
#[test]
fn unfreeze_command_not_frozen_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
write_story_file(
tmp.path(),
"2_current",
"9942_story_notfrozen.md",
"---\nname: Not Frozen\n---\n# Story\n",
None,
);
let output = unfreeze_cmd_with_root(tmp.path(), "9942").unwrap();
assert!(
output.contains("not frozen"),
"should return not-frozen error: {output}"
);
}
#[test]
fn freeze_command_already_frozen_returns_message() {
let tmp = tempfile::TempDir::new().unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
write_story_file(
tmp.path(),
"2_current",
"9943_story_alreadyfrozen.md",
"---\nname: Already Frozen\n---\n# Story\n",
None,
);
// Freeze it first.
freeze_cmd_with_root(tmp.path(), "9943").unwrap();
// Try to freeze again.
let output = freeze_cmd_with_root(tmp.path(), "9943").unwrap();
assert!(
output.contains("already frozen"),
"should say already frozen: {output}"
);
}
}