diff --git a/server/src/crdt_state.rs b/server/src/crdt_state/mod.rs similarity index 59% rename from server/src/crdt_state.rs rename to server/src/crdt_state/mod.rs index 44df6cc5..c2d9515a 100644 --- a/server/src/crdt_state.rs +++ b/server/src/crdt_state/mod.rs @@ -1260,863 +1260,4 @@ pub(crate) mod hex { // ── Tests ──────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - use bft_json_crdt::json_crdt::OpState; - - #[test] - fn crdt_doc_insert_and_view() { - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - - let item_json: JsonValue = json!({ - "story_id": "10_story_test", - "stage": "2_current", - "name": "Test Story", - "agent": "coder-opus", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - - let op = crdt.doc.items.insert(ROOT_ID, item_json).sign(&kp); - assert_eq!(crdt.apply(op), OpState::Ok); - - let view = crdt.doc.items.view(); - assert_eq!(view.len(), 1); - - let item = &crdt.doc.items[0]; - assert_eq!( - item.story_id.view(), - JsonValue::String("10_story_test".to_string()) - ); - assert_eq!( - item.stage.view(), - JsonValue::String("2_current".to_string()) - ); - } - - #[test] - fn crdt_doc_update_stage() { - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - - let item_json: JsonValue = json!({ - "story_id": "20_story_move", - "stage": "1_backlog", - "name": "Move Me", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - - let insert_op = crdt.doc.items.insert(ROOT_ID, item_json).sign(&kp); - crdt.apply(insert_op); - - // Update stage - let stage_op = crdt.doc.items[0] - .stage - .set("2_current".to_string()) - .sign(&kp); - crdt.apply(stage_op); - - assert_eq!( - crdt.doc.items[0].stage.view(), - JsonValue::String("2_current".to_string()) - ); - } - - #[test] - fn crdt_ops_replay_reconstructs_state() { - let kp = make_keypair(); - let mut crdt1 = BaseCrdt::::new(&kp); - - // Build state with a series of ops. - let item_json: JsonValue = json!({ - "story_id": "30_story_replay", - "stage": "1_backlog", - "name": "Replay Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - - let op1 = crdt1.doc.items.insert(ROOT_ID, item_json).sign(&kp); - crdt1.apply(op1.clone()); - - let op2 = crdt1.doc.items[0] - .stage - .set("2_current".to_string()) - .sign(&kp); - crdt1.apply(op2.clone()); - - let op3 = crdt1.doc.items[0] - .name - .set("Updated Name".to_string()) - .sign(&kp); - crdt1.apply(op3.clone()); - - // Replay ops on a fresh CRDT. - let mut crdt2 = BaseCrdt::::new(&kp); - crdt2.apply(op1); - crdt2.apply(op2); - crdt2.apply(op3); - - assert_eq!( - crdt1.doc.items[0].stage.view(), - crdt2.doc.items[0].stage.view() - ); - assert_eq!( - crdt1.doc.items[0].name.view(), - crdt2.doc.items[0].name.view() - ); - } - - #[test] - fn extract_item_view_parses_crdt_item() { - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - - let item_json: JsonValue = json!({ - "story_id": "40_story_view", - "stage": "3_qa", - "name": "View Test", - "agent": "coder-1", - "retry_count": 2.0, - "blocked": true, - "depends_on": "[10,20]", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - - let op = crdt.doc.items.insert(ROOT_ID, item_json).sign(&kp); - crdt.apply(op); - - let view = extract_item_view(&crdt.doc.items[0]).unwrap(); - assert_eq!(view.story_id, "40_story_view"); - assert_eq!(view.stage, "3_qa"); - assert_eq!(view.name.as_deref(), Some("View Test")); - assert_eq!(view.agent.as_deref(), Some("coder-1")); - assert_eq!(view.retry_count, Some(2)); - assert_eq!(view.blocked, Some(true)); - assert_eq!(view.depends_on, Some(vec![10, 20])); - } - - #[test] - fn rebuild_index_maps_story_ids() { - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - - for (sid, stage) in &[("10_story_a", "1_backlog"), ("20_story_b", "2_current")] { - let item: JsonValue = json!({ - "story_id": sid, - "stage": stage, - "name": "", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); - crdt.apply(op); - } - - let index = rebuild_index(&crdt); - assert_eq!(index.len(), 2); - assert!(index.contains_key("10_story_a")); - assert!(index.contains_key("20_story_b")); - } - - #[tokio::test] - async fn init_and_write_read_roundtrip() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("crdt_test.db"); - - // Init directly (not via the global singleton, for test isolation). - let options = SqliteConnectOptions::new() - .filename(&db_path) - .create_if_missing(true); - let pool = SqlitePool::connect_with(options).await.unwrap(); - sqlx::migrate!("./migrations").run(&pool).await.unwrap(); - - let keypair = make_keypair(); - let mut crdt = BaseCrdt::::new(&keypair); - - // Insert and update like write_item does. - let item_json: JsonValue = json!({ - "story_id": "50_story_roundtrip", - "stage": "1_backlog", - "name": "Roundtrip", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - - let insert_op = crdt.doc.items.insert(ROOT_ID, item_json).sign(&keypair); - crdt.apply(insert_op.clone()); - - // Persist the op. - let op_json = serde_json::to_string(&insert_op).unwrap(); - let op_id = hex::encode(&insert_op.id()); - let now = chrono::Utc::now().to_rfc3339(); - sqlx::query( - "INSERT INTO crdt_ops (op_id, seq, op_json, created_at) VALUES (?1, ?2, ?3, ?4)", - ) - .bind(&op_id) - .bind(insert_op.inner.seq as i64) - .bind(&op_json) - .bind(&now) - .execute(&pool) - .await - .unwrap(); - - // Reconstruct from DB. - let rows: Vec<(String,)> = - sqlx::query_as("SELECT op_json FROM crdt_ops ORDER BY rowid ASC") - .fetch_all(&pool) - .await - .unwrap(); - - let mut crdt2 = BaseCrdt::::new(&keypair); - for (json_str,) in &rows { - let op: SignedOp = serde_json::from_str(json_str).unwrap(); - crdt2.apply(op); - } - - let view = extract_item_view(&crdt2.doc.items[0]).unwrap(); - assert_eq!(view.story_id, "50_story_roundtrip"); - assert_eq!(view.stage, "1_backlog"); - assert_eq!(view.name.as_deref(), Some("Roundtrip")); - } - - #[test] - fn signed_op_serialization_roundtrip() { - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - - let item: JsonValue = json!({ - "story_id": "60_story_serde", - "stage": "1_backlog", - "name": "Serde Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - - let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); - let json_str = serde_json::to_string(&op).unwrap(); - let deserialized: SignedOp = serde_json::from_str(&json_str).unwrap(); - - assert_eq!(op.id(), deserialized.id()); - assert_eq!(op.inner.seq, deserialized.inner.seq); - } - - // ── CrdtEvent tests ───────────────────────────────────────────────── - - #[test] - fn crdt_event_has_expected_fields() { - let evt = CrdtEvent { - story_id: "42_story_foo".to_string(), - from_stage: Some("1_backlog".to_string()), - to_stage: "2_current".to_string(), - name: Some("Foo Feature".to_string()), - }; - assert_eq!(evt.story_id, "42_story_foo"); - assert_eq!(evt.from_stage.as_deref(), Some("1_backlog")); - assert_eq!(evt.to_stage, "2_current"); - assert_eq!(evt.name.as_deref(), Some("Foo Feature")); - } - - #[test] - fn crdt_event_clone_preserves_data() { - let evt = CrdtEvent { - story_id: "10_story_bar".to_string(), - from_stage: None, - to_stage: "1_backlog".to_string(), - name: None, - }; - let cloned = evt.clone(); - assert_eq!(cloned.story_id, "10_story_bar"); - assert!(cloned.from_stage.is_none()); - assert!(cloned.name.is_none()); - } - - #[test] - fn emit_event_is_noop_when_channel_not_initialised() { - // Before CRDT_EVENT_TX is set, emit_event should not panic. - // This test verifies the guard clause works. In test binaries the - // OnceLock may already be set by another test, so we just verify - // the function doesn't panic regardless. - emit_event(CrdtEvent { - story_id: "99_story_noop".to_string(), - from_stage: None, - to_stage: "1_backlog".to_string(), - name: None, - }); - } - - #[test] - fn crdt_event_broadcast_channel_round_trip() { - let (tx, mut rx) = broadcast::channel::(16); - let evt = CrdtEvent { - story_id: "70_story_broadcast".to_string(), - from_stage: Some("1_backlog".to_string()), - to_stage: "2_current".to_string(), - name: Some("Broadcast Test".to_string()), - }; - tx.send(evt).unwrap(); - - let received = rx.try_recv().unwrap(); - assert_eq!(received.story_id, "70_story_broadcast"); - assert_eq!(received.from_stage.as_deref(), Some("1_backlog")); - assert_eq!(received.to_stage, "2_current"); - assert_eq!(received.name.as_deref(), Some("Broadcast Test")); - } - - #[test] - fn dep_is_done_crdt_returns_false_when_no_crdt_state() { - // When the global CRDT state is not initialised (or in a test environment), - // dep_is_done_crdt should return false rather than panicking. - // Note: in the test binary the global may or may not be initialised, - // but the function should never panic either way. - let _ = dep_is_done_crdt(9999); - } - - #[test] - fn check_unmet_deps_crdt_returns_empty_when_item_not_found() { - // Non-existent story should return empty deps. - let result = check_unmet_deps_crdt("nonexistent_story"); - assert!(result.is_empty()); - } - - // ── Bug 503: archived-dep visibility ───────────────────────────────────── - - #[test] - fn dep_is_archived_crdt_returns_false_when_no_crdt_state() { - // When the global CRDT state is not initialised, must not panic. - let _ = dep_is_archived_crdt(9998); - } - - #[test] - fn check_archived_deps_crdt_returns_empty_when_item_not_found() { - // Non-existent story should return empty archived deps. - let result = check_archived_deps_crdt("nonexistent_story_archived"); - assert!(result.is_empty()); - } - - // ── 478: WebSocket CRDT sync layer tests ──────────────────────────────── - - #[test] - fn apply_remote_op_returns_false_when_not_initialised() { - // Without the global CRDT state, apply_remote_op should return false. - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - let item: JsonValue = serde_json::json!({ - "story_id": "80_story_remote", - "stage": "1_backlog", - "name": "Remote", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op = crdt - .doc - .items - .insert(bft_json_crdt::op::ROOT_ID, item) - .sign(&kp); - // This uses the global state which may not be initialised in tests. - let _ = apply_remote_op(op); - } - - #[test] - fn signed_op_survives_sync_serialization_roundtrip() { - // Verify that a SignedOp serialised to JSON and back produces - // the same op (critical for the sync wire protocol). - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - let item: JsonValue = serde_json::json!({ - "story_id": "90_story_wire", - "stage": "2_current", - "name": "Wire Test", - "agent": "coder", - "retry_count": 1.0, - "blocked": false, - "depends_on": "[10]", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op = crdt - .doc - .items - .insert(bft_json_crdt::op::ROOT_ID, item) - .sign(&kp); - - let json1 = serde_json::to_string(&op).unwrap(); - let roundtripped: SignedOp = serde_json::from_str(&json1).unwrap(); - let json2 = serde_json::to_string(&roundtripped).unwrap(); - - assert_eq!(json1, json2); - assert_eq!(op.id(), roundtripped.id()); - assert_eq!(op.inner.seq, roundtripped.inner.seq); - assert_eq!(op.author(), roundtripped.author()); - } - - #[test] - fn sync_broadcast_channel_round_trip() { - let (tx, mut rx) = broadcast::channel::(16); - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - let item: JsonValue = serde_json::json!({ - "story_id": "95_story_sync_bcast", - "stage": "1_backlog", - "name": "", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op = crdt - .doc - .items - .insert(bft_json_crdt::op::ROOT_ID, item) - .sign(&kp); - tx.send(op.clone()).unwrap(); - - let received = rx.try_recv().unwrap(); - assert_eq!(received.id(), op.id()); - } - - // ── Bug 511: CRDT lamport clock resets on restart ──────────────────────── - // - // Root cause: Op::sign() always produces SignedOp with depends_on = vec![], - // so the causal dependency queue never engages during replay. Field update - // ops (seq=1,2,3 from each field's LwwRegisterCrdt counter) are replayed - // before list insert ops (seq=N from the items ListCrdt counter) when - // ordered by `seq ASC`. They fail ErrPathMismatch silently, their our_seq - // is never updated, and the next field write re-uses seq=1. - // - // Fix: replay by `rowid ASC` (SQLite insertion order) instead of `seq ASC`. - // Rowid preserves the causal order ops were originally applied in, so field - // updates always come after the item insert they reference. - #[tokio::test] - async fn bug_511_rowid_replay_preserves_field_update_after_list_insert() { - let tmp = tempfile::tempdir().unwrap(); - let db_path = tmp.path().join("bug511.db"); - - let options = SqliteConnectOptions::new() - .filename(&db_path) - .create_if_missing(true); - let pool = SqlitePool::connect_with(options).await.unwrap(); - sqlx::migrate!("./migrations").run(&pool).await.unwrap(); - - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - - // Insert 5 dummy items to advance items.our_seq to 5. - for i in 0..5u32 { - let sid = format!("{}_story_warmup", i); - let item: JsonValue = json!({ - "story_id": sid, - "stage": "1_backlog", - "name": "", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); - crdt.apply(op.clone()); - // We don't persist these to the DB — they are pre-history. - } - - // Now insert the real item. items.our_seq was 5, so this op gets seq=6. - let target_item: JsonValue = json!({ - "story_id": "511_story_target", - "stage": "1_backlog", - "name": "Bug 511 target", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let insert_op = crdt.doc.items.insert(ROOT_ID, target_item).sign(&kp); - crdt.apply(insert_op.clone()); - // insert_op.inner.seq == 6 - - // Now update the stage. The stage LwwRegisterCrdt for this item starts - // at our_seq=0, so this field op gets seq=1. Crucially: seq=1 < seq=6. - let idx = rebuild_index(&crdt)["511_story_target"]; - let stage_op = crdt.doc.items[idx] - .stage - .set("2_current".to_string()) - .sign(&kp); - crdt.apply(stage_op.clone()); - // stage_op.inner.seq == 1 - - // Persist BOTH ops in causal order (insert first, update second). - // This means insert_op gets rowid < stage_op rowid. - let now = chrono::Utc::now().to_rfc3339(); - for op in [&insert_op, &stage_op] { - let op_json = serde_json::to_string(op).unwrap(); - let op_id = hex::encode(&op.id()); - sqlx::query( - "INSERT INTO crdt_ops (op_id, seq, op_json, created_at) VALUES (?1, ?2, ?3, ?4)", - ) - .bind(&op_id) - .bind(op.inner.seq as i64) - .bind(&op_json) - .bind(&now) - .execute(&pool) - .await - .unwrap(); - } - - // Replay by rowid ASC (the fix). The insert must come before the field - // update regardless of their field-level seq values. - let rows: Vec<(String,)> = - sqlx::query_as("SELECT op_json FROM crdt_ops ORDER BY rowid ASC") - .fetch_all(&pool) - .await - .unwrap(); - - let mut crdt2 = BaseCrdt::::new(&kp); - for (json_str,) in &rows { - let op: SignedOp = serde_json::from_str(json_str).unwrap(); - crdt2.apply(op); - } - - // The item must be in the CRDT and must reflect the stage update. - let index2 = rebuild_index(&crdt2); - assert!( - index2.contains_key("511_story_target"), - "item not found after rowid-order replay" - ); - let idx2 = index2["511_story_target"]; - let view = extract_item_view(&crdt2.doc.items[idx2]).unwrap(); - assert_eq!( - view.stage, "2_current", - "stage field update lost during replay (bug 511 regression)" - ); - - // Confirm the bug is reproducible by replaying seq ASC instead. - // With seq ASC the stage_op (seq=1) arrives before insert_op (seq=6), - // fails ErrPathMismatch, and the item ends up at "1_backlog". - let rows_wrong_order: Vec<(String,)> = - sqlx::query_as("SELECT op_json FROM crdt_ops ORDER BY seq ASC") - .fetch_all(&pool) - .await - .unwrap(); - - let mut crdt3 = BaseCrdt::::new(&kp); - for (json_str,) in &rows_wrong_order { - let op: SignedOp = serde_json::from_str(json_str).unwrap(); - crdt3.apply(op); - } - - let index3 = rebuild_index(&crdt3); - // With seq ASC replay, the item is created (insert_op eventually runs) - // but the stage update is lost (it ran before the item existed). - if let Some(idx3) = index3.get("511_story_target") { - let view3 = extract_item_view(&crdt3.doc.items[*idx3]).unwrap(); - // The bug: stage is still "1_backlog" because the update was dropped. - assert_eq!( - view3.stage, "1_backlog", - "expected seq-ASC replay to exhibit the bug (update lost)" - ); - } - } - - // ── Story 518: persist_tx send failure logging ─────────────────────────── - - #[test] - fn persist_tx_send_failure_logs_error() { - let kp = make_keypair(); - let crdt = BaseCrdt::::new(&kp); - let (persist_tx, persist_rx) = mpsc::unbounded_channel::(); - - let mut state = CrdtState { - crdt, - keypair: kp, - index: HashMap::new(), - node_index: HashMap::new(), - persist_tx, - }; - - // Drop the receiver so that the next send fails immediately. - drop(persist_rx); - - let item_json: JsonValue = json!({ - "story_id": "518_story_persist_fail", - "stage": "1_backlog", - "name": "Persist Fail Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - - let before_errors = crate::log_buffer::global() - .get_recent_entries(1000, None, Some(&crate::log_buffer::LogLevel::Error)) - .len(); - - apply_and_persist(&mut state, |s| s.crdt.doc.items.insert(ROOT_ID, item_json)); - - let error_entries = crate::log_buffer::global().get_recent_entries( - 1000, - None, - Some(&crate::log_buffer::LogLevel::Error), - ); - - assert!( - error_entries.len() > before_errors, - "expected an ERROR log entry when persist_tx send fails, but none was added" - ); - - let last_error = &error_entries[error_entries.len() - 1]; - assert!( - last_error.message.contains("persist"), - "error message should mention persist: {}", - last_error.message - ); - assert!( - last_error.message.contains("ahead") || last_error.message.contains("diverged"), - "error message should note in-memory/persisted divergence: {}", - last_error.message - ); - } - - // ── Story 631: vector clock delta sync tests ──────────────────────── - - /// Helper: create N signed insert ops on a CRDT and return them with their JSON. - fn make_ops( - kp: &Ed25519KeyPair, - crdt: &mut BaseCrdt, - count: usize, - prefix: &str, - ) -> Vec<(SignedOp, String)> { - let mut ops = Vec::new(); - for i in 0..count { - let item: JsonValue = json!({ - "story_id": format!("{prefix}_{i}"), - "stage": "1_backlog", - "name": format!("Item {i}"), - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op = crdt.doc.items.insert(ROOT_ID, item).sign(kp); - crdt.apply(op.clone()); - let json = serde_json::to_string(&op).unwrap(); - ops.push((op, json)); - } - ops - } - - /// Build a vector clock from a list of (SignedOp, json) pairs. - fn build_clock(ops: &[(SignedOp, String)]) -> VectorClock { - let mut clock = VectorClock::new(); - for (op, _) in ops { - let author = hex::encode(&op.author()); - *clock.entry(author).or_insert(0) += 1; - } - clock - } - - /// Compute ops_since against a local journal and peer clock. - /// - /// Mirrors the production `ops_since` logic but operates on a local Vec - /// instead of the global `ALL_OPS` static. - fn local_ops_since(all_ops: &[(SignedOp, String)], peer_clock: &VectorClock) -> Vec { - let mut author_counts: HashMap = HashMap::new(); - let mut result = Vec::new(); - for (op, json) in all_ops { - let author = hex::encode(&op.author()); - let count = author_counts.entry(author.clone()).or_insert(0); - *count += 1; - let peer_has = peer_clock.get(&author).copied().unwrap_or(0); - if *count > peer_has { - result.push(json.clone()); - } - } - result - } - - /// Integration test (low-bandwidth sync): two nodes, A applies 100 ops, - /// B reconnects with a current clock — B receives 0 ops on the bulk phase. - #[test] - fn delta_sync_low_bandwidth_fully_caught_up() { - let kp_a = make_keypair(); - let mut crdt_a = BaseCrdt::::new(&kp_a); - - let ops_a = make_ops(&kp_a, &mut crdt_a, 100, "631_low"); - - // B has already seen all 100 ops (its clock matches A's journal). - let clock_b = build_clock(&ops_a); - - // Delta should be empty. - let delta = local_ops_since(&ops_a, &clock_b); - assert_eq!( - delta.len(), - 0, - "caught-up peer should receive 0 ops, got {}", - delta.len() - ); - } - - /// Integration test (mid-stream): A applies 100 ops, B disconnects, - /// A applies 50 more ops, B reconnects — B receives exactly the 50 missed ops. - #[test] - fn delta_sync_mid_stream_partial_catch_up() { - let kp_a = make_keypair(); - let mut crdt_a = BaseCrdt::::new(&kp_a); - - // Phase 1: 100 ops that B has seen. - let ops_phase1 = make_ops(&kp_a, &mut crdt_a, 100, "631_mid1"); - let clock_b = build_clock(&ops_phase1); - - // Phase 2: 50 more ops that B missed. - let ops_phase2 = make_ops(&kp_a, &mut crdt_a, 50, "631_mid2"); - - // A's full journal is phase1 + phase2. - let mut all_ops_a: Vec<(SignedOp, String)> = ops_phase1; - all_ops_a.extend(ops_phase2); - - let delta = local_ops_since(&all_ops_a, &clock_b); - assert_eq!( - delta.len(), - 50, - "peer should receive exactly 50 missed ops, got {}", - delta.len() - ); - } - - /// Integration test (new node): C connects with empty clock, - /// receives all 150 ops — verifies fallback behaviour. - #[test] - fn delta_sync_new_node_receives_all_ops() { - let kp_a = make_keypair(); - let mut crdt_a = BaseCrdt::::new(&kp_a); - - let ops_phase1 = make_ops(&kp_a, &mut crdt_a, 100, "631_new1"); - let ops_phase2 = make_ops(&kp_a, &mut crdt_a, 50, "631_new2"); - - let mut all_ops_a: Vec<(SignedOp, String)> = ops_phase1; - all_ops_a.extend(ops_phase2); - - // Empty clock = new node. - let empty_clock = VectorClock::new(); - let delta = local_ops_since(&all_ops_a, &empty_clock); - assert_eq!( - delta.len(), - 150, - "new node should receive all 150 ops, got {}", - delta.len() - ); - } - - /// Multi-author delta sync: ops from two different nodes, peer has seen - /// all of one author but none of the other. - #[test] - fn delta_sync_multi_author() { - use fastcrypto::traits::KeyPair; - - let kp_a = make_keypair(); - let kp_b = make_keypair(); - let mut crdt_a = BaseCrdt::::new(&kp_a); - let mut crdt_b = BaseCrdt::::new(&kp_b); - - let ops_a = make_ops(&kp_a, &mut crdt_a, 30, "631_ma_a"); - let ops_b = make_ops(&kp_b, &mut crdt_b, 20, "631_ma_b"); - - // Combined journal on a hypothetical server. - let mut all_ops: Vec<(SignedOp, String)> = ops_a.clone(); - all_ops.extend(ops_b); - - // Peer has seen all of A's ops but none of B's. - let mut peer_clock = VectorClock::new(); - let author_a_hex = hex::encode(&kp_a.public().0.to_bytes()); - peer_clock.insert(author_a_hex, 30); - - let delta = local_ops_since(&all_ops, &peer_clock); - assert_eq!( - delta.len(), - 20, - "peer should receive 20 ops from author B, got {}", - delta.len() - ); - } - - /// Vector clock construction from ops. - #[test] - fn build_vector_clock_from_ops() { - use fastcrypto::traits::KeyPair; - - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - let ops = make_ops(&kp, &mut crdt, 10, "631_vc"); - - let clock = build_clock(&ops); - let author_hex = hex::encode(&kp.public().0.to_bytes()); - - assert_eq!(clock.len(), 1, "single author should produce 1 clock entry"); - assert_eq!(clock[&author_hex], 10, "clock should show 10 ops"); - } - - /// Wire format: clock message serialization round-trip. - #[test] - fn clock_message_serialization_roundtrip() { - let mut clock = VectorClock::new(); - clock.insert("aabbcc".to_string(), 42); - clock.insert("ddeeff".to_string(), 7); - - let json = serde_json::to_value(&clock).unwrap(); - assert!(json.is_object()); - let deserialized: VectorClock = serde_json::from_value(json).unwrap(); - assert_eq!(deserialized["aabbcc"], 42); - assert_eq!(deserialized["ddeeff"], 7); - } -} +mod tests; diff --git a/server/src/crdt_state/tests.rs b/server/src/crdt_state/tests.rs new file mode 100644 index 00000000..d8d33026 --- /dev/null +++ b/server/src/crdt_state/tests.rs @@ -0,0 +1,854 @@ +use super::*; +use bft_json_crdt::json_crdt::OpState; + +#[test] +fn crdt_doc_insert_and_view() { + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + + let item_json: JsonValue = json!({ + "story_id": "10_story_test", + "stage": "2_current", + "name": "Test Story", + "agent": "coder-opus", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + + let op = crdt.doc.items.insert(ROOT_ID, item_json).sign(&kp); + assert_eq!(crdt.apply(op), OpState::Ok); + + let view = crdt.doc.items.view(); + assert_eq!(view.len(), 1); + + let item = &crdt.doc.items[0]; + assert_eq!( + item.story_id.view(), + JsonValue::String("10_story_test".to_string()) + ); + assert_eq!( + item.stage.view(), + JsonValue::String("2_current".to_string()) + ); +} + +#[test] +fn crdt_doc_update_stage() { + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + + let item_json: JsonValue = json!({ + "story_id": "20_story_move", + "stage": "1_backlog", + "name": "Move Me", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + + let insert_op = crdt.doc.items.insert(ROOT_ID, item_json).sign(&kp); + crdt.apply(insert_op); + + // Update stage + let stage_op = crdt.doc.items[0] + .stage + .set("2_current".to_string()) + .sign(&kp); + crdt.apply(stage_op); + + assert_eq!( + crdt.doc.items[0].stage.view(), + JsonValue::String("2_current".to_string()) + ); +} + +#[test] +fn crdt_ops_replay_reconstructs_state() { + let kp = make_keypair(); + let mut crdt1 = BaseCrdt::::new(&kp); + + // Build state with a series of ops. + let item_json: JsonValue = json!({ + "story_id": "30_story_replay", + "stage": "1_backlog", + "name": "Replay Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + + let op1 = crdt1.doc.items.insert(ROOT_ID, item_json).sign(&kp); + crdt1.apply(op1.clone()); + + let op2 = crdt1.doc.items[0] + .stage + .set("2_current".to_string()) + .sign(&kp); + crdt1.apply(op2.clone()); + + let op3 = crdt1.doc.items[0] + .name + .set("Updated Name".to_string()) + .sign(&kp); + crdt1.apply(op3.clone()); + + // Replay ops on a fresh CRDT. + let mut crdt2 = BaseCrdt::::new(&kp); + crdt2.apply(op1); + crdt2.apply(op2); + crdt2.apply(op3); + + assert_eq!( + crdt1.doc.items[0].stage.view(), + crdt2.doc.items[0].stage.view() + ); + assert_eq!( + crdt1.doc.items[0].name.view(), + crdt2.doc.items[0].name.view() + ); +} + +#[test] +fn extract_item_view_parses_crdt_item() { + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + + let item_json: JsonValue = json!({ + "story_id": "40_story_view", + "stage": "3_qa", + "name": "View Test", + "agent": "coder-1", + "retry_count": 2.0, + "blocked": true, + "depends_on": "[10,20]", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + + let op = crdt.doc.items.insert(ROOT_ID, item_json).sign(&kp); + crdt.apply(op); + + let view = extract_item_view(&crdt.doc.items[0]).unwrap(); + assert_eq!(view.story_id, "40_story_view"); + assert_eq!(view.stage, "3_qa"); + assert_eq!(view.name.as_deref(), Some("View Test")); + assert_eq!(view.agent.as_deref(), Some("coder-1")); + assert_eq!(view.retry_count, Some(2)); + assert_eq!(view.blocked, Some(true)); + assert_eq!(view.depends_on, Some(vec![10, 20])); +} + +#[test] +fn rebuild_index_maps_story_ids() { + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + + for (sid, stage) in &[("10_story_a", "1_backlog"), ("20_story_b", "2_current")] { + let item: JsonValue = json!({ + "story_id": sid, + "stage": stage, + "name": "", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); + crdt.apply(op); + } + + let index = rebuild_index(&crdt); + assert_eq!(index.len(), 2); + assert!(index.contains_key("10_story_a")); + assert!(index.contains_key("20_story_b")); +} + +#[tokio::test] +async fn init_and_write_read_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("crdt_test.db"); + + // Init directly (not via the global singleton, for test isolation). + let options = SqliteConnectOptions::new() + .filename(&db_path) + .create_if_missing(true); + let pool = SqlitePool::connect_with(options).await.unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + + let keypair = make_keypair(); + let mut crdt = BaseCrdt::::new(&keypair); + + // Insert and update like write_item does. + let item_json: JsonValue = json!({ + "story_id": "50_story_roundtrip", + "stage": "1_backlog", + "name": "Roundtrip", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + + let insert_op = crdt.doc.items.insert(ROOT_ID, item_json).sign(&keypair); + crdt.apply(insert_op.clone()); + + // Persist the op. + let op_json = serde_json::to_string(&insert_op).unwrap(); + let op_id = hex::encode(&insert_op.id()); + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query("INSERT INTO crdt_ops (op_id, seq, op_json, created_at) VALUES (?1, ?2, ?3, ?4)") + .bind(&op_id) + .bind(insert_op.inner.seq as i64) + .bind(&op_json) + .bind(&now) + .execute(&pool) + .await + .unwrap(); + + // Reconstruct from DB. + let rows: Vec<(String,)> = sqlx::query_as("SELECT op_json FROM crdt_ops ORDER BY rowid ASC") + .fetch_all(&pool) + .await + .unwrap(); + + let mut crdt2 = BaseCrdt::::new(&keypair); + for (json_str,) in &rows { + let op: SignedOp = serde_json::from_str(json_str).unwrap(); + crdt2.apply(op); + } + + let view = extract_item_view(&crdt2.doc.items[0]).unwrap(); + assert_eq!(view.story_id, "50_story_roundtrip"); + assert_eq!(view.stage, "1_backlog"); + assert_eq!(view.name.as_deref(), Some("Roundtrip")); +} + +#[test] +fn signed_op_serialization_roundtrip() { + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + + let item: JsonValue = json!({ + "story_id": "60_story_serde", + "stage": "1_backlog", + "name": "Serde Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + + let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); + let json_str = serde_json::to_string(&op).unwrap(); + let deserialized: SignedOp = serde_json::from_str(&json_str).unwrap(); + + assert_eq!(op.id(), deserialized.id()); + assert_eq!(op.inner.seq, deserialized.inner.seq); +} + +// ── CrdtEvent tests ───────────────────────────────────────────────── + +#[test] +fn crdt_event_has_expected_fields() { + let evt = CrdtEvent { + story_id: "42_story_foo".to_string(), + from_stage: Some("1_backlog".to_string()), + to_stage: "2_current".to_string(), + name: Some("Foo Feature".to_string()), + }; + assert_eq!(evt.story_id, "42_story_foo"); + assert_eq!(evt.from_stage.as_deref(), Some("1_backlog")); + assert_eq!(evt.to_stage, "2_current"); + assert_eq!(evt.name.as_deref(), Some("Foo Feature")); +} + +#[test] +fn crdt_event_clone_preserves_data() { + let evt = CrdtEvent { + story_id: "10_story_bar".to_string(), + from_stage: None, + to_stage: "1_backlog".to_string(), + name: None, + }; + let cloned = evt.clone(); + assert_eq!(cloned.story_id, "10_story_bar"); + assert!(cloned.from_stage.is_none()); + assert!(cloned.name.is_none()); +} + +#[test] +fn emit_event_is_noop_when_channel_not_initialised() { + // Before CRDT_EVENT_TX is set, emit_event should not panic. + // This test verifies the guard clause works. In test binaries the + // OnceLock may already be set by another test, so we just verify + // the function doesn't panic regardless. + emit_event(CrdtEvent { + story_id: "99_story_noop".to_string(), + from_stage: None, + to_stage: "1_backlog".to_string(), + name: None, + }); +} + +#[test] +fn crdt_event_broadcast_channel_round_trip() { + let (tx, mut rx) = broadcast::channel::(16); + let evt = CrdtEvent { + story_id: "70_story_broadcast".to_string(), + from_stage: Some("1_backlog".to_string()), + to_stage: "2_current".to_string(), + name: Some("Broadcast Test".to_string()), + }; + tx.send(evt).unwrap(); + + let received = rx.try_recv().unwrap(); + assert_eq!(received.story_id, "70_story_broadcast"); + assert_eq!(received.from_stage.as_deref(), Some("1_backlog")); + assert_eq!(received.to_stage, "2_current"); + assert_eq!(received.name.as_deref(), Some("Broadcast Test")); +} + +#[test] +fn dep_is_done_crdt_returns_false_when_no_crdt_state() { + // When the global CRDT state is not initialised (or in a test environment), + // dep_is_done_crdt should return false rather than panicking. + // Note: in the test binary the global may or may not be initialised, + // but the function should never panic either way. + let _ = dep_is_done_crdt(9999); +} + +#[test] +fn check_unmet_deps_crdt_returns_empty_when_item_not_found() { + // Non-existent story should return empty deps. + let result = check_unmet_deps_crdt("nonexistent_story"); + assert!(result.is_empty()); +} + +// ── Bug 503: archived-dep visibility ───────────────────────────────────── + +#[test] +fn dep_is_archived_crdt_returns_false_when_no_crdt_state() { + // When the global CRDT state is not initialised, must not panic. + let _ = dep_is_archived_crdt(9998); +} + +#[test] +fn check_archived_deps_crdt_returns_empty_when_item_not_found() { + // Non-existent story should return empty archived deps. + let result = check_archived_deps_crdt("nonexistent_story_archived"); + assert!(result.is_empty()); +} + +// ── 478: WebSocket CRDT sync layer tests ──────────────────────────────── + +#[test] +fn apply_remote_op_returns_false_when_not_initialised() { + // Without the global CRDT state, apply_remote_op should return false. + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + let item: JsonValue = serde_json::json!({ + "story_id": "80_story_remote", + "stage": "1_backlog", + "name": "Remote", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op = crdt + .doc + .items + .insert(bft_json_crdt::op::ROOT_ID, item) + .sign(&kp); + // This uses the global state which may not be initialised in tests. + let _ = apply_remote_op(op); +} + +#[test] +fn signed_op_survives_sync_serialization_roundtrip() { + // Verify that a SignedOp serialised to JSON and back produces + // the same op (critical for the sync wire protocol). + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + let item: JsonValue = serde_json::json!({ + "story_id": "90_story_wire", + "stage": "2_current", + "name": "Wire Test", + "agent": "coder", + "retry_count": 1.0, + "blocked": false, + "depends_on": "[10]", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op = crdt + .doc + .items + .insert(bft_json_crdt::op::ROOT_ID, item) + .sign(&kp); + + let json1 = serde_json::to_string(&op).unwrap(); + let roundtripped: SignedOp = serde_json::from_str(&json1).unwrap(); + let json2 = serde_json::to_string(&roundtripped).unwrap(); + + assert_eq!(json1, json2); + assert_eq!(op.id(), roundtripped.id()); + assert_eq!(op.inner.seq, roundtripped.inner.seq); + assert_eq!(op.author(), roundtripped.author()); +} + +#[test] +fn sync_broadcast_channel_round_trip() { + let (tx, mut rx) = broadcast::channel::(16); + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + let item: JsonValue = serde_json::json!({ + "story_id": "95_story_sync_bcast", + "stage": "1_backlog", + "name": "", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op = crdt + .doc + .items + .insert(bft_json_crdt::op::ROOT_ID, item) + .sign(&kp); + tx.send(op.clone()).unwrap(); + + let received = rx.try_recv().unwrap(); + assert_eq!(received.id(), op.id()); +} + +// ── Bug 511: CRDT lamport clock resets on restart ──────────────────────── +// +// Root cause: Op::sign() always produces SignedOp with depends_on = vec![], +// so the causal dependency queue never engages during replay. Field update +// ops (seq=1,2,3 from each field's LwwRegisterCrdt counter) are replayed +// before list insert ops (seq=N from the items ListCrdt counter) when +// ordered by `seq ASC`. They fail ErrPathMismatch silently, their our_seq +// is never updated, and the next field write re-uses seq=1. +// +// Fix: replay by `rowid ASC` (SQLite insertion order) instead of `seq ASC`. +// Rowid preserves the causal order ops were originally applied in, so field +// updates always come after the item insert they reference. +#[tokio::test] +async fn bug_511_rowid_replay_preserves_field_update_after_list_insert() { + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("bug511.db"); + + let options = SqliteConnectOptions::new() + .filename(&db_path) + .create_if_missing(true); + let pool = SqlitePool::connect_with(options).await.unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + + // Insert 5 dummy items to advance items.our_seq to 5. + for i in 0..5u32 { + let sid = format!("{}_story_warmup", i); + let item: JsonValue = json!({ + "story_id": sid, + "stage": "1_backlog", + "name": "", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); + crdt.apply(op.clone()); + // We don't persist these to the DB — they are pre-history. + } + + // Now insert the real item. items.our_seq was 5, so this op gets seq=6. + let target_item: JsonValue = json!({ + "story_id": "511_story_target", + "stage": "1_backlog", + "name": "Bug 511 target", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let insert_op = crdt.doc.items.insert(ROOT_ID, target_item).sign(&kp); + crdt.apply(insert_op.clone()); + // insert_op.inner.seq == 6 + + // Now update the stage. The stage LwwRegisterCrdt for this item starts + // at our_seq=0, so this field op gets seq=1. Crucially: seq=1 < seq=6. + let idx = rebuild_index(&crdt)["511_story_target"]; + let stage_op = crdt.doc.items[idx] + .stage + .set("2_current".to_string()) + .sign(&kp); + crdt.apply(stage_op.clone()); + // stage_op.inner.seq == 1 + + // Persist BOTH ops in causal order (insert first, update second). + // This means insert_op gets rowid < stage_op rowid. + let now = chrono::Utc::now().to_rfc3339(); + for op in [&insert_op, &stage_op] { + let op_json = serde_json::to_string(op).unwrap(); + let op_id = hex::encode(&op.id()); + sqlx::query( + "INSERT INTO crdt_ops (op_id, seq, op_json, created_at) VALUES (?1, ?2, ?3, ?4)", + ) + .bind(&op_id) + .bind(op.inner.seq as i64) + .bind(&op_json) + .bind(&now) + .execute(&pool) + .await + .unwrap(); + } + + // Replay by rowid ASC (the fix). The insert must come before the field + // update regardless of their field-level seq values. + let rows: Vec<(String,)> = sqlx::query_as("SELECT op_json FROM crdt_ops ORDER BY rowid ASC") + .fetch_all(&pool) + .await + .unwrap(); + + let mut crdt2 = BaseCrdt::::new(&kp); + for (json_str,) in &rows { + let op: SignedOp = serde_json::from_str(json_str).unwrap(); + crdt2.apply(op); + } + + // The item must be in the CRDT and must reflect the stage update. + let index2 = rebuild_index(&crdt2); + assert!( + index2.contains_key("511_story_target"), + "item not found after rowid-order replay" + ); + let idx2 = index2["511_story_target"]; + let view = extract_item_view(&crdt2.doc.items[idx2]).unwrap(); + assert_eq!( + view.stage, "2_current", + "stage field update lost during replay (bug 511 regression)" + ); + + // Confirm the bug is reproducible by replaying seq ASC instead. + // With seq ASC the stage_op (seq=1) arrives before insert_op (seq=6), + // fails ErrPathMismatch, and the item ends up at "1_backlog". + let rows_wrong_order: Vec<(String,)> = + sqlx::query_as("SELECT op_json FROM crdt_ops ORDER BY seq ASC") + .fetch_all(&pool) + .await + .unwrap(); + + let mut crdt3 = BaseCrdt::::new(&kp); + for (json_str,) in &rows_wrong_order { + let op: SignedOp = serde_json::from_str(json_str).unwrap(); + crdt3.apply(op); + } + + let index3 = rebuild_index(&crdt3); + // With seq ASC replay, the item is created (insert_op eventually runs) + // but the stage update is lost (it ran before the item existed). + if let Some(idx3) = index3.get("511_story_target") { + let view3 = extract_item_view(&crdt3.doc.items[*idx3]).unwrap(); + // The bug: stage is still "1_backlog" because the update was dropped. + assert_eq!( + view3.stage, "1_backlog", + "expected seq-ASC replay to exhibit the bug (update lost)" + ); + } +} + +// ── Story 518: persist_tx send failure logging ─────────────────────────── + +#[test] +fn persist_tx_send_failure_logs_error() { + let kp = make_keypair(); + let crdt = BaseCrdt::::new(&kp); + let (persist_tx, persist_rx) = mpsc::unbounded_channel::(); + + let mut state = CrdtState { + crdt, + keypair: kp, + index: HashMap::new(), + node_index: HashMap::new(), + persist_tx, + }; + + // Drop the receiver so that the next send fails immediately. + drop(persist_rx); + + let item_json: JsonValue = json!({ + "story_id": "518_story_persist_fail", + "stage": "1_backlog", + "name": "Persist Fail Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + + let before_errors = crate::log_buffer::global() + .get_recent_entries(1000, None, Some(&crate::log_buffer::LogLevel::Error)) + .len(); + + apply_and_persist(&mut state, |s| s.crdt.doc.items.insert(ROOT_ID, item_json)); + + let error_entries = crate::log_buffer::global().get_recent_entries( + 1000, + None, + Some(&crate::log_buffer::LogLevel::Error), + ); + + assert!( + error_entries.len() > before_errors, + "expected an ERROR log entry when persist_tx send fails, but none was added" + ); + + let last_error = &error_entries[error_entries.len() - 1]; + assert!( + last_error.message.contains("persist"), + "error message should mention persist: {}", + last_error.message + ); + assert!( + last_error.message.contains("ahead") || last_error.message.contains("diverged"), + "error message should note in-memory/persisted divergence: {}", + last_error.message + ); +} + +// ── Story 631: vector clock delta sync tests ──────────────────────── + +/// Helper: create N signed insert ops on a CRDT and return them with their JSON. +fn make_ops( + kp: &Ed25519KeyPair, + crdt: &mut BaseCrdt, + count: usize, + prefix: &str, +) -> Vec<(SignedOp, String)> { + let mut ops = Vec::new(); + for i in 0..count { + let item: JsonValue = json!({ + "story_id": format!("{prefix}_{i}"), + "stage": "1_backlog", + "name": format!("Item {i}"), + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op = crdt.doc.items.insert(ROOT_ID, item).sign(kp); + crdt.apply(op.clone()); + let json = serde_json::to_string(&op).unwrap(); + ops.push((op, json)); + } + ops +} + +/// Build a vector clock from a list of (SignedOp, json) pairs. +fn build_clock(ops: &[(SignedOp, String)]) -> VectorClock { + let mut clock = VectorClock::new(); + for (op, _) in ops { + let author = hex::encode(&op.author()); + *clock.entry(author).or_insert(0) += 1; + } + clock +} + +/// Compute ops_since against a local journal and peer clock. +/// +/// Mirrors the production `ops_since` logic but operates on a local Vec +/// instead of the global `ALL_OPS` static. +fn local_ops_since(all_ops: &[(SignedOp, String)], peer_clock: &VectorClock) -> Vec { + let mut author_counts: HashMap = HashMap::new(); + let mut result = Vec::new(); + for (op, json) in all_ops { + let author = hex::encode(&op.author()); + let count = author_counts.entry(author.clone()).or_insert(0); + *count += 1; + let peer_has = peer_clock.get(&author).copied().unwrap_or(0); + if *count > peer_has { + result.push(json.clone()); + } + } + result +} + +/// Integration test (low-bandwidth sync): two nodes, A applies 100 ops, +/// B reconnects with a current clock — B receives 0 ops on the bulk phase. +#[test] +fn delta_sync_low_bandwidth_fully_caught_up() { + let kp_a = make_keypair(); + let mut crdt_a = BaseCrdt::::new(&kp_a); + + let ops_a = make_ops(&kp_a, &mut crdt_a, 100, "631_low"); + + // B has already seen all 100 ops (its clock matches A's journal). + let clock_b = build_clock(&ops_a); + + // Delta should be empty. + let delta = local_ops_since(&ops_a, &clock_b); + assert_eq!( + delta.len(), + 0, + "caught-up peer should receive 0 ops, got {}", + delta.len() + ); +} + +/// Integration test (mid-stream): A applies 100 ops, B disconnects, +/// A applies 50 more ops, B reconnects — B receives exactly the 50 missed ops. +#[test] +fn delta_sync_mid_stream_partial_catch_up() { + let kp_a = make_keypair(); + let mut crdt_a = BaseCrdt::::new(&kp_a); + + // Phase 1: 100 ops that B has seen. + let ops_phase1 = make_ops(&kp_a, &mut crdt_a, 100, "631_mid1"); + let clock_b = build_clock(&ops_phase1); + + // Phase 2: 50 more ops that B missed. + let ops_phase2 = make_ops(&kp_a, &mut crdt_a, 50, "631_mid2"); + + // A's full journal is phase1 + phase2. + let mut all_ops_a: Vec<(SignedOp, String)> = ops_phase1; + all_ops_a.extend(ops_phase2); + + let delta = local_ops_since(&all_ops_a, &clock_b); + assert_eq!( + delta.len(), + 50, + "peer should receive exactly 50 missed ops, got {}", + delta.len() + ); +} + +/// Integration test (new node): C connects with empty clock, +/// receives all 150 ops — verifies fallback behaviour. +#[test] +fn delta_sync_new_node_receives_all_ops() { + let kp_a = make_keypair(); + let mut crdt_a = BaseCrdt::::new(&kp_a); + + let ops_phase1 = make_ops(&kp_a, &mut crdt_a, 100, "631_new1"); + let ops_phase2 = make_ops(&kp_a, &mut crdt_a, 50, "631_new2"); + + let mut all_ops_a: Vec<(SignedOp, String)> = ops_phase1; + all_ops_a.extend(ops_phase2); + + // Empty clock = new node. + let empty_clock = VectorClock::new(); + let delta = local_ops_since(&all_ops_a, &empty_clock); + assert_eq!( + delta.len(), + 150, + "new node should receive all 150 ops, got {}", + delta.len() + ); +} + +/// Multi-author delta sync: ops from two different nodes, peer has seen +/// all of one author but none of the other. +#[test] +fn delta_sync_multi_author() { + use fastcrypto::traits::KeyPair; + + let kp_a = make_keypair(); + let kp_b = make_keypair(); + let mut crdt_a = BaseCrdt::::new(&kp_a); + let mut crdt_b = BaseCrdt::::new(&kp_b); + + let ops_a = make_ops(&kp_a, &mut crdt_a, 30, "631_ma_a"); + let ops_b = make_ops(&kp_b, &mut crdt_b, 20, "631_ma_b"); + + // Combined journal on a hypothetical server. + let mut all_ops: Vec<(SignedOp, String)> = ops_a.clone(); + all_ops.extend(ops_b); + + // Peer has seen all of A's ops but none of B's. + let mut peer_clock = VectorClock::new(); + let author_a_hex = hex::encode(&kp_a.public().0.to_bytes()); + peer_clock.insert(author_a_hex, 30); + + let delta = local_ops_since(&all_ops, &peer_clock); + assert_eq!( + delta.len(), + 20, + "peer should receive 20 ops from author B, got {}", + delta.len() + ); +} + +/// Vector clock construction from ops. +#[test] +fn build_vector_clock_from_ops() { + use fastcrypto::traits::KeyPair; + + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + let ops = make_ops(&kp, &mut crdt, 10, "631_vc"); + + let clock = build_clock(&ops); + let author_hex = hex::encode(&kp.public().0.to_bytes()); + + assert_eq!(clock.len(), 1, "single author should produce 1 clock entry"); + assert_eq!(clock[&author_hex], 10, "clock should show 10 ops"); +} + +/// Wire format: clock message serialization round-trip. +#[test] +fn clock_message_serialization_roundtrip() { + let mut clock = VectorClock::new(); + clock.insert("aabbcc".to_string(), 42); + clock.insert("ddeeff".to_string(), 7); + + let json = serde_json::to_value(&clock).unwrap(); + assert!(json.is_object()); + let deserialized: VectorClock = serde_json::from_value(json).unwrap(); + assert_eq!(deserialized["aabbcc"], 42); + assert_eq!(deserialized["ddeeff"], 7); +} diff --git a/server/src/crdt_sync.rs b/server/src/crdt_sync.rs deleted file mode 100644 index baee5c97..00000000 --- a/server/src/crdt_sync.rs +++ /dev/null @@ -1,3672 +0,0 @@ -//! CRDT sync — WebSocket-based replication of pipeline state between huskies nodes. -/// WebSocket-based CRDT sync layer for replicating pipeline state between -/// huskies nodes. -/// -/// # Protocol -/// -/// ## Version negotiation -/// -/// After the auth handshake, both sides send their first sync message: -/// -/// - **v2 peers** send a `clock` frame: `{"type":"clock","clock":{ : , ... }}` -/// containing a vector clock that maps each author's hex Ed25519 pubkey to the -/// count of ops received from that author. Upon receiving the peer's clock, -/// each side computes the delta via [`crdt_state::ops_since`] and sends only -/// the missing ops as a `bulk` frame. -/// -/// - **v1 (legacy) peers** send a `bulk` frame directly (full op dump). -/// A v2 peer receiving a `bulk` first (instead of a `clock`) falls back to -/// the full-dump path: applies the incoming bulk and responds with its own -/// full bulk. This preserves backward compatibility — no code change needed -/// on the v1 side. -/// -/// ## Text frames -/// A JSON object with a `"type"` field: -/// - `{"type":"clock","clock":{...}}` — Vector clock (v2 protocol). -/// - `{"type":"bulk","ops":[...]}` — Ops dump (full or delta). -/// - `{"type":"ready"}` — Signals that the bulk-delta phase is complete and the -/// sender is ready for real-time op streaming. Locally-generated ops are -/// buffered until the peer's `ready` is received, then flushed in order. -/// -/// ## Binary frames (real-time op broadcast) -/// Individual `SignedOp`s encoded via [`crate::crdt_wire`] (versioned JSON -/// envelope: `{"v":1,"op":{...}}`). Each locally-applied op is immediately -/// broadcast as a binary frame to all connected peers. -/// -/// Both the server endpoint and the rendezvous client use the same protocol, -/// making the connection fully symmetric. -/// -/// ## Backpressure -/// Each connected peer has its own [`tokio::sync::broadcast`] receiver. If a -/// slow peer allows the channel to fill (indicated by a `Lagged` error), the -/// connection is dropped with a warning log. The peer can reconnect and -/// receive a fresh bulk state dump to catch up. -use bft_json_crdt::json_crdt::SignedOp; -use futures::{SinkExt, StreamExt}; -use poem::handler; -use poem::http::StatusCode; -use poem::web::Data; -use poem::web::Query; -use poem::web::websocket::{Message as WsMessage, WebSocket}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::{Arc, OnceLock}; - -use crate::crdt_snapshot; -use crate::crdt_state; -use crate::crdt_wire; -use crate::http::context::AppContext; -use crate::node_identity; -use crate::slog; -use crate::slog_error; -use crate::slog_warn; - -// ── Auth configuration ────────────────────────────────────────────── - -/// Default timeout for the auth handshake (seconds). -const AUTH_TIMEOUT_SECS: u64 = 10; - -// ── Keepalive configuration ───────────────────────────────────────── - -/// Interval (seconds) between WebSocket Ping frames sent by each side. -pub const PING_INTERVAL_SECS: u64 = 30; - -/// Seconds without a Pong response before the connection is dropped. -pub const PONG_TIMEOUT_SECS: u64 = 60; - -/// Trusted public keys loaded once at startup. -static TRUSTED_KEYS: OnceLock> = OnceLock::new(); - -/// Initialise the trusted-key allow-list for connect-time mutual auth. -/// -/// Must be called once at startup before any WebSocket connections are -/// accepted. Subsequent calls are no-ops (OnceLock). -pub fn init_trusted_keys(keys: Vec) { - let _ = TRUSTED_KEYS.set(keys); -} - -/// Return a reference to the trusted-key allow-list. -fn trusted_keys() -> &'static [String] { - TRUSTED_KEYS.get().map(|v| v.as_slice()).unwrap_or(&[]) -} - -// ── Bearer-token auth ─────────────────────────────────────────────── - -/// Time-to-live for CRDT bearer tokens in seconds (30 days). -const TOKEN_TTL_SECS: f64 = 30.0 * 24.0 * 3600.0; - -/// Whether a bearer token is required for `/crdt-sync` connections. -/// `None` (uninitialised) → open access (backward compatible). -static REQUIRE_TOKEN: OnceLock = OnceLock::new(); - -/// Valid bearer tokens — maps token string to its expiry unix timestamp. -static CRDT_TOKENS: OnceLock>> = OnceLock::new(); - -/// Initialise bearer-token auth for CRDT-sync connections. -/// -/// Must be called once at startup before any WebSocket connections are accepted. -/// When `require` is `true`, clients must supply a valid `?token=` query -/// parameter on the upgrade request or receive HTTP 401. When `require` is -/// `false` (default) a token is optional — connections without one are -/// accepted, but a supplied token is still validated. -pub fn init_token_auth(require: bool, tokens: Vec) { - let _ = REQUIRE_TOKEN.set(require); - let store = CRDT_TOKENS.get_or_init(|| std::sync::RwLock::new(HashMap::new())); - if let Ok(mut map) = store.write() { - let now = chrono::Utc::now().timestamp() as f64; - for token in tokens { - map.insert(token, now + TOKEN_TTL_SECS); - } - } -} - -/// Add a bearer token to the CRDT-sync token store. -/// -/// The token expires after [`TOKEN_TTL_SECS`] seconds. Returns the expiry -/// unix timestamp so callers can surface it in admin tooling. -pub fn add_join_token(token: String) -> f64 { - let store = CRDT_TOKENS.get_or_init(|| std::sync::RwLock::new(HashMap::new())); - let now = chrono::Utc::now().timestamp() as f64; - let expires_at = now + TOKEN_TTL_SECS; - if let Ok(mut map) = store.write() { - map.insert(token, expires_at); - } - expires_at -} - -/// Validate a bearer token against the CRDT-sync token store. -/// -/// Returns `true` if the token exists in the store and has not expired. -fn validate_join_token(token: &str) -> bool { - let Some(store) = CRDT_TOKENS.get() else { - return false; - }; - let now = chrono::Utc::now().timestamp() as f64; - store - .read() - .ok() - .and_then(|map| map.get(token).copied()) - .is_some_and(|expires_at| expires_at > now) -} - -// ── Wire protocol types ───────────────────────────────────────────── - -/// Auth handshake: challenge sent by the listener to the connector. -#[derive(Serialize, Deserialize, Debug)] -struct ChallengeMessage { - r#type: String, - nonce: String, -} - -/// Auth handshake: auth reply sent by the connector to the listener. -#[derive(Serialize, Deserialize, Debug)] -struct AuthMessage { - r#type: String, - pubkey_hex: String, - signature_hex: String, -} - -#[derive(Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub(crate) enum SyncMessage { - /// Bulk state dump sent on connect (v1) or delta ops after clock exchange (v2). - Bulk { ops: Vec }, - /// A single new op. - Op { op: String }, - /// Vector clock exchanged on connect (v2 protocol). - /// - /// Each entry maps a node's hex-encoded Ed25519 public key to the count of - /// ops received from that node. The receiving side computes the delta via - /// [`crdt_state::ops_since`] and sends only the missing ops. - Clock { - clock: std::collections::HashMap, - }, - /// Signals that the bulk-delta phase is complete; the sender is ready for - /// real-time op streaming. Locally-generated ops are buffered until the - /// peer's `Ready` is received, then flushed in-order. - Ready, -} - -/// Crate-visible re-export of `SyncMessage` for backwards-compatibility testing. -/// -/// Used by `crdt_snapshot` tests to verify that snapshot messages are NOT -/// parseable as legacy `SyncMessage` variants — confirming that old peers -/// will gracefully reject them. -#[cfg(test)] -pub(crate) type SyncMessagePublic = SyncMessage; - -// ── Server-side WebSocket handler ─────────────────────────────────── - -/// Query parameters accepted on the `/crdt-sync` WebSocket upgrade request. -#[derive(Deserialize)] -struct SyncQueryParams { - /// Optional bearer token. Required when the server is in token-required mode. - token: Option, -} - -/// WebSocket handler for CRDT peer synchronisation. -/// -/// Accepts an optional `?token=` query parameter. When the -/// server is configured with `crdt_require_token = true`, a valid token must -/// be supplied or the upgrade is rejected with HTTP 401. When the server is -/// in open-access mode (the default), a token is optional but still validated -/// if present. -#[handler] -pub async fn crdt_sync_handler( - ws: WebSocket, - _ctx: Data<&Arc>, - remote_addr: &poem::web::RemoteAddr, - Query(params): Query, -) -> poem::Response { - // ── Bearer-token check (pre-upgrade) ──────────────────────────── - let require_token = REQUIRE_TOKEN.get().copied().unwrap_or(false); - match ¶ms.token { - Some(t) => { - if !validate_join_token(t) { - slog!("[crdt-sync] Rejected connection: invalid or expired token"); - return poem::Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body("invalid or expired token"); - } - } - None if require_token => { - slog!("[crdt-sync] Rejected connection: token required but not provided"); - return poem::Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body("token required"); - } - None => {} - } - - // ── WebSocket upgrade ──────────────────────────────────────────── - use poem::IntoResponse as _; - let peer_addr = remote_addr.to_string(); - ws.on_upgrade(move |socket| async move { - let (mut sink, mut stream) = socket.split(); - - slog!("[crdt-sync] Peer connected, starting auth handshake"); - - // ── Step 1: Send challenge to the connecting peer ───────── - let challenge = node_identity::generate_challenge(); - let challenge_msg = ChallengeMessage { - r#type: "challenge".to_string(), - nonce: challenge.clone(), - }; - let challenge_json = match serde_json::to_string(&challenge_msg) { - Ok(j) => j, - Err(_) => return, - }; - if sink.send(WsMessage::Text(challenge_json)).await.is_err() { - return; - } - - // ── Step 2: Await auth reply within timeout ─────────────── - let auth_result = tokio::time::timeout( - std::time::Duration::from_secs(AUTH_TIMEOUT_SECS), - stream.next(), - ) - .await; - - let auth_text = match auth_result { - Ok(Some(Ok(WsMessage::Text(text)))) => text, - Ok(_) | Err(_) => { - // Timeout or connection closed before auth reply. - slog!("[crdt-sync] Auth timeout or connection lost during handshake"); - let _ = sink - .send(WsMessage::Close(Some(( - poem::web::websocket::CloseCode::from(4001), - "auth_timeout".to_string(), - )))) - .await; - let _ = sink.close().await; - return; - } - }; - - // ── Step 3: Verify auth reply ───────────────────────────── - let auth_msg: AuthMessage = match serde_json::from_str(&auth_text) { - Ok(m) => m, - Err(_) => { - slog!("[crdt-sync] Invalid auth message from peer"); - close_with_auth_failed(&mut sink).await; - return; - } - }; - - // Verify signature AND check allow-list. - let sig_valid = - node_identity::verify_challenge(&auth_msg.pubkey_hex, &challenge, &auth_msg.signature_hex); - let key_trusted = trusted_keys().iter().any(|k| k == &auth_msg.pubkey_hex); - - if !sig_valid || !key_trusted { - slog!("[crdt-sync] Auth failed for peer (sig_valid={sig_valid}, key_trusted={key_trusted})"); - close_with_auth_failed(&mut sink).await; - return; - } - - slog!( - "[crdt-sync] Peer authenticated: {:.12}…", - &auth_msg.pubkey_hex - ); - - // ── Auth passed — proceed with CRDT sync ────────────────── - - // v2 protocol: send our vector clock so the peer can compute the delta. - let our_clock = crdt_state::our_vector_clock().unwrap_or_default(); - let clock_msg = SyncMessage::Clock { clock: our_clock }; - if let Ok(json) = serde_json::to_string(&clock_msg) - && sink.send(WsMessage::Text(json)).await.is_err() - { - return; - } - - // Wait for the peer's first sync message to determine protocol version. - let first_msg = tokio::time::timeout( - std::time::Duration::from_secs(AUTH_TIMEOUT_SECS), - wait_for_sync_text(&mut stream, &mut sink), - ) - .await; - - match first_msg { - Ok(Some(SyncMessage::Clock { clock: peer_clock })) => { - // v2 peer — if we have a snapshot and the peer has an empty - // clock (new node), send the snapshot first for onboarding. - if peer_clock.is_empty() - && let Some(snapshot) = crdt_snapshot::latest_snapshot() - { - let snap_msg = crdt_snapshot::SnapshotMessage::Snapshot(snapshot); - if let Ok(json) = serde_json::to_string(&snap_msg) { - if sink.send(WsMessage::Text(json)).await.is_err() { - return; - } - slog!("[crdt-sync] Sent snapshot to new node for onboarding"); - } - } - - // Send only the ops the peer is missing. - let delta = crdt_state::ops_since(&peer_clock).unwrap_or_default(); - slog!( - "[crdt-sync] v2 delta sync: sending {} ops (peer missing)", - delta.len() - ); - let msg = SyncMessage::Bulk { ops: delta }; - if let Ok(json) = serde_json::to_string(&msg) - && sink.send(WsMessage::Text(json)).await.is_err() - { - return; - } - } - Ok(Some(SyncMessage::Bulk { ops })) => { - // v1 peer — apply their bulk and send our full bulk. - let mut applied = 0u64; - for op_json in &ops { - if let Ok(signed_op) = serde_json::from_str::(op_json) - && crdt_state::apply_remote_op(signed_op) - { - applied += 1; - } - } - slog!( - "[crdt-sync] v1 bulk sync: received {} ops, applied {applied}", - ops.len() - ); - if let Some(all) = crdt_state::all_ops_json() { - let msg = SyncMessage::Bulk { ops: all }; - if let Ok(json) = serde_json::to_string(&msg) - && sink.send(WsMessage::Text(json)).await.is_err() - { - return; - } - } - } - Ok(Some(SyncMessage::Op { op })) => { - // Single op before negotiation — treat as v1. - if let Ok(signed_op) = serde_json::from_str::(&op) { - crdt_state::apply_remote_op(signed_op); - } - if let Some(all) = crdt_state::all_ops_json() { - let msg = SyncMessage::Bulk { ops: all }; - if let Ok(json) = serde_json::to_string(&msg) - && sink.send(WsMessage::Text(json)).await.is_err() - { - return; - } - } - } - _ => { - // Timeout or error — send full bulk as fallback. - slog!("[crdt-sync] No sync message from peer; sending full bulk as fallback"); - if let Some(all) = crdt_state::all_ops_json() { - let msg = SyncMessage::Bulk { ops: all }; - if let Ok(json) = serde_json::to_string(&msg) - && sink.send(WsMessage::Text(json)).await.is_err() - { - return; - } - } - } - } - - // Bulk-delta phase complete — signal the peer that we are ready for - // real-time op streaming. - if let Ok(json) = serde_json::to_string(&SyncMessage::Ready) - && sink.send(WsMessage::Text(json)).await.is_err() - { - return; - } - - // Subscribe to new local ops. - let Some(mut op_rx) = crdt_state::subscribe_ops() else { - return; - }; - - // Buffer for locally-generated ops produced before the peer's `ready` - // arrives. Flushed in-order once the peer signals catch-up. - let mut peer_ready = false; - let mut op_buffer: Vec = Vec::new(); - - // ── Keepalive state ─────────────────────────────────────────── - let mut pong_deadline = tokio::time::Instant::now() - + std::time::Duration::from_secs(PONG_TIMEOUT_SECS); - let mut ping_ticker = tokio::time::interval_at( - tokio::time::Instant::now() - + std::time::Duration::from_secs(PING_INTERVAL_SECS), - std::time::Duration::from_secs(PING_INTERVAL_SECS), - ); - - loop { - tokio::select! { - // Send periodic Ping and enforce Pong timeout. - _ = ping_ticker.tick() => { - if tokio::time::Instant::now() >= pong_deadline { - slog_warn!( - "[crdt-sync] No pong from peer {} in {}s; disconnecting", - peer_addr, - PONG_TIMEOUT_SECS - ); - break; - } - if sink.send(WsMessage::Ping(vec![])).await.is_err() { - break; - } - } - // Forward new local ops to the peer encoded via the wire codec. - result = op_rx.recv() => { - match result { - Ok(signed_op) => { - if peer_ready { - let bytes = crdt_wire::encode(&signed_op); - if sink.send(WsMessage::Binary(bytes)).await.is_err() { - break; - } - } else { - // Buffer until the peer signals ready. - op_buffer.push(signed_op); - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - // The peer cannot keep up; disconnect so it can - // reconnect and receive a fresh bulk state dump. - slog!("[crdt-sync] Slow peer lagged {n} ops; disconnecting"); - break; - } - Err(_) => break, - } - } - // Receive ops from the peer. - frame = stream.next() => { - match frame { - Some(Ok(WsMessage::Pong(_))) => { - // Reset the pong deadline on every Pong received. - pong_deadline = tokio::time::Instant::now() - + std::time::Duration::from_secs(PONG_TIMEOUT_SECS); - } - Some(Ok(WsMessage::Ping(data))) => { - // Respond to peer's Ping so the peer's keepalive passes. - let _ = sink.send(WsMessage::Pong(data)).await; - } - Some(Ok(WsMessage::Text(text))) => { - // Check for the ready signal before other text frames. - if let Ok(SyncMessage::Ready) = serde_json::from_str(&text) { - peer_ready = true; - slog!("[crdt-sync] Peer ready; flushing {} buffered ops", op_buffer.len()); - let mut flush_ok = true; - for op in op_buffer.drain(..) { - let bytes = crdt_wire::encode(&op); - if sink.send(WsMessage::Binary(bytes)).await.is_err() { - flush_ok = false; - break; - } - } - if !flush_ok { - break; - } - } else { - // Bulk state dump, legacy op frame, or clock frame. - handle_incoming_text(&text); - } - } - Some(Ok(WsMessage::Binary(bytes))) => { - // Real-time op encoded via wire codec — applied immediately - // regardless of our own ready state. - handle_incoming_binary(&bytes); - } - Some(Ok(WsMessage::Close(_))) | None => break, - _ => {} - } - } - } - } - - slog!("[crdt-sync] Peer disconnected"); - }) - .into_response() -} - -/// Wait for the next text-frame sync message from the peer, handling Ping/Pong -/// transparently. -/// -/// Returns `None` on connection close or read error. -async fn wait_for_sync_text( - stream: &mut futures::stream::SplitStream, - sink: &mut futures::stream::SplitSink, -) -> Option { - loop { - match stream.next().await { - Some(Ok(WsMessage::Text(text))) => { - return serde_json::from_str(&text).ok(); - } - Some(Ok(WsMessage::Ping(data))) => { - let _ = sink.send(WsMessage::Pong(data)).await; - } - Some(Ok(WsMessage::Pong(_))) => continue, - _ => return None, - } - } -} - -/// Close the WebSocket with a generic `auth_failed` reason. -/// -/// The close reason is intentionally the same for all auth failures -/// (bad signature, untrusted key, malformed message) to avoid leaking -/// which check failed. -async fn close_with_auth_failed( - sink: &mut futures::stream::SplitSink, -) { - let _ = sink - .send(WsMessage::Close(Some(( - poem::web::websocket::CloseCode::from(4002), - "auth_failed".to_string(), - )))) - .await; - let _ = sink.close().await; -} - -/// Process an incoming text-frame sync message from a peer. -/// -/// Text frames carry the bulk state dump (`SyncMessage::Bulk`), legacy -/// single-op messages (`SyncMessage::Op`), or snapshot protocol messages. -fn handle_incoming_text(text: &str) { - // First try to parse as a snapshot protocol message. - if let Ok(snapshot_msg) = serde_json::from_str::(text) { - handle_snapshot_message(snapshot_msg); - return; - } - - let msg: SyncMessage = match serde_json::from_str(text) { - Ok(m) => m, - Err(e) => { - slog!("[crdt-sync] Bad text message from peer: {e}"); - return; - } - }; - - match msg { - SyncMessage::Bulk { ops } => { - let mut applied = 0u64; - for op_json in &ops { - if let Ok(signed_op) = serde_json::from_str::(op_json) - && crdt_state::apply_remote_op(signed_op) - { - applied += 1; - } - } - slog!( - "[crdt-sync] Bulk sync: received {} ops, applied {applied}", - ops.len() - ); - } - SyncMessage::Op { op } => { - if let Ok(signed_op) = serde_json::from_str::(&op) { - crdt_state::apply_remote_op(signed_op); - } - } - SyncMessage::Clock { .. } => { - // Clock frames are handled during the initial negotiation phase. - // If one arrives during the streaming loop it is a protocol error - // on the peer's part — log and ignore. - slog!("[crdt-sync] Ignoring unexpected clock frame during streaming phase"); - } - SyncMessage::Ready => { - // Ready frames are intercepted inline in the streaming loop before - // this function is called. If one reaches here it is a protocol - // error — log and ignore. - slog!("[crdt-sync] Ignoring unexpected ready frame in handle_incoming_text"); - } - } -} - -/// Handle an incoming snapshot protocol message. -/// -/// - **Snapshot**: apply the snapshot state and send an ack back. -/// Peers without snapshot support will never reach this code path because -/// the `SnapshotMessage` parse will fail and the message falls through to -/// the legacy `SyncMessage` handler, which logs and ignores unknown types. -/// - **SnapshotAck**: record the ack for quorum tracking. -fn handle_snapshot_message(msg: crdt_snapshot::SnapshotMessage) { - match msg { - crdt_snapshot::SnapshotMessage::Snapshot(snapshot) => { - slog!( - "[crdt-sync] Received snapshot at_seq={}, {} ops, {} manifest entries", - snapshot.at_seq, - snapshot.state.len(), - snapshot.op_manifest.len() - ); - // Apply compaction on this peer. - crdt_snapshot::apply_compaction(snapshot.clone()); - - // Send ack back to leader via the sync broadcast channel. - // The ack is sent as a CRDT event that the streaming loop picks up. - // For now, log the ack intent — actual transport is handled by the - // caller that invokes handle_incoming_text. - slog!( - "[crdt-sync] Snapshot applied, ack for at_seq={}", - snapshot.at_seq - ); - } - crdt_snapshot::SnapshotMessage::SnapshotAck(ack) => { - if let Some(node_id) = crdt_state::our_node_id() { - let _ = node_id; // The ack comes from a peer, not from us. - } - slog!( - "[crdt-sync] Received snapshot_ack for at_seq={}", - ack.at_seq - ); - // Record the ack — the coordination logic checks for quorum. - // Note: we don't know the peer's node_id from the message alone; - // in a full implementation the ack would include the sender's - // node_id. For now we log it for protocol completeness. - } - } -} - -/// Process an incoming binary-frame op from a peer. -/// -/// Binary frames carry a single `SignedOp` encoded via [`crdt_wire`]. -fn handle_incoming_binary(bytes: &[u8]) { - match crdt_wire::decode(bytes) { - Ok(signed_op) => { - crdt_state::apply_remote_op(signed_op); - } - Err(e) => { - slog!("[crdt-sync] Bad binary frame from peer: {e}"); - } - } -} - -// ── Rendezvous client ─────────────────────────────────────────────── - -/// Number of consecutive connection failures before escalating from WARN to ERROR. -pub const RENDEZVOUS_ERROR_THRESHOLD: u32 = 10; - -/// Spawn a background task that connects to the configured rendezvous -/// peer and exchanges CRDT ops bidirectionally. -/// -/// The client reconnects with exponential backoff if the connection drops. -/// Individual failures are logged at WARN; after [`RENDEZVOUS_ERROR_THRESHOLD`] -/// consecutive failures the log level escalates to ERROR. -/// -/// When `token` is provided it is appended to the upgrade URL as -/// `?token=` so the server's bearer-token check is satisfied. This -/// reuses the existing `--join-token` / `HUSKIES_JOIN_TOKEN` plumbing on the -/// agent side. -pub fn spawn_rendezvous_client(url: String, token: Option) { - tokio::spawn(async move { - let mut backoff_secs = 1u64; - let mut consecutive_failures: u32 = 0; - loop { - slog!("[crdt-sync] Connecting to rendezvous peer: {url}"); - match connect_and_sync(&url, token.as_deref()).await { - Ok(()) => { - slog!("[crdt-sync] Rendezvous connection closed cleanly"); - backoff_secs = 1; - consecutive_failures = 0; - } - Err(e) => { - consecutive_failures += 1; - if consecutive_failures >= RENDEZVOUS_ERROR_THRESHOLD { - slog_error!( - "[crdt-sync] Rendezvous peer unreachable ({consecutive_failures} consecutive failures): {e}" - ); - } else { - slog_warn!( - "[crdt-sync] Rendezvous connection error (attempt {consecutive_failures}): {e}" - ); - } - } - } - slog!("[crdt-sync] Reconnecting in {backoff_secs}s..."); - tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await; - backoff_secs = (backoff_secs * 2).min(30); - } - }); -} - -/// Connect to a remote sync endpoint and exchange ops until disconnect. -/// -/// When `token` is supplied it is appended as `?token=` to the -/// connection URL so the server's bearer-token check passes. -pub(crate) async fn connect_and_sync(url: &str, token: Option<&str>) -> Result<(), String> { - let connect_url = match token { - Some(t) => { - if url.contains('?') { - format!("{url}&token={t}") - } else { - format!("{url}?token={t}") - } - } - None => url.to_string(), - }; - let (ws_stream, _) = tokio_tungstenite::connect_async(connect_url.as_str()) - .await - .map_err(|e| format!("WebSocket connect failed: {e}"))?; - - let (mut sink, mut stream) = ws_stream.split(); - - slog!("[crdt-sync] Connected to rendezvous peer, awaiting challenge"); - - // ── Step 1: Receive challenge from listener ─────────────────── - use tokio_tungstenite::tungstenite::Message as TungsteniteMsg; - - let challenge_frame = tokio::time::timeout( - std::time::Duration::from_secs(AUTH_TIMEOUT_SECS), - stream.next(), - ) - .await - .map_err(|_| "Auth timeout waiting for challenge".to_string())? - .ok_or_else(|| "Connection closed before challenge".to_string())? - .map_err(|e| format!("WebSocket read error: {e}"))?; - - let challenge_text = match challenge_frame { - TungsteniteMsg::Text(t) => t.to_string(), - _ => return Err("Expected text frame for challenge".to_string()), - }; - - let challenge_msg: ChallengeMessage = serde_json::from_str(&challenge_text) - .map_err(|e| format!("Invalid challenge message: {e}"))?; - - if challenge_msg.r#type != "challenge" { - return Err(format!( - "Expected challenge message, got type={}", - challenge_msg.r#type - )); - } - - // ── Step 2: Sign challenge and send auth reply ──────────────── - let (pubkey_hex, signature_hex) = crdt_state::sign_challenge(&challenge_msg.nonce) - .ok_or_else(|| "CRDT not initialised — cannot sign challenge".to_string())?; - - let auth_msg = AuthMessage { - r#type: "auth".to_string(), - pubkey_hex, - signature_hex, - }; - let auth_json = serde_json::to_string(&auth_msg).map_err(|e| format!("Serialize auth: {e}"))?; - sink.send(TungsteniteMsg::Text(auth_json.into())) - .await - .map_err(|e| format!("Send auth failed: {e}"))?; - - slog!("[crdt-sync] Auth reply sent, waiting for sync data"); - - // v2 protocol: send our vector clock. - let our_clock = crdt_state::our_vector_clock().unwrap_or_default(); - let clock_msg = SyncMessage::Clock { clock: our_clock }; - if let Ok(json) = serde_json::to_string(&clock_msg) { - sink.send(TungsteniteMsg::Text(json.into())) - .await - .map_err(|e| format!("Send clock failed: {e}"))?; - } - - // Wait for the server's first sync message. - let first_msg = tokio::time::timeout( - std::time::Duration::from_secs(AUTH_TIMEOUT_SECS), - wait_for_rendezvous_sync_text(&mut stream), - ) - .await - .map_err(|_| "Timeout waiting for server sync message".to_string())?; - - match first_msg { - Some(SyncMessage::Clock { clock: peer_clock }) => { - // v2 server — send only the ops the server is missing. - let delta = crdt_state::ops_since(&peer_clock).unwrap_or_default(); - slog!( - "[crdt-sync] v2 delta sync: sending {} ops to server (server missing)", - delta.len() - ); - let msg = SyncMessage::Bulk { ops: delta }; - if let Ok(json) = serde_json::to_string(&msg) { - sink.send(TungsteniteMsg::Text(json.into())) - .await - .map_err(|e| format!("Send delta failed: {e}"))?; - } - } - Some(SyncMessage::Bulk { ops }) => { - // v1 server — apply their bulk and send our full bulk. - let mut applied = 0u64; - for op_json in &ops { - if let Ok(signed_op) = serde_json::from_str::(op_json) - && crdt_state::apply_remote_op(signed_op) - { - applied += 1; - } - } - slog!( - "[crdt-sync] v1 bulk sync: received {} ops from server, applied {applied}", - ops.len() - ); - if let Some(all) = crdt_state::all_ops_json() { - let msg = SyncMessage::Bulk { ops: all }; - if let Ok(json) = serde_json::to_string(&msg) { - sink.send(TungsteniteMsg::Text(json.into())) - .await - .map_err(|e| format!("Send bulk failed: {e}"))?; - } - } - } - _ => { - // Fallback — send full bulk. - slog!("[crdt-sync] No sync message from server; sending full bulk as fallback"); - if let Some(all) = crdt_state::all_ops_json() { - let msg = SyncMessage::Bulk { ops: all }; - if let Ok(json) = serde_json::to_string(&msg) { - sink.send(TungsteniteMsg::Text(json.into())) - .await - .map_err(|e| format!("Send bulk failed: {e}"))?; - } - } - } - } - - // Bulk-delta phase complete — signal the server that we are ready for - // real-time op streaming. - if let Ok(json) = serde_json::to_string(&SyncMessage::Ready) { - sink.send(TungsteniteMsg::Text(json.into())) - .await - .map_err(|e| format!("Send ready failed: {e}"))?; - } - - // Subscribe to new local ops. - let Some(mut op_rx) = crdt_state::subscribe_ops() else { - return Err("CRDT not initialised".to_string()); - }; - - // Buffer for locally-generated ops produced before the server's `ready` - // arrives. Flushed in-order once the server signals catch-up. - let mut peer_ready = false; - let mut op_buffer: Vec = Vec::new(); - - // ── Keepalive state ─────────────────────────────────────────────── - let mut pong_deadline = - tokio::time::Instant::now() + std::time::Duration::from_secs(PONG_TIMEOUT_SECS); - let mut ping_ticker = tokio::time::interval_at( - tokio::time::Instant::now() + std::time::Duration::from_secs(PING_INTERVAL_SECS), - std::time::Duration::from_secs(PING_INTERVAL_SECS), - ); - - loop { - tokio::select! { - // Send periodic Ping and enforce Pong timeout. - _ = ping_ticker.tick() => { - if tokio::time::Instant::now() >= pong_deadline { - slog_warn!( - "[crdt-sync] No pong from rendezvous peer {} in {}s; disconnecting", - url, - PONG_TIMEOUT_SECS - ); - return Err(format!( - "Keepalive timeout: no pong from {url} in {PONG_TIMEOUT_SECS}s" - )); - } - use tokio_tungstenite::tungstenite::Message as TungsteniteMsg; - if sink.send(TungsteniteMsg::Ping(bytes::Bytes::new())).await.is_err() { - break; - } - } - result = op_rx.recv() => { - match result { - Ok(signed_op) => { - if peer_ready { - // Encode via wire codec and send as binary frame. - let bytes = crdt_wire::encode(&signed_op); - use tokio_tungstenite::tungstenite::Message as TungsteniteMsg; - if sink.send(TungsteniteMsg::Binary(bytes.into())).await.is_err() { - break; - } - } else { - // Buffer until the server signals ready. - op_buffer.push(signed_op); - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - slog!("[crdt-sync] Slow rendezvous link lagged {n} ops; disconnecting"); - break; - } - Err(_) => break, - } - } - frame = stream.next() => { - match frame { - Some(Ok(tokio_tungstenite::tungstenite::Message::Pong(_))) => { - // Reset the pong deadline on every Pong received. - pong_deadline = tokio::time::Instant::now() - + std::time::Duration::from_secs(PONG_TIMEOUT_SECS); - } - Some(Ok(tokio_tungstenite::tungstenite::Message::Ping(_))) => { - // tungstenite auto-responds to Ping with Pong at the - // protocol level; no manual response needed here. - } - Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text))) => { - // Check for the ready signal before other text frames. - if let Ok(SyncMessage::Ready) = serde_json::from_str(text.as_ref()) { - peer_ready = true; - slog!("[crdt-sync] Server ready; flushing {} buffered ops", op_buffer.len()); - let mut flush_ok = true; - for op in op_buffer.drain(..) { - let bytes = crdt_wire::encode(&op); - use tokio_tungstenite::tungstenite::Message as TungsteniteMsg; - if sink.send(TungsteniteMsg::Binary(bytes.into())).await.is_err() { - flush_ok = false; - break; - } - } - if !flush_ok { - break; - } - } else { - handle_incoming_text(text.as_ref()); - } - } - Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(bytes))) => { - // Real-time op — applied immediately regardless of ready state. - handle_incoming_binary(&bytes); - } - Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) | None => break, - Some(Err(e)) => { - slog!("[crdt-sync] Rendezvous read error: {e}"); - break; - } - _ => {} - } - } - } - } - - Ok(()) -} - -/// Wait for the next text-frame sync message from a tungstenite stream, -/// handling Ping/Pong transparently. -/// -/// Returns `None` on connection close or read error. -async fn wait_for_rendezvous_sync_text( - stream: &mut futures::stream::SplitStream< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - >, -) -> Option { - use tokio_tungstenite::tungstenite::Message as TungsteniteMsg; - loop { - match stream.next().await { - Some(Ok(TungsteniteMsg::Text(text))) => { - return serde_json::from_str(text.as_ref()).ok(); - } - Some(Ok(TungsteniteMsg::Ping(_) | TungsteniteMsg::Pong(_))) => continue, - _ => return None, - } - } -} - -// ── Tests ──────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sync_message_bulk_serialization_roundtrip() { - let msg = SyncMessage::Bulk { - ops: vec!["op1".to_string(), "op2".to_string()], - }; - let json = serde_json::to_string(&msg).unwrap(); - assert!(json.contains(r#""type":"bulk""#)); - let deserialized: SyncMessage = serde_json::from_str(&json).unwrap(); - match deserialized { - SyncMessage::Bulk { ops } => { - assert_eq!(ops.len(), 2); - assert_eq!(ops[0], "op1"); - assert_eq!(ops[1], "op2"); - } - _ => panic!("Expected Bulk"), - } - } - - #[test] - fn sync_message_op_serialization_roundtrip() { - let msg = SyncMessage::Op { - op: r#"{"inner":{}}"#.to_string(), - }; - let json = serde_json::to_string(&msg).unwrap(); - assert!(json.contains(r#""type":"op""#)); - let deserialized: SyncMessage = serde_json::from_str(&json).unwrap(); - match deserialized { - SyncMessage::Op { op } => { - assert_eq!(op, r#"{"inner":{}}"#); - } - _ => panic!("Expected Op"), - } - } - - #[test] - fn handle_incoming_text_bad_json_does_not_panic() { - handle_incoming_text("not valid json"); - } - - #[test] - fn handle_incoming_text_bulk_with_invalid_ops_does_not_panic() { - let msg = SyncMessage::Bulk { - ops: vec!["not a valid signed op".to_string()], - }; - let json = serde_json::to_string(&msg).unwrap(); - handle_incoming_text(&json); - } - - #[test] - fn handle_incoming_text_op_with_invalid_op_does_not_panic() { - let msg = SyncMessage::Op { - op: "garbage".to_string(), - }; - let json = serde_json::to_string(&msg).unwrap(); - handle_incoming_text(&json); - } - - #[test] - fn handle_incoming_binary_bad_bytes_does_not_panic() { - handle_incoming_binary(b"not valid wire codec"); - } - - #[test] - fn handle_incoming_binary_empty_bytes_does_not_panic() { - handle_incoming_binary(b""); - } - - #[test] - fn subscribe_ops_returns_none_before_init() { - // Before crdt_state::init() the channel doesn't exist yet. - // In test binaries it may or may not be initialised depending on - // other tests, so we just verify no panic. - let _ = crdt_state::subscribe_ops(); - } - - #[test] - fn all_ops_json_returns_none_before_init() { - let _ = crdt_state::all_ops_json(); - } - - #[test] - fn sync_message_bulk_empty_ops() { - let msg = SyncMessage::Bulk { ops: vec![] }; - let json = serde_json::to_string(&msg).unwrap(); - let deserialized: SyncMessage = serde_json::from_str(&json).unwrap(); - match deserialized { - SyncMessage::Bulk { ops } => assert!(ops.is_empty()), - _ => panic!("Expected Bulk"), - } - } - - /// Simulate the sync protocol by creating real SignedOps on two separate - /// CRDT instances and exchanging them through the SyncMessage wire format. - #[test] - fn two_node_sync_via_protocol_messages() { - use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, OpState}; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use serde_json::json; - - use crate::crdt_state::PipelineDoc; - - // ── Node A: create an item ── - let kp_a = make_keypair(); - let mut crdt_a = BaseCrdt::::new(&kp_a); - - let item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "100_story_sync_test", - "stage": "1_backlog", - "name": "Sync Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op1 = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp_a); - assert_eq!(crdt_a.apply(op1.clone()), OpState::Ok); - - // Serialise op1 into a SyncMessage::Op. - let op1_json = serde_json::to_string(&op1).unwrap(); - let wire_msg = SyncMessage::Op { - op: op1_json.clone(), - }; - let wire_json = serde_json::to_string(&wire_msg).unwrap(); - - // ── Node B: receive the op through protocol ── - let kp_b = make_keypair(); - let mut crdt_b = BaseCrdt::::new(&kp_b); - assert!(crdt_b.doc.items.view().is_empty()); - - // Parse wire message and apply. - let parsed: SyncMessage = serde_json::from_str(&wire_json).unwrap(); - match parsed { - SyncMessage::Op { op } => { - let signed_op: bft_json_crdt::json_crdt::SignedOp = - serde_json::from_str(&op).unwrap(); - let result = crdt_b.apply(signed_op); - assert_eq!(result, OpState::Ok); - } - _ => panic!("Expected Op"), - } - - // Verify Node B has the same state as Node A. - assert_eq!(crdt_b.doc.items.view().len(), 1); - assert_eq!( - crdt_a.doc.items[0].story_id.view(), - crdt_b.doc.items[0].story_id.view() - ); - assert_eq!( - crdt_a.doc.items[0].stage.view(), - crdt_b.doc.items[0].stage.view() - ); - - // ── Node A: update stage ── - let op2 = crdt_a.doc.items[0] - .stage - .set("2_current".to_string()) - .sign(&kp_a); - crdt_a.apply(op2.clone()); - - // Send via bulk message. - let op2_json = serde_json::to_string(&op2).unwrap(); - let bulk_msg = SyncMessage::Bulk { - ops: vec![op1_json, op2_json], - }; - let bulk_wire = serde_json::to_string(&bulk_msg).unwrap(); - - // ── Node C: receives full state via bulk ── - let kp_c = make_keypair(); - let mut crdt_c = BaseCrdt::::new(&kp_c); - - let parsed_bulk: SyncMessage = serde_json::from_str(&bulk_wire).unwrap(); - match parsed_bulk { - SyncMessage::Bulk { ops } => { - for op_str in &ops { - let signed: bft_json_crdt::json_crdt::SignedOp = - serde_json::from_str(op_str).unwrap(); - crdt_c.apply(signed); - } - } - _ => panic!("Expected Bulk"), - } - - // Node C should have the updated stage. - assert_eq!(crdt_c.doc.items.view().len(), 1); - assert_eq!( - crdt_c.doc.items[0].stage.view(), - bft_json_crdt::json_crdt::JsonValue::String("2_current".to_string()) - ); - } - - /// Verify that a single node's ops (insert + update) can be replayed - /// on another node via bulk sync and produce the same final state. - /// This is the core property needed for partition healing: when a - /// disconnected node reconnects, it sends all its ops as a bulk - /// message and the receiver catches up. - #[test] - fn partition_heal_via_bulk_replay() { - use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV}; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use serde_json::json; - - use crate::crdt_state::PipelineDoc; - - let kp = make_keypair(); - - // Node A creates an item and advances it. - let mut crdt_a = BaseCrdt::::new(&kp); - let item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "200_story_heal", - "stage": "1_backlog", - "name": "Heal Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - - let op1 = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp); - crdt_a.apply(op1.clone()); - - let op2 = crdt_a.doc.items[0] - .stage - .set("2_current".to_string()) - .sign(&kp); - crdt_a.apply(op2.clone()); - - let op3 = crdt_a.doc.items[0].stage.set("3_qa".to_string()).sign(&kp); - crdt_a.apply(op3.clone()); - - // Serialise all ops as a bulk message (simulates partition heal). - let ops_json: Vec = [&op1, &op2, &op3] - .iter() - .map(|op| serde_json::to_string(op).unwrap()) - .collect(); - let bulk = SyncMessage::Bulk { ops: ops_json }; - let wire = serde_json::to_string(&bulk).unwrap(); - - // Node B receives the bulk and reconstructs state. - let mut crdt_b = BaseCrdt::::new(&kp); - let parsed: SyncMessage = serde_json::from_str(&wire).unwrap(); - match parsed { - SyncMessage::Bulk { ops } => { - for op_str in &ops { - let signed: bft_json_crdt::json_crdt::SignedOp = - serde_json::from_str(op_str).unwrap(); - crdt_b.apply(signed); - } - } - _ => panic!("Expected Bulk"), - } - - // Node B should match Node A exactly. - assert_eq!(crdt_b.doc.items.view().len(), 1); - assert_eq!( - crdt_b.doc.items[0].stage.view(), - JV::String("3_qa".to_string()) - ); - assert_eq!( - crdt_a.doc.items[0].stage.view(), - crdt_b.doc.items[0].stage.view() - ); - assert_eq!( - crdt_a.doc.items[0].name.view(), - crdt_b.doc.items[0].name.view() - ); - } - - #[test] - fn config_rendezvous_parsed_from_toml() { - let toml_str = r#" -rendezvous = "ws://remote:3001/crdt-sync" - -[[agent]] -name = "test" -"#; - let config: crate::config::ProjectConfig = toml::from_str(toml_str).unwrap(); - assert_eq!( - config.rendezvous.as_deref(), - Some("ws://remote:3001/crdt-sync") - ); - } - - #[test] - fn config_rendezvous_defaults_to_none() { - let config = crate::config::ProjectConfig::default(); - assert!(config.rendezvous.is_none()); - } - - // ── AC4: Failure logging escalation ────────────────────────────────────── - - /// AC4: Connection errors must be logged at WARN for the first nine - /// consecutive failures and escalate to ERROR from the tenth onwards. - #[test] - fn failure_counter_warn_below_threshold() { - let threshold = super::RENDEZVOUS_ERROR_THRESHOLD; - let mut consecutive_failures: u32 = 0; - - // First threshold-1 failures are below the ERROR threshold. - for _ in 0..(threshold - 1) { - consecutive_failures += 1; - assert!( - consecutive_failures < threshold, - "failure {consecutive_failures} must be below ERROR threshold {threshold}" - ); - } - } - - /// AC4: The tenth consecutive failure must trigger ERROR-level logging. - #[test] - fn failure_counter_error_at_threshold() { - let threshold = super::RENDEZVOUS_ERROR_THRESHOLD; - let consecutive_failures: u32 = threshold; - assert!( - consecutive_failures >= threshold, - "failure {consecutive_failures} must reach or exceed ERROR threshold {threshold}" - ); - } - - /// AC4: A successful connection resets the failure counter so subsequent - /// single failures are again logged at WARN (not ERROR). - #[test] - fn failure_counter_resets_on_success() { - let threshold = super::RENDEZVOUS_ERROR_THRESHOLD; - // Simulate sustained failure. - let mut consecutive_failures: u32 = threshold + 5; - assert!(consecutive_failures >= threshold); - - // Simulate a clean reconnect. - consecutive_failures = 0; - assert_eq!( - consecutive_failures, 0, - "counter must reset to 0 on success" - ); - - // Next error is attempt 1 — well below the ERROR threshold. - consecutive_failures += 1; - assert!( - consecutive_failures < threshold, - "first failure after reset must be below ERROR threshold" - ); - } - - /// AC4: The RENDEZVOUS_ERROR_THRESHOLD constant must equal 10. - #[test] - fn error_threshold_is_ten() { - assert_eq!( - super::RENDEZVOUS_ERROR_THRESHOLD, - 10, - "ERROR escalation threshold must be 10 consecutive failures" - ); - } - - // ── AC5: Self-loop dedup ────────────────────────────────────────────────── - - /// AC5: Applying the same SignedOp twice returns AlreadySeen on the second - /// call and leaves the CRDT state unchanged. - #[test] - fn self_loop_dedup_second_apply_is_noop() { - use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV, OpState}; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use serde_json::json; - - use crate::crdt_state::PipelineDoc; - - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - - let item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "507_dedup_test", - "stage": "1_backlog", - "name": "Dedup Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - - let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); - - // First apply: succeeds. - assert_eq!(crdt.apply(op.clone()), OpState::Ok); - assert_eq!(crdt.doc.items.view().len(), 1); - - // Second apply (self-loop): must be a no-op. - assert_eq!(crdt.apply(op.clone()), OpState::AlreadySeen); - - // State must not have changed. - assert_eq!(crdt.doc.items.view().len(), 1); - - // Stage update also deduplicated correctly. - let stage_op = crdt.doc.items[0] - .stage - .set("2_current".to_string()) - .sign(&kp); - assert_eq!(crdt.apply(stage_op.clone()), OpState::Ok); - assert_eq!( - crdt.doc.items[0].stage.view(), - JV::String("2_current".to_string()) - ); - assert_eq!(crdt.apply(stage_op), OpState::AlreadySeen); - assert_eq!( - crdt.doc.items[0].stage.view(), - JV::String("2_current".to_string()), - "stage must not change on duplicate apply" - ); - } - - // ── AC3 & AC7: Out-of-order causal queueing ─────────────────────────────── - - /// AC3/AC7: An op whose causal dependency has not yet arrived is held in the - /// queue (returns MissingCausalDependencies). When the dependency arrives - /// the queued op is released and applied automatically. - #[test] - fn out_of_order_causal_queueing_releases_on_dep_arrival() { - use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV, OpState}; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use serde_json::json; - - use crate::crdt_state::PipelineDoc; - - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - - let item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "507_causal_test", - "stage": "1_backlog", - "name": "Causal Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - - // op1 = insert item (no deps) - let op1 = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); - - // op2 = set stage, declared to depend on op1 - // We must first apply op1 locally to generate op2 from the right state, - // then we'll test op2-before-op1 on a fresh CRDT. - crdt.apply(op1.clone()); - let op2 = crdt.doc.items[0] - .stage - .set("2_current".to_string()) - .sign_with_dependencies(&kp, vec![&op1]); - - // Create a fresh receiver CRDT. - let mut receiver = BaseCrdt::::new(&kp); - - // Apply op2 first — dependency (op1) has not arrived yet. - let r = receiver.apply(op2.clone()); - assert_eq!( - r, - OpState::MissingCausalDependencies, - "op2 must be queued when op1 has not arrived" - ); - // Queue length must reflect the held op. - assert_eq!(receiver.causal_queue_len(), 1); - - // Item has NOT been inserted yet (op1 not applied). - assert_eq!(receiver.doc.items.view().len(), 0); - - // Now deliver op1 — this should trigger op2 to be flushed automatically. - let r = receiver.apply(op1.clone()); - assert_eq!(r, OpState::Ok); - - // Both ops are now applied — item is present at stage 2_current. - assert_eq!(receiver.doc.items.view().len(), 1); - assert_eq!( - receiver.doc.items[0].stage.view(), - JV::String("2_current".to_string()), - "op2 must have been applied automatically after op1 arrived" - ); - - // Queue must be empty now. - assert_eq!(receiver.causal_queue_len(), 0); - } - - /// AC7: In-order apply works correctly (no causal queueing needed). - #[test] - fn in_order_apply_works_without_queueing() { - use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV, OpState}; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use serde_json::json; - - use crate::crdt_state::PipelineDoc; - - let kp = make_keypair(); - let mut crdt_a = BaseCrdt::::new(&kp); - - let item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "507_inorder_test", - "stage": "1_backlog", - "name": "In-Order Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - - let op1 = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp); - crdt_a.apply(op1.clone()); - let op2 = crdt_a.doc.items[0] - .stage - .set("2_current".to_string()) - .sign(&kp); - crdt_a.apply(op2.clone()); - let op3 = crdt_a.doc.items[0].stage.set("3_qa".to_string()).sign(&kp); - crdt_a.apply(op3.clone()); - - // Receiver applies all ops in the correct order. - let mut crdt_b = BaseCrdt::::new(&kp); - assert_eq!(crdt_b.apply(op1), OpState::Ok); - assert_eq!(crdt_b.apply(op2), OpState::Ok); - assert_eq!(crdt_b.apply(op3), OpState::Ok); - assert_eq!(crdt_b.causal_queue_len(), 0); - assert_eq!( - crdt_b.doc.items[0].stage.view(), - JV::String("3_qa".to_string()) - ); - } - - // ── AC4: Queue overflow behaviour ───────────────────────────────────────── - - /// AC4: When the causal-order queue exceeds CAUSAL_QUEUE_MAX the oldest - /// pending op is evicted (queue never grows beyond the cap). - #[test] - fn causal_queue_overflow_drops_oldest() { - use bft_json_crdt::json_crdt::{BaseCrdt, CAUSAL_QUEUE_MAX, OpState}; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use serde_json::json; - - use crate::crdt_state::PipelineDoc; - - let kp = make_keypair(); - - // Build one "phantom" op that we'll claim as a dependency but never deliver. - // We do this by creating it on a separate CRDT and never applying it. - let mut source = BaseCrdt::::new(&kp); - let phantom_item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "507_phantom", - "stage": "1_backlog", - "name": "Phantom", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let phantom_op = source.doc.items.insert(ROOT_ID, phantom_item).sign(&kp); - - // Receiver never sees phantom_op, so any op declaring it as a dep will - // sit in the causal queue forever (until evicted by overflow). - let mut receiver = BaseCrdt::::new(&kp); - source.apply(phantom_op.clone()); - - // Send CAUSAL_QUEUE_MAX + 5 stage-update ops all depending on phantom_op. - // Each one will be queued because phantom_op is never delivered. - let mut queued = 0usize; - for i in 0..CAUSAL_QUEUE_MAX + 5 { - let stage_name = format!("stage_{i}"); - // Generate from source so seq numbers are valid. - let op = source.doc.items[0] - .stage - .set(stage_name) - .sign_with_dependencies(&kp, vec![&phantom_op]); - source.apply(op.clone()); - let r = receiver.apply(op); - if r == OpState::MissingCausalDependencies { - queued += 1; - } - } - - // We sent more than CAUSAL_QUEUE_MAX ops, but the queue must stay bounded. - assert!( - receiver.causal_queue_len() <= CAUSAL_QUEUE_MAX, - "queue ({}) must not exceed CAUSAL_QUEUE_MAX ({CAUSAL_QUEUE_MAX})", - receiver.causal_queue_len() - ); - assert!( - queued > 0, - "at least some ops must have been accepted into the queue" - ); - } - - // ── AC6: Convergence test ───────────────────────────────────────────────── - - /// AC6: Two CRDT instances generate interleaved ops on each side, simulate a - /// network partition by withholding each other's ops, then exchange all - /// buffered ops. Final state must be byte-identical on both nodes. - #[test] - fn convergence_after_partition_and_replay() { - use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV, OpState}; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use serde_json::json; - - use crate::crdt_state::PipelineDoc; - - let kp_a = make_keypair(); - let kp_b = make_keypair(); - - let mut crdt_a = BaseCrdt::::new(&kp_a); - let mut crdt_b = BaseCrdt::::new(&kp_b); - - // ── Phase 1: A generates ops while partitioned from B ────────────── - - let item_a: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "507_convergence_a", - "stage": "1_backlog", - "name": "Story A", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op_a1 = crdt_a.doc.items.insert(ROOT_ID, item_a).sign(&kp_a); - crdt_a.apply(op_a1.clone()); - - let op_a2 = crdt_a.doc.items[0] - .stage - .set("2_current".to_string()) - .sign(&kp_a); - crdt_a.apply(op_a2.clone()); - - // ── Phase 2: B generates ops while partitioned from A ────────────── - - let item_b: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "507_convergence_b", - "stage": "1_backlog", - "name": "Story B", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op_b1 = crdt_b.doc.items.insert(ROOT_ID, item_b).sign(&kp_b); - crdt_b.apply(op_b1.clone()); - - let op_b2 = crdt_b.doc.items[0] - .stage - .set("2_current".to_string()) - .sign(&kp_b); - crdt_b.apply(op_b2.clone()); - - // ── Phase 3: Reconnect — both sides replay all buffered ops ──────── - - // A sends its ops to B. - let r = crdt_b.apply(op_a1.clone()); - assert!(r == OpState::Ok || r == OpState::AlreadySeen); - let r = crdt_b.apply(op_a2.clone()); - assert!(r == OpState::Ok || r == OpState::AlreadySeen); - - // B sends its ops to A. - let r = crdt_a.apply(op_b1.clone()); - assert!(r == OpState::Ok || r == OpState::AlreadySeen); - let r = crdt_a.apply(op_b2.clone()); - assert!(r == OpState::Ok || r == OpState::AlreadySeen); - - // ── Phase 4: Assert convergence ──────────────────────────────────── - - // Both nodes must have both stories. - assert_eq!( - crdt_a.doc.items.view().len(), - 2, - "A must have 2 items after convergence" - ); - assert_eq!( - crdt_b.doc.items.view().len(), - 2, - "B must have 2 items after convergence" - ); - - // Serialise both CRDT views to JSON and assert byte-identical. - let view_a = serde_json::to_string(&CrdtNode::view(&crdt_a.doc.items)).unwrap(); - let view_b = serde_json::to_string(&CrdtNode::view(&crdt_b.doc.items)).unwrap(); - assert_eq!( - view_a, view_b, - "CRDT states must be byte-identical after convergence" - ); - - // Spot-check: both stories are at 2_current on both nodes. - let stories_a: Vec = crdt_a - .doc - .items - .view() - .iter() - .filter_map(|item| { - if let JV::Object(m) = CrdtNode::view(item) { - m.get("story_id").and_then(|s| { - if let JV::String(s) = s { - Some(s.clone()) - } else { - None - } - }) - } else { - None - } - }) - .collect(); - assert!( - stories_a.contains(&"507_convergence_a".to_string()), - "A must contain story_a" - ); - assert!( - stories_a.contains(&"507_convergence_b".to_string()), - "A must contain story_b" - ); - } - - // ── AC8: peer lifecycle tests ───────────────────────────────────────────── - - /// AC8: A peer that connects and then receives a subsequently-applied op - /// gets that op encoded via the wire codec (binary frame). - #[test] - fn peer_receives_op_encoded_via_wire_codec() { - use bft_json_crdt::json_crdt::BaseCrdt; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use serde_json::json; - - use crate::crdt_state::PipelineDoc; - use crate::crdt_wire; - - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - let item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "506_story_lifecycle_test", - "stage": "1_backlog", - "name": "Lifecycle Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); - - // Simulate what the broadcast handler does: encode via wire codec. - let bytes = crdt_wire::encode(&op); - - // The bytes must be a versioned JSON envelope, not a SyncMessage wrapper. - let text = std::str::from_utf8(&bytes).expect("wire output is valid UTF-8"); - assert!( - text.contains("\"v\":1"), - "wire codec version tag must be present: {text}" - ); - assert!( - !text.contains("\"type\":\"op\""), - "must not be wrapped in SyncMessage: {text}" - ); - - // The receiving peer can decode and apply the op. - let decoded = crdt_wire::decode(&bytes).expect("decode must succeed"); - assert_eq!(op, decoded); - } - - /// AC8: Multiple connected peers all receive the same broadcast op. - #[tokio::test] - async fn multiple_peers_all_receive_broadcast_op() { - use bft_json_crdt::json_crdt::BaseCrdt; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use serde_json::json; - use tokio::sync::broadcast; - - use crate::crdt_state::PipelineDoc; - use crate::crdt_wire; - - // Create a broadcast channel (analogous to SYNC_TX). - let (tx, _) = broadcast::channel::(16); - let mut rx_peer1 = tx.subscribe(); - let mut rx_peer2 = tx.subscribe(); - - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - let item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "506_story_multi_peer_test", - "stage": "1_backlog", - "name": "Multi-Peer Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); - - // Broadcast one op. - tx.send(op.clone()).expect("send must succeed"); - - // Both peers receive the same op. - let received1 = rx_peer1.recv().await.expect("peer 1 must receive"); - let received2 = rx_peer2.recv().await.expect("peer 2 must receive"); - assert_eq!(received1, op); - assert_eq!(received2, op); - - // Both encode identically via wire codec. - let bytes1 = crdt_wire::encode(&received1); - let bytes2 = crdt_wire::encode(&received2); - assert_eq!(bytes1, bytes2, "wire-encoded bytes must be identical"); - } - - /// AC8: A peer disconnecting mid-broadcast does not panic. - /// Simulated by dropping the receiver before the sender sends an op. - #[test] - fn disconnected_peer_does_not_panic() { - use bft_json_crdt::json_crdt::BaseCrdt; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use serde_json::json; - use tokio::sync::broadcast; - - use crate::crdt_state::PipelineDoc; - - let (tx, rx) = broadcast::channel::(16); - // Drop the receiver to simulate a peer that disconnected. - drop(rx); - - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - let item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "506_story_disconnect_test", - "stage": "1_backlog", - "name": "Disconnect Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); - - // Sending to a channel with no receivers returns an error; must not panic. - let _ = tx.send(op); - } - - /// AC8: A lagged receiver gets a `Lagged` error (confirming the - /// disconnect-on-overflow behaviour is reachable). - #[tokio::test] - async fn lagged_peer_gets_lagged_error() { - use bft_json_crdt::json_crdt::BaseCrdt; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use serde_json::json; - use tokio::sync::broadcast; - - use crate::crdt_state::PipelineDoc; - - // Tiny capacity so we can trigger Lagged easily. - let (tx, mut rx) = broadcast::channel::(2); - - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - let item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "506_story_lag_test", - "stage": "1_backlog", - "name": "Lag Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op1 = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); - crdt.apply(op1.clone()); - - // Overflow the tiny buffer by sending more ops than the capacity. - let op2 = crdt.doc.items[0] - .stage - .set("2_current".to_string()) - .sign(&kp); - crdt.apply(op2.clone()); - let op3 = crdt.doc.items[0].stage.set("3_qa".to_string()).sign(&kp); - crdt.apply(op3.clone()); - let op4 = crdt.doc.items[0].stage.set("4_merge".to_string()).sign(&kp); - crdt.apply(op4.clone()); - - // Send more ops than the channel capacity without consuming. - let _ = tx.send(op1); - let _ = tx.send(op2); - let _ = tx.send(op3); - let _ = tx.send(op4); - - // The slow peer should now see a Lagged error on next recv. - // Consume until we hit Lagged or run out. - let mut got_lagged = false; - for _ in 0..10 { - match rx.recv().await { - Err(broadcast::error::RecvError::Lagged(_)) => { - got_lagged = true; - break; - } - Ok(_) => continue, - Err(broadcast::error::RecvError::Closed) => break, - } - } - assert!( - got_lagged, - "slow peer must receive a Lagged error when channel overflows" - ); - } - - // ── AC5 (story 508): E2E convergence test via real WebSocket ───────────── - - /// AC5: Spin up two in-process WebSocket nodes. Node A serves a - /// `/crdt-sync`-compatible endpoint; Node B connects as a rendezvous client. - /// Node A sends a bulk state; Node B applies it. Assert both nodes see the - /// same items within a bounded time window. - #[tokio::test] - async fn e2e_convergence_two_websocket_nodes() { - use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV, OpState}; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use futures::{SinkExt, StreamExt}; - use serde_json::json; - use std::sync::{Arc, Mutex}; - use tokio::net::TcpListener; - use tokio_tungstenite::tungstenite::Message as TMsg; - use tokio_tungstenite::{accept_async, connect_async}; - - use crate::crdt_state::PipelineDoc; - - // ── Node A: build local state ────────────────────────────────────── - let kp_a = make_keypair(); - let mut crdt_a = BaseCrdt::::new(&kp_a); - - let item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "508_e2e_convergence", - "stage": "2_current", - "name": "E2E Convergence Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op1 = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp_a); - crdt_a.apply(op1.clone()); - - // Serialise A's full state as a bulk message. - let op1_json = serde_json::to_string(&op1).unwrap(); - let bulk_msg = SyncMessage::Bulk { - ops: vec![op1_json], - }; - let bulk_wire = serde_json::to_string(&bulk_msg).unwrap(); - - // ── Start Node A's WebSocket server on a random port ─────────────── - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - let bulk_to_send = bulk_wire.clone(); - let received_by_a: Arc>> = Arc::new(Mutex::new(vec![])); - let received_by_a_clone = received_by_a.clone(); - - tokio::spawn(async move { - let (tcp_stream, _) = listener.accept().await.unwrap(); - let ws_stream = accept_async(tcp_stream).await.unwrap(); - let (mut sink, mut stream) = ws_stream.split(); - - // Send bulk state to the connecting peer. - sink.send(TMsg::Text(bulk_to_send.into())).await.unwrap(); - - // Also listen for ops sent by the peer. - if let Some(Ok(TMsg::Text(txt))) = stream.next().await { - received_by_a_clone.lock().unwrap().push(txt.to_string()); - } - }); - - // ── Node B: connect to Node A and exchange state ─────────────────── - let kp_b = make_keypair(); - let mut crdt_b = BaseCrdt::::new(&kp_b); - - let url = format!("ws://{addr}"); - let (ws_b, _) = connect_async(&url).await.unwrap(); - let (mut sink_b, mut stream_b) = ws_b.split(); - - // Node B receives bulk from A. - if let Some(Ok(TMsg::Text(txt))) = stream_b.next().await { - let msg: SyncMessage = serde_json::from_str(txt.as_str()).unwrap(); - match msg { - SyncMessage::Bulk { ops } => { - for op_str in &ops { - let signed: bft_json_crdt::json_crdt::SignedOp = - serde_json::from_str(op_str).unwrap(); - let r = crdt_b.apply(signed); - assert!(r == OpState::Ok || r == OpState::AlreadySeen); - } - } - _ => panic!("Expected Bulk from Node A"), - } - } - - // Node B also creates a new op and sends it to A. - let item_b: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "508_e2e_convergence_b", - "stage": "1_backlog", - "name": "E2E Convergence B", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op_b1 = crdt_b.doc.items.insert(ROOT_ID, item_b).sign(&kp_b); - crdt_b.apply(op_b1.clone()); - - let op_b1_json = serde_json::to_string(&op_b1).unwrap(); - let msg_to_a = SyncMessage::Op { op: op_b1_json }; - sink_b - .send(TMsg::Text(serde_json::to_string(&msg_to_a).unwrap().into())) - .await - .unwrap(); - - // Wait a moment for Node A to process. - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - // ── Assert convergence ───────────────────────────────────────────── - - // Node B received Node A's item. - assert_eq!( - crdt_b.doc.items.view().len(), - 2, - "Node B must see both items after sync" - ); - let has_a_item = crdt_b - .doc - .items - .view() - .iter() - .any(|item| item.story_id.view() == JV::String("508_e2e_convergence".to_string())); - assert!(has_a_item, "Node B must have Node A's item"); - - // Node A received Node B's op via the WebSocket. - let a_received = received_by_a.lock().unwrap(); - assert!( - !a_received.is_empty(), - "Node A must have received an op from Node B" - ); - } - - // ── AC6 (story 508): Partition healing E2E via real WebSocket ───────────── - - /// AC6: Two nodes exchange ops, the connection is dropped (partition), each - /// side mutates independently, then they reconnect and the reconnecting - /// client sends a fresh bulk state. Assert both converge to the same final - /// state. - #[tokio::test] - async fn e2e_partition_healing_websocket() { - use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV}; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use futures::{SinkExt, StreamExt}; - use serde_json::json; - use tokio::net::TcpListener; - use tokio_tungstenite::tungstenite::Message as TMsg; - use tokio_tungstenite::{accept_async, connect_async}; - - use crate::crdt_state::PipelineDoc; - - // ── Phase 1: Both nodes start with op_a1 (before partition) ─────── - let kp_a = make_keypair(); - let kp_b = make_keypair(); - let mut crdt_a = BaseCrdt::::new(&kp_a); - let mut crdt_b = BaseCrdt::::new(&kp_b); - - let item_a: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "508_heal_a", - "stage": "1_backlog", - "name": "Heal A", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op_a1 = crdt_a.doc.items.insert(ROOT_ID, item_a).sign(&kp_a); - crdt_a.apply(op_a1.clone()); - // B also starts with op_a1 (shared state before partition). - crdt_b.apply(op_a1.clone()); - - // ── Phase 2: Partition — each side mutates independently ────────── - // A advances its story stage. - let op_a2 = crdt_a.doc.items[0] - .stage - .set("2_current".to_string()) - .sign(&kp_a); - crdt_a.apply(op_a2.clone()); - - // B inserts a new story that A doesn't know about yet. - let item_b: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "508_heal_b", - "stage": "1_backlog", - "name": "Heal B", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op_b1 = crdt_b.doc.items.insert(ROOT_ID, item_b).sign(&kp_b); - crdt_b.apply(op_b1.clone()); - - // Collect B's full state as bulk (what it will send on reconnect). - let b_ops: Vec = [&op_a1, &op_b1] - .iter() - .map(|op| serde_json::to_string(op).unwrap()) - .collect(); - let b_bulk_wire = serde_json::to_string(&SyncMessage::Bulk { ops: b_ops }).unwrap(); - - // Collect A's full state as bulk (what it will send on reconnect). - let a_ops: Vec = [&op_a1, &op_a2] - .iter() - .map(|op| serde_json::to_string(op).unwrap()) - .collect(); - let a_bulk_wire = serde_json::to_string(&SyncMessage::Bulk { ops: a_ops }).unwrap(); - - // ── Phase 3: Reconnect — use a real WebSocket to exchange bulk ──── - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - let a_bulk_to_send = a_bulk_wire.clone(); - let a_received_bulk: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let a_received_clone = a_received_bulk.clone(); - - tokio::spawn(async move { - let (tcp, _) = listener.accept().await.unwrap(); - let ws = accept_async(tcp).await.unwrap(); - let (mut sink, mut stream) = ws.split(); - // A sends its bulk state. - sink.send(TMsg::Text(a_bulk_to_send.into())).await.unwrap(); - // A receives B's bulk state. - if let Some(Ok(TMsg::Text(txt))) = stream.next().await { - *a_received_clone.lock().unwrap() = Some(txt.to_string()); - } - }); - - // B connects, exchanges bulk state. - let (ws_b, _) = connect_async(format!("ws://{addr}")).await.unwrap(); - let (mut sink_b, mut stream_b) = ws_b.split(); - - // B receives A's bulk and applies it. - if let Some(Ok(TMsg::Text(txt))) = stream_b.next().await { - let msg: SyncMessage = serde_json::from_str(txt.as_str()).unwrap(); - if let SyncMessage::Bulk { ops } = msg { - for op_str in &ops { - let signed: bft_json_crdt::json_crdt::SignedOp = - serde_json::from_str(op_str).unwrap(); - let _ = crdt_b.apply(signed); - } - } - } - - // B sends its bulk state to A. - sink_b.send(TMsg::Text(b_bulk_wire.into())).await.unwrap(); - - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - // Apply A's received ops into crdt_a. - if let Some(bulk_str) = a_received_bulk.lock().unwrap().take() { - let msg: SyncMessage = serde_json::from_str(&bulk_str).unwrap(); - if let SyncMessage::Bulk { ops } = msg { - for op_str in &ops { - let signed: bft_json_crdt::json_crdt::SignedOp = - serde_json::from_str(op_str).unwrap(); - let _ = crdt_a.apply(signed); - } - } - } - - // ── Assert convergence ───────────────────────────────────────────── - - // Both nodes must have 2 items. - assert_eq!( - crdt_a.doc.items.view().len(), - 2, - "A must have 2 items after healing" - ); - assert_eq!( - crdt_b.doc.items.view().len(), - 2, - "B must have 2 items after healing" - ); - - // A must see B's story. - let b_story_on_a = crdt_a - .doc - .items - .view() - .iter() - .any(|item| item.story_id.view() == JV::String("508_heal_b".to_string())); - assert!(b_story_on_a, "A must have B's story after healing"); - - // B must see A's stage advance. - let a_story_on_b = crdt_b - .doc - .items - .view() - .iter() - .any(|item| item.story_id.view() == JV::String("508_heal_a".to_string())); - assert!(a_story_on_b, "B must have A's story after healing"); - - // CRDT views must be byte-identical (convergence). - let view_a = serde_json::to_string(&CrdtNode::view(&crdt_a.doc.items)).unwrap(); - let view_b = serde_json::to_string(&CrdtNode::view(&crdt_b.doc.items)).unwrap(); - assert_eq!( - view_a, view_b, - "Both nodes must converge to identical state" - ); - } - - // ── Story 628: Connect-time mutual auth integration tests ──────────── - - /// Helper: run a listener that performs the server-side auth handshake. - /// Returns the TCP listener address so the connector can connect. - /// `trusted_keys` controls which pubkeys the listener accepts. - /// If `on_authenticated` is provided, it runs after successful auth. - async fn start_auth_listener( - trusted_keys: Vec, - ) -> ( - std::net::SocketAddr, - tokio::sync::oneshot::Receiver, - ) { - use tokio::net::TcpListener; - use tokio_tungstenite::accept_async; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - let (result_tx, result_rx) = tokio::sync::oneshot::channel(); - - tokio::spawn(async move { - let (tcp_stream, _) = listener.accept().await.unwrap(); - let ws_stream = accept_async(tcp_stream).await.unwrap(); - let (mut sink, mut stream) = ws_stream.split(); - - use tokio_tungstenite::tungstenite::Message as TMsg; - - // Step 1: Send challenge. - let challenge = crate::node_identity::generate_challenge(); - let challenge_msg = super::ChallengeMessage { - r#type: "challenge".to_string(), - nonce: challenge.clone(), - }; - let challenge_json = serde_json::to_string(&challenge_msg).unwrap(); - if sink.send(TMsg::Text(challenge_json.into())).await.is_err() { - let _ = result_tx.send(AuthListenerResult::ConnectionLost); - return; - } - - // Step 2: Await auth reply (10s timeout). - let auth_frame = - tokio::time::timeout(std::time::Duration::from_secs(10), stream.next()).await; - - let auth_text = match auth_frame { - Ok(Some(Ok(TMsg::Text(t)))) => t.to_string(), - Ok(Some(Ok(TMsg::Close(reason)))) => { - let _ = result_tx.send(AuthListenerResult::PeerClosedEarly( - reason.map(|r| r.reason.to_string()), - )); - return; - } - _ => { - let _ = sink - .send(TMsg::Close(Some(tokio_tungstenite::tungstenite::protocol::CloseFrame { - code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::from(4001), - reason: "auth_timeout".into(), - }))) - .await; - let _ = result_tx.send(AuthListenerResult::AuthTimeout); - return; - } - }; - - // Step 3: Verify. - let auth_msg: super::AuthMessage = match serde_json::from_str(&auth_text) { - Ok(m) => m, - Err(_) => { - let _ = close_listener_auth_failed(&mut sink).await; - let _ = result_tx.send(AuthListenerResult::AuthFailed("bad_json".into())); - return; - } - }; - - let sig_valid = crate::node_identity::verify_challenge( - &auth_msg.pubkey_hex, - &challenge, - &auth_msg.signature_hex, - ); - let key_trusted = trusted_keys.iter().any(|k| k == &auth_msg.pubkey_hex); - - if !sig_valid || !key_trusted { - let _ = close_listener_auth_failed(&mut sink).await; - let _ = result_tx.send(AuthListenerResult::AuthFailed(format!( - "sig_valid={sig_valid}, key_trusted={key_trusted}" - ))); - return; - } - - // Auth passed! Send a bulk state with one op to prove sync works. - let kp = bft_json_crdt::keypair::make_keypair(); - let mut crdt = - bft_json_crdt::json_crdt::BaseCrdt::::new(&kp); - let item: bft_json_crdt::json_crdt::JsonValue = serde_json::json!({ - "story_id": "628_auth_test_item", - "stage": "1_backlog", - "name": "Auth Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op = crdt - .doc - .items - .insert(bft_json_crdt::op::ROOT_ID, item) - .sign(&kp); - let op_json = serde_json::to_string(&op).unwrap(); - let bulk = super::SyncMessage::Bulk { ops: vec![op_json] }; - let bulk_json = serde_json::to_string(&bulk).unwrap(); - let _ = sink.send(TMsg::Text(bulk_json.into())).await; - - let _ = result_tx.send(AuthListenerResult::Authenticated(auth_msg.pubkey_hex)); - }); - - (addr, result_rx) - } - - #[derive(Debug)] - #[allow(dead_code)] - enum AuthListenerResult { - Authenticated(String), - AuthFailed(String), - AuthTimeout, - ConnectionLost, - PeerClosedEarly(Option), - } - - async fn close_listener_auth_failed( - sink: &mut futures::stream::SplitSink< - tokio_tungstenite::WebSocketStream, - tokio_tungstenite::tungstenite::Message, - >, - ) { - use tokio_tungstenite::tungstenite::protocol::CloseFrame; - use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; - let _ = sink - .send(tokio_tungstenite::tungstenite::Message::Close(Some( - CloseFrame { - code: CloseCode::from(4002), - reason: "auth_failed".into(), - }, - ))) - .await; - } - - /// AC5 (story 628): Happy path — two nodes with each other's pubkeys - /// allow-listed complete the handshake and exchange at least one CRDT op. - #[tokio::test] - async fn auth_happy_path_handshake_and_sync() { - use bft_json_crdt::keypair::make_keypair; - use futures::{SinkExt, StreamExt}; - use tokio_tungstenite::connect_async; - use tokio_tungstenite::tungstenite::Message as TMsg; - - let connector_kp = make_keypair(); - let connector_pubkey = crate::node_identity::public_key_hex(&connector_kp); - - // Start listener that trusts the connector's pubkey. - let (addr, result_rx) = start_auth_listener(vec![connector_pubkey.clone()]).await; - - // Connect and do the handshake. - let url = format!("ws://{addr}"); - let (ws, _) = connect_async(&url).await.unwrap(); - let (mut sink, mut stream) = ws.split(); - - // Receive challenge. - let challenge_frame = stream.next().await.unwrap().unwrap(); - let challenge_text = match challenge_frame { - TMsg::Text(t) => t.to_string(), - other => panic!("Expected text frame, got {other:?}"), - }; - let challenge_msg: super::ChallengeMessage = serde_json::from_str(&challenge_text).unwrap(); - assert_eq!(challenge_msg.r#type, "challenge"); - assert_eq!( - challenge_msg.nonce.len(), - 64, - "Challenge must be 64 hex chars" - ); - - // Sign and reply. - let sig = crate::node_identity::sign_challenge(&connector_kp, &challenge_msg.nonce); - let auth_msg = super::AuthMessage { - r#type: "auth".to_string(), - pubkey_hex: connector_pubkey.clone(), - signature_hex: sig, - }; - let auth_json = serde_json::to_string(&auth_msg).unwrap(); - sink.send(TMsg::Text(auth_json.into())).await.unwrap(); - - // After auth, we should receive a bulk sync message with at least one op. - let bulk_frame = tokio::time::timeout(std::time::Duration::from_secs(5), stream.next()) - .await - .expect("should receive bulk within 5s") - .unwrap() - .unwrap(); - - let bulk_text = match bulk_frame { - TMsg::Text(t) => t.to_string(), - other => panic!("Expected bulk text frame, got {other:?}"), - }; - let bulk_msg: super::SyncMessage = serde_json::from_str(&bulk_text).unwrap(); - match bulk_msg { - super::SyncMessage::Bulk { ops } => { - assert!( - !ops.is_empty(), - "Bulk sync must contain at least one op after successful auth" - ); - // Verify we can deserialize the op. - let _signed: bft_json_crdt::json_crdt::SignedOp = - serde_json::from_str(&ops[0]).unwrap(); - } - _ => panic!("Expected Bulk message after auth"), - } - - // Verify listener also reports success. - let listener_result = result_rx.await.unwrap(); - match listener_result { - AuthListenerResult::Authenticated(pubkey) => { - assert_eq!(pubkey, connector_pubkey); - } - other => panic!("Expected Authenticated, got {other:?}"), - } - } - - /// AC6 (story 628): Untrusted pubkey — connector whose pubkey is NOT in - /// the listener's allow-list is rejected with close reason auth_failed, - /// and zero CRDT ops are exchanged. - #[tokio::test] - async fn auth_untrusted_pubkey_rejected() { - use bft_json_crdt::keypair::make_keypair; - use futures::{SinkExt, StreamExt}; - use tokio_tungstenite::connect_async; - use tokio_tungstenite::tungstenite::Message as TMsg; - - let connector_kp = make_keypair(); - let connector_pubkey = crate::node_identity::public_key_hex(&connector_kp); - - // Listener trusts a DIFFERENT key, not the connector's. - let other_kp = make_keypair(); - let other_pubkey = crate::node_identity::public_key_hex(&other_kp); - - let (addr, result_rx) = start_auth_listener(vec![other_pubkey]).await; - - let url = format!("ws://{addr}"); - let (ws, _) = connect_async(&url).await.unwrap(); - let (mut sink, mut stream) = ws.split(); - - // Receive challenge and sign with our (untrusted) key. - let challenge_frame = stream.next().await.unwrap().unwrap(); - let challenge_text = match challenge_frame { - TMsg::Text(t) => t.to_string(), - _ => panic!("Expected text frame"), - }; - let challenge_msg: super::ChallengeMessage = serde_json::from_str(&challenge_text).unwrap(); - - let sig = crate::node_identity::sign_challenge(&connector_kp, &challenge_msg.nonce); - let auth_msg = super::AuthMessage { - r#type: "auth".to_string(), - pubkey_hex: connector_pubkey, - signature_hex: sig, - }; - sink.send(TMsg::Text(serde_json::to_string(&auth_msg).unwrap().into())) - .await - .unwrap(); - - // Should receive a close frame with auth_failed. - let close_frame = tokio::time::timeout(std::time::Duration::from_secs(5), stream.next()) - .await - .expect("should receive close within 5s"); - - match close_frame { - Some(Ok(TMsg::Close(Some(frame)))) => { - assert_eq!( - &*frame.reason, "auth_failed", - "Close reason must be 'auth_failed'" - ); - } - Some(Ok(TMsg::Close(None))) => { - // Some implementations omit the close frame payload — that's acceptable - // as long as no sync data was sent. - } - other => { - // Connection dropped without close frame is also acceptable. - // The key assertion is below: no ops were exchanged. - let _ = other; - } - } - - // Verify listener reports auth failure. - let listener_result = result_rx.await.unwrap(); - match listener_result { - AuthListenerResult::AuthFailed(reason) => { - assert!(reason.contains("key_trusted=false"), "Reason: {reason}"); - } - other => panic!("Expected AuthFailed, got {other:?}"), - } - } - - /// AC7 (story 628): Bad signature — connector signs with wrong keypair. - /// Rejected with the same auth_failed — indistinguishable from untrusted key. - #[tokio::test] - async fn auth_bad_signature_rejected() { - use bft_json_crdt::keypair::make_keypair; - use futures::{SinkExt, StreamExt}; - use tokio_tungstenite::connect_async; - use tokio_tungstenite::tungstenite::Message as TMsg; - - let legitimate_kp = make_keypair(); - let legitimate_pubkey = crate::node_identity::public_key_hex(&legitimate_kp); - - // A different keypair that will sign the challenge (wrong key). - let impersonator_kp = make_keypair(); - - // Listener trusts the legitimate pubkey. - let (addr, result_rx) = start_auth_listener(vec![legitimate_pubkey.clone()]).await; - - let url = format!("ws://{addr}"); - let (ws, _) = connect_async(&url).await.unwrap(); - let (mut sink, mut stream) = ws.split(); - - // Receive challenge. - let challenge_frame = stream.next().await.unwrap().unwrap(); - let challenge_text = match challenge_frame { - TMsg::Text(t) => t.to_string(), - _ => panic!("Expected text frame"), - }; - let challenge_msg: super::ChallengeMessage = serde_json::from_str(&challenge_text).unwrap(); - - // Sign with the WRONG keypair but claim to be the legitimate pubkey. - let bad_sig = crate::node_identity::sign_challenge(&impersonator_kp, &challenge_msg.nonce); - let auth_msg = super::AuthMessage { - r#type: "auth".to_string(), - pubkey_hex: legitimate_pubkey, - signature_hex: bad_sig, - }; - sink.send(TMsg::Text(serde_json::to_string(&auth_msg).unwrap().into())) - .await - .unwrap(); - - // Should be rejected. - let close_frame = tokio::time::timeout(std::time::Duration::from_secs(5), stream.next()) - .await - .expect("should receive close within 5s"); - - match close_frame { - Some(Ok(TMsg::Close(Some(frame)))) => { - assert_eq!( - &*frame.reason, "auth_failed", - "Close reason must be 'auth_failed' — same as untrusted key" - ); - } - _ => { - // Connection closed is acceptable. - } - } - - // Verify listener reports auth failure with sig_valid=false. - let listener_result = result_rx.await.unwrap(); - match listener_result { - AuthListenerResult::AuthFailed(reason) => { - assert!(reason.contains("sig_valid=false"), "Reason: {reason}"); - } - other => panic!("Expected AuthFailed, got {other:?}"), - } - } - - /// AC8 (story 628): Replay protection sanity — two consecutive connect - /// attempts receive different challenges (fresh nonce per accept). - #[tokio::test] - async fn auth_replay_protection_fresh_nonces() { - use futures::StreamExt; - use tokio::net::TcpListener; - use tokio_tungstenite::tungstenite::Message as TMsg; - use tokio_tungstenite::{accept_async, connect_async}; - - // Start a listener that sends challenges but doesn't complete auth. - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - let (nonce_tx, mut nonce_rx) = tokio::sync::mpsc::channel::(2); - - tokio::spawn(async move { - for _ in 0..2 { - let (tcp, _) = listener.accept().await.unwrap(); - let ws = accept_async(tcp).await.unwrap(); - let (mut sink, _stream) = ws.split(); - - let challenge = crate::node_identity::generate_challenge(); - let msg = super::ChallengeMessage { - r#type: "challenge".to_string(), - nonce: challenge.clone(), - }; - let json = serde_json::to_string(&msg).unwrap(); - let _ = sink.send(TMsg::Text(json.into())).await; - let _ = nonce_tx.send(challenge).await; - } - }); - - // Connect twice and collect the nonces. - let mut nonces = Vec::new(); - for _ in 0..2 { - let url = format!("ws://{addr}"); - let (ws, _) = connect_async(&url).await.unwrap(); - let (_sink, mut stream) = ws.split(); - - let frame = stream.next().await.unwrap().unwrap(); - let text = match frame { - TMsg::Text(t) => t.to_string(), - _ => panic!("Expected text"), - }; - let msg: super::ChallengeMessage = serde_json::from_str(&text).unwrap(); - nonces.push(msg.nonce); - // Drop connection so listener accepts the next one. - drop(stream); - } - - // Also collect nonces from the listener side. - let server_nonce_1 = nonce_rx.recv().await.unwrap(); - let server_nonce_2 = nonce_rx.recv().await.unwrap(); - - assert_ne!( - nonces[0], nonces[1], - "Consecutive challenges must be different" - ); - assert_ne!( - server_nonce_1, server_nonce_2, - "Server must generate fresh nonce per accept" - ); - assert_eq!(nonces[0], server_nonce_1, "Client/server nonces must match"); - assert_eq!(nonces[1], server_nonce_2, "Client/server nonces must match"); - } - - /// AC4 (story 628): trusted_keys config is parsed from project.toml. - #[test] - fn config_trusted_keys_parsed_from_toml() { - let toml_str = r#" -trusted_keys = [ - "aabbccdd00112233aabbccdd00112233aabbccdd00112233aabbccdd00112233", - "11223344556677881122334455667788112233445566778811223344556677ab", -] - -[[agent]] -name = "test" -"#; - let config: crate::config::ProjectConfig = - crate::config::ProjectConfig::parse(toml_str).unwrap(); - assert_eq!(config.trusted_keys.len(), 2); - assert_eq!( - config.trusted_keys[0], - "aabbccdd00112233aabbccdd00112233aabbccdd00112233aabbccdd00112233" - ); - } - - /// AC4 (story 628): trusted_keys defaults to empty (reject all). - #[test] - fn config_trusted_keys_defaults_to_empty() { - let config = crate::config::ProjectConfig::default(); - assert!( - config.trusted_keys.is_empty(), - "trusted_keys must default to empty (reject all)" - ); - } - - /// AC9 (story 628): Existing per-op signed replication tests still pass. - /// This is verified implicitly by running the full test suite — this marker - /// test documents the intent. - #[test] - fn existing_sync_tests_unchanged() { - // If we got here, all previous crdt_sync tests compiled and passed. - // This test exists as a documentation anchor for AC9. - } - - // ── Story 630: WebSocket keepalive (ping/pong) ──────────────────────────── - - /// AC1/AC2: PING_INTERVAL_SECS is 30 and PONG_TIMEOUT_SECS is 60 — the - /// transport-level constants are correct. - #[test] - fn keepalive_constants_are_correct() { - assert_eq!( - super::PING_INTERVAL_SECS, - 30, - "Ping interval must be 30 seconds" - ); - assert_eq!( - super::PONG_TIMEOUT_SECS, - 60, - "Pong timeout must be 60 seconds" - ); - } - - /// AC5: The agent-mode heartbeat interval (SCAN_INTERVAL_SECS) is 15s and - /// must not be changed by the keepalive work. - #[test] - fn agent_mode_heartbeat_interval_unchanged() { - assert_eq!( - crate::agent_mode::SCAN_INTERVAL_SECS, - 15, - "Agent-mode heartbeat interval must remain 15s" - ); - } - - /// AC4: Reconnect backoff constants are unchanged. - #[test] - fn reconnect_backoff_constants_unchanged() { - assert_eq!( - super::RENDEZVOUS_ERROR_THRESHOLD, - 10, - "Backoff threshold must still be 10" - ); - } - - /// AC1: Server (accept_async side) emits a Ping frame after the configured - /// interval. Uses short durations (100 ms ping) so the test finishes fast. - #[tokio::test] - async fn server_sends_ping_to_peer_at_interval() { - use futures::{SinkExt, StreamExt}; - use std::time::Duration; - use tokio::net::TcpListener; - use tokio_tungstenite::tungstenite::Message as TMsg; - use tokio_tungstenite::{accept_async, connect_async}; - - let ping_ms = 100u64; - let timeout_ms = 400u64; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - // Server task: keepalive sender with short intervals. - tokio::spawn(async move { - let (tcp, _) = listener.accept().await.unwrap(); - let ws = accept_async(tcp).await.unwrap(); - let (mut sink, mut stream) = ws.split(); - - let mut pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); - let mut ticker = tokio::time::interval_at( - tokio::time::Instant::now() + Duration::from_millis(ping_ms), - Duration::from_millis(ping_ms), - ); - - loop { - tokio::select! { - _ = ticker.tick() => { - if tokio::time::Instant::now() >= pong_deadline { break; } - if sink.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() { break; } - } - frame = stream.next() => { - match frame { - Some(Ok(TMsg::Pong(_))) => { - pong_deadline = tokio::time::Instant::now() - + Duration::from_millis(timeout_ms); - } - None | Some(Err(_)) => break, - _ => {} - } - } - } - } - }); - - let (ws_client, _) = connect_async(format!("ws://{addr}")).await.unwrap(); - let (_sink_c, mut stream_c) = ws_client.split(); - - // Wait for more than one ping interval. - tokio::time::sleep(Duration::from_millis(ping_ms * 2)).await; - - // Client should receive a Ping from the server. - let frame = tokio::time::timeout(Duration::from_millis(200), stream_c.next()).await; - let got_ping = matches!(frame, Ok(Some(Ok(TMsg::Ping(_))))); - assert!( - got_ping, - "Client must receive a Ping frame from the server after the ping interval" - ); - } - - /// AC2: Client-side keepalive sender emits a Ping after the interval, - /// symmetrically to the server side. - #[tokio::test] - async fn client_sends_ping_to_server_at_interval() { - use futures::{SinkExt, StreamExt}; - use std::time::Duration; - use tokio::net::TcpListener; - use tokio_tungstenite::tungstenite::Message as TMsg; - use tokio_tungstenite::{accept_async, connect_async}; - - let ping_ms = 100u64; - let timeout_ms = 400u64; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - let (ping_tx, ping_rx) = tokio::sync::oneshot::channel::<()>(); - - // Server task: wait for the first Ping the client sends. - tokio::spawn(async move { - let (tcp, _) = listener.accept().await.unwrap(); - let ws = accept_async(tcp).await.unwrap(); - let (_sink, mut stream) = ws.split(); - loop { - match stream.next().await { - Some(Ok(TMsg::Ping(_))) => { - let _ = ping_tx.send(()); - break; - } - Some(Ok(_)) => continue, - _ => break, - } - } - }); - - let (ws_client, _) = connect_async(format!("ws://{addr}")).await.unwrap(); - let (mut sink_c, mut stream_c) = ws_client.split(); - - // Client keepalive task. - tokio::spawn(async move { - let mut pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); - let mut ticker = tokio::time::interval_at( - tokio::time::Instant::now() + Duration::from_millis(ping_ms), - Duration::from_millis(ping_ms), - ); - loop { - tokio::select! { - _ = ticker.tick() => { - if tokio::time::Instant::now() >= pong_deadline { break; } - if sink_c.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() { break; } - } - frame = stream_c.next() => { - match frame { - Some(Ok(TMsg::Pong(_))) => { - pong_deadline = tokio::time::Instant::now() - + Duration::from_millis(timeout_ms); - } - None | Some(Err(_)) => break, - _ => {} - } - } - } - } - }); - - let result = tokio::time::timeout(Duration::from_millis(ping_ms * 3), ping_rx).await; - assert!( - result.is_ok(), - "Server must receive a Ping from the client after the ping interval" - ); - } - - /// AC3: Either side disconnects when no Pong is received within the timeout. - /// The keepalive sender returns `true` (timed out) when Pongs are withheld. - #[tokio::test] - async fn keepalive_disconnects_when_pong_withheld() { - use futures::{SinkExt, StreamExt}; - use std::time::Duration; - use tokio::net::TcpListener; - use tokio_tungstenite::tungstenite::Message as TMsg; - use tokio_tungstenite::{accept_async, connect_async}; - - let ping_ms = 100u64; - let timeout_ms = 250u64; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - let (done_tx, done_rx) = tokio::sync::oneshot::channel::(); - - // Server: sends Pings, never receives Pong (client swallows all). - tokio::spawn(async move { - let (tcp, _) = listener.accept().await.unwrap(); - let ws = accept_async(tcp).await.unwrap(); - let (mut sink, mut stream) = ws.split(); - - let pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); - let mut ticker = tokio::time::interval_at( - tokio::time::Instant::now() + Duration::from_millis(ping_ms), - Duration::from_millis(ping_ms), - ); - - let timed_out = loop { - tokio::select! { - _ = ticker.tick() => { - if tokio::time::Instant::now() >= pong_deadline { break true; } - if sink.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() { - break false; - } - } - frame = stream.next() => { - match frame { - Some(Ok(_)) => {} // swallow — no Pong sent - _ => break false, - } - } - } - }; - let _ = done_tx.send(timed_out); - }); - - // Client: connect but never respond to Pings. - let (_ws_client, _) = connect_async(format!("ws://{addr}")).await.unwrap(); - - let result = - tokio::time::timeout(Duration::from_millis(timeout_ms + ping_ms * 3), done_rx).await; - let timed_out = result - .expect("Server must report within expected wall-clock time") - .expect("oneshot intact"); - - assert!( - timed_out, - "Server must disconnect on keepalive timeout when Pong is withheld" - ); - } - - /// AC3 (positive path): Connection stays alive when Pong responses arrive - /// before the timeout fires. - #[tokio::test] - async fn keepalive_connection_survives_with_pong_responses() { - use futures::{SinkExt, StreamExt}; - use std::time::Duration; - use tokio::net::TcpListener; - use tokio_tungstenite::tungstenite::Message as TMsg; - use tokio_tungstenite::{accept_async, connect_async}; - - let ping_ms = 100u64; - let timeout_ms = 250u64; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - let (result_tx, result_rx) = tokio::sync::oneshot::channel::(); - - // Server: sends Pings, resets deadline on Pong. - tokio::spawn(async move { - let (tcp, _) = listener.accept().await.unwrap(); - let ws = accept_async(tcp).await.unwrap(); - let (mut sink, mut stream) = ws.split(); - - let mut pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); - let mut ticker = tokio::time::interval_at( - tokio::time::Instant::now() + Duration::from_millis(ping_ms), - Duration::from_millis(ping_ms), - ); - - let timed_out = loop { - tokio::select! { - _ = ticker.tick() => { - if tokio::time::Instant::now() >= pong_deadline { break true; } - if sink.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() { - break false; - } - } - frame = stream.next() => { - match frame { - Some(Ok(TMsg::Pong(_))) => { - pong_deadline = tokio::time::Instant::now() - + Duration::from_millis(timeout_ms); - } - None | Some(Err(_)) => break false, // clean close - _ => {} - } - } - } - }; - let _ = result_tx.send(timed_out); - }); - - let (ws_client, _) = connect_async(format!("ws://{addr}")).await.unwrap(); - let (mut sink_c, mut stream_c) = ws_client.split(); - - // Client: respond to every Ping with Pong for several intervals. - let respond_task = tokio::spawn(async move { - while let Some(Ok(msg)) = stream_c.next().await { - if let TMsg::Ping(data) = msg - && sink_c.send(TMsg::Pong(data)).await.is_err() - { - break; - } - } - }); - - // Run for a few intervals, then drop the client. - tokio::time::sleep(Duration::from_millis(ping_ms * 3)).await; - respond_task.abort(); - - let result = tokio::time::timeout(Duration::from_millis(200), result_rx).await; - let timed_out = result.unwrap_or(Ok(false)).unwrap_or(false); - assert!( - !timed_out, - "Server must NOT timeout when the client responds to Pings with Pongs" - ); - } - - /// AC6: Integration — one node swallows Pongs; the other drops the - /// connection within the timeout, then reconnect is possible via backoff. - #[tokio::test] - async fn two_node_pong_swallow_causes_disconnect_within_timeout() { - use futures::{SinkExt, StreamExt}; - use std::time::Duration; - use tokio::net::TcpListener; - use tokio_tungstenite::tungstenite::Message as TMsg; - use tokio_tungstenite::{accept_async, connect_async}; - - let ping_ms = 100u64; - let timeout_ms = 250u64; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - - // Node A (listener): sends Pings, never receives Pong. - let (a_done_tx, a_done_rx) = tokio::sync::oneshot::channel::(); - tokio::spawn(async move { - let (tcp, _) = listener.accept().await.unwrap(); - let ws = accept_async(tcp).await.unwrap(); - let (mut sink, mut stream) = ws.split(); - - let pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); - let mut ticker = tokio::time::interval_at( - tokio::time::Instant::now() + Duration::from_millis(ping_ms), - Duration::from_millis(ping_ms), - ); - - let timed_out = loop { - tokio::select! { - _ = ticker.tick() => { - if tokio::time::Instant::now() >= pong_deadline { break true; } - if sink.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() { - break false; - } - } - frame = stream.next() => { - match frame { - Some(Ok(_)) => {} // swallow all frames - _ => break false, - } - } - } - }; - let _ = a_done_tx.send(timed_out); - }); - - // Node B: connects, drains frames silently (swallows Pings, never pongs). - let (ws_b, _) = connect_async(format!("ws://{addr}")).await.unwrap(); - let (_sink_b, mut stream_b) = ws_b.split(); - tokio::spawn(async move { while let Some(Ok(_)) = stream_b.next().await {} }); - - let result = - tokio::time::timeout(Duration::from_millis(timeout_ms + ping_ms * 3), a_done_rx).await; - let timed_out = result - .expect("Node A must report within expected wall-clock time") - .expect("channel intact"); - - assert!( - timed_out, - "Node A must disconnect due to keepalive timeout when Node B swallows Pongs" - ); - } - - // ── Story 631: vector clock wire format tests ─────────────────────── - - /// Clock message serialization round-trip via SyncMessage. - #[test] - fn sync_message_clock_serialization_roundtrip() { - let mut clock = std::collections::HashMap::new(); - clock.insert("aabbcc00".to_string(), 42u64); - clock.insert("ddeeff11".to_string(), 7u64); - - let msg = SyncMessage::Clock { clock }; - let json = serde_json::to_string(&msg).unwrap(); - assert!(json.contains(r#""type":"clock""#)); - - let deserialized: SyncMessage = serde_json::from_str(&json).unwrap(); - match deserialized { - SyncMessage::Clock { clock } => { - assert_eq!(clock["aabbcc00"], 42); - assert_eq!(clock["ddeeff11"], 7); - } - _ => panic!("Expected Clock"), - } - } - - /// Empty clock (new node) serializes correctly. - #[test] - fn sync_message_clock_empty() { - let msg = SyncMessage::Clock { - clock: std::collections::HashMap::new(), - }; - let json = serde_json::to_string(&msg).unwrap(); - let deserialized: SyncMessage = serde_json::from_str(&json).unwrap(); - match deserialized { - SyncMessage::Clock { clock } => assert!(clock.is_empty()), - _ => panic!("Expected Clock"), - } - } - - /// v1 compat: a v1 peer that only knows `bulk` and `op` will fail to parse - /// a `clock` message — verify the parse error is a clean serde error, not a - /// panic. - #[test] - fn v1_peer_ignores_clock_message_gracefully() { - // Simulate: v1 peer only knows Bulk and Op. - // A clock message should fail deserialization (unknown variant). - let clock_json = r#"{"type":"clock","clock":{"abc":10}}"#; - // handle_incoming_text logs and returns — must not panic. - handle_incoming_text(clock_json); - } - - /// v2 delta sync simulation: two CRDT nodes, exchange clocks, send deltas. - #[test] - fn v2_delta_sync_via_clock_exchange() { - use bft_json_crdt::json_crdt::BaseCrdt; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use fastcrypto::traits::KeyPair; - use serde_json::json; - - use crate::crdt_state::PipelineDoc; - - let kp_a = make_keypair(); - let mut crdt_a = BaseCrdt::::new(&kp_a); - - // A creates 5 items. - let mut ops_a = Vec::new(); - for i in 0..5 { - let item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": format!("631_v2_{i}"), - "stage": "1_backlog", - "name": format!("v2 item {i}"), - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp_a); - crdt_a.apply(op.clone()); - ops_a.push(op); - } - - // B has seen the first 3 ops (clock says 3). - let kp_b = make_keypair(); - let mut crdt_b = BaseCrdt::::new(&kp_b); - for op in &ops_a[..3] { - crdt_b.apply(op.clone()); - } - assert_eq!(crdt_b.doc.items.view().len(), 3); - - // Build B's clock. - let author_a_hex = crate::crdt_state::hex::encode(&kp_a.public().0.to_bytes()); - let mut clock_b = std::collections::HashMap::new(); - clock_b.insert(author_a_hex.clone(), 3u64); - - // Serialize clock as wire message. - let clock_msg = SyncMessage::Clock { - clock: clock_b.clone(), - }; - let clock_wire = serde_json::to_string(&clock_msg).unwrap(); - - // A receives B's clock and computes delta. - let parsed: SyncMessage = serde_json::from_str(&clock_wire).unwrap(); - let delta_ops = match parsed { - SyncMessage::Clock { clock: peer_clock } => { - // Simulate ops_since: A has 5 ops from author_a, B has 3. - let all_json: Vec = ops_a - .iter() - .map(|op| serde_json::to_string(op).unwrap()) - .collect(); - let mut result = Vec::new(); - let mut count = 0u64; - for (i, _op) in ops_a.iter().enumerate() { - count += 1; - let peer_has = peer_clock.get(&author_a_hex).copied().unwrap_or(0); - if count > peer_has { - result.push(all_json[i].clone()); - } - } - result - } - _ => panic!("Expected Clock"), - }; - - assert_eq!(delta_ops.len(), 2, "delta should be 2 ops (ops 4 and 5)"); - - // B applies the delta. - for op_str in &delta_ops { - let signed: bft_json_crdt::json_crdt::SignedOp = serde_json::from_str(op_str).unwrap(); - crdt_b.apply(signed); - } - assert_eq!(crdt_b.doc.items.view().len(), 5); - } - - // ── Story 632: Ready ACK handshake ──────────────────────────────────────── - - /// AC1: `{"type":"ready"}` serialises and deserialises correctly. - #[test] - fn sync_message_ready_serialization_roundtrip() { - let msg = SyncMessage::Ready; - let json = serde_json::to_string(&msg).unwrap(); - assert_eq!(json, r#"{"type":"ready"}"#); - let deserialized: SyncMessage = serde_json::from_str(&json).unwrap(); - assert!(matches!(deserialized, SyncMessage::Ready)); - } - - /// AC4 (buffer flush): Locally-generated ops are buffered while `peer_ready` - /// is false, then flushed in-order once the peer's `Ready` frame arrives. - /// Frame capture verifies ordering. - #[test] - fn buffer_flush_ops_held_until_peer_ready() { - use bft_json_crdt::json_crdt::BaseCrdt; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use serde_json::json; - - use crate::crdt_state::PipelineDoc; - - // Simulate the buffer-flush state machine without real WebSockets. - let mut peer_ready = false; - let mut op_buffer: Vec> = Vec::new(); // encoded wire frames - let mut sent_frames: Vec> = Vec::new(); // captured "sent" frames - - // Build two local ops on a fresh CRDT. - let kp = make_keypair(); - let mut crdt = BaseCrdt::::new(&kp); - let item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "632_buffer_a", - "stage": "1_backlog", - "name": "Buffer A", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let op1 = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); - crdt.apply(op1.clone()); - let op2 = crdt.doc.items[0] - .stage - .set("2_current".to_string()) - .sign(&kp); - crdt.apply(op2.clone()); - - // Simulate op1 arriving from broadcast while peer is NOT ready. - let frame1 = crate::crdt_wire::encode(&op1); - if peer_ready { - sent_frames.push(frame1.clone()); - } else { - op_buffer.push(frame1.clone()); - } - - // Simulate op2 arriving while peer is still NOT ready. - let frame2 = crate::crdt_wire::encode(&op2); - if peer_ready { - sent_frames.push(frame2.clone()); - } else { - op_buffer.push(frame2.clone()); - } - - // Both ops must be buffered, none sent yet. - assert_eq!( - sent_frames.len(), - 0, - "ops must be buffered before peer Ready arrives" - ); - assert_eq!(op_buffer.len(), 2, "buffer must hold both ops"); - - // Simulate receiving the peer's Ready frame. - let ready_json = serde_json::to_string(&SyncMessage::Ready).unwrap(); - if let Ok(SyncMessage::Ready) = serde_json::from_str::(&ready_json) { - peer_ready = true; - for encoded in op_buffer.drain(..) { - sent_frames.push(encoded); - } - } - - assert!(peer_ready, "peer_ready must be true after Ready frame"); - assert_eq!(op_buffer.len(), 0, "buffer must be empty after flush"); - assert_eq!( - sent_frames.len(), - 2, - "both buffered ops must appear in captured frames after flush" - ); - // Order preserved: op1 before op2. - assert_eq!(sent_frames[0], frame1, "op1 must be first flushed frame"); - assert_eq!(sent_frames[1], frame2, "op2 must be second flushed frame"); - - // After flush, a new op is sent immediately (no buffering). - let op3 = crdt.doc.items[0].stage.set("3_qa".to_string()).sign(&kp); - crdt.apply(op3.clone()); - let frame3 = crate::crdt_wire::encode(&op3); - if peer_ready { - sent_frames.push(frame3.clone()); - } else { - op_buffer.push(frame3.clone()); - } - assert_eq!( - sent_frames.len(), - 3, - "op after flush must be sent immediately" - ); - assert_eq!(op_buffer.len(), 0, "buffer must remain empty"); - } - - /// AC5 (causal queueing regression): Real-time binary ops from the peer are - /// applied immediately regardless of our own ready state. When a real-time - /// op causally depends on a bulk op, the CRDT causal queue ensures it is - /// applied correctly once the dependency arrives. - #[test] - fn realtime_op_from_peer_applied_immediately_regardless_of_ready_state() { - use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV, OpState}; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use serde_json::json; - - use crate::crdt_state::PipelineDoc; - - // Node A creates a bulk op and a real-time op that causally depends on it. - let kp_a = make_keypair(); - let mut crdt_a = BaseCrdt::::new(&kp_a); - let item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "632_causal_test", - "stage": "1_backlog", - "name": "Causal Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let bulk_op = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp_a); - crdt_a.apply(bulk_op.clone()); - - // Real-time op that causally depends on bulk_op. - let rt_op = crdt_a.doc.items[0] - .stage - .set("2_current".to_string()) - .sign_with_dependencies(&kp_a, vec![&bulk_op]); - crdt_a.apply(rt_op.clone()); - - // Node B receives the bulk op first (simulating the bulk-delta phase). - let kp_b = make_keypair(); - let mut crdt_b = BaseCrdt::::new(&kp_b); - assert_eq!( - crdt_b.apply(bulk_op.clone()), - OpState::Ok, - "bulk op must apply cleanly on node B" - ); - - // Node B has not yet sent its own Ready frame, but it receives A's - // real-time binary frame. It must be applied immediately via - // handle_incoming_binary (causal queue handles ordering). - let wire = crate::crdt_wire::encode(&rt_op); - let decoded = crate::crdt_wire::decode(&wire).unwrap(); - let result = crdt_b.apply(decoded); - assert_eq!( - result, - OpState::Ok, - "real-time op depending on bulk op must apply immediately after bulk op is present" - ); - - // Both nodes converge to the same state. - assert_eq!( - crdt_b.doc.items[0].stage.view(), - JV::String("2_current".to_string()), - "Node B must converge to stage 2_current" - ); - assert_eq!( - CrdtNode::view(&crdt_a.doc.items), - CrdtNode::view(&crdt_b.doc.items), - "Both nodes must converge to identical state" - ); - } - - /// AC3: Real-time ops received from the peer BEFORE our own ready is sent - /// are applied immediately (no buffering on the receive side). - #[test] - fn incoming_realtime_op_applied_before_we_send_ready() { - use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV, OpState}; - use bft_json_crdt::keypair::make_keypair; - use bft_json_crdt::op::ROOT_ID; - use serde_json::json; - - use crate::crdt_state::PipelineDoc; - - // Simulate: we have NOT sent our ready yet (own_ready_sent = false), - // but the peer sends us a real-time binary op. It must be applied. - let own_ready_sent = false; // we haven't sent ready yet - let _ = own_ready_sent; // doc-only; receiving side never gates on this - - let kp_peer = make_keypair(); - let mut crdt_peer = BaseCrdt::::new(&kp_peer); - let item: bft_json_crdt::json_crdt::JsonValue = json!({ - "story_id": "632_ac3_test", - "stage": "1_backlog", - "name": "AC3 Test", - "agent": "", - "retry_count": 0.0, - "blocked": false, - "depends_on": "", - "claimed_by": "", - "claimed_at": 0.0, - }) - .into(); - let rt_op = crdt_peer.doc.items.insert(ROOT_ID, item).sign(&kp_peer); - crdt_peer.apply(rt_op.clone()); - - // Node B decodes and applies the peer's real-time op directly. - let kp_b = make_keypair(); - let mut crdt_b = BaseCrdt::::new(&kp_b); - let wire = crate::crdt_wire::encode(&rt_op); - let decoded = crate::crdt_wire::decode(&wire).unwrap(); - // This mirrors handle_incoming_binary() — applied unconditionally. - let result = crdt_b.apply(decoded); - assert_eq!( - result, - OpState::Ok, - "incoming real-time op must be applied immediately regardless of own ready state" - ); - assert_eq!( - crdt_b.doc.items[0].stage.view(), - JV::String("1_backlog".to_string()), - "op content must be correct" - ); - } - - /// AC6: All existing CRDT sync tests pass — verified by this marker test. - #[test] - fn existing_crdt_sync_tests_pass_unchanged() { - // Reaching this point means all prior tests in this module compiled - // and passed. This test documents the AC6 intent. - } - - // ── Story 633: bearer-token connection auth ─────────────────────────────── - - /// AC4: Valid token — `validate_join_token` returns `true` for a token that - /// has been added via `add_join_token` and has not expired. - #[test] - fn valid_token_passes_validation() { - let token = format!("test-valid-{}", uuid::Uuid::new_v4()); - super::add_join_token(token.clone()); - assert!( - super::validate_join_token(&token), - "A freshly added token must pass validation" - ); - } - - /// AC5: Invalid (bogus) token — `validate_join_token` returns `false` for a - /// token that was never added to the store. - #[test] - fn bogus_token_fails_validation() { - let bogus = "this-token-was-never-added-to-the-store"; - assert!( - !super::validate_join_token(bogus), - "An unknown token must fail validation" - ); - } - - /// AC5: Expired token — `validate_join_token` returns `false` for a token - /// whose `expires_at` is in the past. - #[test] - fn expired_token_fails_validation() { - // Insert a token directly with an already-past expiry timestamp. - let token = format!("test-expired-{}", uuid::Uuid::new_v4()); - let store = super::CRDT_TOKENS - .get_or_init(|| std::sync::RwLock::new(std::collections::HashMap::new())); - // expires_at = 1 (way in the past — 1970-01-01T00:00:01Z) - store.write().unwrap().insert(token.clone(), 1.0_f64); - assert!( - !super::validate_join_token(&token), - "An expired token must fail validation" - ); - } - - /// AC6: No token when server requires one — `validate_join_token` returns - /// `false` and the caller must reject with 401. Verifies the logic path - /// used by `crdt_sync_handler`. - #[test] - fn no_token_with_require_true_is_rejected() { - // Simulate: require_token=true, token=None → reject. - let require_token = true; - let token: Option<&str> = None; - let should_reject = match token { - Some(t) => !super::validate_join_token(t), - None if require_token => true, - None => false, - }; - assert!( - should_reject, - "Missing token must be rejected when token is required" - ); - } - - /// AC6: No token when server is in open mode — connection is accepted. - #[test] - fn no_token_with_require_false_is_accepted() { - let require_token = false; - let token: Option<&str> = None; - let should_reject = match token { - Some(t) => !super::validate_join_token(t), - None if require_token => true, - None => false, - }; - assert!( - !should_reject, - "Missing token must be accepted in open mode" - ); - } - - /// AC3: `spawn_rendezvous_client` URL construction — when a token is provided - /// the `?token=` query parameter is appended correctly. - #[test] - fn rendezvous_url_with_token_appended() { - let base = "ws://host:3001/crdt-sync"; - let token = "my-secret-token"; - let url_with_token = if base.contains('?') { - format!("{base}&token={token}") - } else { - format!("{base}?token={token}") - }; - assert_eq!( - url_with_token, - "ws://host:3001/crdt-sync?token=my-secret-token" - ); - - // With existing query params. - let base_with_query = "ws://host:3001/crdt-sync?foo=bar"; - let url_appended = if base_with_query.contains('?') { - format!("{base_with_query}&token={token}") - } else { - format!("{base_with_query}?token={token}") - }; - assert_eq!( - url_appended, - "ws://host:3001/crdt-sync?foo=bar&token=my-secret-token" - ); - } - - /// AC3: Without a token, the URL is used as-is. - #[test] - fn rendezvous_url_without_token_unchanged() { - let base = "ws://host:3001/crdt-sync"; - let token: Option<&str> = None; - let connect_url = match token { - Some(t) => format!("{base}?token={t}"), - None => base.to_string(), - }; - assert_eq!(connect_url, base); - } - - /// `add_join_token` returns a future expiry timestamp that is in the future. - #[test] - fn add_join_token_returns_future_expiry() { - let token = format!("test-expiry-{}", uuid::Uuid::new_v4()); - let now = chrono::Utc::now().timestamp() as f64; - let expires_at = super::add_join_token(token); - assert!( - expires_at > now, - "Expiry timestamp must be in the future (got {expires_at}, now={now})" - ); - } - - /// TOKEN_TTL_SECS must be 30 days. - #[test] - fn token_ttl_is_thirty_days() { - assert_eq!( - super::TOKEN_TTL_SECS, - 30.0 * 24.0 * 3600.0, - "TOKEN_TTL_SECS must be 30 days" - ); - } - - /// Config: `crdt_require_token` defaults to `false`. - #[test] - fn config_crdt_require_token_defaults_to_false() { - let config = crate::config::ProjectConfig::default(); - assert!( - !config.crdt_require_token, - "crdt_require_token must default to false (open access)" - ); - } - - /// Config: `crdt_tokens` defaults to empty. - #[test] - fn config_crdt_tokens_defaults_to_empty() { - let config = crate::config::ProjectConfig::default(); - assert!( - config.crdt_tokens.is_empty(), - "crdt_tokens must default to empty" - ); - } - - /// Config: `crdt_require_token` and `crdt_tokens` are parsed from TOML. - #[test] - fn config_crdt_token_fields_parsed_from_toml() { - let toml_str = r#" -crdt_require_token = true -crdt_tokens = ["token-abc", "token-xyz"] - -[[agent]] -name = "test" -"#; - let config: crate::config::ProjectConfig = toml::from_str(toml_str).unwrap(); - assert!(config.crdt_require_token); - assert_eq!(config.crdt_tokens, vec!["token-abc", "token-xyz"]); - } -} diff --git a/server/src/crdt_sync/mod.rs b/server/src/crdt_sync/mod.rs new file mode 100644 index 00000000..ff772105 --- /dev/null +++ b/server/src/crdt_sync/mod.rs @@ -0,0 +1,1003 @@ +//! CRDT sync — WebSocket-based replication of pipeline state between huskies nodes. +/// WebSocket-based CRDT sync layer for replicating pipeline state between +/// huskies nodes. +/// +/// # Protocol +/// +/// ## Version negotiation +/// +/// After the auth handshake, both sides send their first sync message: +/// +/// - **v2 peers** send a `clock` frame: `{"type":"clock","clock":{ : , ... }}` +/// containing a vector clock that maps each author's hex Ed25519 pubkey to the +/// count of ops received from that author. Upon receiving the peer's clock, +/// each side computes the delta via [`crdt_state::ops_since`] and sends only +/// the missing ops as a `bulk` frame. +/// +/// - **v1 (legacy) peers** send a `bulk` frame directly (full op dump). +/// A v2 peer receiving a `bulk` first (instead of a `clock`) falls back to +/// the full-dump path: applies the incoming bulk and responds with its own +/// full bulk. This preserves backward compatibility — no code change needed +/// on the v1 side. +/// +/// ## Text frames +/// A JSON object with a `"type"` field: +/// - `{"type":"clock","clock":{...}}` — Vector clock (v2 protocol). +/// - `{"type":"bulk","ops":[...]}` — Ops dump (full or delta). +/// - `{"type":"ready"}` — Signals that the bulk-delta phase is complete and the +/// sender is ready for real-time op streaming. Locally-generated ops are +/// buffered until the peer's `ready` is received, then flushed in order. +/// +/// ## Binary frames (real-time op broadcast) +/// Individual `SignedOp`s encoded via [`crate::crdt_wire`] (versioned JSON +/// envelope: `{"v":1,"op":{...}}`). Each locally-applied op is immediately +/// broadcast as a binary frame to all connected peers. +/// +/// Both the server endpoint and the rendezvous client use the same protocol, +/// making the connection fully symmetric. +/// +/// ## Backpressure +/// Each connected peer has its own [`tokio::sync::broadcast`] receiver. If a +/// slow peer allows the channel to fill (indicated by a `Lagged` error), the +/// connection is dropped with a warning log. The peer can reconnect and +/// receive a fresh bulk state dump to catch up. +use bft_json_crdt::json_crdt::SignedOp; +use futures::{SinkExt, StreamExt}; +use poem::handler; +use poem::http::StatusCode; +use poem::web::Data; +use poem::web::Query; +use poem::web::websocket::{Message as WsMessage, WebSocket}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; + +use crate::crdt_snapshot; +use crate::crdt_state; +use crate::crdt_wire; +use crate::http::context::AppContext; +use crate::node_identity; +use crate::slog; +use crate::slog_error; +use crate::slog_warn; + +// ── Auth configuration ────────────────────────────────────────────── + +/// Default timeout for the auth handshake (seconds). +const AUTH_TIMEOUT_SECS: u64 = 10; + +// ── Keepalive configuration ───────────────────────────────────────── + +/// Interval (seconds) between WebSocket Ping frames sent by each side. +pub const PING_INTERVAL_SECS: u64 = 30; + +/// Seconds without a Pong response before the connection is dropped. +pub const PONG_TIMEOUT_SECS: u64 = 60; + +/// Trusted public keys loaded once at startup. +static TRUSTED_KEYS: OnceLock> = OnceLock::new(); + +/// Initialise the trusted-key allow-list for connect-time mutual auth. +/// +/// Must be called once at startup before any WebSocket connections are +/// accepted. Subsequent calls are no-ops (OnceLock). +pub fn init_trusted_keys(keys: Vec) { + let _ = TRUSTED_KEYS.set(keys); +} + +/// Return a reference to the trusted-key allow-list. +fn trusted_keys() -> &'static [String] { + TRUSTED_KEYS.get().map(|v| v.as_slice()).unwrap_or(&[]) +} + +// ── Bearer-token auth ─────────────────────────────────────────────── + +/// Time-to-live for CRDT bearer tokens in seconds (30 days). +const TOKEN_TTL_SECS: f64 = 30.0 * 24.0 * 3600.0; + +/// Whether a bearer token is required for `/crdt-sync` connections. +/// `None` (uninitialised) → open access (backward compatible). +static REQUIRE_TOKEN: OnceLock = OnceLock::new(); + +/// Valid bearer tokens — maps token string to its expiry unix timestamp. +static CRDT_TOKENS: OnceLock>> = OnceLock::new(); + +/// Initialise bearer-token auth for CRDT-sync connections. +/// +/// Must be called once at startup before any WebSocket connections are accepted. +/// When `require` is `true`, clients must supply a valid `?token=` query +/// parameter on the upgrade request or receive HTTP 401. When `require` is +/// `false` (default) a token is optional — connections without one are +/// accepted, but a supplied token is still validated. +pub fn init_token_auth(require: bool, tokens: Vec) { + let _ = REQUIRE_TOKEN.set(require); + let store = CRDT_TOKENS.get_or_init(|| std::sync::RwLock::new(HashMap::new())); + if let Ok(mut map) = store.write() { + let now = chrono::Utc::now().timestamp() as f64; + for token in tokens { + map.insert(token, now + TOKEN_TTL_SECS); + } + } +} + +/// Add a bearer token to the CRDT-sync token store. +/// +/// The token expires after [`TOKEN_TTL_SECS`] seconds. Returns the expiry +/// unix timestamp so callers can surface it in admin tooling. +pub fn add_join_token(token: String) -> f64 { + let store = CRDT_TOKENS.get_or_init(|| std::sync::RwLock::new(HashMap::new())); + let now = chrono::Utc::now().timestamp() as f64; + let expires_at = now + TOKEN_TTL_SECS; + if let Ok(mut map) = store.write() { + map.insert(token, expires_at); + } + expires_at +} + +/// Validate a bearer token against the CRDT-sync token store. +/// +/// Returns `true` if the token exists in the store and has not expired. +fn validate_join_token(token: &str) -> bool { + let Some(store) = CRDT_TOKENS.get() else { + return false; + }; + let now = chrono::Utc::now().timestamp() as f64; + store + .read() + .ok() + .and_then(|map| map.get(token).copied()) + .is_some_and(|expires_at| expires_at > now) +} + +// ── Wire protocol types ───────────────────────────────────────────── + +/// Auth handshake: challenge sent by the listener to the connector. +#[derive(Serialize, Deserialize, Debug)] +struct ChallengeMessage { + r#type: String, + nonce: String, +} + +/// Auth handshake: auth reply sent by the connector to the listener. +#[derive(Serialize, Deserialize, Debug)] +struct AuthMessage { + r#type: String, + pubkey_hex: String, + signature_hex: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum SyncMessage { + /// Bulk state dump sent on connect (v1) or delta ops after clock exchange (v2). + Bulk { ops: Vec }, + /// A single new op. + Op { op: String }, + /// Vector clock exchanged on connect (v2 protocol). + /// + /// Each entry maps a node's hex-encoded Ed25519 public key to the count of + /// ops received from that node. The receiving side computes the delta via + /// [`crdt_state::ops_since`] and sends only the missing ops. + Clock { + clock: std::collections::HashMap, + }, + /// Signals that the bulk-delta phase is complete; the sender is ready for + /// real-time op streaming. Locally-generated ops are buffered until the + /// peer's `Ready` is received, then flushed in-order. + Ready, +} + +/// Crate-visible re-export of `SyncMessage` for backwards-compatibility testing. +/// +/// Used by `crdt_snapshot` tests to verify that snapshot messages are NOT +/// parseable as legacy `SyncMessage` variants — confirming that old peers +/// will gracefully reject them. +#[cfg(test)] +pub(crate) type SyncMessagePublic = SyncMessage; + +// ── Server-side WebSocket handler ─────────────────────────────────── + +/// Query parameters accepted on the `/crdt-sync` WebSocket upgrade request. +#[derive(Deserialize)] +struct SyncQueryParams { + /// Optional bearer token. Required when the server is in token-required mode. + token: Option, +} + +/// WebSocket handler for CRDT peer synchronisation. +/// +/// Accepts an optional `?token=` query parameter. When the +/// server is configured with `crdt_require_token = true`, a valid token must +/// be supplied or the upgrade is rejected with HTTP 401. When the server is +/// in open-access mode (the default), a token is optional but still validated +/// if present. +#[handler] +pub async fn crdt_sync_handler( + ws: WebSocket, + _ctx: Data<&Arc>, + remote_addr: &poem::web::RemoteAddr, + Query(params): Query, +) -> poem::Response { + // ── Bearer-token check (pre-upgrade) ──────────────────────────── + let require_token = REQUIRE_TOKEN.get().copied().unwrap_or(false); + match ¶ms.token { + Some(t) => { + if !validate_join_token(t) { + slog!("[crdt-sync] Rejected connection: invalid or expired token"); + return poem::Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body("invalid or expired token"); + } + } + None if require_token => { + slog!("[crdt-sync] Rejected connection: token required but not provided"); + return poem::Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body("token required"); + } + None => {} + } + + // ── WebSocket upgrade ──────────────────────────────────────────── + use poem::IntoResponse as _; + let peer_addr = remote_addr.to_string(); + ws.on_upgrade(move |socket| async move { + let (mut sink, mut stream) = socket.split(); + + slog!("[crdt-sync] Peer connected, starting auth handshake"); + + // ── Step 1: Send challenge to the connecting peer ───────── + let challenge = node_identity::generate_challenge(); + let challenge_msg = ChallengeMessage { + r#type: "challenge".to_string(), + nonce: challenge.clone(), + }; + let challenge_json = match serde_json::to_string(&challenge_msg) { + Ok(j) => j, + Err(_) => return, + }; + if sink.send(WsMessage::Text(challenge_json)).await.is_err() { + return; + } + + // ── Step 2: Await auth reply within timeout ─────────────── + let auth_result = tokio::time::timeout( + std::time::Duration::from_secs(AUTH_TIMEOUT_SECS), + stream.next(), + ) + .await; + + let auth_text = match auth_result { + Ok(Some(Ok(WsMessage::Text(text)))) => text, + Ok(_) | Err(_) => { + // Timeout or connection closed before auth reply. + slog!("[crdt-sync] Auth timeout or connection lost during handshake"); + let _ = sink + .send(WsMessage::Close(Some(( + poem::web::websocket::CloseCode::from(4001), + "auth_timeout".to_string(), + )))) + .await; + let _ = sink.close().await; + return; + } + }; + + // ── Step 3: Verify auth reply ───────────────────────────── + let auth_msg: AuthMessage = match serde_json::from_str(&auth_text) { + Ok(m) => m, + Err(_) => { + slog!("[crdt-sync] Invalid auth message from peer"); + close_with_auth_failed(&mut sink).await; + return; + } + }; + + // Verify signature AND check allow-list. + let sig_valid = + node_identity::verify_challenge(&auth_msg.pubkey_hex, &challenge, &auth_msg.signature_hex); + let key_trusted = trusted_keys().iter().any(|k| k == &auth_msg.pubkey_hex); + + if !sig_valid || !key_trusted { + slog!("[crdt-sync] Auth failed for peer (sig_valid={sig_valid}, key_trusted={key_trusted})"); + close_with_auth_failed(&mut sink).await; + return; + } + + slog!( + "[crdt-sync] Peer authenticated: {:.12}…", + &auth_msg.pubkey_hex + ); + + // ── Auth passed — proceed with CRDT sync ────────────────── + + // v2 protocol: send our vector clock so the peer can compute the delta. + let our_clock = crdt_state::our_vector_clock().unwrap_or_default(); + let clock_msg = SyncMessage::Clock { clock: our_clock }; + if let Ok(json) = serde_json::to_string(&clock_msg) + && sink.send(WsMessage::Text(json)).await.is_err() + { + return; + } + + // Wait for the peer's first sync message to determine protocol version. + let first_msg = tokio::time::timeout( + std::time::Duration::from_secs(AUTH_TIMEOUT_SECS), + wait_for_sync_text(&mut stream, &mut sink), + ) + .await; + + match first_msg { + Ok(Some(SyncMessage::Clock { clock: peer_clock })) => { + // v2 peer — if we have a snapshot and the peer has an empty + // clock (new node), send the snapshot first for onboarding. + if peer_clock.is_empty() + && let Some(snapshot) = crdt_snapshot::latest_snapshot() + { + let snap_msg = crdt_snapshot::SnapshotMessage::Snapshot(snapshot); + if let Ok(json) = serde_json::to_string(&snap_msg) { + if sink.send(WsMessage::Text(json)).await.is_err() { + return; + } + slog!("[crdt-sync] Sent snapshot to new node for onboarding"); + } + } + + // Send only the ops the peer is missing. + let delta = crdt_state::ops_since(&peer_clock).unwrap_or_default(); + slog!( + "[crdt-sync] v2 delta sync: sending {} ops (peer missing)", + delta.len() + ); + let msg = SyncMessage::Bulk { ops: delta }; + if let Ok(json) = serde_json::to_string(&msg) + && sink.send(WsMessage::Text(json)).await.is_err() + { + return; + } + } + Ok(Some(SyncMessage::Bulk { ops })) => { + // v1 peer — apply their bulk and send our full bulk. + let mut applied = 0u64; + for op_json in &ops { + if let Ok(signed_op) = serde_json::from_str::(op_json) + && crdt_state::apply_remote_op(signed_op) + { + applied += 1; + } + } + slog!( + "[crdt-sync] v1 bulk sync: received {} ops, applied {applied}", + ops.len() + ); + if let Some(all) = crdt_state::all_ops_json() { + let msg = SyncMessage::Bulk { ops: all }; + if let Ok(json) = serde_json::to_string(&msg) + && sink.send(WsMessage::Text(json)).await.is_err() + { + return; + } + } + } + Ok(Some(SyncMessage::Op { op })) => { + // Single op before negotiation — treat as v1. + if let Ok(signed_op) = serde_json::from_str::(&op) { + crdt_state::apply_remote_op(signed_op); + } + if let Some(all) = crdt_state::all_ops_json() { + let msg = SyncMessage::Bulk { ops: all }; + if let Ok(json) = serde_json::to_string(&msg) + && sink.send(WsMessage::Text(json)).await.is_err() + { + return; + } + } + } + _ => { + // Timeout or error — send full bulk as fallback. + slog!("[crdt-sync] No sync message from peer; sending full bulk as fallback"); + if let Some(all) = crdt_state::all_ops_json() { + let msg = SyncMessage::Bulk { ops: all }; + if let Ok(json) = serde_json::to_string(&msg) + && sink.send(WsMessage::Text(json)).await.is_err() + { + return; + } + } + } + } + + // Bulk-delta phase complete — signal the peer that we are ready for + // real-time op streaming. + if let Ok(json) = serde_json::to_string(&SyncMessage::Ready) + && sink.send(WsMessage::Text(json)).await.is_err() + { + return; + } + + // Subscribe to new local ops. + let Some(mut op_rx) = crdt_state::subscribe_ops() else { + return; + }; + + // Buffer for locally-generated ops produced before the peer's `ready` + // arrives. Flushed in-order once the peer signals catch-up. + let mut peer_ready = false; + let mut op_buffer: Vec = Vec::new(); + + // ── Keepalive state ─────────────────────────────────────────── + let mut pong_deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(PONG_TIMEOUT_SECS); + let mut ping_ticker = tokio::time::interval_at( + tokio::time::Instant::now() + + std::time::Duration::from_secs(PING_INTERVAL_SECS), + std::time::Duration::from_secs(PING_INTERVAL_SECS), + ); + + loop { + tokio::select! { + // Send periodic Ping and enforce Pong timeout. + _ = ping_ticker.tick() => { + if tokio::time::Instant::now() >= pong_deadline { + slog_warn!( + "[crdt-sync] No pong from peer {} in {}s; disconnecting", + peer_addr, + PONG_TIMEOUT_SECS + ); + break; + } + if sink.send(WsMessage::Ping(vec![])).await.is_err() { + break; + } + } + // Forward new local ops to the peer encoded via the wire codec. + result = op_rx.recv() => { + match result { + Ok(signed_op) => { + if peer_ready { + let bytes = crdt_wire::encode(&signed_op); + if sink.send(WsMessage::Binary(bytes)).await.is_err() { + break; + } + } else { + // Buffer until the peer signals ready. + op_buffer.push(signed_op); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + // The peer cannot keep up; disconnect so it can + // reconnect and receive a fresh bulk state dump. + slog!("[crdt-sync] Slow peer lagged {n} ops; disconnecting"); + break; + } + Err(_) => break, + } + } + // Receive ops from the peer. + frame = stream.next() => { + match frame { + Some(Ok(WsMessage::Pong(_))) => { + // Reset the pong deadline on every Pong received. + pong_deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(PONG_TIMEOUT_SECS); + } + Some(Ok(WsMessage::Ping(data))) => { + // Respond to peer's Ping so the peer's keepalive passes. + let _ = sink.send(WsMessage::Pong(data)).await; + } + Some(Ok(WsMessage::Text(text))) => { + // Check for the ready signal before other text frames. + if let Ok(SyncMessage::Ready) = serde_json::from_str(&text) { + peer_ready = true; + slog!("[crdt-sync] Peer ready; flushing {} buffered ops", op_buffer.len()); + let mut flush_ok = true; + for op in op_buffer.drain(..) { + let bytes = crdt_wire::encode(&op); + if sink.send(WsMessage::Binary(bytes)).await.is_err() { + flush_ok = false; + break; + } + } + if !flush_ok { + break; + } + } else { + // Bulk state dump, legacy op frame, or clock frame. + handle_incoming_text(&text); + } + } + Some(Ok(WsMessage::Binary(bytes))) => { + // Real-time op encoded via wire codec — applied immediately + // regardless of our own ready state. + handle_incoming_binary(&bytes); + } + Some(Ok(WsMessage::Close(_))) | None => break, + _ => {} + } + } + } + } + + slog!("[crdt-sync] Peer disconnected"); + }) + .into_response() +} + +/// Wait for the next text-frame sync message from the peer, handling Ping/Pong +/// transparently. +/// +/// Returns `None` on connection close or read error. +async fn wait_for_sync_text( + stream: &mut futures::stream::SplitStream, + sink: &mut futures::stream::SplitSink, +) -> Option { + loop { + match stream.next().await { + Some(Ok(WsMessage::Text(text))) => { + return serde_json::from_str(&text).ok(); + } + Some(Ok(WsMessage::Ping(data))) => { + let _ = sink.send(WsMessage::Pong(data)).await; + } + Some(Ok(WsMessage::Pong(_))) => continue, + _ => return None, + } + } +} + +/// Close the WebSocket with a generic `auth_failed` reason. +/// +/// The close reason is intentionally the same for all auth failures +/// (bad signature, untrusted key, malformed message) to avoid leaking +/// which check failed. +async fn close_with_auth_failed( + sink: &mut futures::stream::SplitSink, +) { + let _ = sink + .send(WsMessage::Close(Some(( + poem::web::websocket::CloseCode::from(4002), + "auth_failed".to_string(), + )))) + .await; + let _ = sink.close().await; +} + +/// Process an incoming text-frame sync message from a peer. +/// +/// Text frames carry the bulk state dump (`SyncMessage::Bulk`), legacy +/// single-op messages (`SyncMessage::Op`), or snapshot protocol messages. +fn handle_incoming_text(text: &str) { + // First try to parse as a snapshot protocol message. + if let Ok(snapshot_msg) = serde_json::from_str::(text) { + handle_snapshot_message(snapshot_msg); + return; + } + + let msg: SyncMessage = match serde_json::from_str(text) { + Ok(m) => m, + Err(e) => { + slog!("[crdt-sync] Bad text message from peer: {e}"); + return; + } + }; + + match msg { + SyncMessage::Bulk { ops } => { + let mut applied = 0u64; + for op_json in &ops { + if let Ok(signed_op) = serde_json::from_str::(op_json) + && crdt_state::apply_remote_op(signed_op) + { + applied += 1; + } + } + slog!( + "[crdt-sync] Bulk sync: received {} ops, applied {applied}", + ops.len() + ); + } + SyncMessage::Op { op } => { + if let Ok(signed_op) = serde_json::from_str::(&op) { + crdt_state::apply_remote_op(signed_op); + } + } + SyncMessage::Clock { .. } => { + // Clock frames are handled during the initial negotiation phase. + // If one arrives during the streaming loop it is a protocol error + // on the peer's part — log and ignore. + slog!("[crdt-sync] Ignoring unexpected clock frame during streaming phase"); + } + SyncMessage::Ready => { + // Ready frames are intercepted inline in the streaming loop before + // this function is called. If one reaches here it is a protocol + // error — log and ignore. + slog!("[crdt-sync] Ignoring unexpected ready frame in handle_incoming_text"); + } + } +} + +/// Handle an incoming snapshot protocol message. +/// +/// - **Snapshot**: apply the snapshot state and send an ack back. +/// Peers without snapshot support will never reach this code path because +/// the `SnapshotMessage` parse will fail and the message falls through to +/// the legacy `SyncMessage` handler, which logs and ignores unknown types. +/// - **SnapshotAck**: record the ack for quorum tracking. +fn handle_snapshot_message(msg: crdt_snapshot::SnapshotMessage) { + match msg { + crdt_snapshot::SnapshotMessage::Snapshot(snapshot) => { + slog!( + "[crdt-sync] Received snapshot at_seq={}, {} ops, {} manifest entries", + snapshot.at_seq, + snapshot.state.len(), + snapshot.op_manifest.len() + ); + // Apply compaction on this peer. + crdt_snapshot::apply_compaction(snapshot.clone()); + + // Send ack back to leader via the sync broadcast channel. + // The ack is sent as a CRDT event that the streaming loop picks up. + // For now, log the ack intent — actual transport is handled by the + // caller that invokes handle_incoming_text. + slog!( + "[crdt-sync] Snapshot applied, ack for at_seq={}", + snapshot.at_seq + ); + } + crdt_snapshot::SnapshotMessage::SnapshotAck(ack) => { + if let Some(node_id) = crdt_state::our_node_id() { + let _ = node_id; // The ack comes from a peer, not from us. + } + slog!( + "[crdt-sync] Received snapshot_ack for at_seq={}", + ack.at_seq + ); + // Record the ack — the coordination logic checks for quorum. + // Note: we don't know the peer's node_id from the message alone; + // in a full implementation the ack would include the sender's + // node_id. For now we log it for protocol completeness. + } + } +} + +/// Process an incoming binary-frame op from a peer. +/// +/// Binary frames carry a single `SignedOp` encoded via [`crdt_wire`]. +fn handle_incoming_binary(bytes: &[u8]) { + match crdt_wire::decode(bytes) { + Ok(signed_op) => { + crdt_state::apply_remote_op(signed_op); + } + Err(e) => { + slog!("[crdt-sync] Bad binary frame from peer: {e}"); + } + } +} + +// ── Rendezvous client ─────────────────────────────────────────────── + +/// Number of consecutive connection failures before escalating from WARN to ERROR. +pub const RENDEZVOUS_ERROR_THRESHOLD: u32 = 10; + +/// Spawn a background task that connects to the configured rendezvous +/// peer and exchanges CRDT ops bidirectionally. +/// +/// The client reconnects with exponential backoff if the connection drops. +/// Individual failures are logged at WARN; after [`RENDEZVOUS_ERROR_THRESHOLD`] +/// consecutive failures the log level escalates to ERROR. +/// +/// When `token` is provided it is appended to the upgrade URL as +/// `?token=` so the server's bearer-token check is satisfied. This +/// reuses the existing `--join-token` / `HUSKIES_JOIN_TOKEN` plumbing on the +/// agent side. +pub fn spawn_rendezvous_client(url: String, token: Option) { + tokio::spawn(async move { + let mut backoff_secs = 1u64; + let mut consecutive_failures: u32 = 0; + loop { + slog!("[crdt-sync] Connecting to rendezvous peer: {url}"); + match connect_and_sync(&url, token.as_deref()).await { + Ok(()) => { + slog!("[crdt-sync] Rendezvous connection closed cleanly"); + backoff_secs = 1; + consecutive_failures = 0; + } + Err(e) => { + consecutive_failures += 1; + if consecutive_failures >= RENDEZVOUS_ERROR_THRESHOLD { + slog_error!( + "[crdt-sync] Rendezvous peer unreachable ({consecutive_failures} consecutive failures): {e}" + ); + } else { + slog_warn!( + "[crdt-sync] Rendezvous connection error (attempt {consecutive_failures}): {e}" + ); + } + } + } + slog!("[crdt-sync] Reconnecting in {backoff_secs}s..."); + tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await; + backoff_secs = (backoff_secs * 2).min(30); + } + }); +} + +/// Connect to a remote sync endpoint and exchange ops until disconnect. +/// +/// When `token` is supplied it is appended as `?token=` to the +/// connection URL so the server's bearer-token check passes. +pub(crate) async fn connect_and_sync(url: &str, token: Option<&str>) -> Result<(), String> { + let connect_url = match token { + Some(t) => { + if url.contains('?') { + format!("{url}&token={t}") + } else { + format!("{url}?token={t}") + } + } + None => url.to_string(), + }; + let (ws_stream, _) = tokio_tungstenite::connect_async(connect_url.as_str()) + .await + .map_err(|e| format!("WebSocket connect failed: {e}"))?; + + let (mut sink, mut stream) = ws_stream.split(); + + slog!("[crdt-sync] Connected to rendezvous peer, awaiting challenge"); + + // ── Step 1: Receive challenge from listener ─────────────────── + use tokio_tungstenite::tungstenite::Message as TungsteniteMsg; + + let challenge_frame = tokio::time::timeout( + std::time::Duration::from_secs(AUTH_TIMEOUT_SECS), + stream.next(), + ) + .await + .map_err(|_| "Auth timeout waiting for challenge".to_string())? + .ok_or_else(|| "Connection closed before challenge".to_string())? + .map_err(|e| format!("WebSocket read error: {e}"))?; + + let challenge_text = match challenge_frame { + TungsteniteMsg::Text(t) => t.to_string(), + _ => return Err("Expected text frame for challenge".to_string()), + }; + + let challenge_msg: ChallengeMessage = serde_json::from_str(&challenge_text) + .map_err(|e| format!("Invalid challenge message: {e}"))?; + + if challenge_msg.r#type != "challenge" { + return Err(format!( + "Expected challenge message, got type={}", + challenge_msg.r#type + )); + } + + // ── Step 2: Sign challenge and send auth reply ──────────────── + let (pubkey_hex, signature_hex) = crdt_state::sign_challenge(&challenge_msg.nonce) + .ok_or_else(|| "CRDT not initialised — cannot sign challenge".to_string())?; + + let auth_msg = AuthMessage { + r#type: "auth".to_string(), + pubkey_hex, + signature_hex, + }; + let auth_json = serde_json::to_string(&auth_msg).map_err(|e| format!("Serialize auth: {e}"))?; + sink.send(TungsteniteMsg::Text(auth_json.into())) + .await + .map_err(|e| format!("Send auth failed: {e}"))?; + + slog!("[crdt-sync] Auth reply sent, waiting for sync data"); + + // v2 protocol: send our vector clock. + let our_clock = crdt_state::our_vector_clock().unwrap_or_default(); + let clock_msg = SyncMessage::Clock { clock: our_clock }; + if let Ok(json) = serde_json::to_string(&clock_msg) { + sink.send(TungsteniteMsg::Text(json.into())) + .await + .map_err(|e| format!("Send clock failed: {e}"))?; + } + + // Wait for the server's first sync message. + let first_msg = tokio::time::timeout( + std::time::Duration::from_secs(AUTH_TIMEOUT_SECS), + wait_for_rendezvous_sync_text(&mut stream), + ) + .await + .map_err(|_| "Timeout waiting for server sync message".to_string())?; + + match first_msg { + Some(SyncMessage::Clock { clock: peer_clock }) => { + // v2 server — send only the ops the server is missing. + let delta = crdt_state::ops_since(&peer_clock).unwrap_or_default(); + slog!( + "[crdt-sync] v2 delta sync: sending {} ops to server (server missing)", + delta.len() + ); + let msg = SyncMessage::Bulk { ops: delta }; + if let Ok(json) = serde_json::to_string(&msg) { + sink.send(TungsteniteMsg::Text(json.into())) + .await + .map_err(|e| format!("Send delta failed: {e}"))?; + } + } + Some(SyncMessage::Bulk { ops }) => { + // v1 server — apply their bulk and send our full bulk. + let mut applied = 0u64; + for op_json in &ops { + if let Ok(signed_op) = serde_json::from_str::(op_json) + && crdt_state::apply_remote_op(signed_op) + { + applied += 1; + } + } + slog!( + "[crdt-sync] v1 bulk sync: received {} ops from server, applied {applied}", + ops.len() + ); + if let Some(all) = crdt_state::all_ops_json() { + let msg = SyncMessage::Bulk { ops: all }; + if let Ok(json) = serde_json::to_string(&msg) { + sink.send(TungsteniteMsg::Text(json.into())) + .await + .map_err(|e| format!("Send bulk failed: {e}"))?; + } + } + } + _ => { + // Fallback — send full bulk. + slog!("[crdt-sync] No sync message from server; sending full bulk as fallback"); + if let Some(all) = crdt_state::all_ops_json() { + let msg = SyncMessage::Bulk { ops: all }; + if let Ok(json) = serde_json::to_string(&msg) { + sink.send(TungsteniteMsg::Text(json.into())) + .await + .map_err(|e| format!("Send bulk failed: {e}"))?; + } + } + } + } + + // Bulk-delta phase complete — signal the server that we are ready for + // real-time op streaming. + if let Ok(json) = serde_json::to_string(&SyncMessage::Ready) { + sink.send(TungsteniteMsg::Text(json.into())) + .await + .map_err(|e| format!("Send ready failed: {e}"))?; + } + + // Subscribe to new local ops. + let Some(mut op_rx) = crdt_state::subscribe_ops() else { + return Err("CRDT not initialised".to_string()); + }; + + // Buffer for locally-generated ops produced before the server's `ready` + // arrives. Flushed in-order once the server signals catch-up. + let mut peer_ready = false; + let mut op_buffer: Vec = Vec::new(); + + // ── Keepalive state ─────────────────────────────────────────────── + let mut pong_deadline = + tokio::time::Instant::now() + std::time::Duration::from_secs(PONG_TIMEOUT_SECS); + let mut ping_ticker = tokio::time::interval_at( + tokio::time::Instant::now() + std::time::Duration::from_secs(PING_INTERVAL_SECS), + std::time::Duration::from_secs(PING_INTERVAL_SECS), + ); + + loop { + tokio::select! { + // Send periodic Ping and enforce Pong timeout. + _ = ping_ticker.tick() => { + if tokio::time::Instant::now() >= pong_deadline { + slog_warn!( + "[crdt-sync] No pong from rendezvous peer {} in {}s; disconnecting", + url, + PONG_TIMEOUT_SECS + ); + return Err(format!( + "Keepalive timeout: no pong from {url} in {PONG_TIMEOUT_SECS}s" + )); + } + use tokio_tungstenite::tungstenite::Message as TungsteniteMsg; + if sink.send(TungsteniteMsg::Ping(bytes::Bytes::new())).await.is_err() { + break; + } + } + result = op_rx.recv() => { + match result { + Ok(signed_op) => { + if peer_ready { + // Encode via wire codec and send as binary frame. + let bytes = crdt_wire::encode(&signed_op); + use tokio_tungstenite::tungstenite::Message as TungsteniteMsg; + if sink.send(TungsteniteMsg::Binary(bytes.into())).await.is_err() { + break; + } + } else { + // Buffer until the server signals ready. + op_buffer.push(signed_op); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + slog!("[crdt-sync] Slow rendezvous link lagged {n} ops; disconnecting"); + break; + } + Err(_) => break, + } + } + frame = stream.next() => { + match frame { + Some(Ok(tokio_tungstenite::tungstenite::Message::Pong(_))) => { + // Reset the pong deadline on every Pong received. + pong_deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(PONG_TIMEOUT_SECS); + } + Some(Ok(tokio_tungstenite::tungstenite::Message::Ping(_))) => { + // tungstenite auto-responds to Ping with Pong at the + // protocol level; no manual response needed here. + } + Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text))) => { + // Check for the ready signal before other text frames. + if let Ok(SyncMessage::Ready) = serde_json::from_str(text.as_ref()) { + peer_ready = true; + slog!("[crdt-sync] Server ready; flushing {} buffered ops", op_buffer.len()); + let mut flush_ok = true; + for op in op_buffer.drain(..) { + let bytes = crdt_wire::encode(&op); + use tokio_tungstenite::tungstenite::Message as TungsteniteMsg; + if sink.send(TungsteniteMsg::Binary(bytes.into())).await.is_err() { + flush_ok = false; + break; + } + } + if !flush_ok { + break; + } + } else { + handle_incoming_text(text.as_ref()); + } + } + Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(bytes))) => { + // Real-time op — applied immediately regardless of ready state. + handle_incoming_binary(&bytes); + } + Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) | None => break, + Some(Err(e)) => { + slog!("[crdt-sync] Rendezvous read error: {e}"); + break; + } + _ => {} + } + } + } + } + + Ok(()) +} + +/// Wait for the next text-frame sync message from a tungstenite stream, +/// handling Ping/Pong transparently. +/// +/// Returns `None` on connection close or read error. +async fn wait_for_rendezvous_sync_text( + stream: &mut futures::stream::SplitStream< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + >, +) -> Option { + use tokio_tungstenite::tungstenite::Message as TungsteniteMsg; + loop { + match stream.next().await { + Some(Ok(TungsteniteMsg::Text(text))) => { + return serde_json::from_str(text.as_ref()).ok(); + } + Some(Ok(TungsteniteMsg::Ping(_) | TungsteniteMsg::Pong(_))) => continue, + _ => return None, + } + } +} + +// ── Tests ──────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests; diff --git a/server/src/crdt_sync/tests.rs b/server/src/crdt_sync/tests.rs new file mode 100644 index 00000000..d36860b5 --- /dev/null +++ b/server/src/crdt_sync/tests.rs @@ -0,0 +1,2667 @@ +use super::*; + +#[test] +fn sync_message_bulk_serialization_roundtrip() { + let msg = SyncMessage::Bulk { + ops: vec!["op1".to_string(), "op2".to_string()], + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""type":"bulk""#)); + let deserialized: SyncMessage = serde_json::from_str(&json).unwrap(); + match deserialized { + SyncMessage::Bulk { ops } => { + assert_eq!(ops.len(), 2); + assert_eq!(ops[0], "op1"); + assert_eq!(ops[1], "op2"); + } + _ => panic!("Expected Bulk"), + } +} + +#[test] +fn sync_message_op_serialization_roundtrip() { + let msg = SyncMessage::Op { + op: r#"{"inner":{}}"#.to_string(), + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""type":"op""#)); + let deserialized: SyncMessage = serde_json::from_str(&json).unwrap(); + match deserialized { + SyncMessage::Op { op } => { + assert_eq!(op, r#"{"inner":{}}"#); + } + _ => panic!("Expected Op"), + } +} + +#[test] +fn handle_incoming_text_bad_json_does_not_panic() { + handle_incoming_text("not valid json"); +} + +#[test] +fn handle_incoming_text_bulk_with_invalid_ops_does_not_panic() { + let msg = SyncMessage::Bulk { + ops: vec!["not a valid signed op".to_string()], + }; + let json = serde_json::to_string(&msg).unwrap(); + handle_incoming_text(&json); +} + +#[test] +fn handle_incoming_text_op_with_invalid_op_does_not_panic() { + let msg = SyncMessage::Op { + op: "garbage".to_string(), + }; + let json = serde_json::to_string(&msg).unwrap(); + handle_incoming_text(&json); +} + +#[test] +fn handle_incoming_binary_bad_bytes_does_not_panic() { + handle_incoming_binary(b"not valid wire codec"); +} + +#[test] +fn handle_incoming_binary_empty_bytes_does_not_panic() { + handle_incoming_binary(b""); +} + +#[test] +fn subscribe_ops_returns_none_before_init() { + // Before crdt_state::init() the channel doesn't exist yet. + // In test binaries it may or may not be initialised depending on + // other tests, so we just verify no panic. + let _ = crdt_state::subscribe_ops(); +} + +#[test] +fn all_ops_json_returns_none_before_init() { + let _ = crdt_state::all_ops_json(); +} + +#[test] +fn sync_message_bulk_empty_ops() { + let msg = SyncMessage::Bulk { ops: vec![] }; + let json = serde_json::to_string(&msg).unwrap(); + let deserialized: SyncMessage = serde_json::from_str(&json).unwrap(); + match deserialized { + SyncMessage::Bulk { ops } => assert!(ops.is_empty()), + _ => panic!("Expected Bulk"), + } +} + +/// Simulate the sync protocol by creating real SignedOps on two separate +/// CRDT instances and exchanging them through the SyncMessage wire format. +#[test] +fn two_node_sync_via_protocol_messages() { + use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, OpState}; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use serde_json::json; + + use crate::crdt_state::PipelineDoc; + + // ── Node A: create an item ── + let kp_a = make_keypair(); + let mut crdt_a = BaseCrdt::::new(&kp_a); + + let item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "100_story_sync_test", + "stage": "1_backlog", + "name": "Sync Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op1 = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp_a); + assert_eq!(crdt_a.apply(op1.clone()), OpState::Ok); + + // Serialise op1 into a SyncMessage::Op. + let op1_json = serde_json::to_string(&op1).unwrap(); + let wire_msg = SyncMessage::Op { + op: op1_json.clone(), + }; + let wire_json = serde_json::to_string(&wire_msg).unwrap(); + + // ── Node B: receive the op through protocol ── + let kp_b = make_keypair(); + let mut crdt_b = BaseCrdt::::new(&kp_b); + assert!(crdt_b.doc.items.view().is_empty()); + + // Parse wire message and apply. + let parsed: SyncMessage = serde_json::from_str(&wire_json).unwrap(); + match parsed { + SyncMessage::Op { op } => { + let signed_op: bft_json_crdt::json_crdt::SignedOp = serde_json::from_str(&op).unwrap(); + let result = crdt_b.apply(signed_op); + assert_eq!(result, OpState::Ok); + } + _ => panic!("Expected Op"), + } + + // Verify Node B has the same state as Node A. + assert_eq!(crdt_b.doc.items.view().len(), 1); + assert_eq!( + crdt_a.doc.items[0].story_id.view(), + crdt_b.doc.items[0].story_id.view() + ); + assert_eq!( + crdt_a.doc.items[0].stage.view(), + crdt_b.doc.items[0].stage.view() + ); + + // ── Node A: update stage ── + let op2 = crdt_a.doc.items[0] + .stage + .set("2_current".to_string()) + .sign(&kp_a); + crdt_a.apply(op2.clone()); + + // Send via bulk message. + let op2_json = serde_json::to_string(&op2).unwrap(); + let bulk_msg = SyncMessage::Bulk { + ops: vec![op1_json, op2_json], + }; + let bulk_wire = serde_json::to_string(&bulk_msg).unwrap(); + + // ── Node C: receives full state via bulk ── + let kp_c = make_keypair(); + let mut crdt_c = BaseCrdt::::new(&kp_c); + + let parsed_bulk: SyncMessage = serde_json::from_str(&bulk_wire).unwrap(); + match parsed_bulk { + SyncMessage::Bulk { ops } => { + for op_str in &ops { + let signed: bft_json_crdt::json_crdt::SignedOp = + serde_json::from_str(op_str).unwrap(); + crdt_c.apply(signed); + } + } + _ => panic!("Expected Bulk"), + } + + // Node C should have the updated stage. + assert_eq!(crdt_c.doc.items.view().len(), 1); + assert_eq!( + crdt_c.doc.items[0].stage.view(), + bft_json_crdt::json_crdt::JsonValue::String("2_current".to_string()) + ); +} + +/// Verify that a single node's ops (insert + update) can be replayed +/// on another node via bulk sync and produce the same final state. +/// This is the core property needed for partition healing: when a +/// disconnected node reconnects, it sends all its ops as a bulk +/// message and the receiver catches up. +#[test] +fn partition_heal_via_bulk_replay() { + use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV}; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use serde_json::json; + + use crate::crdt_state::PipelineDoc; + + let kp = make_keypair(); + + // Node A creates an item and advances it. + let mut crdt_a = BaseCrdt::::new(&kp); + let item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "200_story_heal", + "stage": "1_backlog", + "name": "Heal Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + + let op1 = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp); + crdt_a.apply(op1.clone()); + + let op2 = crdt_a.doc.items[0] + .stage + .set("2_current".to_string()) + .sign(&kp); + crdt_a.apply(op2.clone()); + + let op3 = crdt_a.doc.items[0].stage.set("3_qa".to_string()).sign(&kp); + crdt_a.apply(op3.clone()); + + // Serialise all ops as a bulk message (simulates partition heal). + let ops_json: Vec = [&op1, &op2, &op3] + .iter() + .map(|op| serde_json::to_string(op).unwrap()) + .collect(); + let bulk = SyncMessage::Bulk { ops: ops_json }; + let wire = serde_json::to_string(&bulk).unwrap(); + + // Node B receives the bulk and reconstructs state. + let mut crdt_b = BaseCrdt::::new(&kp); + let parsed: SyncMessage = serde_json::from_str(&wire).unwrap(); + match parsed { + SyncMessage::Bulk { ops } => { + for op_str in &ops { + let signed: bft_json_crdt::json_crdt::SignedOp = + serde_json::from_str(op_str).unwrap(); + crdt_b.apply(signed); + } + } + _ => panic!("Expected Bulk"), + } + + // Node B should match Node A exactly. + assert_eq!(crdt_b.doc.items.view().len(), 1); + assert_eq!( + crdt_b.doc.items[0].stage.view(), + JV::String("3_qa".to_string()) + ); + assert_eq!( + crdt_a.doc.items[0].stage.view(), + crdt_b.doc.items[0].stage.view() + ); + assert_eq!( + crdt_a.doc.items[0].name.view(), + crdt_b.doc.items[0].name.view() + ); +} + +#[test] +fn config_rendezvous_parsed_from_toml() { + let toml_str = r#" +rendezvous = "ws://remote:3001/crdt-sync" + +[[agent]] +name = "test" +"#; + let config: crate::config::ProjectConfig = toml::from_str(toml_str).unwrap(); + assert_eq!( + config.rendezvous.as_deref(), + Some("ws://remote:3001/crdt-sync") + ); +} + +#[test] +fn config_rendezvous_defaults_to_none() { + let config = crate::config::ProjectConfig::default(); + assert!(config.rendezvous.is_none()); +} + +// ── AC4: Failure logging escalation ────────────────────────────────────── + +/// AC4: Connection errors must be logged at WARN for the first nine +/// consecutive failures and escalate to ERROR from the tenth onwards. +#[test] +fn failure_counter_warn_below_threshold() { + let threshold = super::RENDEZVOUS_ERROR_THRESHOLD; + let mut consecutive_failures: u32 = 0; + + // First threshold-1 failures are below the ERROR threshold. + for _ in 0..(threshold - 1) { + consecutive_failures += 1; + assert!( + consecutive_failures < threshold, + "failure {consecutive_failures} must be below ERROR threshold {threshold}" + ); + } +} + +/// AC4: The tenth consecutive failure must trigger ERROR-level logging. +#[test] +fn failure_counter_error_at_threshold() { + let threshold = super::RENDEZVOUS_ERROR_THRESHOLD; + let consecutive_failures: u32 = threshold; + assert!( + consecutive_failures >= threshold, + "failure {consecutive_failures} must reach or exceed ERROR threshold {threshold}" + ); +} + +/// AC4: A successful connection resets the failure counter so subsequent +/// single failures are again logged at WARN (not ERROR). +#[test] +fn failure_counter_resets_on_success() { + let threshold = super::RENDEZVOUS_ERROR_THRESHOLD; + // Simulate sustained failure. + let mut consecutive_failures: u32 = threshold + 5; + assert!(consecutive_failures >= threshold); + + // Simulate a clean reconnect. + consecutive_failures = 0; + assert_eq!( + consecutive_failures, 0, + "counter must reset to 0 on success" + ); + + // Next error is attempt 1 — well below the ERROR threshold. + consecutive_failures += 1; + assert!( + consecutive_failures < threshold, + "first failure after reset must be below ERROR threshold" + ); +} + +/// AC4: The RENDEZVOUS_ERROR_THRESHOLD constant must equal 10. +#[test] +fn error_threshold_is_ten() { + assert_eq!( + super::RENDEZVOUS_ERROR_THRESHOLD, + 10, + "ERROR escalation threshold must be 10 consecutive failures" + ); +} + +// ── AC5: Self-loop dedup ────────────────────────────────────────────────── + +/// AC5: Applying the same SignedOp twice returns AlreadySeen on the second +/// call and leaves the CRDT state unchanged. +#[test] +fn self_loop_dedup_second_apply_is_noop() { + use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV, OpState}; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use serde_json::json; + + use crate::crdt_state::PipelineDoc; + + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + + let item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "507_dedup_test", + "stage": "1_backlog", + "name": "Dedup Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + + let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); + + // First apply: succeeds. + assert_eq!(crdt.apply(op.clone()), OpState::Ok); + assert_eq!(crdt.doc.items.view().len(), 1); + + // Second apply (self-loop): must be a no-op. + assert_eq!(crdt.apply(op.clone()), OpState::AlreadySeen); + + // State must not have changed. + assert_eq!(crdt.doc.items.view().len(), 1); + + // Stage update also deduplicated correctly. + let stage_op = crdt.doc.items[0] + .stage + .set("2_current".to_string()) + .sign(&kp); + assert_eq!(crdt.apply(stage_op.clone()), OpState::Ok); + assert_eq!( + crdt.doc.items[0].stage.view(), + JV::String("2_current".to_string()) + ); + assert_eq!(crdt.apply(stage_op), OpState::AlreadySeen); + assert_eq!( + crdt.doc.items[0].stage.view(), + JV::String("2_current".to_string()), + "stage must not change on duplicate apply" + ); +} + +// ── AC3 & AC7: Out-of-order causal queueing ─────────────────────────────── + +/// AC3/AC7: An op whose causal dependency has not yet arrived is held in the +/// queue (returns MissingCausalDependencies). When the dependency arrives +/// the queued op is released and applied automatically. +#[test] +fn out_of_order_causal_queueing_releases_on_dep_arrival() { + use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV, OpState}; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use serde_json::json; + + use crate::crdt_state::PipelineDoc; + + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + + let item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "507_causal_test", + "stage": "1_backlog", + "name": "Causal Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + + // op1 = insert item (no deps) + let op1 = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); + + // op2 = set stage, declared to depend on op1 + // We must first apply op1 locally to generate op2 from the right state, + // then we'll test op2-before-op1 on a fresh CRDT. + crdt.apply(op1.clone()); + let op2 = crdt.doc.items[0] + .stage + .set("2_current".to_string()) + .sign_with_dependencies(&kp, vec![&op1]); + + // Create a fresh receiver CRDT. + let mut receiver = BaseCrdt::::new(&kp); + + // Apply op2 first — dependency (op1) has not arrived yet. + let r = receiver.apply(op2.clone()); + assert_eq!( + r, + OpState::MissingCausalDependencies, + "op2 must be queued when op1 has not arrived" + ); + // Queue length must reflect the held op. + assert_eq!(receiver.causal_queue_len(), 1); + + // Item has NOT been inserted yet (op1 not applied). + assert_eq!(receiver.doc.items.view().len(), 0); + + // Now deliver op1 — this should trigger op2 to be flushed automatically. + let r = receiver.apply(op1.clone()); + assert_eq!(r, OpState::Ok); + + // Both ops are now applied — item is present at stage 2_current. + assert_eq!(receiver.doc.items.view().len(), 1); + assert_eq!( + receiver.doc.items[0].stage.view(), + JV::String("2_current".to_string()), + "op2 must have been applied automatically after op1 arrived" + ); + + // Queue must be empty now. + assert_eq!(receiver.causal_queue_len(), 0); +} + +/// AC7: In-order apply works correctly (no causal queueing needed). +#[test] +fn in_order_apply_works_without_queueing() { + use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV, OpState}; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use serde_json::json; + + use crate::crdt_state::PipelineDoc; + + let kp = make_keypair(); + let mut crdt_a = BaseCrdt::::new(&kp); + + let item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "507_inorder_test", + "stage": "1_backlog", + "name": "In-Order Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + + let op1 = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp); + crdt_a.apply(op1.clone()); + let op2 = crdt_a.doc.items[0] + .stage + .set("2_current".to_string()) + .sign(&kp); + crdt_a.apply(op2.clone()); + let op3 = crdt_a.doc.items[0].stage.set("3_qa".to_string()).sign(&kp); + crdt_a.apply(op3.clone()); + + // Receiver applies all ops in the correct order. + let mut crdt_b = BaseCrdt::::new(&kp); + assert_eq!(crdt_b.apply(op1), OpState::Ok); + assert_eq!(crdt_b.apply(op2), OpState::Ok); + assert_eq!(crdt_b.apply(op3), OpState::Ok); + assert_eq!(crdt_b.causal_queue_len(), 0); + assert_eq!( + crdt_b.doc.items[0].stage.view(), + JV::String("3_qa".to_string()) + ); +} + +// ── AC4: Queue overflow behaviour ───────────────────────────────────────── + +/// AC4: When the causal-order queue exceeds CAUSAL_QUEUE_MAX the oldest +/// pending op is evicted (queue never grows beyond the cap). +#[test] +fn causal_queue_overflow_drops_oldest() { + use bft_json_crdt::json_crdt::{BaseCrdt, CAUSAL_QUEUE_MAX, OpState}; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use serde_json::json; + + use crate::crdt_state::PipelineDoc; + + let kp = make_keypair(); + + // Build one "phantom" op that we'll claim as a dependency but never deliver. + // We do this by creating it on a separate CRDT and never applying it. + let mut source = BaseCrdt::::new(&kp); + let phantom_item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "507_phantom", + "stage": "1_backlog", + "name": "Phantom", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let phantom_op = source.doc.items.insert(ROOT_ID, phantom_item).sign(&kp); + + // Receiver never sees phantom_op, so any op declaring it as a dep will + // sit in the causal queue forever (until evicted by overflow). + let mut receiver = BaseCrdt::::new(&kp); + source.apply(phantom_op.clone()); + + // Send CAUSAL_QUEUE_MAX + 5 stage-update ops all depending on phantom_op. + // Each one will be queued because phantom_op is never delivered. + let mut queued = 0usize; + for i in 0..CAUSAL_QUEUE_MAX + 5 { + let stage_name = format!("stage_{i}"); + // Generate from source so seq numbers are valid. + let op = source.doc.items[0] + .stage + .set(stage_name) + .sign_with_dependencies(&kp, vec![&phantom_op]); + source.apply(op.clone()); + let r = receiver.apply(op); + if r == OpState::MissingCausalDependencies { + queued += 1; + } + } + + // We sent more than CAUSAL_QUEUE_MAX ops, but the queue must stay bounded. + assert!( + receiver.causal_queue_len() <= CAUSAL_QUEUE_MAX, + "queue ({}) must not exceed CAUSAL_QUEUE_MAX ({CAUSAL_QUEUE_MAX})", + receiver.causal_queue_len() + ); + assert!( + queued > 0, + "at least some ops must have been accepted into the queue" + ); +} + +// ── AC6: Convergence test ───────────────────────────────────────────────── + +/// AC6: Two CRDT instances generate interleaved ops on each side, simulate a +/// network partition by withholding each other's ops, then exchange all +/// buffered ops. Final state must be byte-identical on both nodes. +#[test] +fn convergence_after_partition_and_replay() { + use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV, OpState}; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use serde_json::json; + + use crate::crdt_state::PipelineDoc; + + let kp_a = make_keypair(); + let kp_b = make_keypair(); + + let mut crdt_a = BaseCrdt::::new(&kp_a); + let mut crdt_b = BaseCrdt::::new(&kp_b); + + // ── Phase 1: A generates ops while partitioned from B ────────────── + + let item_a: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "507_convergence_a", + "stage": "1_backlog", + "name": "Story A", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op_a1 = crdt_a.doc.items.insert(ROOT_ID, item_a).sign(&kp_a); + crdt_a.apply(op_a1.clone()); + + let op_a2 = crdt_a.doc.items[0] + .stage + .set("2_current".to_string()) + .sign(&kp_a); + crdt_a.apply(op_a2.clone()); + + // ── Phase 2: B generates ops while partitioned from A ────────────── + + let item_b: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "507_convergence_b", + "stage": "1_backlog", + "name": "Story B", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op_b1 = crdt_b.doc.items.insert(ROOT_ID, item_b).sign(&kp_b); + crdt_b.apply(op_b1.clone()); + + let op_b2 = crdt_b.doc.items[0] + .stage + .set("2_current".to_string()) + .sign(&kp_b); + crdt_b.apply(op_b2.clone()); + + // ── Phase 3: Reconnect — both sides replay all buffered ops ──────── + + // A sends its ops to B. + let r = crdt_b.apply(op_a1.clone()); + assert!(r == OpState::Ok || r == OpState::AlreadySeen); + let r = crdt_b.apply(op_a2.clone()); + assert!(r == OpState::Ok || r == OpState::AlreadySeen); + + // B sends its ops to A. + let r = crdt_a.apply(op_b1.clone()); + assert!(r == OpState::Ok || r == OpState::AlreadySeen); + let r = crdt_a.apply(op_b2.clone()); + assert!(r == OpState::Ok || r == OpState::AlreadySeen); + + // ── Phase 4: Assert convergence ──────────────────────────────────── + + // Both nodes must have both stories. + assert_eq!( + crdt_a.doc.items.view().len(), + 2, + "A must have 2 items after convergence" + ); + assert_eq!( + crdt_b.doc.items.view().len(), + 2, + "B must have 2 items after convergence" + ); + + // Serialise both CRDT views to JSON and assert byte-identical. + let view_a = serde_json::to_string(&CrdtNode::view(&crdt_a.doc.items)).unwrap(); + let view_b = serde_json::to_string(&CrdtNode::view(&crdt_b.doc.items)).unwrap(); + assert_eq!( + view_a, view_b, + "CRDT states must be byte-identical after convergence" + ); + + // Spot-check: both stories are at 2_current on both nodes. + let stories_a: Vec = crdt_a + .doc + .items + .view() + .iter() + .filter_map(|item| { + if let JV::Object(m) = CrdtNode::view(item) { + m.get("story_id").and_then(|s| { + if let JV::String(s) = s { + Some(s.clone()) + } else { + None + } + }) + } else { + None + } + }) + .collect(); + assert!( + stories_a.contains(&"507_convergence_a".to_string()), + "A must contain story_a" + ); + assert!( + stories_a.contains(&"507_convergence_b".to_string()), + "A must contain story_b" + ); +} + +// ── AC8: peer lifecycle tests ───────────────────────────────────────────── + +/// AC8: A peer that connects and then receives a subsequently-applied op +/// gets that op encoded via the wire codec (binary frame). +#[test] +fn peer_receives_op_encoded_via_wire_codec() { + use bft_json_crdt::json_crdt::BaseCrdt; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use serde_json::json; + + use crate::crdt_state::PipelineDoc; + use crate::crdt_wire; + + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + let item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "506_story_lifecycle_test", + "stage": "1_backlog", + "name": "Lifecycle Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); + + // Simulate what the broadcast handler does: encode via wire codec. + let bytes = crdt_wire::encode(&op); + + // The bytes must be a versioned JSON envelope, not a SyncMessage wrapper. + let text = std::str::from_utf8(&bytes).expect("wire output is valid UTF-8"); + assert!( + text.contains("\"v\":1"), + "wire codec version tag must be present: {text}" + ); + assert!( + !text.contains("\"type\":\"op\""), + "must not be wrapped in SyncMessage: {text}" + ); + + // The receiving peer can decode and apply the op. + let decoded = crdt_wire::decode(&bytes).expect("decode must succeed"); + assert_eq!(op, decoded); +} + +/// AC8: Multiple connected peers all receive the same broadcast op. +#[tokio::test] +async fn multiple_peers_all_receive_broadcast_op() { + use bft_json_crdt::json_crdt::BaseCrdt; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use serde_json::json; + use tokio::sync::broadcast; + + use crate::crdt_state::PipelineDoc; + use crate::crdt_wire; + + // Create a broadcast channel (analogous to SYNC_TX). + let (tx, _) = broadcast::channel::(16); + let mut rx_peer1 = tx.subscribe(); + let mut rx_peer2 = tx.subscribe(); + + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + let item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "506_story_multi_peer_test", + "stage": "1_backlog", + "name": "Multi-Peer Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); + + // Broadcast one op. + tx.send(op.clone()).expect("send must succeed"); + + // Both peers receive the same op. + let received1 = rx_peer1.recv().await.expect("peer 1 must receive"); + let received2 = rx_peer2.recv().await.expect("peer 2 must receive"); + assert_eq!(received1, op); + assert_eq!(received2, op); + + // Both encode identically via wire codec. + let bytes1 = crdt_wire::encode(&received1); + let bytes2 = crdt_wire::encode(&received2); + assert_eq!(bytes1, bytes2, "wire-encoded bytes must be identical"); +} + +/// AC8: A peer disconnecting mid-broadcast does not panic. +/// Simulated by dropping the receiver before the sender sends an op. +#[test] +fn disconnected_peer_does_not_panic() { + use bft_json_crdt::json_crdt::BaseCrdt; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use serde_json::json; + use tokio::sync::broadcast; + + use crate::crdt_state::PipelineDoc; + + let (tx, rx) = broadcast::channel::(16); + // Drop the receiver to simulate a peer that disconnected. + drop(rx); + + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + let item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "506_story_disconnect_test", + "stage": "1_backlog", + "name": "Disconnect Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); + + // Sending to a channel with no receivers returns an error; must not panic. + let _ = tx.send(op); +} + +/// AC8: A lagged receiver gets a `Lagged` error (confirming the +/// disconnect-on-overflow behaviour is reachable). +#[tokio::test] +async fn lagged_peer_gets_lagged_error() { + use bft_json_crdt::json_crdt::BaseCrdt; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use serde_json::json; + use tokio::sync::broadcast; + + use crate::crdt_state::PipelineDoc; + + // Tiny capacity so we can trigger Lagged easily. + let (tx, mut rx) = broadcast::channel::(2); + + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + let item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "506_story_lag_test", + "stage": "1_backlog", + "name": "Lag Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op1 = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); + crdt.apply(op1.clone()); + + // Overflow the tiny buffer by sending more ops than the capacity. + let op2 = crdt.doc.items[0] + .stage + .set("2_current".to_string()) + .sign(&kp); + crdt.apply(op2.clone()); + let op3 = crdt.doc.items[0].stage.set("3_qa".to_string()).sign(&kp); + crdt.apply(op3.clone()); + let op4 = crdt.doc.items[0].stage.set("4_merge".to_string()).sign(&kp); + crdt.apply(op4.clone()); + + // Send more ops than the channel capacity without consuming. + let _ = tx.send(op1); + let _ = tx.send(op2); + let _ = tx.send(op3); + let _ = tx.send(op4); + + // The slow peer should now see a Lagged error on next recv. + // Consume until we hit Lagged or run out. + let mut got_lagged = false; + for _ in 0..10 { + match rx.recv().await { + Err(broadcast::error::RecvError::Lagged(_)) => { + got_lagged = true; + break; + } + Ok(_) => continue, + Err(broadcast::error::RecvError::Closed) => break, + } + } + assert!( + got_lagged, + "slow peer must receive a Lagged error when channel overflows" + ); +} + +// ── AC5 (story 508): E2E convergence test via real WebSocket ───────────── + +/// AC5: Spin up two in-process WebSocket nodes. Node A serves a +/// `/crdt-sync`-compatible endpoint; Node B connects as a rendezvous client. +/// Node A sends a bulk state; Node B applies it. Assert both nodes see the +/// same items within a bounded time window. +#[tokio::test] +async fn e2e_convergence_two_websocket_nodes() { + use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV, OpState}; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use futures::{SinkExt, StreamExt}; + use serde_json::json; + use std::sync::{Arc, Mutex}; + use tokio::net::TcpListener; + use tokio_tungstenite::tungstenite::Message as TMsg; + use tokio_tungstenite::{accept_async, connect_async}; + + use crate::crdt_state::PipelineDoc; + + // ── Node A: build local state ────────────────────────────────────── + let kp_a = make_keypair(); + let mut crdt_a = BaseCrdt::::new(&kp_a); + + let item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "508_e2e_convergence", + "stage": "2_current", + "name": "E2E Convergence Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op1 = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp_a); + crdt_a.apply(op1.clone()); + + // Serialise A's full state as a bulk message. + let op1_json = serde_json::to_string(&op1).unwrap(); + let bulk_msg = SyncMessage::Bulk { + ops: vec![op1_json], + }; + let bulk_wire = serde_json::to_string(&bulk_msg).unwrap(); + + // ── Start Node A's WebSocket server on a random port ─────────────── + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let bulk_to_send = bulk_wire.clone(); + let received_by_a: Arc>> = Arc::new(Mutex::new(vec![])); + let received_by_a_clone = received_by_a.clone(); + + tokio::spawn(async move { + let (tcp_stream, _) = listener.accept().await.unwrap(); + let ws_stream = accept_async(tcp_stream).await.unwrap(); + let (mut sink, mut stream) = ws_stream.split(); + + // Send bulk state to the connecting peer. + sink.send(TMsg::Text(bulk_to_send.into())).await.unwrap(); + + // Also listen for ops sent by the peer. + if let Some(Ok(TMsg::Text(txt))) = stream.next().await { + received_by_a_clone.lock().unwrap().push(txt.to_string()); + } + }); + + // ── Node B: connect to Node A and exchange state ─────────────────── + let kp_b = make_keypair(); + let mut crdt_b = BaseCrdt::::new(&kp_b); + + let url = format!("ws://{addr}"); + let (ws_b, _) = connect_async(&url).await.unwrap(); + let (mut sink_b, mut stream_b) = ws_b.split(); + + // Node B receives bulk from A. + if let Some(Ok(TMsg::Text(txt))) = stream_b.next().await { + let msg: SyncMessage = serde_json::from_str(txt.as_str()).unwrap(); + match msg { + SyncMessage::Bulk { ops } => { + for op_str in &ops { + let signed: bft_json_crdt::json_crdt::SignedOp = + serde_json::from_str(op_str).unwrap(); + let r = crdt_b.apply(signed); + assert!(r == OpState::Ok || r == OpState::AlreadySeen); + } + } + _ => panic!("Expected Bulk from Node A"), + } + } + + // Node B also creates a new op and sends it to A. + let item_b: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "508_e2e_convergence_b", + "stage": "1_backlog", + "name": "E2E Convergence B", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op_b1 = crdt_b.doc.items.insert(ROOT_ID, item_b).sign(&kp_b); + crdt_b.apply(op_b1.clone()); + + let op_b1_json = serde_json::to_string(&op_b1).unwrap(); + let msg_to_a = SyncMessage::Op { op: op_b1_json }; + sink_b + .send(TMsg::Text(serde_json::to_string(&msg_to_a).unwrap().into())) + .await + .unwrap(); + + // Wait a moment for Node A to process. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // ── Assert convergence ───────────────────────────────────────────── + + // Node B received Node A's item. + assert_eq!( + crdt_b.doc.items.view().len(), + 2, + "Node B must see both items after sync" + ); + let has_a_item = crdt_b + .doc + .items + .view() + .iter() + .any(|item| item.story_id.view() == JV::String("508_e2e_convergence".to_string())); + assert!(has_a_item, "Node B must have Node A's item"); + + // Node A received Node B's op via the WebSocket. + let a_received = received_by_a.lock().unwrap(); + assert!( + !a_received.is_empty(), + "Node A must have received an op from Node B" + ); +} + +// ── AC6 (story 508): Partition healing E2E via real WebSocket ───────────── + +/// AC6: Two nodes exchange ops, the connection is dropped (partition), each +/// side mutates independently, then they reconnect and the reconnecting +/// client sends a fresh bulk state. Assert both converge to the same final +/// state. +#[tokio::test] +async fn e2e_partition_healing_websocket() { + use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV}; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use futures::{SinkExt, StreamExt}; + use serde_json::json; + use tokio::net::TcpListener; + use tokio_tungstenite::tungstenite::Message as TMsg; + use tokio_tungstenite::{accept_async, connect_async}; + + use crate::crdt_state::PipelineDoc; + + // ── Phase 1: Both nodes start with op_a1 (before partition) ─────── + let kp_a = make_keypair(); + let kp_b = make_keypair(); + let mut crdt_a = BaseCrdt::::new(&kp_a); + let mut crdt_b = BaseCrdt::::new(&kp_b); + + let item_a: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "508_heal_a", + "stage": "1_backlog", + "name": "Heal A", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op_a1 = crdt_a.doc.items.insert(ROOT_ID, item_a).sign(&kp_a); + crdt_a.apply(op_a1.clone()); + // B also starts with op_a1 (shared state before partition). + crdt_b.apply(op_a1.clone()); + + // ── Phase 2: Partition — each side mutates independently ────────── + // A advances its story stage. + let op_a2 = crdt_a.doc.items[0] + .stage + .set("2_current".to_string()) + .sign(&kp_a); + crdt_a.apply(op_a2.clone()); + + // B inserts a new story that A doesn't know about yet. + let item_b: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "508_heal_b", + "stage": "1_backlog", + "name": "Heal B", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op_b1 = crdt_b.doc.items.insert(ROOT_ID, item_b).sign(&kp_b); + crdt_b.apply(op_b1.clone()); + + // Collect B's full state as bulk (what it will send on reconnect). + let b_ops: Vec = [&op_a1, &op_b1] + .iter() + .map(|op| serde_json::to_string(op).unwrap()) + .collect(); + let b_bulk_wire = serde_json::to_string(&SyncMessage::Bulk { ops: b_ops }).unwrap(); + + // Collect A's full state as bulk (what it will send on reconnect). + let a_ops: Vec = [&op_a1, &op_a2] + .iter() + .map(|op| serde_json::to_string(op).unwrap()) + .collect(); + let a_bulk_wire = serde_json::to_string(&SyncMessage::Bulk { ops: a_ops }).unwrap(); + + // ── Phase 3: Reconnect — use a real WebSocket to exchange bulk ──── + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let a_bulk_to_send = a_bulk_wire.clone(); + let a_received_bulk: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(None)); + let a_received_clone = a_received_bulk.clone(); + + tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let ws = accept_async(tcp).await.unwrap(); + let (mut sink, mut stream) = ws.split(); + // A sends its bulk state. + sink.send(TMsg::Text(a_bulk_to_send.into())).await.unwrap(); + // A receives B's bulk state. + if let Some(Ok(TMsg::Text(txt))) = stream.next().await { + *a_received_clone.lock().unwrap() = Some(txt.to_string()); + } + }); + + // B connects, exchanges bulk state. + let (ws_b, _) = connect_async(format!("ws://{addr}")).await.unwrap(); + let (mut sink_b, mut stream_b) = ws_b.split(); + + // B receives A's bulk and applies it. + if let Some(Ok(TMsg::Text(txt))) = stream_b.next().await { + let msg: SyncMessage = serde_json::from_str(txt.as_str()).unwrap(); + if let SyncMessage::Bulk { ops } = msg { + for op_str in &ops { + let signed: bft_json_crdt::json_crdt::SignedOp = + serde_json::from_str(op_str).unwrap(); + let _ = crdt_b.apply(signed); + } + } + } + + // B sends its bulk state to A. + sink_b.send(TMsg::Text(b_bulk_wire.into())).await.unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + // Apply A's received ops into crdt_a. + if let Some(bulk_str) = a_received_bulk.lock().unwrap().take() { + let msg: SyncMessage = serde_json::from_str(&bulk_str).unwrap(); + if let SyncMessage::Bulk { ops } = msg { + for op_str in &ops { + let signed: bft_json_crdt::json_crdt::SignedOp = + serde_json::from_str(op_str).unwrap(); + let _ = crdt_a.apply(signed); + } + } + } + + // ── Assert convergence ───────────────────────────────────────────── + + // Both nodes must have 2 items. + assert_eq!( + crdt_a.doc.items.view().len(), + 2, + "A must have 2 items after healing" + ); + assert_eq!( + crdt_b.doc.items.view().len(), + 2, + "B must have 2 items after healing" + ); + + // A must see B's story. + let b_story_on_a = crdt_a + .doc + .items + .view() + .iter() + .any(|item| item.story_id.view() == JV::String("508_heal_b".to_string())); + assert!(b_story_on_a, "A must have B's story after healing"); + + // B must see A's stage advance. + let a_story_on_b = crdt_b + .doc + .items + .view() + .iter() + .any(|item| item.story_id.view() == JV::String("508_heal_a".to_string())); + assert!(a_story_on_b, "B must have A's story after healing"); + + // CRDT views must be byte-identical (convergence). + let view_a = serde_json::to_string(&CrdtNode::view(&crdt_a.doc.items)).unwrap(); + let view_b = serde_json::to_string(&CrdtNode::view(&crdt_b.doc.items)).unwrap(); + assert_eq!( + view_a, view_b, + "Both nodes must converge to identical state" + ); +} + +// ── Story 628: Connect-time mutual auth integration tests ──────────── + +/// Helper: run a listener that performs the server-side auth handshake. +/// Returns the TCP listener address so the connector can connect. +/// `trusted_keys` controls which pubkeys the listener accepts. +/// If `on_authenticated` is provided, it runs after successful auth. +async fn start_auth_listener( + trusted_keys: Vec, +) -> ( + std::net::SocketAddr, + tokio::sync::oneshot::Receiver, +) { + use tokio::net::TcpListener; + use tokio_tungstenite::accept_async; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let (result_tx, result_rx) = tokio::sync::oneshot::channel(); + + tokio::spawn(async move { + let (tcp_stream, _) = listener.accept().await.unwrap(); + let ws_stream = accept_async(tcp_stream).await.unwrap(); + let (mut sink, mut stream) = ws_stream.split(); + + use tokio_tungstenite::tungstenite::Message as TMsg; + + // Step 1: Send challenge. + let challenge = crate::node_identity::generate_challenge(); + let challenge_msg = super::ChallengeMessage { + r#type: "challenge".to_string(), + nonce: challenge.clone(), + }; + let challenge_json = serde_json::to_string(&challenge_msg).unwrap(); + if sink.send(TMsg::Text(challenge_json.into())).await.is_err() { + let _ = result_tx.send(AuthListenerResult::ConnectionLost); + return; + } + + // Step 2: Await auth reply (10s timeout). + let auth_frame = + tokio::time::timeout(std::time::Duration::from_secs(10), stream.next()).await; + + let auth_text = match auth_frame { + Ok(Some(Ok(TMsg::Text(t)))) => t.to_string(), + Ok(Some(Ok(TMsg::Close(reason)))) => { + let _ = result_tx.send(AuthListenerResult::PeerClosedEarly( + reason.map(|r| r.reason.to_string()), + )); + return; + } + _ => { + let _ = sink + .send(TMsg::Close(Some(tokio_tungstenite::tungstenite::protocol::CloseFrame { + code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::from(4001), + reason: "auth_timeout".into(), + }))) + .await; + let _ = result_tx.send(AuthListenerResult::AuthTimeout); + return; + } + }; + + // Step 3: Verify. + let auth_msg: super::AuthMessage = match serde_json::from_str(&auth_text) { + Ok(m) => m, + Err(_) => { + let _ = close_listener_auth_failed(&mut sink).await; + let _ = result_tx.send(AuthListenerResult::AuthFailed("bad_json".into())); + return; + } + }; + + let sig_valid = crate::node_identity::verify_challenge( + &auth_msg.pubkey_hex, + &challenge, + &auth_msg.signature_hex, + ); + let key_trusted = trusted_keys.iter().any(|k| k == &auth_msg.pubkey_hex); + + if !sig_valid || !key_trusted { + let _ = close_listener_auth_failed(&mut sink).await; + let _ = result_tx.send(AuthListenerResult::AuthFailed(format!( + "sig_valid={sig_valid}, key_trusted={key_trusted}" + ))); + return; + } + + // Auth passed! Send a bulk state with one op to prove sync works. + let kp = bft_json_crdt::keypair::make_keypair(); + let mut crdt = + bft_json_crdt::json_crdt::BaseCrdt::::new(&kp); + let item: bft_json_crdt::json_crdt::JsonValue = serde_json::json!({ + "story_id": "628_auth_test_item", + "stage": "1_backlog", + "name": "Auth Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op = crdt + .doc + .items + .insert(bft_json_crdt::op::ROOT_ID, item) + .sign(&kp); + let op_json = serde_json::to_string(&op).unwrap(); + let bulk = super::SyncMessage::Bulk { ops: vec![op_json] }; + let bulk_json = serde_json::to_string(&bulk).unwrap(); + let _ = sink.send(TMsg::Text(bulk_json.into())).await; + + let _ = result_tx.send(AuthListenerResult::Authenticated(auth_msg.pubkey_hex)); + }); + + (addr, result_rx) +} + +#[derive(Debug)] +#[allow(dead_code)] +enum AuthListenerResult { + Authenticated(String), + AuthFailed(String), + AuthTimeout, + ConnectionLost, + PeerClosedEarly(Option), +} + +async fn close_listener_auth_failed( + sink: &mut futures::stream::SplitSink< + tokio_tungstenite::WebSocketStream, + tokio_tungstenite::tungstenite::Message, + >, +) { + use tokio_tungstenite::tungstenite::protocol::CloseFrame; + use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; + let _ = sink + .send(tokio_tungstenite::tungstenite::Message::Close(Some( + CloseFrame { + code: CloseCode::from(4002), + reason: "auth_failed".into(), + }, + ))) + .await; +} + +/// AC5 (story 628): Happy path — two nodes with each other's pubkeys +/// allow-listed complete the handshake and exchange at least one CRDT op. +#[tokio::test] +async fn auth_happy_path_handshake_and_sync() { + use bft_json_crdt::keypair::make_keypair; + use futures::{SinkExt, StreamExt}; + use tokio_tungstenite::connect_async; + use tokio_tungstenite::tungstenite::Message as TMsg; + + let connector_kp = make_keypair(); + let connector_pubkey = crate::node_identity::public_key_hex(&connector_kp); + + // Start listener that trusts the connector's pubkey. + let (addr, result_rx) = start_auth_listener(vec![connector_pubkey.clone()]).await; + + // Connect and do the handshake. + let url = format!("ws://{addr}"); + let (ws, _) = connect_async(&url).await.unwrap(); + let (mut sink, mut stream) = ws.split(); + + // Receive challenge. + let challenge_frame = stream.next().await.unwrap().unwrap(); + let challenge_text = match challenge_frame { + TMsg::Text(t) => t.to_string(), + other => panic!("Expected text frame, got {other:?}"), + }; + let challenge_msg: super::ChallengeMessage = serde_json::from_str(&challenge_text).unwrap(); + assert_eq!(challenge_msg.r#type, "challenge"); + assert_eq!( + challenge_msg.nonce.len(), + 64, + "Challenge must be 64 hex chars" + ); + + // Sign and reply. + let sig = crate::node_identity::sign_challenge(&connector_kp, &challenge_msg.nonce); + let auth_msg = super::AuthMessage { + r#type: "auth".to_string(), + pubkey_hex: connector_pubkey.clone(), + signature_hex: sig, + }; + let auth_json = serde_json::to_string(&auth_msg).unwrap(); + sink.send(TMsg::Text(auth_json.into())).await.unwrap(); + + // After auth, we should receive a bulk sync message with at least one op. + let bulk_frame = tokio::time::timeout(std::time::Duration::from_secs(5), stream.next()) + .await + .expect("should receive bulk within 5s") + .unwrap() + .unwrap(); + + let bulk_text = match bulk_frame { + TMsg::Text(t) => t.to_string(), + other => panic!("Expected bulk text frame, got {other:?}"), + }; + let bulk_msg: super::SyncMessage = serde_json::from_str(&bulk_text).unwrap(); + match bulk_msg { + super::SyncMessage::Bulk { ops } => { + assert!( + !ops.is_empty(), + "Bulk sync must contain at least one op after successful auth" + ); + // Verify we can deserialize the op. + let _signed: bft_json_crdt::json_crdt::SignedOp = + serde_json::from_str(&ops[0]).unwrap(); + } + _ => panic!("Expected Bulk message after auth"), + } + + // Verify listener also reports success. + let listener_result = result_rx.await.unwrap(); + match listener_result { + AuthListenerResult::Authenticated(pubkey) => { + assert_eq!(pubkey, connector_pubkey); + } + other => panic!("Expected Authenticated, got {other:?}"), + } +} + +/// AC6 (story 628): Untrusted pubkey — connector whose pubkey is NOT in +/// the listener's allow-list is rejected with close reason auth_failed, +/// and zero CRDT ops are exchanged. +#[tokio::test] +async fn auth_untrusted_pubkey_rejected() { + use bft_json_crdt::keypair::make_keypair; + use futures::{SinkExt, StreamExt}; + use tokio_tungstenite::connect_async; + use tokio_tungstenite::tungstenite::Message as TMsg; + + let connector_kp = make_keypair(); + let connector_pubkey = crate::node_identity::public_key_hex(&connector_kp); + + // Listener trusts a DIFFERENT key, not the connector's. + let other_kp = make_keypair(); + let other_pubkey = crate::node_identity::public_key_hex(&other_kp); + + let (addr, result_rx) = start_auth_listener(vec![other_pubkey]).await; + + let url = format!("ws://{addr}"); + let (ws, _) = connect_async(&url).await.unwrap(); + let (mut sink, mut stream) = ws.split(); + + // Receive challenge and sign with our (untrusted) key. + let challenge_frame = stream.next().await.unwrap().unwrap(); + let challenge_text = match challenge_frame { + TMsg::Text(t) => t.to_string(), + _ => panic!("Expected text frame"), + }; + let challenge_msg: super::ChallengeMessage = serde_json::from_str(&challenge_text).unwrap(); + + let sig = crate::node_identity::sign_challenge(&connector_kp, &challenge_msg.nonce); + let auth_msg = super::AuthMessage { + r#type: "auth".to_string(), + pubkey_hex: connector_pubkey, + signature_hex: sig, + }; + sink.send(TMsg::Text(serde_json::to_string(&auth_msg).unwrap().into())) + .await + .unwrap(); + + // Should receive a close frame with auth_failed. + let close_frame = tokio::time::timeout(std::time::Duration::from_secs(5), stream.next()) + .await + .expect("should receive close within 5s"); + + match close_frame { + Some(Ok(TMsg::Close(Some(frame)))) => { + assert_eq!( + &*frame.reason, "auth_failed", + "Close reason must be 'auth_failed'" + ); + } + Some(Ok(TMsg::Close(None))) => { + // Some implementations omit the close frame payload — that's acceptable + // as long as no sync data was sent. + } + other => { + // Connection dropped without close frame is also acceptable. + // The key assertion is below: no ops were exchanged. + let _ = other; + } + } + + // Verify listener reports auth failure. + let listener_result = result_rx.await.unwrap(); + match listener_result { + AuthListenerResult::AuthFailed(reason) => { + assert!(reason.contains("key_trusted=false"), "Reason: {reason}"); + } + other => panic!("Expected AuthFailed, got {other:?}"), + } +} + +/// AC7 (story 628): Bad signature — connector signs with wrong keypair. +/// Rejected with the same auth_failed — indistinguishable from untrusted key. +#[tokio::test] +async fn auth_bad_signature_rejected() { + use bft_json_crdt::keypair::make_keypair; + use futures::{SinkExt, StreamExt}; + use tokio_tungstenite::connect_async; + use tokio_tungstenite::tungstenite::Message as TMsg; + + let legitimate_kp = make_keypair(); + let legitimate_pubkey = crate::node_identity::public_key_hex(&legitimate_kp); + + // A different keypair that will sign the challenge (wrong key). + let impersonator_kp = make_keypair(); + + // Listener trusts the legitimate pubkey. + let (addr, result_rx) = start_auth_listener(vec![legitimate_pubkey.clone()]).await; + + let url = format!("ws://{addr}"); + let (ws, _) = connect_async(&url).await.unwrap(); + let (mut sink, mut stream) = ws.split(); + + // Receive challenge. + let challenge_frame = stream.next().await.unwrap().unwrap(); + let challenge_text = match challenge_frame { + TMsg::Text(t) => t.to_string(), + _ => panic!("Expected text frame"), + }; + let challenge_msg: super::ChallengeMessage = serde_json::from_str(&challenge_text).unwrap(); + + // Sign with the WRONG keypair but claim to be the legitimate pubkey. + let bad_sig = crate::node_identity::sign_challenge(&impersonator_kp, &challenge_msg.nonce); + let auth_msg = super::AuthMessage { + r#type: "auth".to_string(), + pubkey_hex: legitimate_pubkey, + signature_hex: bad_sig, + }; + sink.send(TMsg::Text(serde_json::to_string(&auth_msg).unwrap().into())) + .await + .unwrap(); + + // Should be rejected. + let close_frame = tokio::time::timeout(std::time::Duration::from_secs(5), stream.next()) + .await + .expect("should receive close within 5s"); + + match close_frame { + Some(Ok(TMsg::Close(Some(frame)))) => { + assert_eq!( + &*frame.reason, "auth_failed", + "Close reason must be 'auth_failed' — same as untrusted key" + ); + } + _ => { + // Connection closed is acceptable. + } + } + + // Verify listener reports auth failure with sig_valid=false. + let listener_result = result_rx.await.unwrap(); + match listener_result { + AuthListenerResult::AuthFailed(reason) => { + assert!(reason.contains("sig_valid=false"), "Reason: {reason}"); + } + other => panic!("Expected AuthFailed, got {other:?}"), + } +} + +/// AC8 (story 628): Replay protection sanity — two consecutive connect +/// attempts receive different challenges (fresh nonce per accept). +#[tokio::test] +async fn auth_replay_protection_fresh_nonces() { + use futures::StreamExt; + use tokio::net::TcpListener; + use tokio_tungstenite::tungstenite::Message as TMsg; + use tokio_tungstenite::{accept_async, connect_async}; + + // Start a listener that sends challenges but doesn't complete auth. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let (nonce_tx, mut nonce_rx) = tokio::sync::mpsc::channel::(2); + + tokio::spawn(async move { + for _ in 0..2 { + let (tcp, _) = listener.accept().await.unwrap(); + let ws = accept_async(tcp).await.unwrap(); + let (mut sink, _stream) = ws.split(); + + let challenge = crate::node_identity::generate_challenge(); + let msg = super::ChallengeMessage { + r#type: "challenge".to_string(), + nonce: challenge.clone(), + }; + let json = serde_json::to_string(&msg).unwrap(); + let _ = sink.send(TMsg::Text(json.into())).await; + let _ = nonce_tx.send(challenge).await; + } + }); + + // Connect twice and collect the nonces. + let mut nonces = Vec::new(); + for _ in 0..2 { + let url = format!("ws://{addr}"); + let (ws, _) = connect_async(&url).await.unwrap(); + let (_sink, mut stream) = ws.split(); + + let frame = stream.next().await.unwrap().unwrap(); + let text = match frame { + TMsg::Text(t) => t.to_string(), + _ => panic!("Expected text"), + }; + let msg: super::ChallengeMessage = serde_json::from_str(&text).unwrap(); + nonces.push(msg.nonce); + // Drop connection so listener accepts the next one. + drop(stream); + } + + // Also collect nonces from the listener side. + let server_nonce_1 = nonce_rx.recv().await.unwrap(); + let server_nonce_2 = nonce_rx.recv().await.unwrap(); + + assert_ne!( + nonces[0], nonces[1], + "Consecutive challenges must be different" + ); + assert_ne!( + server_nonce_1, server_nonce_2, + "Server must generate fresh nonce per accept" + ); + assert_eq!(nonces[0], server_nonce_1, "Client/server nonces must match"); + assert_eq!(nonces[1], server_nonce_2, "Client/server nonces must match"); +} + +/// AC4 (story 628): trusted_keys config is parsed from project.toml. +#[test] +fn config_trusted_keys_parsed_from_toml() { + let toml_str = r#" +trusted_keys = [ +"aabbccdd00112233aabbccdd00112233aabbccdd00112233aabbccdd00112233", +"11223344556677881122334455667788112233445566778811223344556677ab", +] + +[[agent]] +name = "test" +"#; + let config: crate::config::ProjectConfig = + crate::config::ProjectConfig::parse(toml_str).unwrap(); + assert_eq!(config.trusted_keys.len(), 2); + assert_eq!( + config.trusted_keys[0], + "aabbccdd00112233aabbccdd00112233aabbccdd00112233aabbccdd00112233" + ); +} + +/// AC4 (story 628): trusted_keys defaults to empty (reject all). +#[test] +fn config_trusted_keys_defaults_to_empty() { + let config = crate::config::ProjectConfig::default(); + assert!( + config.trusted_keys.is_empty(), + "trusted_keys must default to empty (reject all)" + ); +} + +/// AC9 (story 628): Existing per-op signed replication tests still pass. +/// This is verified implicitly by running the full test suite — this marker +/// test documents the intent. +#[test] +fn existing_sync_tests_unchanged() { + // If we got here, all previous crdt_sync tests compiled and passed. + // This test exists as a documentation anchor for AC9. +} + +// ── Story 630: WebSocket keepalive (ping/pong) ──────────────────────────── + +/// AC1/AC2: PING_INTERVAL_SECS is 30 and PONG_TIMEOUT_SECS is 60 — the +/// transport-level constants are correct. +#[test] +fn keepalive_constants_are_correct() { + assert_eq!( + super::PING_INTERVAL_SECS, + 30, + "Ping interval must be 30 seconds" + ); + assert_eq!( + super::PONG_TIMEOUT_SECS, + 60, + "Pong timeout must be 60 seconds" + ); +} + +/// AC5: The agent-mode heartbeat interval (SCAN_INTERVAL_SECS) is 15s and +/// must not be changed by the keepalive work. +#[test] +fn agent_mode_heartbeat_interval_unchanged() { + assert_eq!( + crate::agent_mode::SCAN_INTERVAL_SECS, + 15, + "Agent-mode heartbeat interval must remain 15s" + ); +} + +/// AC4: Reconnect backoff constants are unchanged. +#[test] +fn reconnect_backoff_constants_unchanged() { + assert_eq!( + super::RENDEZVOUS_ERROR_THRESHOLD, + 10, + "Backoff threshold must still be 10" + ); +} + +/// AC1: Server (accept_async side) emits a Ping frame after the configured +/// interval. Uses short durations (100 ms ping) so the test finishes fast. +#[tokio::test] +async fn server_sends_ping_to_peer_at_interval() { + use futures::{SinkExt, StreamExt}; + use std::time::Duration; + use tokio::net::TcpListener; + use tokio_tungstenite::tungstenite::Message as TMsg; + use tokio_tungstenite::{accept_async, connect_async}; + + let ping_ms = 100u64; + let timeout_ms = 400u64; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // Server task: keepalive sender with short intervals. + tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let ws = accept_async(tcp).await.unwrap(); + let (mut sink, mut stream) = ws.split(); + + let mut pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); + let mut ticker = tokio::time::interval_at( + tokio::time::Instant::now() + Duration::from_millis(ping_ms), + Duration::from_millis(ping_ms), + ); + + loop { + tokio::select! { + _ = ticker.tick() => { + if tokio::time::Instant::now() >= pong_deadline { break; } + if sink.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() { break; } + } + frame = stream.next() => { + match frame { + Some(Ok(TMsg::Pong(_))) => { + pong_deadline = tokio::time::Instant::now() + + Duration::from_millis(timeout_ms); + } + None | Some(Err(_)) => break, + _ => {} + } + } + } + } + }); + + let (ws_client, _) = connect_async(format!("ws://{addr}")).await.unwrap(); + let (_sink_c, mut stream_c) = ws_client.split(); + + // Wait for more than one ping interval. + tokio::time::sleep(Duration::from_millis(ping_ms * 2)).await; + + // Client should receive a Ping from the server. + let frame = tokio::time::timeout(Duration::from_millis(200), stream_c.next()).await; + let got_ping = matches!(frame, Ok(Some(Ok(TMsg::Ping(_))))); + assert!( + got_ping, + "Client must receive a Ping frame from the server after the ping interval" + ); +} + +/// AC2: Client-side keepalive sender emits a Ping after the interval, +/// symmetrically to the server side. +#[tokio::test] +async fn client_sends_ping_to_server_at_interval() { + use futures::{SinkExt, StreamExt}; + use std::time::Duration; + use tokio::net::TcpListener; + use tokio_tungstenite::tungstenite::Message as TMsg; + use tokio_tungstenite::{accept_async, connect_async}; + + let ping_ms = 100u64; + let timeout_ms = 400u64; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let (ping_tx, ping_rx) = tokio::sync::oneshot::channel::<()>(); + + // Server task: wait for the first Ping the client sends. + tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let ws = accept_async(tcp).await.unwrap(); + let (_sink, mut stream) = ws.split(); + loop { + match stream.next().await { + Some(Ok(TMsg::Ping(_))) => { + let _ = ping_tx.send(()); + break; + } + Some(Ok(_)) => continue, + _ => break, + } + } + }); + + let (ws_client, _) = connect_async(format!("ws://{addr}")).await.unwrap(); + let (mut sink_c, mut stream_c) = ws_client.split(); + + // Client keepalive task. + tokio::spawn(async move { + let mut pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); + let mut ticker = tokio::time::interval_at( + tokio::time::Instant::now() + Duration::from_millis(ping_ms), + Duration::from_millis(ping_ms), + ); + loop { + tokio::select! { + _ = ticker.tick() => { + if tokio::time::Instant::now() >= pong_deadline { break; } + if sink_c.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() { break; } + } + frame = stream_c.next() => { + match frame { + Some(Ok(TMsg::Pong(_))) => { + pong_deadline = tokio::time::Instant::now() + + Duration::from_millis(timeout_ms); + } + None | Some(Err(_)) => break, + _ => {} + } + } + } + } + }); + + let result = tokio::time::timeout(Duration::from_millis(ping_ms * 3), ping_rx).await; + assert!( + result.is_ok(), + "Server must receive a Ping from the client after the ping interval" + ); +} + +/// AC3: Either side disconnects when no Pong is received within the timeout. +/// The keepalive sender returns `true` (timed out) when Pongs are withheld. +#[tokio::test] +async fn keepalive_disconnects_when_pong_withheld() { + use futures::{SinkExt, StreamExt}; + use std::time::Duration; + use tokio::net::TcpListener; + use tokio_tungstenite::tungstenite::Message as TMsg; + use tokio_tungstenite::{accept_async, connect_async}; + + let ping_ms = 100u64; + let timeout_ms = 250u64; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let (done_tx, done_rx) = tokio::sync::oneshot::channel::(); + + // Server: sends Pings, never receives Pong (client swallows all). + tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let ws = accept_async(tcp).await.unwrap(); + let (mut sink, mut stream) = ws.split(); + + let pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); + let mut ticker = tokio::time::interval_at( + tokio::time::Instant::now() + Duration::from_millis(ping_ms), + Duration::from_millis(ping_ms), + ); + + let timed_out = loop { + tokio::select! { + _ = ticker.tick() => { + if tokio::time::Instant::now() >= pong_deadline { break true; } + if sink.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() { + break false; + } + } + frame = stream.next() => { + match frame { + Some(Ok(_)) => {} // swallow — no Pong sent + _ => break false, + } + } + } + }; + let _ = done_tx.send(timed_out); + }); + + // Client: connect but never respond to Pings. + let (_ws_client, _) = connect_async(format!("ws://{addr}")).await.unwrap(); + + let result = + tokio::time::timeout(Duration::from_millis(timeout_ms + ping_ms * 3), done_rx).await; + let timed_out = result + .expect("Server must report within expected wall-clock time") + .expect("oneshot intact"); + + assert!( + timed_out, + "Server must disconnect on keepalive timeout when Pong is withheld" + ); +} + +/// AC3 (positive path): Connection stays alive when Pong responses arrive +/// before the timeout fires. +#[tokio::test] +async fn keepalive_connection_survives_with_pong_responses() { + use futures::{SinkExt, StreamExt}; + use std::time::Duration; + use tokio::net::TcpListener; + use tokio_tungstenite::tungstenite::Message as TMsg; + use tokio_tungstenite::{accept_async, connect_async}; + + let ping_ms = 100u64; + let timeout_ms = 250u64; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let (result_tx, result_rx) = tokio::sync::oneshot::channel::(); + + // Server: sends Pings, resets deadline on Pong. + tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let ws = accept_async(tcp).await.unwrap(); + let (mut sink, mut stream) = ws.split(); + + let mut pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); + let mut ticker = tokio::time::interval_at( + tokio::time::Instant::now() + Duration::from_millis(ping_ms), + Duration::from_millis(ping_ms), + ); + + let timed_out = loop { + tokio::select! { + _ = ticker.tick() => { + if tokio::time::Instant::now() >= pong_deadline { break true; } + if sink.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() { + break false; + } + } + frame = stream.next() => { + match frame { + Some(Ok(TMsg::Pong(_))) => { + pong_deadline = tokio::time::Instant::now() + + Duration::from_millis(timeout_ms); + } + None | Some(Err(_)) => break false, // clean close + _ => {} + } + } + } + }; + let _ = result_tx.send(timed_out); + }); + + let (ws_client, _) = connect_async(format!("ws://{addr}")).await.unwrap(); + let (mut sink_c, mut stream_c) = ws_client.split(); + + // Client: respond to every Ping with Pong for several intervals. + let respond_task = tokio::spawn(async move { + while let Some(Ok(msg)) = stream_c.next().await { + if let TMsg::Ping(data) = msg + && sink_c.send(TMsg::Pong(data)).await.is_err() + { + break; + } + } + }); + + // Run for a few intervals, then drop the client. + tokio::time::sleep(Duration::from_millis(ping_ms * 3)).await; + respond_task.abort(); + + let result = tokio::time::timeout(Duration::from_millis(200), result_rx).await; + let timed_out = result.unwrap_or(Ok(false)).unwrap_or(false); + assert!( + !timed_out, + "Server must NOT timeout when the client responds to Pings with Pongs" + ); +} + +/// AC6: Integration — one node swallows Pongs; the other drops the +/// connection within the timeout, then reconnect is possible via backoff. +#[tokio::test] +async fn two_node_pong_swallow_causes_disconnect_within_timeout() { + use futures::{SinkExt, StreamExt}; + use std::time::Duration; + use tokio::net::TcpListener; + use tokio_tungstenite::tungstenite::Message as TMsg; + use tokio_tungstenite::{accept_async, connect_async}; + + let ping_ms = 100u64; + let timeout_ms = 250u64; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // Node A (listener): sends Pings, never receives Pong. + let (a_done_tx, a_done_rx) = tokio::sync::oneshot::channel::(); + tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let ws = accept_async(tcp).await.unwrap(); + let (mut sink, mut stream) = ws.split(); + + let pong_deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms); + let mut ticker = tokio::time::interval_at( + tokio::time::Instant::now() + Duration::from_millis(ping_ms), + Duration::from_millis(ping_ms), + ); + + let timed_out = loop { + tokio::select! { + _ = ticker.tick() => { + if tokio::time::Instant::now() >= pong_deadline { break true; } + if sink.send(TMsg::Ping(bytes::Bytes::new())).await.is_err() { + break false; + } + } + frame = stream.next() => { + match frame { + Some(Ok(_)) => {} // swallow all frames + _ => break false, + } + } + } + }; + let _ = a_done_tx.send(timed_out); + }); + + // Node B: connects, drains frames silently (swallows Pings, never pongs). + let (ws_b, _) = connect_async(format!("ws://{addr}")).await.unwrap(); + let (_sink_b, mut stream_b) = ws_b.split(); + tokio::spawn(async move { while let Some(Ok(_)) = stream_b.next().await {} }); + + let result = + tokio::time::timeout(Duration::from_millis(timeout_ms + ping_ms * 3), a_done_rx).await; + let timed_out = result + .expect("Node A must report within expected wall-clock time") + .expect("channel intact"); + + assert!( + timed_out, + "Node A must disconnect due to keepalive timeout when Node B swallows Pongs" + ); +} + +// ── Story 631: vector clock wire format tests ─────────────────────── + +/// Clock message serialization round-trip via SyncMessage. +#[test] +fn sync_message_clock_serialization_roundtrip() { + let mut clock = std::collections::HashMap::new(); + clock.insert("aabbcc00".to_string(), 42u64); + clock.insert("ddeeff11".to_string(), 7u64); + + let msg = SyncMessage::Clock { clock }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains(r#""type":"clock""#)); + + let deserialized: SyncMessage = serde_json::from_str(&json).unwrap(); + match deserialized { + SyncMessage::Clock { clock } => { + assert_eq!(clock["aabbcc00"], 42); + assert_eq!(clock["ddeeff11"], 7); + } + _ => panic!("Expected Clock"), + } +} + +/// Empty clock (new node) serializes correctly. +#[test] +fn sync_message_clock_empty() { + let msg = SyncMessage::Clock { + clock: std::collections::HashMap::new(), + }; + let json = serde_json::to_string(&msg).unwrap(); + let deserialized: SyncMessage = serde_json::from_str(&json).unwrap(); + match deserialized { + SyncMessage::Clock { clock } => assert!(clock.is_empty()), + _ => panic!("Expected Clock"), + } +} + +/// v1 compat: a v1 peer that only knows `bulk` and `op` will fail to parse +/// a `clock` message — verify the parse error is a clean serde error, not a +/// panic. +#[test] +fn v1_peer_ignores_clock_message_gracefully() { + // Simulate: v1 peer only knows Bulk and Op. + // A clock message should fail deserialization (unknown variant). + let clock_json = r#"{"type":"clock","clock":{"abc":10}}"#; + // handle_incoming_text logs and returns — must not panic. + handle_incoming_text(clock_json); +} + +/// v2 delta sync simulation: two CRDT nodes, exchange clocks, send deltas. +#[test] +fn v2_delta_sync_via_clock_exchange() { + use bft_json_crdt::json_crdt::BaseCrdt; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use fastcrypto::traits::KeyPair; + use serde_json::json; + + use crate::crdt_state::PipelineDoc; + + let kp_a = make_keypair(); + let mut crdt_a = BaseCrdt::::new(&kp_a); + + // A creates 5 items. + let mut ops_a = Vec::new(); + for i in 0..5 { + let item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": format!("631_v2_{i}"), + "stage": "1_backlog", + "name": format!("v2 item {i}"), + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp_a); + crdt_a.apply(op.clone()); + ops_a.push(op); + } + + // B has seen the first 3 ops (clock says 3). + let kp_b = make_keypair(); + let mut crdt_b = BaseCrdt::::new(&kp_b); + for op in &ops_a[..3] { + crdt_b.apply(op.clone()); + } + assert_eq!(crdt_b.doc.items.view().len(), 3); + + // Build B's clock. + let author_a_hex = crate::crdt_state::hex::encode(&kp_a.public().0.to_bytes()); + let mut clock_b = std::collections::HashMap::new(); + clock_b.insert(author_a_hex.clone(), 3u64); + + // Serialize clock as wire message. + let clock_msg = SyncMessage::Clock { + clock: clock_b.clone(), + }; + let clock_wire = serde_json::to_string(&clock_msg).unwrap(); + + // A receives B's clock and computes delta. + let parsed: SyncMessage = serde_json::from_str(&clock_wire).unwrap(); + let delta_ops = match parsed { + SyncMessage::Clock { clock: peer_clock } => { + // Simulate ops_since: A has 5 ops from author_a, B has 3. + let all_json: Vec = ops_a + .iter() + .map(|op| serde_json::to_string(op).unwrap()) + .collect(); + let mut result = Vec::new(); + let mut count = 0u64; + for (i, _op) in ops_a.iter().enumerate() { + count += 1; + let peer_has = peer_clock.get(&author_a_hex).copied().unwrap_or(0); + if count > peer_has { + result.push(all_json[i].clone()); + } + } + result + } + _ => panic!("Expected Clock"), + }; + + assert_eq!(delta_ops.len(), 2, "delta should be 2 ops (ops 4 and 5)"); + + // B applies the delta. + for op_str in &delta_ops { + let signed: bft_json_crdt::json_crdt::SignedOp = serde_json::from_str(op_str).unwrap(); + crdt_b.apply(signed); + } + assert_eq!(crdt_b.doc.items.view().len(), 5); +} + +// ── Story 632: Ready ACK handshake ──────────────────────────────────────── + +/// AC1: `{"type":"ready"}` serialises and deserialises correctly. +#[test] +fn sync_message_ready_serialization_roundtrip() { + let msg = SyncMessage::Ready; + let json = serde_json::to_string(&msg).unwrap(); + assert_eq!(json, r#"{"type":"ready"}"#); + let deserialized: SyncMessage = serde_json::from_str(&json).unwrap(); + assert!(matches!(deserialized, SyncMessage::Ready)); +} + +/// AC4 (buffer flush): Locally-generated ops are buffered while `peer_ready` +/// is false, then flushed in-order once the peer's `Ready` frame arrives. +/// Frame capture verifies ordering. +#[test] +fn buffer_flush_ops_held_until_peer_ready() { + use bft_json_crdt::json_crdt::BaseCrdt; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use serde_json::json; + + use crate::crdt_state::PipelineDoc; + + // Simulate the buffer-flush state machine without real WebSockets. + let mut peer_ready = false; + let mut op_buffer: Vec> = Vec::new(); // encoded wire frames + let mut sent_frames: Vec> = Vec::new(); // captured "sent" frames + + // Build two local ops on a fresh CRDT. + let kp = make_keypair(); + let mut crdt = BaseCrdt::::new(&kp); + let item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "632_buffer_a", + "stage": "1_backlog", + "name": "Buffer A", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let op1 = crdt.doc.items.insert(ROOT_ID, item).sign(&kp); + crdt.apply(op1.clone()); + let op2 = crdt.doc.items[0] + .stage + .set("2_current".to_string()) + .sign(&kp); + crdt.apply(op2.clone()); + + // Simulate op1 arriving from broadcast while peer is NOT ready. + let frame1 = crate::crdt_wire::encode(&op1); + if peer_ready { + sent_frames.push(frame1.clone()); + } else { + op_buffer.push(frame1.clone()); + } + + // Simulate op2 arriving while peer is still NOT ready. + let frame2 = crate::crdt_wire::encode(&op2); + if peer_ready { + sent_frames.push(frame2.clone()); + } else { + op_buffer.push(frame2.clone()); + } + + // Both ops must be buffered, none sent yet. + assert_eq!( + sent_frames.len(), + 0, + "ops must be buffered before peer Ready arrives" + ); + assert_eq!(op_buffer.len(), 2, "buffer must hold both ops"); + + // Simulate receiving the peer's Ready frame. + let ready_json = serde_json::to_string(&SyncMessage::Ready).unwrap(); + if let Ok(SyncMessage::Ready) = serde_json::from_str::(&ready_json) { + peer_ready = true; + for encoded in op_buffer.drain(..) { + sent_frames.push(encoded); + } + } + + assert!(peer_ready, "peer_ready must be true after Ready frame"); + assert_eq!(op_buffer.len(), 0, "buffer must be empty after flush"); + assert_eq!( + sent_frames.len(), + 2, + "both buffered ops must appear in captured frames after flush" + ); + // Order preserved: op1 before op2. + assert_eq!(sent_frames[0], frame1, "op1 must be first flushed frame"); + assert_eq!(sent_frames[1], frame2, "op2 must be second flushed frame"); + + // After flush, a new op is sent immediately (no buffering). + let op3 = crdt.doc.items[0].stage.set("3_qa".to_string()).sign(&kp); + crdt.apply(op3.clone()); + let frame3 = crate::crdt_wire::encode(&op3); + if peer_ready { + sent_frames.push(frame3.clone()); + } else { + op_buffer.push(frame3.clone()); + } + assert_eq!( + sent_frames.len(), + 3, + "op after flush must be sent immediately" + ); + assert_eq!(op_buffer.len(), 0, "buffer must remain empty"); +} + +/// AC5 (causal queueing regression): Real-time binary ops from the peer are +/// applied immediately regardless of our own ready state. When a real-time +/// op causally depends on a bulk op, the CRDT causal queue ensures it is +/// applied correctly once the dependency arrives. +#[test] +fn realtime_op_from_peer_applied_immediately_regardless_of_ready_state() { + use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV, OpState}; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use serde_json::json; + + use crate::crdt_state::PipelineDoc; + + // Node A creates a bulk op and a real-time op that causally depends on it. + let kp_a = make_keypair(); + let mut crdt_a = BaseCrdt::::new(&kp_a); + let item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "632_causal_test", + "stage": "1_backlog", + "name": "Causal Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let bulk_op = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp_a); + crdt_a.apply(bulk_op.clone()); + + // Real-time op that causally depends on bulk_op. + let rt_op = crdt_a.doc.items[0] + .stage + .set("2_current".to_string()) + .sign_with_dependencies(&kp_a, vec![&bulk_op]); + crdt_a.apply(rt_op.clone()); + + // Node B receives the bulk op first (simulating the bulk-delta phase). + let kp_b = make_keypair(); + let mut crdt_b = BaseCrdt::::new(&kp_b); + assert_eq!( + crdt_b.apply(bulk_op.clone()), + OpState::Ok, + "bulk op must apply cleanly on node B" + ); + + // Node B has not yet sent its own Ready frame, but it receives A's + // real-time binary frame. It must be applied immediately via + // handle_incoming_binary (causal queue handles ordering). + let wire = crate::crdt_wire::encode(&rt_op); + let decoded = crate::crdt_wire::decode(&wire).unwrap(); + let result = crdt_b.apply(decoded); + assert_eq!( + result, + OpState::Ok, + "real-time op depending on bulk op must apply immediately after bulk op is present" + ); + + // Both nodes converge to the same state. + assert_eq!( + crdt_b.doc.items[0].stage.view(), + JV::String("2_current".to_string()), + "Node B must converge to stage 2_current" + ); + assert_eq!( + CrdtNode::view(&crdt_a.doc.items), + CrdtNode::view(&crdt_b.doc.items), + "Both nodes must converge to identical state" + ); +} + +/// AC3: Real-time ops received from the peer BEFORE our own ready is sent +/// are applied immediately (no buffering on the receive side). +#[test] +fn incoming_realtime_op_applied_before_we_send_ready() { + use bft_json_crdt::json_crdt::{BaseCrdt, CrdtNode, JsonValue as JV, OpState}; + use bft_json_crdt::keypair::make_keypair; + use bft_json_crdt::op::ROOT_ID; + use serde_json::json; + + use crate::crdt_state::PipelineDoc; + + // Simulate: we have NOT sent our ready yet (own_ready_sent = false), + // but the peer sends us a real-time binary op. It must be applied. + let own_ready_sent = false; // we haven't sent ready yet + let _ = own_ready_sent; // doc-only; receiving side never gates on this + + let kp_peer = make_keypair(); + let mut crdt_peer = BaseCrdt::::new(&kp_peer); + let item: bft_json_crdt::json_crdt::JsonValue = json!({ + "story_id": "632_ac3_test", + "stage": "1_backlog", + "name": "AC3 Test", + "agent": "", + "retry_count": 0.0, + "blocked": false, + "depends_on": "", + "claimed_by": "", + "claimed_at": 0.0, + }) + .into(); + let rt_op = crdt_peer.doc.items.insert(ROOT_ID, item).sign(&kp_peer); + crdt_peer.apply(rt_op.clone()); + + // Node B decodes and applies the peer's real-time op directly. + let kp_b = make_keypair(); + let mut crdt_b = BaseCrdt::::new(&kp_b); + let wire = crate::crdt_wire::encode(&rt_op); + let decoded = crate::crdt_wire::decode(&wire).unwrap(); + // This mirrors handle_incoming_binary() — applied unconditionally. + let result = crdt_b.apply(decoded); + assert_eq!( + result, + OpState::Ok, + "incoming real-time op must be applied immediately regardless of own ready state" + ); + assert_eq!( + crdt_b.doc.items[0].stage.view(), + JV::String("1_backlog".to_string()), + "op content must be correct" + ); +} + +/// AC6: All existing CRDT sync tests pass — verified by this marker test. +#[test] +fn existing_crdt_sync_tests_pass_unchanged() { + // Reaching this point means all prior tests in this module compiled + // and passed. This test documents the AC6 intent. +} + +// ── Story 633: bearer-token connection auth ─────────────────────────────── + +/// AC4: Valid token — `validate_join_token` returns `true` for a token that +/// has been added via `add_join_token` and has not expired. +#[test] +fn valid_token_passes_validation() { + let token = format!("test-valid-{}", uuid::Uuid::new_v4()); + super::add_join_token(token.clone()); + assert!( + super::validate_join_token(&token), + "A freshly added token must pass validation" + ); +} + +/// AC5: Invalid (bogus) token — `validate_join_token` returns `false` for a +/// token that was never added to the store. +#[test] +fn bogus_token_fails_validation() { + let bogus = "this-token-was-never-added-to-the-store"; + assert!( + !super::validate_join_token(bogus), + "An unknown token must fail validation" + ); +} + +/// AC5: Expired token — `validate_join_token` returns `false` for a token +/// whose `expires_at` is in the past. +#[test] +fn expired_token_fails_validation() { + // Insert a token directly with an already-past expiry timestamp. + let token = format!("test-expired-{}", uuid::Uuid::new_v4()); + let store = + super::CRDT_TOKENS.get_or_init(|| std::sync::RwLock::new(std::collections::HashMap::new())); + // expires_at = 1 (way in the past — 1970-01-01T00:00:01Z) + store.write().unwrap().insert(token.clone(), 1.0_f64); + assert!( + !super::validate_join_token(&token), + "An expired token must fail validation" + ); +} + +/// AC6: No token when server requires one — `validate_join_token` returns +/// `false` and the caller must reject with 401. Verifies the logic path +/// used by `crdt_sync_handler`. +#[test] +fn no_token_with_require_true_is_rejected() { + // Simulate: require_token=true, token=None → reject. + let require_token = true; + let token: Option<&str> = None; + let should_reject = match token { + Some(t) => !super::validate_join_token(t), + None if require_token => true, + None => false, + }; + assert!( + should_reject, + "Missing token must be rejected when token is required" + ); +} + +/// AC6: No token when server is in open mode — connection is accepted. +#[test] +fn no_token_with_require_false_is_accepted() { + let require_token = false; + let token: Option<&str> = None; + let should_reject = match token { + Some(t) => !super::validate_join_token(t), + None if require_token => true, + None => false, + }; + assert!( + !should_reject, + "Missing token must be accepted in open mode" + ); +} + +/// AC3: `spawn_rendezvous_client` URL construction — when a token is provided +/// the `?token=` query parameter is appended correctly. +#[test] +fn rendezvous_url_with_token_appended() { + let base = "ws://host:3001/crdt-sync"; + let token = "my-secret-token"; + let url_with_token = if base.contains('?') { + format!("{base}&token={token}") + } else { + format!("{base}?token={token}") + }; + assert_eq!( + url_with_token, + "ws://host:3001/crdt-sync?token=my-secret-token" + ); + + // With existing query params. + let base_with_query = "ws://host:3001/crdt-sync?foo=bar"; + let url_appended = if base_with_query.contains('?') { + format!("{base_with_query}&token={token}") + } else { + format!("{base_with_query}?token={token}") + }; + assert_eq!( + url_appended, + "ws://host:3001/crdt-sync?foo=bar&token=my-secret-token" + ); +} + +/// AC3: Without a token, the URL is used as-is. +#[test] +fn rendezvous_url_without_token_unchanged() { + let base = "ws://host:3001/crdt-sync"; + let token: Option<&str> = None; + let connect_url = match token { + Some(t) => format!("{base}?token={t}"), + None => base.to_string(), + }; + assert_eq!(connect_url, base); +} + +/// `add_join_token` returns a future expiry timestamp that is in the future. +#[test] +fn add_join_token_returns_future_expiry() { + let token = format!("test-expiry-{}", uuid::Uuid::new_v4()); + let now = chrono::Utc::now().timestamp() as f64; + let expires_at = super::add_join_token(token); + assert!( + expires_at > now, + "Expiry timestamp must be in the future (got {expires_at}, now={now})" + ); +} + +/// TOKEN_TTL_SECS must be 30 days. +#[test] +fn token_ttl_is_thirty_days() { + assert_eq!( + super::TOKEN_TTL_SECS, + 30.0 * 24.0 * 3600.0, + "TOKEN_TTL_SECS must be 30 days" + ); +} + +/// Config: `crdt_require_token` defaults to `false`. +#[test] +fn config_crdt_require_token_defaults_to_false() { + let config = crate::config::ProjectConfig::default(); + assert!( + !config.crdt_require_token, + "crdt_require_token must default to false (open access)" + ); +} + +/// Config: `crdt_tokens` defaults to empty. +#[test] +fn config_crdt_tokens_defaults_to_empty() { + let config = crate::config::ProjectConfig::default(); + assert!( + config.crdt_tokens.is_empty(), + "crdt_tokens must default to empty" + ); +} + +/// Config: `crdt_require_token` and `crdt_tokens` are parsed from TOML. +#[test] +fn config_crdt_token_fields_parsed_from_toml() { + let toml_str = r#" +crdt_require_token = true +crdt_tokens = ["token-abc", "token-xyz"] + +[[agent]] +name = "test" +"#; + let config: crate::config::ProjectConfig = toml::from_str(toml_str).unwrap(); + assert!(config.crdt_require_token); + assert_eq!(config.crdt_tokens, vec!["token-abc", "token-xyz"]); +} diff --git a/server/src/http/mcp/mod.rs b/server/src/http/mcp/mod.rs index 1d53e3e1..82292361 100644 --- a/server/src/http/mcp/mod.rs +++ b/server/src/http/mcp/mod.rs @@ -1403,480 +1403,4 @@ async fn handle_tools_call(id: Option, params: &Value, ctx: &AppContext) } #[cfg(test)] -mod tests { - use super::*; - use crate::http::test_helpers::test_ctx; - - #[test] - fn json_rpc_response_serializes_success() { - let resp = JsonRpcResponse::success(Some(json!(1)), json!({"ok": true})); - let s = serde_json::to_string(&resp).unwrap(); - assert!(s.contains("\"result\"")); - assert!(!s.contains("\"error\"")); - } - - #[test] - fn json_rpc_response_serializes_error() { - let resp = JsonRpcResponse::error(Some(json!(1)), -32600, "bad".into()); - let s = serde_json::to_string(&resp).unwrap(); - assert!(s.contains("\"error\"")); - assert!(!s.contains("\"result\"")); - } - - #[test] - fn initialize_returns_capabilities() { - let resp = handle_initialize( - Some(json!(1)), - &json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}), - ); - let result = resp.result.unwrap(); - assert_eq!(result["protocolVersion"], "2025-03-26"); - assert!(result["capabilities"]["tools"].is_object()); - assert_eq!(result["serverInfo"]["name"], "huskies"); - } - - #[test] - fn tools_list_returns_all_tools() { - let resp = handle_tools_list(Some(json!(2))); - let result = resp.result.unwrap(); - let tools = result["tools"].as_array().unwrap(); - let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); - assert!(names.contains(&"create_story")); - assert!(names.contains(&"validate_stories")); - assert!(names.contains(&"list_upcoming")); - assert!(names.contains(&"get_story_todos")); - assert!(names.contains(&"record_tests")); - assert!(names.contains(&"ensure_acceptance")); - assert!(names.contains(&"start_agent")); - assert!(names.contains(&"stop_agent")); - assert!(names.contains(&"list_agents")); - assert!(names.contains(&"get_agent_config")); - assert!(names.contains(&"reload_agent_config")); - assert!(names.contains(&"get_agent_output")); - assert!(names.contains(&"wait_for_agent")); - assert!(names.contains(&"get_agent_remaining_turns_and_budget")); - assert!(names.contains(&"create_worktree")); - assert!(names.contains(&"list_worktrees")); - assert!(names.contains(&"remove_worktree")); - assert!(names.contains(&"get_editor_command")); - assert!(!names.contains(&"report_completion")); - assert!(names.contains(&"accept_story")); - assert!(names.contains(&"check_criterion")); - assert!(names.contains(&"add_criterion")); - assert!(names.contains(&"update_story")); - assert!(names.contains(&"create_spike")); - assert!(names.contains(&"create_bug")); - assert!(names.contains(&"list_bugs")); - assert!(names.contains(&"close_bug")); - assert!(names.contains(&"create_refactor")); - assert!(names.contains(&"list_refactors")); - assert!(names.contains(&"merge_agent_work")); - assert!(names.contains(&"get_merge_status")); - assert!(names.contains(&"move_story_to_merge")); - assert!(names.contains(&"report_merge_failure")); - assert!(names.contains(&"request_qa")); - assert!(names.contains(&"approve_qa")); - assert!(names.contains(&"reject_qa")); - assert!(names.contains(&"launch_qa_app")); - assert!(names.contains(&"get_server_logs")); - assert!(names.contains(&"prompt_permission")); - assert!(names.contains(&"get_pipeline_status")); - assert!(names.contains(&"rebuild_and_restart")); - assert!(names.contains(&"get_token_usage")); - assert!(names.contains(&"move_story")); - assert!(names.contains(&"unblock_story")); - assert!(names.contains(&"delete_story")); - assert!(names.contains(&"run_command")); - assert!(names.contains(&"run_tests")); - assert!(names.contains(&"get_test_result")); - assert!(names.contains(&"run_build")); - assert!(names.contains(&"run_lint")); - assert!(names.contains(&"git_status")); - assert!(names.contains(&"git_diff")); - assert!(names.contains(&"git_add")); - assert!(names.contains(&"git_commit")); - assert!(names.contains(&"git_log")); - assert!(names.contains(&"status")); - assert!(names.contains(&"loc_file")); - assert!(names.contains(&"dump_crdt")); - assert!(names.contains(&"get_version")); - assert!(names.contains(&"remove_criterion")); - assert_eq!(tools.len(), 66); - } - - #[test] - fn tools_list_schemas_have_required_fields() { - let resp = handle_tools_list(Some(json!(1))); - let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); - for tool in &tools { - assert!(tool["name"].is_string(), "tool missing name"); - assert!(tool["description"].is_string(), "tool missing description"); - assert!(tool["inputSchema"].is_object(), "tool missing inputSchema"); - assert_eq!(tool["inputSchema"]["type"], "object"); - } - } - - #[test] - fn handle_tools_call_unknown_tool() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let rt = tokio::runtime::Runtime::new().unwrap(); - let resp = rt.block_on(handle_tools_call( - Some(json!(1)), - &json!({"name": "bogus_tool", "arguments": {}}), - &ctx, - )); - let result = resp.result.unwrap(); - assert_eq!(result["isError"], true); - assert!( - result["content"][0]["text"] - .as_str() - .unwrap() - .contains("Unknown tool") - ); - } - - #[test] - fn to_sse_response_wraps_in_data_prefix() { - let resp = JsonRpcResponse::success(Some(json!(1)), json!({"ok": true})); - let http_resp = to_sse_response(resp); - assert_eq!( - http_resp.headers().get("content-type").unwrap(), - "text/event-stream" - ); - } - - #[test] - fn wants_sse_detects_accept_header() { - // Can't easily construct a Request in tests without TestClient, - // so test the logic indirectly via to_sse_response format - let resp = JsonRpcResponse::success(Some(json!(1)), json!("ok")); - let json_resp = to_json_response(resp); - assert_eq!( - json_resp.headers().get("content-type").unwrap(), - "application/json" - ); - } - - #[test] - fn json_rpc_error_response_builds_json_response() { - let resp = json_rpc_error_response(Some(json!(42)), -32600, "test error".into()); - assert_eq!(resp.status(), poem::http::StatusCode::OK); - assert_eq!( - resp.headers().get("content-type").unwrap(), - "application/json" - ); - } - - // ── HTTP handler tests (TestClient) ─────────────────────────── - - fn test_mcp_app(ctx: std::sync::Arc) -> impl poem::Endpoint { - use poem::EndpointExt; - poem::Route::new() - .at("/mcp", poem::post(mcp_post_handler).get(mcp_get_handler)) - .data(ctx) - } - - async fn read_body_json(resp: poem::test::TestResponse) -> Value { - let body = resp.0.into_body().into_string().await.unwrap(); - serde_json::from_str(&body).unwrap() - } - - async fn post_json_mcp( - cli: &poem::test::TestClient, - payload: &str, - ) -> Value { - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .body(payload.to_string()) - .send() - .await; - read_body_json(resp).await - } - - #[tokio::test] - async fn mcp_get_handler_returns_405() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli.get("/mcp").send().await; - assert_eq!(resp.0.status(), poem::http::StatusCode::METHOD_NOT_ALLOWED); - } - - #[tokio::test] - async fn mcp_post_invalid_content_type_returns_error() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "text/plain") - .body("{}") - .send() - .await; - let body = read_body_json(resp).await; - assert!(body.get("error").is_some(), "expected error field: {body}"); - } - - #[tokio::test] - async fn mcp_post_invalid_json_returns_parse_error() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .body("not-valid-json") - .send() - .await; - let body = read_body_json(resp).await; - assert!(body.get("error").is_some(), "expected error field: {body}"); - } - - #[tokio::test] - async fn mcp_post_wrong_jsonrpc_version_returns_error() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let body = post_json_mcp( - &cli, - r#"{"jsonrpc":"1.0","id":1,"method":"initialize","params":{}}"#, - ) - .await; - assert!( - body["error"]["message"] - .as_str() - .unwrap_or("") - .contains("version"), - "expected version error: {body}" - ); - } - - #[tokio::test] - async fn mcp_post_notification_with_null_id_returns_accepted() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#) - .send() - .await; - assert_eq!(resp.0.status(), poem::http::StatusCode::ACCEPTED); - } - - #[tokio::test] - async fn mcp_post_notification_with_explicit_null_id_returns_accepted() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .body(r#"{"jsonrpc":"2.0","id":null,"method":"notifications/initialized","params":{}}"#) - .send() - .await; - assert_eq!(resp.0.status(), poem::http::StatusCode::ACCEPTED); - } - - #[tokio::test] - async fn mcp_post_missing_id_non_notification_returns_error() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let body = post_json_mcp( - &cli, - r#"{"jsonrpc":"2.0","method":"initialize","params":{}}"#, - ) - .await; - assert!(body.get("error").is_some(), "expected error: {body}"); - } - - #[tokio::test] - async fn mcp_post_unknown_method_returns_error() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let body = post_json_mcp( - &cli, - r#"{"jsonrpc":"2.0","id":1,"method":"bogus/method","params":{}}"#, - ) - .await; - assert!( - body["error"]["message"] - .as_str() - .unwrap_or("") - .contains("Unknown method"), - "expected unknown method error: {body}" - ); - } - - #[tokio::test] - async fn mcp_post_initialize_returns_capabilities() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let body = post_json_mcp( - &cli, - r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#, - ) - .await; - assert_eq!(body["result"]["protocolVersion"], "2025-03-26"); - assert_eq!(body["result"]["serverInfo"]["name"], "huskies"); - } - - #[tokio::test] - async fn mcp_post_tools_list_returns_tools() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let body = post_json_mcp( - &cli, - r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#, - ) - .await; - assert!(body["result"]["tools"].is_array()); - } - - #[tokio::test] - async fn mcp_post_sse_returns_event_stream_content_type() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#) - .send() - .await; - assert_eq!( - resp.0.headers().get("content-type").unwrap(), - "text/event-stream" - ); - } - - #[tokio::test] - async fn mcp_post_sse_get_agent_output_missing_story_id() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{}}}"#) - .send() - .await; - assert_eq!( - resp.0.headers().get("content-type").unwrap(), - "text/event-stream", - "expected SSE content-type" - ); - } - - #[tokio::test] - async fn mcp_post_sse_get_agent_output_without_agent_name_returns_disk_content() { - // Without agent_name the SSE live-streaming intercept is skipped and - // the disk-based handler runs. The transport still wraps the result in - // SSE format (data: …\n\n) because the client sent Accept: text/event-stream, - // but the content should be a valid JSON-RPC result, not a subscribe error. - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"1_test"}}}"#) - .send() - .await; - let body = resp.0.into_body().into_string().await.unwrap(); - // Body is SSE-wrapped: "data: {…}\n\n" — strip the prefix and verify it's - // a valid JSON-RPC result (not an error about missing agent_name). - let json_part = body - .trim_start_matches("data: ") - .trim_end_matches("\n\n") - .trim(); - let parsed: serde_json::Value = serde_json::from_str(json_part) - .unwrap_or_else(|_| panic!("expected JSON-RPC in SSE body, got: {body}")); - assert!( - parsed.get("result").is_some(), - "expected JSON-RPC result (disk-based handler ran): {parsed}" - ); - // Must NOT be an error about missing agent_name (agent_name is now optional) - assert!( - parsed.get("error").is_none(), - "unexpected error when agent_name omitted: {parsed}" - ); - } - - #[tokio::test] - async fn mcp_post_sse_get_agent_output_no_agent_no_logs_returns_not_found() { - // Agent not in pool and no log files → SSE success with "No log files found" message. - let tmp = tempfile::tempdir().unwrap(); - let ctx = std::sync::Arc::new(test_ctx(tmp.path())); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"99_nope","agent_name":"bot"}}}"#) - .send() - .await; - assert_eq!( - resp.0.headers().get("content-type").unwrap(), - "text/event-stream" - ); - let body = resp.0.into_body().into_string().await.unwrap(); - assert!(body.contains("data:"), "expected SSE data prefix: {body}"); - // Must NOT return isError — should be a success result with "No log files found" - assert!( - !body.contains("isError"), - "expected no isError for missing agent: {body}" - ); - assert!( - body.contains("No log files found"), - "expected not-found message: {body}" - ); - } - - #[tokio::test] - async fn mcp_post_sse_get_agent_output_exited_agent_reads_disk_logs() { - use crate::agent_log::AgentLogWriter; - use crate::agents::AgentEvent; - // Agent has exited (not in pool) but wrote logs to disk. - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-sse").unwrap(); - writer - .write_event(&AgentEvent::Output { - story_id: "42_story_foo".to_string(), - agent_name: "coder-1".to_string(), - text: "disk output".to_string(), - }) - .unwrap(); - drop(writer); - - let ctx = std::sync::Arc::new(test_ctx(root)); - let cli = poem::test::TestClient::new(test_mcp_app(ctx)); - let resp = cli - .post("/mcp") - .header("content-type", "application/json") - .header("accept", "text/event-stream") - .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"42_story_foo","agent_name":"coder-1"}}}"#) - .send() - .await; - let body = resp.0.into_body().into_string().await.unwrap(); - assert!( - body.contains("disk output"), - "expected disk log content in SSE response: {body}" - ); - assert!( - !body.contains("isError"), - "expected no error for exited agent with logs: {body}" - ); - } -} +mod tests; diff --git a/server/src/http/mcp/story_tools.rs b/server/src/http/mcp/story_tools.rs deleted file mode 100644 index 2fd3d148..00000000 --- a/server/src/http/mcp/story_tools.rs +++ /dev/null @@ -1,1864 +0,0 @@ -//! MCP story tools — create, update, move, and manage stories, bugs, and refactors via MCP. -//! -//! This file is a thin adapter: it deserialises MCP payloads, delegates to -//! `crate::service::story` and `crate::http::workflow` for business logic, -//! and serialises responses. -use crate::agents::{ - close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done, -}; -use crate::http::context::AppContext; -use crate::http::workflow::{ - add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file, - create_spike_file, create_story_file, edit_criterion_in_file, list_bug_files, - list_refactor_files, load_pipeline_state, load_upcoming_stories, remove_criterion_from_file, - update_story_in_file, validate_story_dirs, -}; -use crate::io::story_metadata::{ - check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos, -}; -use crate::service::story::parse_test_cases; -use crate::slog_warn; -#[allow(unused_imports)] -use crate::workflow::{TestCaseResult, TestStatus, evaluate_acceptance_with_coverage}; -use serde_json::{Value, json}; -use std::collections::HashMap; -use std::fs; - -pub(super) fn tool_create_story(args: &Value, ctx: &AppContext) -> Result { - let name = args - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: name")?; - let user_story = args.get("user_story").and_then(|v| v.as_str()); - let description = args.get("description").and_then(|v| v.as_str()); - let acceptance_criteria: Option> = args - .get("acceptance_criteria") - .and_then(|v| serde_json::from_value(v.clone()).ok()); - let depends_on: Option> = args - .get("depends_on") - .and_then(|v| serde_json::from_value(v.clone()).ok()); - // Spike 61: write the file only — the filesystem watcher detects the new - // .md file in work/1_backlog/ and auto-commits with a deterministic message. - let commit = false; - - let root = ctx.state.get_project_root()?; - let story_id = create_story_file( - &root, - name, - user_story, - description, - acceptance_criteria.as_deref(), - depends_on.as_deref(), - commit, - )?; - - // Bug 503: warn at creation time if any depends_on points at an already-archived story. - // Archived = satisfied semantics: the dep will resolve immediately on the next promotion - // tick, which is surprising if the archived story was abandoned rather than cleanly done. - let archived_deps = depends_on - .as_deref() - .map(|deps| check_archived_deps_from_list(&root, deps)) - .unwrap_or_default(); - if !archived_deps.is_empty() { - slog_warn!( - "[create-story] Story '{story_id}' depends_on {archived_deps:?} which \ - are already in 6_archived. The dep will be treated as satisfied on the \ - next promotion tick. If these deps were abandoned (not cleanly completed), \ - consider removing the depends_on or keeping the story in backlog manually." - ); - return Ok(format!( - "Created story: {story_id}\n\n\ - WARNING: depends_on {archived_deps:?} point at stories already in \ - 6_archived. These deps are treated as satisfied (archived = satisfied \ - semantics), so this story may be auto-promoted from backlog immediately. \ - If the archived deps were abandoned rather than completed, remove the \ - depends_on or move the story back to backlog manually after promotion." - )); - } - - Ok(format!("Created story: {story_id}")) -} - -/// Purge a story from the in-memory CRDT by writing a tombstone op (story 521). -/// -/// This is the eviction primitive for the four-state-machine drift problem -/// we hit on 2026-04-09 — when a story gets stuck in the running server's -/// in-memory CRDT and can't be cleared by sqlite deletes alone (because the -/// in-memory state outlives any pipeline_items / crdt_ops manipulation), -/// this tool writes a proper CRDT delete op via `crdt_state::evict_item`. -/// -/// The tombstone op: -/// - Marks the in-memory CRDT item as `is_deleted = true` immediately -/// (so subsequent `read_all_items` / `read_item` calls skip it) -/// - Is persisted to `crdt_ops` so the eviction survives a server restart -/// - Drops the in-memory `CONTENT_STORE` entry for the story -/// -/// This tool does NOT touch: running agents, worktrees, the `pipeline_items` -/// shadow table, `timers.json`, or filesystem shadows. Compose with -/// `stop_agent`, `remove_worktree`, etc. as needed for a full purge — or -/// see story 514 (delete_story full cleanup) for a future "do it all" tool. -pub(super) fn tool_purge_story(args: &Value, _ctx: &AppContext) -> Result { - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - - crate::crdt_state::evict_item(story_id)?; - - Ok(format!( - "Evicted '{story_id}' from in-memory CRDT (tombstone op persisted to crdt_ops; CONTENT_STORE entry dropped)." - )) -} - -pub(super) fn tool_validate_stories(ctx: &AppContext) -> Result { - let root = ctx.state.get_project_root()?; - let results = validate_story_dirs(&root)?; - serde_json::to_string_pretty(&json!( - results - .iter() - .map(|r| json!({ - "story_id": r.story_id, - "valid": r.valid, - "error": r.error, - })) - .collect::>() - )) - .map_err(|e| format!("Serialization error: {e}")) -} - -pub(super) fn tool_list_upcoming(ctx: &AppContext) -> Result { - let stories = load_upcoming_stories(ctx)?; - serde_json::to_string_pretty(&json!( - stories - .iter() - .map(|s| json!({ - "story_id": s.story_id, - "name": s.name, - "error": s.error, - })) - .collect::>() - )) - .map_err(|e| format!("Serialization error: {e}")) -} - -pub(super) fn tool_get_pipeline_status(ctx: &AppContext) -> Result { - let state = load_pipeline_state(ctx)?; - - fn map_items(items: &[crate::http::workflow::UpcomingStory], stage: &str) -> Vec { - items - .iter() - .map(|s| { - let mut item = json!({ - "story_id": s.story_id, - "name": s.name, - "stage": stage, - "agent": s.agent.as_ref().map(|a| json!({ - "agent_name": a.agent_name, - "model": a.model, - "status": a.status, - })), - }); - // Include blocked/retry_count when present so callers can - // identify stories stuck in the pipeline. - if let Some(true) = s.blocked { - item["blocked"] = json!(true); - } - if let Some(rc) = s.retry_count { - item["retry_count"] = json!(rc); - } - if let Some(ref mf) = s.merge_failure { - item["merge_failure"] = json!(mf); - } - item - }) - .collect() - } - - let mut active: Vec = Vec::new(); - active.extend(map_items(&state.current, "current")); - active.extend(map_items(&state.qa, "qa")); - active.extend(map_items(&state.merge, "merge")); - active.extend(map_items(&state.done, "done")); - - let backlog: Vec = state - .backlog - .iter() - .map(|s| json!({ "story_id": s.story_id, "name": s.name })) - .collect(); - - serde_json::to_string_pretty(&json!({ - "active": active, - "backlog": backlog, - "backlog_count": backlog.len(), - })) - .map_err(|e| format!("Serialization error: {e}")) -} - -pub(super) fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result { - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - - let root = ctx.state.get_project_root()?; - - // Read from DB content store, falling back to filesystem. - let contents = crate::http::workflow::read_story_content(&root, story_id) - .map_err(|_| format!("Story file not found: {story_id}.md"))?; - - let story_name = parse_front_matter(&contents).ok().and_then(|m| m.name); - let todos = parse_unchecked_todos(&contents); - - serde_json::to_string_pretty(&json!({ - "story_id": story_id, - "story_name": story_name, - "todos": todos, - })) - .map_err(|e| format!("Serialization error: {e}")) -} - -pub(super) fn tool_record_tests(args: &Value, ctx: &AppContext) -> Result { - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - - let unit = parse_test_cases(args.get("unit"))?; - let integration = parse_test_cases(args.get("integration"))?; - - let mut workflow = ctx - .workflow - .lock() - .map_err(|e| format!("Lock error: {e}"))?; - - workflow.record_test_results_validated(story_id.to_string(), unit, integration)?; - - // Persist to story file (best-effort — file write errors are warnings, not failures). - if let Ok(project_root) = ctx.state.get_project_root() - && let Some(results) = workflow.results.get(story_id) - && let Err(e) = crate::http::workflow::write_test_results_to_story_file( - &project_root, - story_id, - results, - ) - { - slog_warn!("[record_tests] Could not persist results to story file: {e}"); - } - - Ok("Test results recorded.".to_string()) -} - -pub(super) fn tool_ensure_acceptance(args: &Value, ctx: &AppContext) -> Result { - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - - let workflow = ctx - .workflow - .lock() - .map_err(|e| format!("Lock error: {e}"))?; - - // Use in-memory results if present; otherwise fall back to file-persisted results. - let file_results; - let results = if let Some(r) = workflow.results.get(story_id) { - r - } else { - let project_root = ctx.state.get_project_root().ok(); - file_results = project_root.as_deref().and_then(|root| { - crate::http::workflow::read_test_results_from_story_file(root, story_id) - }); - file_results.as_ref().map_or_else( - || { - // No results anywhere — use empty default for the acceptance check - // (it will fail with "No test results recorded") - static EMPTY: std::sync::OnceLock = - std::sync::OnceLock::new(); - EMPTY.get_or_init(Default::default) - }, - |r| r, - ) - }; - - let coverage = workflow.coverage.get(story_id); - let decision = evaluate_acceptance_with_coverage(results, coverage); - - if decision.can_accept { - Ok("Story can be accepted. All gates pass.".to_string()) - } else { - let mut parts = decision.reasons; - if let Some(w) = decision.warning { - parts.push(w); - } - Err(format!("Acceptance blocked: {}", parts.join("; "))) - } -} - -pub(super) fn tool_accept_story(args: &Value, ctx: &AppContext) -> Result { - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - - let project_root = ctx.services.agents.get_project_root(&ctx.state)?; - - // Bug 226: Refuse to accept if the feature branch has unmerged code. - // The code must be squash-merged via merge_agent_work first. - if feature_branch_has_unmerged_changes(&project_root, story_id) { - return Err(format!( - "Cannot accept story '{story_id}': feature branch 'feature/story-{story_id}' \ - has unmerged changes. Use merge_agent_work to squash-merge the code into \ - master first." - )); - } - - move_story_to_done(&project_root, story_id)?; - ctx.services.agents.remove_agents_for_story(story_id); - - Ok(format!( - "Story '{story_id}' accepted, moved to done/, and committed to master." - )) -} - -pub(super) fn tool_check_criterion(args: &Value, ctx: &AppContext) -> Result { - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - let criterion_index = args - .get("criterion_index") - .and_then(|v| v.as_u64()) - .ok_or("Missing required argument: criterion_index")? as usize; - - let root = ctx.state.get_project_root()?; - check_criterion_in_file(&root, story_id, criterion_index)?; - - Ok(format!( - "Criterion {criterion_index} checked for story '{story_id}'. Committed to master." - )) -} - -pub(super) fn tool_edit_criterion(args: &Value, ctx: &AppContext) -> Result { - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - let criterion_index = args - .get("criterion_index") - .and_then(|v| v.as_u64()) - .ok_or("Missing required argument: criterion_index")? as usize; - let new_text = args - .get("new_text") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: new_text")?; - - let root = ctx.state.get_project_root()?; - edit_criterion_in_file(&root, story_id, criterion_index, new_text)?; - - Ok(format!( - "Criterion {criterion_index} updated for story '{story_id}'." - )) -} - -pub(super) fn tool_add_criterion(args: &Value, ctx: &AppContext) -> Result { - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - let criterion = args - .get("criterion") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: criterion")?; - - let root = ctx.state.get_project_root()?; - add_criterion_to_file(&root, story_id, criterion)?; - - Ok(format!( - "Added criterion to story '{story_id}': - [ ] {criterion}" - )) -} - -pub(super) fn tool_remove_criterion(args: &Value, ctx: &AppContext) -> Result { - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - let criterion_index = args - .get("criterion_index") - .and_then(|v| v.as_u64()) - .ok_or("Missing required argument: criterion_index")? as usize; - - let root = ctx.state.get_project_root()?; - remove_criterion_from_file(&root, story_id, criterion_index)?; - - Ok(format!( - "Removed criterion {criterion_index} from story '{story_id}'." - )) -} - -pub(super) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result { - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - let user_story = args.get("user_story").and_then(|v| v.as_str()); - let description = args.get("description").and_then(|v| v.as_str()); - - // Collect front matter fields: explicit `agent` param + arbitrary `front_matter` object. - // Values are passed as serde_json::Value so native booleans, numbers, and arrays are - // preserved and encoded correctly as unquoted YAML by update_story_in_file. - let mut front_matter: HashMap = HashMap::new(); - if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) { - front_matter.insert("agent".to_string(), Value::String(agent.to_string())); - } - if let Some(obj) = args.get("front_matter").and_then(|v| v.as_object()) { - for (k, v) in obj { - front_matter.insert(k.clone(), v.clone()); - } - } - let front_matter_opt = if front_matter.is_empty() { - None - } else { - Some(&front_matter) - }; - - let root = ctx.state.get_project_root()?; - update_story_in_file(&root, story_id, user_story, description, front_matter_opt)?; - - // Bug 503: warn if any depends_on in the (now updated) story points at an archived story. - let stage = crate::pipeline_state::read_typed(story_id) - .ok() - .flatten() - .map(|i| i.stage.dir_name().to_string()) - .unwrap_or_else(|| "1_backlog".to_string()); - let archived_deps = check_archived_deps(&root, &stage, story_id); - if !archived_deps.is_empty() { - slog_warn!( - "[update-story] Story '{story_id}' depends_on {archived_deps:?} which \ - are already in 6_archived. The dep will be treated as satisfied on the \ - next promotion tick. If these deps were abandoned (not cleanly completed), \ - consider removing the depends_on or keeping the story in backlog manually." - ); - return Ok(format!( - "Updated story '{story_id}'.\n\n\ - WARNING: depends_on {archived_deps:?} point at stories already in \ - 6_archived. These deps are treated as satisfied (archived = satisfied \ - semantics), so this story may be auto-promoted from backlog immediately. \ - If the archived deps were abandoned rather than completed, remove the \ - depends_on or move the story back to backlog manually after promotion." - )); - } - - Ok(format!("Updated story '{story_id}'.")) -} - -pub(super) fn tool_create_spike(args: &Value, ctx: &AppContext) -> Result { - let name = args - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: name")?; - let description = args.get("description").and_then(|v| v.as_str()); - - let root = ctx.state.get_project_root()?; - let spike_id = create_spike_file(&root, name, description)?; - - Ok(format!("Created spike: {spike_id}")) -} - -pub(super) fn tool_create_bug(args: &Value, ctx: &AppContext) -> Result { - let name = args - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: name")?; - let description = args - .get("description") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: description")?; - let steps_to_reproduce = args - .get("steps_to_reproduce") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: steps_to_reproduce")?; - let actual_result = args - .get("actual_result") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: actual_result")?; - let expected_result = args - .get("expected_result") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: expected_result")?; - let acceptance_criteria: Option> = args - .get("acceptance_criteria") - .and_then(|v| serde_json::from_value(v.clone()).ok()); - let depends_on: Option> = args - .get("depends_on") - .and_then(|v| serde_json::from_value(v.clone()).ok()); - - let root = ctx.state.get_project_root()?; - let bug_id = create_bug_file( - &root, - name, - description, - steps_to_reproduce, - actual_result, - expected_result, - acceptance_criteria.as_deref(), - depends_on.as_deref(), - )?; - - Ok(format!("Created bug: {bug_id}")) -} - -pub(super) fn tool_list_bugs(ctx: &AppContext) -> Result { - let root = ctx.state.get_project_root()?; - let bugs = list_bug_files(&root)?; - serde_json::to_string_pretty(&json!( - bugs.iter() - .map(|(id, name)| json!({ "bug_id": id, "name": name })) - .collect::>() - )) - .map_err(|e| format!("Serialization error: {e}")) -} - -pub(super) fn tool_close_bug(args: &Value, ctx: &AppContext) -> Result { - let bug_id = args - .get("bug_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: bug_id")?; - - let root = ctx.services.agents.get_project_root(&ctx.state)?; - close_bug_to_archive(&root, bug_id)?; - ctx.services.agents.remove_agents_for_story(bug_id); - - Ok(format!( - "Bug '{bug_id}' closed, moved to bugs/archive/, and committed to master." - )) -} - -pub(super) fn tool_unblock_story(args: &Value, ctx: &AppContext) -> Result { - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - - let root = ctx.state.get_project_root()?; - - // Extract the numeric prefix (e.g. "42" from "42_story_foo") - let story_number = story_id - .split('_') - .next() - .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) - .ok_or_else(|| format!("Invalid story_id format: '{story_id}'. Expected a numeric prefix (e.g. '42_story_foo')."))?; - - Ok(crate::chat::commands::unblock::unblock_by_number( - &root, - story_number, - )) -} - -pub(super) async fn tool_delete_story(args: &Value, ctx: &AppContext) -> Result { - let story_id = args - .get("story_id") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: story_id")?; - - let project_root = ctx.services.agents.get_project_root(&ctx.state)?; - let mut failed_steps: Vec = Vec::new(); - - // 0. Cancel any pending rate-limit retry timers for this story (bug 514). - // Must happen before stopping agents so the tick loop cannot re-spawn - // an agent after we tear everything else down. - let timer_removed = ctx.timer_store.remove(story_id); - if timer_removed { - slog_warn!("[delete_story] Cancelled pending timer for '{story_id}'"); - } else { - slog_warn!("[delete_story] No pending timer found for '{story_id}'"); - } - - // 1. Stop any running agents for this story (best-effort). - if let Ok(agents) = ctx.services.agents.list_agents() { - for agent in agents.iter().filter(|a| a.story_id == story_id) { - match ctx - .services - .agents - .stop_agent(&project_root, story_id, &agent.agent_name) - .await - { - Ok(()) => { - slog_warn!( - "[delete_story] Stopped agent '{}' for '{story_id}'", - agent.agent_name - ); - } - Err(e) => { - slog_warn!( - "[delete_story] Failed to stop agent '{}' for '{story_id}': {e}", - agent.agent_name - ); - failed_steps.push(format!("stop_agent({}): {e}", agent.agent_name)); - } - } - } - } - - // 2. Remove agent pool entries. - let removed_count = ctx.services.agents.remove_agents_for_story(story_id); - slog_warn!("[delete_story] Removed {removed_count} agent pool entries for '{story_id}'"); - - // 3. Remove worktree (best-effort). - if let Ok(config) = crate::config::ProjectConfig::load(&project_root) { - match crate::worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await { - Ok(()) => slog_warn!("[delete_story] Removed worktree for '{story_id}'"), - Err(e) => slog_warn!("[delete_story] Worktree removal for '{story_id}': {e}"), - } - } - - // 4. Write a CRDT tombstone op so the story is evicted from the in-memory - // state machine and the deletion is persisted to crdt_ops (survives - // restart). Best-effort: legacy filesystem-only stories may not have a - // CRDT entry, so a "not found" error is expected and non-fatal. - match crate::crdt_state::evict_item(story_id) { - Ok(()) => { - slog_warn!( - "[delete_story] Evicted '{story_id}' from CRDT (tombstone persisted to crdt_ops)" - ); - } - Err(e) => { - slog_warn!("[delete_story] CRDT eviction for '{story_id}': {e}"); - } - } - - // 5. Delete from database content store and shadow table. - let found_in_db = crate::db::read_content(story_id).is_some() - || crate::pipeline_state::read_typed(story_id) - .ok() - .flatten() - .is_some(); - crate::db::delete_item(story_id); - slog_warn!("[delete_story] Deleted '{story_id}' from content store / shadow table"); - - // 6. Remove the filesystem shadow file from work/N_stage/. - let sk = project_root.join(".huskies").join("work"); - let stage_dirs = [ - "1_backlog", - "2_current", - "3_qa", - "4_merge", - "5_done", - "6_archived", - ]; - let mut deleted_from_fs = false; - for stage in &stage_dirs { - let path = sk.join(stage).join(format!("{story_id}.md")); - if path.exists() { - match fs::remove_file(&path) { - Ok(()) => { - slog_warn!( - "[delete_story] Deleted filesystem shadow '{story_id}' from work/{stage}/" - ); - deleted_from_fs = true; - } - Err(e) => { - slog_warn!( - "[delete_story] Failed to delete filesystem shadow '{story_id}' from work/{stage}/: {e}" - ); - failed_steps.push(format!("delete_filesystem({stage}): {e}")); - } - } - break; - } - } - - if !found_in_db && !deleted_from_fs && !timer_removed { - return Err(format!( - "Story '{story_id}' not found in any pipeline stage." - )); - } - - if !failed_steps.is_empty() { - return Err(format!( - "Story '{story_id}' partially deleted. Failed steps: {}.", - failed_steps.join("; ") - )); - } - - Ok(format!("Story '{story_id}' deleted from pipeline.")) -} - -pub(super) fn tool_create_refactor(args: &Value, ctx: &AppContext) -> Result { - let name = args - .get("name") - .and_then(|v| v.as_str()) - .ok_or("Missing required argument: name")?; - let description = args.get("description").and_then(|v| v.as_str()); - let acceptance_criteria: Option> = args - .get("acceptance_criteria") - .and_then(|v| serde_json::from_value(v.clone()).ok()); - let depends_on: Option> = args - .get("depends_on") - .and_then(|v| serde_json::from_value(v.clone()).ok()); - - let root = ctx.state.get_project_root()?; - let refactor_id = create_refactor_file( - &root, - name, - description, - acceptance_criteria.as_deref(), - depends_on.as_deref(), - )?; - - Ok(format!("Created refactor: {refactor_id}")) -} - -pub(super) fn tool_list_refactors(ctx: &AppContext) -> Result { - let root = ctx.state.get_project_root()?; - let refactors = list_refactor_files(&root)?; - serde_json::to_string_pretty(&json!( - refactors - .iter() - .map(|(id, name)| json!({ "refactor_id": id, "name": name })) - .collect::>() - )) - .map_err(|e| format!("Serialization error: {e}")) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::http::test_helpers::test_ctx; - - #[test] - fn parse_test_cases_empty() { - let result = parse_test_cases(None).unwrap(); - assert!(result.is_empty()); - } - - #[test] - fn parse_test_cases_valid() { - let input = json!([ - {"name": "test1", "status": "pass"}, - {"name": "test2", "status": "fail", "details": "assertion failed"} - ]); - let result = parse_test_cases(Some(&input)).unwrap(); - assert_eq!(result.len(), 2); - assert_eq!(result[0].status, TestStatus::Pass); - assert_eq!(result[1].status, TestStatus::Fail); - assert_eq!(result[1].details, Some("assertion failed".to_string())); - } - - #[test] - fn parse_test_cases_invalid_status() { - let input = json!([{"name": "t", "status": "maybe"}]); - assert!(parse_test_cases(Some(&input)).is_err()); - } - - #[test] - fn parse_test_cases_null_value_returns_empty() { - let null_val = json!(null); - let result = parse_test_cases(Some(&null_val)).unwrap(); - assert!(result.is_empty()); - } - - #[test] - fn parse_test_cases_non_array_returns_error() { - let obj = json!({"invalid": "input"}); - let result = parse_test_cases(Some(&obj)); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Expected array")); - } - - #[test] - fn parse_test_cases_missing_name_returns_error() { - let input = json!([{"status": "pass"}]); - let result = parse_test_cases(Some(&input)); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("name")); - } - - #[test] - fn parse_test_cases_missing_status_returns_error() { - let input = json!([{"name": "test1"}]); - let result = parse_test_cases(Some(&input)); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("status")); - } - - #[test] - fn tool_validate_stories_empty_project() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_validate_stories(&ctx).unwrap(); - // CRDT is global; other tests may have inserted items. - // Just verify it parses without error. - let _parsed: Vec = serde_json::from_str(&result).unwrap(); - } - - #[test] - fn tool_create_story_and_list_upcoming() { - let tmp = tempfile::tempdir().unwrap(); - // No git repo needed: spike 61 — create_story just writes the file; - // the filesystem watcher handles the commit asynchronously. - let ctx = test_ctx(tmp.path()); - - let result = tool_create_story( - &json!({"name": "Test Story", "acceptance_criteria": ["AC1", "AC2"]}), - &ctx, - ) - .unwrap(); - assert!(result.contains("Created story:")); - - // List should return it (CRDT is global, so filter for our story) - let list = tool_list_upcoming(&ctx).unwrap(); - let parsed: Vec = serde_json::from_str(&list).unwrap(); - assert!( - parsed.iter().any(|s| s["name"] == "Test Story"), - "expected 'Test Story' in upcoming list: {parsed:?}" - ); - } - - #[test] - fn tool_create_story_rejects_empty_name() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_create_story(&json!({"name": "!!!"}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("alphanumeric")); - } - - #[test] - fn tool_create_story_missing_name() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_create_story(&json!({}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Missing required argument")); - } - - // Regression test for bug 509: description was silently dropped. - #[test] - fn tool_create_story_description_is_written_to_file() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - - let result = tool_create_story( - &json!({ - "name": "Story With Description", - "description": "This is the background context." - }), - &ctx, - ) - .unwrap(); - assert!(result.contains("Created story:")); - - let story_id = result - .trim_start_matches("Created story: ") - .trim() - .to_string(); - let content = crate::db::read_content(&story_id).expect("story content should exist"); - assert!( - content.contains("## Description"), - "Description section missing from story: {content}" - ); - assert!( - content.contains("This is the background context."), - "Description text missing from story: {content}" - ); - } - - #[test] - fn tool_get_pipeline_status_returns_structured_response() { - let tmp = tempfile::tempdir().unwrap(); - - crate::db::ensure_content_store(); - for (stage, id, name) in &[ - ("1_backlog", "9910_story_upcoming", "Upcoming Story"), - ("2_current", "9920_story_current", "Current Story"), - ("3_qa", "9930_story_qa", "QA Story"), - ("4_merge", "9940_story_merge", "Merge Story"), - ("5_done", "9950_story_done", "Done Story"), - ] { - crate::db::write_item_with_content(id, stage, &format!("---\nname: \"{name}\"\n---\n")); - } - - let ctx = test_ctx(tmp.path()); - let result = tool_get_pipeline_status(&ctx).unwrap(); - let parsed: Value = serde_json::from_str(&result).unwrap(); - - // Active stages include current, qa, merge, done - let active = parsed["active"].as_array().unwrap(); - let stages: Vec<&str> = active - .iter() - .map(|i| i["stage"].as_str().unwrap()) - .collect(); - assert!(stages.contains(&"current")); - assert!(stages.contains(&"qa")); - assert!(stages.contains(&"merge")); - assert!(stages.contains(&"done")); - - // Backlog should contain our item - let backlog = parsed["backlog"].as_array().unwrap(); - assert!( - backlog - .iter() - .any(|b| b["story_id"] == "9910_story_upcoming"), - "expected 9910_story_upcoming in backlog: {backlog:?}" - ); - } - - #[test] - fn tool_get_pipeline_status_includes_agent_assignment() { - let tmp = tempfile::tempdir().unwrap(); - - crate::db::ensure_content_store(); - crate::db::write_item_with_content( - "9921_story_active", - "2_current", - "---\nname: \"Active Story\"\n---\n", - ); - - let ctx = test_ctx(tmp.path()); - ctx.services.agents.inject_test_agent( - "9921_story_active", - "coder-1", - crate::agents::AgentStatus::Running, - ); - - let result = tool_get_pipeline_status(&ctx).unwrap(); - let parsed: Value = serde_json::from_str(&result).unwrap(); - - let active = parsed["active"].as_array().unwrap(); - let item = active - .iter() - .find(|i| i["story_id"] == "9921_story_active") - .expect("expected 9921_story_active in active items"); - assert_eq!(item["stage"], "current"); - assert!(!item["agent"].is_null(), "agent should be present"); - assert_eq!(item["agent"]["agent_name"], "coder-1"); - assert_eq!(item["agent"]["status"], "running"); - } - - #[test] - fn tool_get_story_todos_missing_file() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_get_story_todos(&json!({"story_id": "99_nonexistent"}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("not found")); - } - - #[test] - fn tool_get_story_todos_returns_unchecked() { - let tmp = tempfile::tempdir().unwrap(); - - crate::db::ensure_content_store(); - crate::db::write_item_with_content( - "9901_test", - "2_current", - "---\nname: Test\n---\n## AC\n- [ ] First\n- [x] Done\n- [ ] Second\n", - ); - - let ctx = test_ctx(tmp.path()); - let result = tool_get_story_todos(&json!({"story_id": "9901_test"}), &ctx).unwrap(); - let parsed: Value = serde_json::from_str(&result).unwrap(); - assert_eq!(parsed["todos"].as_array().unwrap().len(), 2); - assert_eq!(parsed["story_name"], "Test"); - } - - #[test] - fn tool_record_tests_and_ensure_acceptance() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - - // Record passing tests - let result = tool_record_tests( - &json!({ - "story_id": "1_test", - "unit": [{"name": "u1", "status": "pass"}], - "integration": [{"name": "i1", "status": "pass"}] - }), - &ctx, - ) - .unwrap(); - assert!(result.contains("recorded")); - - // Should be acceptable - let result = tool_ensure_acceptance(&json!({"story_id": "1_test"}), &ctx).unwrap(); - assert!(result.contains("All gates pass")); - } - - #[test] - fn tool_ensure_acceptance_blocks_on_failures() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - - tool_record_tests( - &json!({ - "story_id": "1_test", - "unit": [{"name": "u1", "status": "fail"}], - "integration": [] - }), - &ctx, - ) - .unwrap(); - - let result = tool_ensure_acceptance(&json!({"story_id": "1_test"}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("blocked")); - } - - fn setup_git_repo_in(dir: &std::path::Path) { - std::process::Command::new("git") - .args(["init"]) - .current_dir(dir) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["config", "user.email", "test@test.com"]) - .current_dir(dir) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["config", "user.name", "Test"]) - .current_dir(dir) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["commit", "--allow-empty", "-m", "init"]) - .current_dir(dir) - .output() - .unwrap(); - } - - #[test] - fn create_bug_in_tools_list() { - use super::super::handle_tools_list; - let resp = handle_tools_list(Some(json!(1))); - let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); - let tool = tools.iter().find(|t| t["name"] == "create_bug"); - assert!(tool.is_some(), "create_bug missing from tools list"); - let t = tool.unwrap(); - let desc = t["description"].as_str().unwrap(); - assert!( - desc.contains("work/1_backlog/"), - "create_bug description should reference work/1_backlog/, got: {desc}" - ); - assert!( - !desc.contains(".huskies/bugs"), - "create_bug description should not reference nonexistent .huskies/bugs/, got: {desc}" - ); - let required = t["inputSchema"]["required"].as_array().unwrap(); - let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); - assert!(req_names.contains(&"name")); - assert!(req_names.contains(&"description")); - assert!(req_names.contains(&"steps_to_reproduce")); - assert!(req_names.contains(&"actual_result")); - assert!(req_names.contains(&"expected_result")); - } - - #[test] - fn list_bugs_in_tools_list() { - use super::super::handle_tools_list; - let resp = handle_tools_list(Some(json!(1))); - let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); - let tool = tools.iter().find(|t| t["name"] == "list_bugs"); - assert!(tool.is_some(), "list_bugs missing from tools list"); - let t = tool.unwrap(); - let desc = t["description"].as_str().unwrap(); - assert!( - desc.contains("work/1_backlog/"), - "list_bugs description should reference work/1_backlog/, got: {desc}" - ); - assert!( - !desc.contains(".huskies/bugs"), - "list_bugs description should not reference nonexistent .huskies/bugs/, got: {desc}" - ); - } - - #[test] - fn close_bug_in_tools_list() { - use super::super::handle_tools_list; - let resp = handle_tools_list(Some(json!(1))); - let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); - let tool = tools.iter().find(|t| t["name"] == "close_bug"); - assert!(tool.is_some(), "close_bug missing from tools list"); - let t = tool.unwrap(); - let desc = t["description"].as_str().unwrap(); - assert!( - !desc.contains(".huskies/bugs"), - "close_bug description should not reference nonexistent .huskies/bugs/, got: {desc}" - ); - assert!( - desc.contains("work/5_done/"), - "close_bug description should reference work/5_done/, got: {desc}" - ); - let required = t["inputSchema"]["required"].as_array().unwrap(); - let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); - assert!(req_names.contains(&"bug_id")); - } - - #[test] - fn tool_create_bug_missing_name() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_create_bug( - &json!({ - "description": "d", - "steps_to_reproduce": "s", - "actual_result": "a", - "expected_result": "e" - }), - &ctx, - ); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("name")); - } - - #[test] - fn tool_create_bug_missing_description() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_create_bug( - &json!({ - "name": "Bug", - "steps_to_reproduce": "s", - "actual_result": "a", - "expected_result": "e" - }), - &ctx, - ); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("description")); - } - - #[test] - fn tool_create_bug_creates_file_and_commits() { - let tmp = tempfile::tempdir().unwrap(); - setup_git_repo_in(tmp.path()); - let ctx = test_ctx(tmp.path()); - - let result = tool_create_bug( - &json!({ - "name": "Login Crash", - "description": "The app crashes on login.", - "steps_to_reproduce": "1. Open app\n2. Click login", - "actual_result": "500 error", - "expected_result": "Successful login" - }), - &ctx, - ) - .unwrap(); - - assert!( - result.contains("_bug_login_crash"), - "result should contain bug ID: {result}" - ); - // Extract the actual bug ID from the result message (format: "Created bug: "). - let bug_id = result.trim_start_matches("Created bug: ").trim(); - // Bug content should exist in the CRDT content store. - assert!( - crate::db::read_content(bug_id).is_some(), - "expected bug content in CRDT for {bug_id}" - ); - } - - #[test] - fn tool_list_bugs_no_crash_on_empty_root() { - // list_bugs reads from the global CRDT, not the filesystem. - // Verify it returns valid JSON without panicking. - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_list_bugs(&ctx).unwrap(); - // Verify result is valid JSON array (may contain bugs from - // the shared global CRDT populated by other tests). - let _parsed: Vec = serde_json::from_str(&result).unwrap(); - } - - #[test] - fn tool_list_bugs_returns_open_bugs() { - let tmp = tempfile::tempdir().unwrap(); - - crate::db::ensure_content_store(); - crate::db::write_item_with_content( - "9902_bug_crash", - "1_backlog", - "---\nname: \"App Crash\"\n---\n# Bug 9902: App Crash\n", - ); - crate::db::write_item_with_content( - "9903_bug_typo", - "1_backlog", - "---\nname: \"Typo in Header\"\n---\n# Bug 9903: Typo in Header\n", - ); - - let ctx = test_ctx(tmp.path()); - let result = tool_list_bugs(&ctx).unwrap(); - let parsed: Vec = serde_json::from_str(&result).unwrap(); - assert!( - parsed - .iter() - .any(|b| b["bug_id"] == "9902_bug_crash" && b["name"] == "App Crash"), - "expected 9902_bug_crash in bugs list: {parsed:?}" - ); - assert!( - parsed - .iter() - .any(|b| b["bug_id"] == "9903_bug_typo" && b["name"] == "Typo in Header"), - "expected 9903_bug_typo in bugs list: {parsed:?}" - ); - } - - #[test] - fn tool_close_bug_missing_bug_id() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_close_bug(&json!({}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("bug_id")); - } - - #[test] - fn tool_close_bug_moves_to_archive() { - let tmp = tempfile::tempdir().unwrap(); - setup_git_repo_in(tmp.path()); - let backlog_dir = tmp.path().join(".huskies/work/1_backlog"); - std::fs::create_dir_all(&backlog_dir).unwrap(); - let bug_file = backlog_dir.join("9901_bug_crash.md"); - let content = "# Bug 9901: Crash\n"; - std::fs::write(&bug_file, content).unwrap(); - crate::db::ensure_content_store(); - crate::db::write_content("9901_bug_crash", content); - // Stage the file so it's tracked - std::process::Command::new("git") - .args(["add", "."]) - .current_dir(tmp.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["commit", "-m", "add bug"]) - .current_dir(tmp.path()) - .output() - .unwrap(); - - let ctx = test_ctx(tmp.path()); - let result = tool_close_bug(&json!({"bug_id": "9901_bug_crash"}), &ctx).unwrap(); - assert!(result.contains("9901_bug_crash")); - assert!( - crate::db::read_content("9901_bug_crash").is_some(), - "content store should have the bug after close" - ); - } - - #[test] - fn create_spike_in_tools_list() { - use super::super::handle_tools_list; - let resp = handle_tools_list(Some(json!(1))); - let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); - let tool = tools.iter().find(|t| t["name"] == "create_spike"); - assert!(tool.is_some(), "create_spike missing from tools list"); - let t = tool.unwrap(); - assert!(t["description"].is_string()); - let required = t["inputSchema"]["required"].as_array().unwrap(); - let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); - assert!(req_names.contains(&"name")); - // description is optional - assert!(!req_names.contains(&"description")); - } - - #[test] - fn tool_create_spike_missing_name() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_create_spike(&json!({}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("name")); - } - - #[test] - fn tool_create_spike_rejects_empty_name() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_create_spike(&json!({"name": "!!!"}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("alphanumeric")); - } - - #[test] - fn tool_create_spike_creates_file() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - - let result = tool_create_spike( - &json!({"name": "Compare Encoders", "description": "Which encoder is fastest?"}), - &ctx, - ) - .unwrap(); - - assert!( - result.contains("_spike_compare_encoders"), - "result should contain spike ID: {result}" - ); - // Extract the actual spike ID from the result message (format: "Created spike: "). - let spike_id = result.trim_start_matches("Created spike: ").trim(); - // Spike content should exist in the CRDT content store. - let contents = crate::db::read_content(spike_id).expect("expected spike content in CRDT"); - assert!(contents.starts_with("---\nname: \"Compare Encoders\"\n---")); - assert!(contents.contains("Which encoder is fastest?")); - } - - #[test] - fn tool_create_spike_creates_file_without_description() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - - let result = tool_create_spike(&json!({"name": "My Spike"}), &ctx).unwrap(); - assert!( - result.contains("_spike_my_spike"), - "result should contain spike ID: {result}" - ); - // Extract the actual spike ID from the result message (format: "Created spike: "). - let spike_id = result.trim_start_matches("Created spike: ").trim(); - - // Spike content should exist in the CRDT content store. - let contents = crate::db::read_content(spike_id).expect("expected spike content in CRDT"); - assert!(contents.starts_with("---\nname: \"My Spike\"\n---")); - assert!(contents.contains("## Question\n\n- TBD\n")); - } - - #[test] - fn tool_record_tests_missing_story_id() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_record_tests(&json!({"unit": [], "integration": []}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("story_id")); - } - - #[test] - fn tool_record_tests_invalid_unit_type_returns_error() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_record_tests( - &json!({ - "story_id": "1_test", - "unit": "not_an_array", - "integration": [] - }), - &ctx, - ); - assert!(result.is_err()); - } - - #[test] - fn tool_ensure_acceptance_missing_story_id() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_ensure_acceptance(&json!({}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("story_id")); - } - - #[test] - fn tool_validate_stories_with_valid_story() { - let tmp = tempfile::tempdir().unwrap(); - - crate::db::ensure_content_store(); - crate::db::write_item_with_content( - "9907_test", - "2_current", - "---\nname: \"Valid Story\"\n---\n## AC\n- [ ] First\n", - ); - - let ctx = test_ctx(tmp.path()); - let result = tool_validate_stories(&ctx).unwrap(); - let parsed: Vec = serde_json::from_str(&result).unwrap(); - let item = parsed - .iter() - .find(|v| v["story_id"] == "9907_test") - .expect("expected 9907_test in validation results"); - assert_eq!(item["valid"], true); - } - - #[test] - fn tool_validate_stories_with_invalid_front_matter() { - let tmp = tempfile::tempdir().unwrap(); - - crate::db::ensure_content_store(); - crate::db::write_item_with_content("9908_test", "2_current", "## No front matter at all\n"); - - let ctx = test_ctx(tmp.path()); - let result = tool_validate_stories(&ctx).unwrap(); - let parsed: Vec = serde_json::from_str(&result).unwrap(); - let item = parsed - .iter() - .find(|v| v["story_id"] == "9908_test") - .expect("expected 9908_test in validation results"); - assert_eq!(item["valid"], false); - } - - #[test] - fn record_tests_persists_to_story_file() { - let tmp = tempfile::tempdir().unwrap(); - - crate::db::ensure_content_store(); - crate::db::write_item_with_content( - "9906_story_persist", - "2_current", - "---\nname: Persist\n---\n# Story\n", - ); - - let ctx = test_ctx(tmp.path()); - tool_record_tests( - &json!({ - "story_id": "9906_story_persist", - "unit": [{"name": "u1", "status": "pass"}], - "integration": [] - }), - &ctx, - ) - .unwrap(); - - let contents = crate::db::read_content("9906_story_persist") - .expect("story content should exist in CRDT"); - assert!( - contents.contains("## Test Results"), - "content should have Test Results section" - ); - assert!( - contents.contains("huskies-test-results:"), - "content should have JSON marker" - ); - assert!(contents.contains("u1"), "content should contain test name"); - } - - #[test] - fn ensure_acceptance_reads_from_file_when_not_in_memory() { - let tmp = tempfile::tempdir().unwrap(); - - // Write story content to CRDT with a pre-populated Test Results section - let story_content = "---\nname: Persist\n---\n# Story\n\n## Test Results\n\n\n"; - crate::db::ensure_content_store(); - crate::db::write_item_with_content("9905_story_file_only", "2_current", story_content); - - let ctx = test_ctx(tmp.path()); - - // ensure_acceptance should read from content store and succeed - let result = tool_ensure_acceptance(&json!({"story_id": "9905_story_file_only"}), &ctx); - assert!( - result.is_ok(), - "should accept based on content store data, got: {:?}", - result - ); - assert!(result.unwrap().contains("All gates pass")); - } - - #[test] - fn ensure_acceptance_file_with_failures_still_blocks() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".huskies/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - - let story_content = "---\nname: Fail\n---\n# Story\n\n## Test Results\n\n\n"; - fs::write(current.join("3_story_fail.md"), story_content).unwrap(); - - let ctx = test_ctx(tmp.path()); - let result = tool_ensure_acceptance(&json!({"story_id": "3_story_fail"}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("blocked")); - } - - #[tokio::test] - async fn tool_delete_story_missing_story_id() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_delete_story(&json!({}), &ctx).await; - assert!(result.is_err()); - assert!(result.unwrap_err().contains("story_id")); - } - - #[tokio::test] - async fn tool_delete_story_not_found_returns_error() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_delete_story(&json!({"story_id": "99_nonexistent"}), &ctx).await; - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .contains("not found in any pipeline stage") - ); - } - - #[tokio::test] - async fn tool_delete_story_deletes_file_from_backlog() { - let tmp = tempfile::tempdir().unwrap(); - let backlog = tmp.path().join(".huskies/work/1_backlog"); - fs::create_dir_all(&backlog).unwrap(); - let story_file = backlog.join("10_story_cleanup.md"); - fs::write(&story_file, "---\nname: Cleanup\n---\n").unwrap(); - - let ctx = test_ctx(tmp.path()); - let result = tool_delete_story(&json!({"story_id": "10_story_cleanup"}), &ctx).await; - assert!(result.is_ok(), "expected ok: {result:?}"); - assert!(!story_file.exists(), "story file should be deleted"); - } - - #[tokio::test] - async fn tool_delete_story_deletes_file_from_current() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".huskies/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - let story_file = current.join("11_story_active.md"); - fs::write(&story_file, "---\nname: Active\n---\n").unwrap(); - - let ctx = test_ctx(tmp.path()); - let result = tool_delete_story(&json!({"story_id": "11_story_active"}), &ctx).await; - assert!(result.is_ok(), "expected ok: {result:?}"); - assert!(!story_file.exists(), "story file should be deleted"); - } - - #[test] - fn tool_accept_story_missing_story_id() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_accept_story(&json!({}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("story_id")); - } - - #[test] - fn tool_accept_story_nonexistent_story_returns_error() { - let tmp = tempfile::tempdir().unwrap(); - setup_git_repo_in(tmp.path()); - let ctx = test_ctx(tmp.path()); - // No story file in current/ — should fail - let result = tool_accept_story(&json!({"story_id": "99_nonexistent"}), &ctx); - assert!(result.is_err()); - } - - /// Bug 226: accept_story must refuse when the feature branch has unmerged code. - #[test] - fn tool_accept_story_refuses_when_feature_branch_has_unmerged_code() { - let tmp = tempfile::tempdir().unwrap(); - setup_git_repo_in(tmp.path()); - - // Create a feature branch with code changes. - std::process::Command::new("git") - .args(["checkout", "-b", "feature/story-50_story_test"]) - .current_dir(tmp.path()) - .output() - .unwrap(); - std::fs::write(tmp.path().join("feature.rs"), "fn main() {}").unwrap(); - std::process::Command::new("git") - .args(["add", "."]) - .current_dir(tmp.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["commit", "-m", "add feature"]) - .current_dir(tmp.path()) - .output() - .unwrap(); - std::process::Command::new("git") - .args(["checkout", "master"]) - .current_dir(tmp.path()) - .output() - .unwrap(); - - // Create story file in current/ so move_story_to_done would work. - let current_dir = tmp.path().join(".huskies/work/2_current"); - std::fs::create_dir_all(¤t_dir).unwrap(); - std::fs::write( - current_dir.join("50_story_test.md"), - "---\nname: Test\n---\n", - ) - .unwrap(); - - let ctx = test_ctx(tmp.path()); - let result = tool_accept_story(&json!({"story_id": "50_story_test"}), &ctx); - assert!( - result.is_err(), - "should refuse when feature branch has unmerged code" - ); - let err = result.unwrap_err(); - assert!( - err.contains("unmerged"), - "error should mention unmerged changes: {err}" - ); - } - - /// Bug 226: accept_story succeeds when no feature branch exists (e.g. manual stories). - #[test] - fn tool_accept_story_succeeds_when_no_feature_branch() { - let tmp = tempfile::tempdir().unwrap(); - setup_git_repo_in(tmp.path()); - - // Create story file in current/ (no feature branch). - let current_dir = tmp.path().join(".huskies/work/2_current"); - std::fs::create_dir_all(¤t_dir).unwrap(); - let content = "---\nname: No Branch\n---\n"; - std::fs::write(current_dir.join("51_story_no_branch.md"), content).unwrap(); - crate::db::ensure_content_store(); - crate::db::write_content("51_story_no_branch", content); - - let ctx = test_ctx(tmp.path()); - let result = tool_accept_story(&json!({"story_id": "51_story_no_branch"}), &ctx); - assert!( - result.is_ok(), - "should succeed when no feature branch: {result:?}" - ); - } - - // ── tool_update_story non-string front matter tests ─────────────────────── - - fn setup_story_for_update(dir: &std::path::Path, story_id: &str, content: &str) { - let current = dir.join(".huskies/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - fs::write(current.join(format!("{story_id}.md")), content).unwrap(); - crate::db::ensure_content_store(); - crate::db::write_content(story_id, content); - } - - #[test] - fn tool_update_story_front_matter_json_bool_written_unquoted() { - let tmp = tempfile::tempdir().unwrap(); - setup_story_for_update( - tmp.path(), - "504_bool_test", - "---\nname: Bool Test\n---\n\nNo sections.\n", - ); - let ctx = test_ctx(tmp.path()); - - let result = tool_update_story( - &json!({"story_id": "504_bool_test", "front_matter": {"blocked": false}}), - &ctx, - ); - assert!(result.is_ok(), "Expected ok: {result:?}"); - - let content = crate::db::read_content("504_bool_test").unwrap(); - assert!( - content.contains("blocked: false"), - "bool should be unquoted: {content}" - ); - assert!( - !content.contains("blocked: \"false\""), - "bool must not be quoted: {content}" - ); - } - - #[test] - fn tool_update_story_front_matter_json_number_written_unquoted() { - let tmp = tempfile::tempdir().unwrap(); - setup_story_for_update( - tmp.path(), - "504_num_test", - "---\nname: Num Test\n---\n\nNo sections.\n", - ); - let ctx = test_ctx(tmp.path()); - - let result = tool_update_story( - &json!({"story_id": "504_num_test", "front_matter": {"retry_count": 3}}), - &ctx, - ); - assert!(result.is_ok(), "Expected ok: {result:?}"); - - let content = crate::db::read_content("504_num_test").unwrap(); - assert!( - content.contains("retry_count: 3"), - "number should be unquoted: {content}" - ); - assert!( - !content.contains("retry_count: \"3\""), - "number must not be quoted: {content}" - ); - } - - #[test] - fn tool_update_story_front_matter_json_array_written_as_yaml_sequence() { - let tmp = tempfile::tempdir().unwrap(); - setup_story_for_update( - tmp.path(), - "504_arr_test", - "---\nname: Array Test\n---\n\nNo sections.\n", - ); - let ctx = test_ctx(tmp.path()); - - let result = tool_update_story( - &json!({"story_id": "504_arr_test", "front_matter": {"depends_on": [490, 491]}}), - &ctx, - ); - assert!(result.is_ok(), "Expected ok: {result:?}"); - - let content = crate::db::read_content("504_arr_test").unwrap(); - // YAML inline sequences use spaces after commas - assert!( - content.contains("depends_on: [490, 491]"), - "array should be unquoted YAML: {content}" - ); - assert!( - !content.contains("depends_on: \""), - "array must not be quoted: {content}" - ); - - // The YAML must be parseable as a vec - let meta = crate::io::story_metadata::parse_front_matter(&content) - .expect("front matter should parse"); - assert_eq!(meta.depends_on, Some(vec![490, 491])); - } - - #[test] - fn tool_check_criterion_missing_story_id() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_check_criterion(&json!({"criterion_index": 0}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("story_id")); - } - - #[test] - fn tool_check_criterion_missing_criterion_index() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_check_criterion(&json!({"story_id": "1_test"}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("criterion_index")); - } - - #[test] - fn tool_check_criterion_marks_unchecked_item() { - let tmp = tempfile::tempdir().unwrap(); - setup_git_repo_in(tmp.path()); - - crate::db::ensure_content_store(); - crate::db::write_item_with_content( - "9904_test", - "2_current", - "---\nname: Test\n---\n## AC\n- [ ] First criterion\n- [x] Already done\n", - ); - - let ctx = test_ctx(tmp.path()); - let result = tool_check_criterion( - &json!({"story_id": "9904_test", "criterion_index": 0}), - &ctx, - ); - assert!(result.is_ok(), "Expected ok: {result:?}"); - assert!(result.unwrap().contains("Criterion 0 checked")); - } - - #[test] - fn tool_remove_criterion_missing_story_id() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_remove_criterion(&json!({"criterion_index": 0}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("story_id")); - } - - #[test] - fn tool_remove_criterion_missing_criterion_index() { - let tmp = tempfile::tempdir().unwrap(); - let ctx = test_ctx(tmp.path()); - let result = tool_remove_criterion(&json!({"story_id": "1_test"}), &ctx); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("criterion_index")); - } - - #[test] - fn tool_remove_criterion_removes_item() { - let tmp = tempfile::tempdir().unwrap(); - setup_git_repo_in(tmp.path()); - - crate::db::ensure_content_store(); - crate::db::write_item_with_content( - "9905_test", - "2_current", - "---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Keep me\n- [ ] Remove me\n", - ); - - let ctx = test_ctx(tmp.path()); - let result = tool_remove_criterion( - &json!({"story_id": "9905_test", "criterion_index": 1}), - &ctx, - ); - assert!(result.is_ok(), "Expected ok: {result:?}"); - assert!(result.unwrap().contains("Removed criterion 1")); - } - - #[test] - fn tool_remove_criterion_out_of_range() { - let tmp = tempfile::tempdir().unwrap(); - setup_git_repo_in(tmp.path()); - - crate::db::ensure_content_store(); - crate::db::write_item_with_content( - "9906_test", - "2_current", - "---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Only one\n", - ); - - let ctx = test_ctx(tmp.path()); - let result = tool_remove_criterion( - &json!({"story_id": "9906_test", "criterion_index": 5}), - &ctx, - ); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("out of range")); - } - - /// Regression test for bug 514: deleting a story must cancel its pending - /// rate-limit retry timer so the tick loop cannot re-spawn an agent. - /// - /// Repro (2026-04-09): `delete_story 478_…` returned success and removed - /// the filesystem shadow, but the timer entry in `.huskies/timers.json` - /// survived. Five minutes later the tick loop fired and re-spawned - /// `coder-1` on the deleted story. - #[tokio::test] - async fn delete_story_cancels_pending_timer() { - use chrono::Utc; - - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path(); - - // Create a story file in the backlog. - let backlog = root.join(".huskies/work/1_backlog"); - fs::create_dir_all(&backlog).unwrap(); - fs::write( - backlog.join("478_story_rate_limit_repro.md"), - "---\nname: \"Rate Limit Repro\"\n---\n", - ) - .unwrap(); - - let ctx = test_ctx(root); - - // Schedule a rate-limit retry timer for the story (simulates the - // auto-scheduler that fires after a rate-limit event). - let future_time = Utc::now() + chrono::Duration::minutes(5); - ctx.timer_store - .add("478_story_rate_limit_repro".to_string(), future_time) - .unwrap(); - - // Sanity: timer is present before deletion. - assert_eq!(ctx.timer_store.list().len(), 1); - - // Delete the story. - let result = - tool_delete_story(&json!({"story_id": "478_story_rate_limit_repro"}), &ctx).await; - assert!(result.is_ok(), "delete_story failed: {result:?}"); - - // Timer must be gone — fast-forwarding past the scheduled time should - // return no entries. - assert!( - ctx.timer_store.list().is_empty(), - "timer was not cancelled by delete_story" - ); - let far_future = Utc::now() + chrono::Duration::hours(1); - let due = ctx.timer_store.take_due(far_future); - assert!( - due.is_empty(), - "take_due returned a timer for the deleted story: {due:?}" - ); - - // Filesystem shadow must also be gone. - assert!( - !backlog.join("478_story_rate_limit_repro.md").exists(), - "filesystem shadow was not removed" - ); - } -} diff --git a/server/src/http/mcp/story_tools/mod.rs b/server/src/http/mcp/story_tools/mod.rs new file mode 100644 index 00000000..d3cca22e --- /dev/null +++ b/server/src/http/mcp/story_tools/mod.rs @@ -0,0 +1,725 @@ +//! MCP story tools — create, update, move, and manage stories, bugs, and refactors via MCP. +//! +//! This file is a thin adapter: it deserialises MCP payloads, delegates to +//! `crate::service::story` and `crate::http::workflow` for business logic, +//! and serialises responses. +use crate::agents::{ + close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done, +}; +use crate::http::context::AppContext; +use crate::http::workflow::{ + add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file, + create_spike_file, create_story_file, edit_criterion_in_file, list_bug_files, + list_refactor_files, load_pipeline_state, load_upcoming_stories, remove_criterion_from_file, + update_story_in_file, validate_story_dirs, +}; +use crate::io::story_metadata::{ + check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos, +}; +use crate::service::story::parse_test_cases; +use crate::slog_warn; +#[allow(unused_imports)] +use crate::workflow::{TestCaseResult, TestStatus, evaluate_acceptance_with_coverage}; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::fs; + +pub(super) fn tool_create_story(args: &Value, ctx: &AppContext) -> Result { + let name = args + .get("name") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: name")?; + let user_story = args.get("user_story").and_then(|v| v.as_str()); + let description = args.get("description").and_then(|v| v.as_str()); + let acceptance_criteria: Option> = args + .get("acceptance_criteria") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + let depends_on: Option> = args + .get("depends_on") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + // Spike 61: write the file only — the filesystem watcher detects the new + // .md file in work/1_backlog/ and auto-commits with a deterministic message. + let commit = false; + + let root = ctx.state.get_project_root()?; + let story_id = create_story_file( + &root, + name, + user_story, + description, + acceptance_criteria.as_deref(), + depends_on.as_deref(), + commit, + )?; + + // Bug 503: warn at creation time if any depends_on points at an already-archived story. + // Archived = satisfied semantics: the dep will resolve immediately on the next promotion + // tick, which is surprising if the archived story was abandoned rather than cleanly done. + let archived_deps = depends_on + .as_deref() + .map(|deps| check_archived_deps_from_list(&root, deps)) + .unwrap_or_default(); + if !archived_deps.is_empty() { + slog_warn!( + "[create-story] Story '{story_id}' depends_on {archived_deps:?} which \ + are already in 6_archived. The dep will be treated as satisfied on the \ + next promotion tick. If these deps were abandoned (not cleanly completed), \ + consider removing the depends_on or keeping the story in backlog manually." + ); + return Ok(format!( + "Created story: {story_id}\n\n\ + WARNING: depends_on {archived_deps:?} point at stories already in \ + 6_archived. These deps are treated as satisfied (archived = satisfied \ + semantics), so this story may be auto-promoted from backlog immediately. \ + If the archived deps were abandoned rather than completed, remove the \ + depends_on or move the story back to backlog manually after promotion." + )); + } + + Ok(format!("Created story: {story_id}")) +} + +/// Purge a story from the in-memory CRDT by writing a tombstone op (story 521). +/// +/// This is the eviction primitive for the four-state-machine drift problem +/// we hit on 2026-04-09 — when a story gets stuck in the running server's +/// in-memory CRDT and can't be cleared by sqlite deletes alone (because the +/// in-memory state outlives any pipeline_items / crdt_ops manipulation), +/// this tool writes a proper CRDT delete op via `crdt_state::evict_item`. +/// +/// The tombstone op: +/// - Marks the in-memory CRDT item as `is_deleted = true` immediately +/// (so subsequent `read_all_items` / `read_item` calls skip it) +/// - Is persisted to `crdt_ops` so the eviction survives a server restart +/// - Drops the in-memory `CONTENT_STORE` entry for the story +/// +/// This tool does NOT touch: running agents, worktrees, the `pipeline_items` +/// shadow table, `timers.json`, or filesystem shadows. Compose with +/// `stop_agent`, `remove_worktree`, etc. as needed for a full purge — or +/// see story 514 (delete_story full cleanup) for a future "do it all" tool. +pub(super) fn tool_purge_story(args: &Value, _ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + crate::crdt_state::evict_item(story_id)?; + + Ok(format!( + "Evicted '{story_id}' from in-memory CRDT (tombstone op persisted to crdt_ops; CONTENT_STORE entry dropped)." + )) +} + +pub(super) fn tool_validate_stories(ctx: &AppContext) -> Result { + let root = ctx.state.get_project_root()?; + let results = validate_story_dirs(&root)?; + serde_json::to_string_pretty(&json!( + results + .iter() + .map(|r| json!({ + "story_id": r.story_id, + "valid": r.valid, + "error": r.error, + })) + .collect::>() + )) + .map_err(|e| format!("Serialization error: {e}")) +} + +pub(super) fn tool_list_upcoming(ctx: &AppContext) -> Result { + let stories = load_upcoming_stories(ctx)?; + serde_json::to_string_pretty(&json!( + stories + .iter() + .map(|s| json!({ + "story_id": s.story_id, + "name": s.name, + "error": s.error, + })) + .collect::>() + )) + .map_err(|e| format!("Serialization error: {e}")) +} + +pub(super) fn tool_get_pipeline_status(ctx: &AppContext) -> Result { + let state = load_pipeline_state(ctx)?; + + fn map_items(items: &[crate::http::workflow::UpcomingStory], stage: &str) -> Vec { + items + .iter() + .map(|s| { + let mut item = json!({ + "story_id": s.story_id, + "name": s.name, + "stage": stage, + "agent": s.agent.as_ref().map(|a| json!({ + "agent_name": a.agent_name, + "model": a.model, + "status": a.status, + })), + }); + // Include blocked/retry_count when present so callers can + // identify stories stuck in the pipeline. + if let Some(true) = s.blocked { + item["blocked"] = json!(true); + } + if let Some(rc) = s.retry_count { + item["retry_count"] = json!(rc); + } + if let Some(ref mf) = s.merge_failure { + item["merge_failure"] = json!(mf); + } + item + }) + .collect() + } + + let mut active: Vec = Vec::new(); + active.extend(map_items(&state.current, "current")); + active.extend(map_items(&state.qa, "qa")); + active.extend(map_items(&state.merge, "merge")); + active.extend(map_items(&state.done, "done")); + + let backlog: Vec = state + .backlog + .iter() + .map(|s| json!({ "story_id": s.story_id, "name": s.name })) + .collect(); + + serde_json::to_string_pretty(&json!({ + "active": active, + "backlog": backlog, + "backlog_count": backlog.len(), + })) + .map_err(|e| format!("Serialization error: {e}")) +} + +pub(super) fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let root = ctx.state.get_project_root()?; + + // Read from DB content store, falling back to filesystem. + let contents = crate::http::workflow::read_story_content(&root, story_id) + .map_err(|_| format!("Story file not found: {story_id}.md"))?; + + let story_name = parse_front_matter(&contents).ok().and_then(|m| m.name); + let todos = parse_unchecked_todos(&contents); + + serde_json::to_string_pretty(&json!({ + "story_id": story_id, + "story_name": story_name, + "todos": todos, + })) + .map_err(|e| format!("Serialization error: {e}")) +} + +pub(super) fn tool_record_tests(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let unit = parse_test_cases(args.get("unit"))?; + let integration = parse_test_cases(args.get("integration"))?; + + let mut workflow = ctx + .workflow + .lock() + .map_err(|e| format!("Lock error: {e}"))?; + + workflow.record_test_results_validated(story_id.to_string(), unit, integration)?; + + // Persist to story file (best-effort — file write errors are warnings, not failures). + if let Ok(project_root) = ctx.state.get_project_root() + && let Some(results) = workflow.results.get(story_id) + && let Err(e) = crate::http::workflow::write_test_results_to_story_file( + &project_root, + story_id, + results, + ) + { + slog_warn!("[record_tests] Could not persist results to story file: {e}"); + } + + Ok("Test results recorded.".to_string()) +} + +pub(super) fn tool_ensure_acceptance(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let workflow = ctx + .workflow + .lock() + .map_err(|e| format!("Lock error: {e}"))?; + + // Use in-memory results if present; otherwise fall back to file-persisted results. + let file_results; + let results = if let Some(r) = workflow.results.get(story_id) { + r + } else { + let project_root = ctx.state.get_project_root().ok(); + file_results = project_root.as_deref().and_then(|root| { + crate::http::workflow::read_test_results_from_story_file(root, story_id) + }); + file_results.as_ref().map_or_else( + || { + // No results anywhere — use empty default for the acceptance check + // (it will fail with "No test results recorded") + static EMPTY: std::sync::OnceLock = + std::sync::OnceLock::new(); + EMPTY.get_or_init(Default::default) + }, + |r| r, + ) + }; + + let coverage = workflow.coverage.get(story_id); + let decision = evaluate_acceptance_with_coverage(results, coverage); + + if decision.can_accept { + Ok("Story can be accepted. All gates pass.".to_string()) + } else { + let mut parts = decision.reasons; + if let Some(w) = decision.warning { + parts.push(w); + } + Err(format!("Acceptance blocked: {}", parts.join("; "))) + } +} + +pub(super) fn tool_accept_story(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let project_root = ctx.services.agents.get_project_root(&ctx.state)?; + + // Bug 226: Refuse to accept if the feature branch has unmerged code. + // The code must be squash-merged via merge_agent_work first. + if feature_branch_has_unmerged_changes(&project_root, story_id) { + return Err(format!( + "Cannot accept story '{story_id}': feature branch 'feature/story-{story_id}' \ + has unmerged changes. Use merge_agent_work to squash-merge the code into \ + master first." + )); + } + + move_story_to_done(&project_root, story_id)?; + ctx.services.agents.remove_agents_for_story(story_id); + + Ok(format!( + "Story '{story_id}' accepted, moved to done/, and committed to master." + )) +} + +pub(super) fn tool_check_criterion(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + let criterion_index = args + .get("criterion_index") + .and_then(|v| v.as_u64()) + .ok_or("Missing required argument: criterion_index")? as usize; + + let root = ctx.state.get_project_root()?; + check_criterion_in_file(&root, story_id, criterion_index)?; + + Ok(format!( + "Criterion {criterion_index} checked for story '{story_id}'. Committed to master." + )) +} + +pub(super) fn tool_edit_criterion(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + let criterion_index = args + .get("criterion_index") + .and_then(|v| v.as_u64()) + .ok_or("Missing required argument: criterion_index")? as usize; + let new_text = args + .get("new_text") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: new_text")?; + + let root = ctx.state.get_project_root()?; + edit_criterion_in_file(&root, story_id, criterion_index, new_text)?; + + Ok(format!( + "Criterion {criterion_index} updated for story '{story_id}'." + )) +} + +pub(super) fn tool_add_criterion(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + let criterion = args + .get("criterion") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: criterion")?; + + let root = ctx.state.get_project_root()?; + add_criterion_to_file(&root, story_id, criterion)?; + + Ok(format!( + "Added criterion to story '{story_id}': - [ ] {criterion}" + )) +} + +pub(super) fn tool_remove_criterion(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + let criterion_index = args + .get("criterion_index") + .and_then(|v| v.as_u64()) + .ok_or("Missing required argument: criterion_index")? as usize; + + let root = ctx.state.get_project_root()?; + remove_criterion_from_file(&root, story_id, criterion_index)?; + + Ok(format!( + "Removed criterion {criterion_index} from story '{story_id}'." + )) +} + +pub(super) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + let user_story = args.get("user_story").and_then(|v| v.as_str()); + let description = args.get("description").and_then(|v| v.as_str()); + + // Collect front matter fields: explicit `agent` param + arbitrary `front_matter` object. + // Values are passed as serde_json::Value so native booleans, numbers, and arrays are + // preserved and encoded correctly as unquoted YAML by update_story_in_file. + let mut front_matter: HashMap = HashMap::new(); + if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) { + front_matter.insert("agent".to_string(), Value::String(agent.to_string())); + } + if let Some(obj) = args.get("front_matter").and_then(|v| v.as_object()) { + for (k, v) in obj { + front_matter.insert(k.clone(), v.clone()); + } + } + let front_matter_opt = if front_matter.is_empty() { + None + } else { + Some(&front_matter) + }; + + let root = ctx.state.get_project_root()?; + update_story_in_file(&root, story_id, user_story, description, front_matter_opt)?; + + // Bug 503: warn if any depends_on in the (now updated) story points at an archived story. + let stage = crate::pipeline_state::read_typed(story_id) + .ok() + .flatten() + .map(|i| i.stage.dir_name().to_string()) + .unwrap_or_else(|| "1_backlog".to_string()); + let archived_deps = check_archived_deps(&root, &stage, story_id); + if !archived_deps.is_empty() { + slog_warn!( + "[update-story] Story '{story_id}' depends_on {archived_deps:?} which \ + are already in 6_archived. The dep will be treated as satisfied on the \ + next promotion tick. If these deps were abandoned (not cleanly completed), \ + consider removing the depends_on or keeping the story in backlog manually." + ); + return Ok(format!( + "Updated story '{story_id}'.\n\n\ + WARNING: depends_on {archived_deps:?} point at stories already in \ + 6_archived. These deps are treated as satisfied (archived = satisfied \ + semantics), so this story may be auto-promoted from backlog immediately. \ + If the archived deps were abandoned rather than completed, remove the \ + depends_on or move the story back to backlog manually after promotion." + )); + } + + Ok(format!("Updated story '{story_id}'.")) +} + +pub(super) fn tool_create_spike(args: &Value, ctx: &AppContext) -> Result { + let name = args + .get("name") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: name")?; + let description = args.get("description").and_then(|v| v.as_str()); + + let root = ctx.state.get_project_root()?; + let spike_id = create_spike_file(&root, name, description)?; + + Ok(format!("Created spike: {spike_id}")) +} + +pub(super) fn tool_create_bug(args: &Value, ctx: &AppContext) -> Result { + let name = args + .get("name") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: name")?; + let description = args + .get("description") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: description")?; + let steps_to_reproduce = args + .get("steps_to_reproduce") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: steps_to_reproduce")?; + let actual_result = args + .get("actual_result") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: actual_result")?; + let expected_result = args + .get("expected_result") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: expected_result")?; + let acceptance_criteria: Option> = args + .get("acceptance_criteria") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + let depends_on: Option> = args + .get("depends_on") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + let root = ctx.state.get_project_root()?; + let bug_id = create_bug_file( + &root, + name, + description, + steps_to_reproduce, + actual_result, + expected_result, + acceptance_criteria.as_deref(), + depends_on.as_deref(), + )?; + + Ok(format!("Created bug: {bug_id}")) +} + +pub(super) fn tool_list_bugs(ctx: &AppContext) -> Result { + let root = ctx.state.get_project_root()?; + let bugs = list_bug_files(&root)?; + serde_json::to_string_pretty(&json!( + bugs.iter() + .map(|(id, name)| json!({ "bug_id": id, "name": name })) + .collect::>() + )) + .map_err(|e| format!("Serialization error: {e}")) +} + +pub(super) fn tool_close_bug(args: &Value, ctx: &AppContext) -> Result { + let bug_id = args + .get("bug_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: bug_id")?; + + let root = ctx.services.agents.get_project_root(&ctx.state)?; + close_bug_to_archive(&root, bug_id)?; + ctx.services.agents.remove_agents_for_story(bug_id); + + Ok(format!( + "Bug '{bug_id}' closed, moved to bugs/archive/, and committed to master." + )) +} + +pub(super) fn tool_unblock_story(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let root = ctx.state.get_project_root()?; + + // Extract the numeric prefix (e.g. "42" from "42_story_foo") + let story_number = story_id + .split('_') + .next() + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) + .ok_or_else(|| format!("Invalid story_id format: '{story_id}'. Expected a numeric prefix (e.g. '42_story_foo')."))?; + + Ok(crate::chat::commands::unblock::unblock_by_number( + &root, + story_number, + )) +} + +pub(super) async fn tool_delete_story(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let project_root = ctx.services.agents.get_project_root(&ctx.state)?; + let mut failed_steps: Vec = Vec::new(); + + // 0. Cancel any pending rate-limit retry timers for this story (bug 514). + // Must happen before stopping agents so the tick loop cannot re-spawn + // an agent after we tear everything else down. + let timer_removed = ctx.timer_store.remove(story_id); + if timer_removed { + slog_warn!("[delete_story] Cancelled pending timer for '{story_id}'"); + } else { + slog_warn!("[delete_story] No pending timer found for '{story_id}'"); + } + + // 1. Stop any running agents for this story (best-effort). + if let Ok(agents) = ctx.services.agents.list_agents() { + for agent in agents.iter().filter(|a| a.story_id == story_id) { + match ctx + .services + .agents + .stop_agent(&project_root, story_id, &agent.agent_name) + .await + { + Ok(()) => { + slog_warn!( + "[delete_story] Stopped agent '{}' for '{story_id}'", + agent.agent_name + ); + } + Err(e) => { + slog_warn!( + "[delete_story] Failed to stop agent '{}' for '{story_id}': {e}", + agent.agent_name + ); + failed_steps.push(format!("stop_agent({}): {e}", agent.agent_name)); + } + } + } + } + + // 2. Remove agent pool entries. + let removed_count = ctx.services.agents.remove_agents_for_story(story_id); + slog_warn!("[delete_story] Removed {removed_count} agent pool entries for '{story_id}'"); + + // 3. Remove worktree (best-effort). + if let Ok(config) = crate::config::ProjectConfig::load(&project_root) { + match crate::worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await { + Ok(()) => slog_warn!("[delete_story] Removed worktree for '{story_id}'"), + Err(e) => slog_warn!("[delete_story] Worktree removal for '{story_id}': {e}"), + } + } + + // 4. Write a CRDT tombstone op so the story is evicted from the in-memory + // state machine and the deletion is persisted to crdt_ops (survives + // restart). Best-effort: legacy filesystem-only stories may not have a + // CRDT entry, so a "not found" error is expected and non-fatal. + match crate::crdt_state::evict_item(story_id) { + Ok(()) => { + slog_warn!( + "[delete_story] Evicted '{story_id}' from CRDT (tombstone persisted to crdt_ops)" + ); + } + Err(e) => { + slog_warn!("[delete_story] CRDT eviction for '{story_id}': {e}"); + } + } + + // 5. Delete from database content store and shadow table. + let found_in_db = crate::db::read_content(story_id).is_some() + || crate::pipeline_state::read_typed(story_id) + .ok() + .flatten() + .is_some(); + crate::db::delete_item(story_id); + slog_warn!("[delete_story] Deleted '{story_id}' from content store / shadow table"); + + // 6. Remove the filesystem shadow file from work/N_stage/. + let sk = project_root.join(".huskies").join("work"); + let stage_dirs = [ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", + ]; + let mut deleted_from_fs = false; + for stage in &stage_dirs { + let path = sk.join(stage).join(format!("{story_id}.md")); + if path.exists() { + match fs::remove_file(&path) { + Ok(()) => { + slog_warn!( + "[delete_story] Deleted filesystem shadow '{story_id}' from work/{stage}/" + ); + deleted_from_fs = true; + } + Err(e) => { + slog_warn!( + "[delete_story] Failed to delete filesystem shadow '{story_id}' from work/{stage}/: {e}" + ); + failed_steps.push(format!("delete_filesystem({stage}): {e}")); + } + } + break; + } + } + + if !found_in_db && !deleted_from_fs && !timer_removed { + return Err(format!( + "Story '{story_id}' not found in any pipeline stage." + )); + } + + if !failed_steps.is_empty() { + return Err(format!( + "Story '{story_id}' partially deleted. Failed steps: {}.", + failed_steps.join("; ") + )); + } + + Ok(format!("Story '{story_id}' deleted from pipeline.")) +} + +pub(super) fn tool_create_refactor(args: &Value, ctx: &AppContext) -> Result { + let name = args + .get("name") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: name")?; + let description = args.get("description").and_then(|v| v.as_str()); + let acceptance_criteria: Option> = args + .get("acceptance_criteria") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + let depends_on: Option> = args + .get("depends_on") + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + let root = ctx.state.get_project_root()?; + let refactor_id = create_refactor_file( + &root, + name, + description, + acceptance_criteria.as_deref(), + depends_on.as_deref(), + )?; + + Ok(format!("Created refactor: {refactor_id}")) +} + +pub(super) fn tool_list_refactors(ctx: &AppContext) -> Result { + let root = ctx.state.get_project_root()?; + let refactors = list_refactor_files(&root)?; + serde_json::to_string_pretty(&json!( + refactors + .iter() + .map(|(id, name)| json!({ "refactor_id": id, "name": name })) + .collect::>() + )) + .map_err(|e| format!("Serialization error: {e}")) +} + +#[cfg(test)] +mod tests; diff --git a/server/src/http/mcp/story_tools/tests.rs b/server/src/http/mcp/story_tools/tests.rs new file mode 100644 index 00000000..1f89bba5 --- /dev/null +++ b/server/src/http/mcp/story_tools/tests.rs @@ -0,0 +1,1137 @@ +use super::*; +use crate::http::test_helpers::test_ctx; + +#[test] +fn parse_test_cases_empty() { + let result = parse_test_cases(None).unwrap(); + assert!(result.is_empty()); +} + +#[test] +fn parse_test_cases_valid() { + let input = json!([ + {"name": "test1", "status": "pass"}, + {"name": "test2", "status": "fail", "details": "assertion failed"} + ]); + let result = parse_test_cases(Some(&input)).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].status, TestStatus::Pass); + assert_eq!(result[1].status, TestStatus::Fail); + assert_eq!(result[1].details, Some("assertion failed".to_string())); +} + +#[test] +fn parse_test_cases_invalid_status() { + let input = json!([{"name": "t", "status": "maybe"}]); + assert!(parse_test_cases(Some(&input)).is_err()); +} + +#[test] +fn parse_test_cases_null_value_returns_empty() { + let null_val = json!(null); + let result = parse_test_cases(Some(&null_val)).unwrap(); + assert!(result.is_empty()); +} + +#[test] +fn parse_test_cases_non_array_returns_error() { + let obj = json!({"invalid": "input"}); + let result = parse_test_cases(Some(&obj)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Expected array")); +} + +#[test] +fn parse_test_cases_missing_name_returns_error() { + let input = json!([{"status": "pass"}]); + let result = parse_test_cases(Some(&input)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("name")); +} + +#[test] +fn parse_test_cases_missing_status_returns_error() { + let input = json!([{"name": "test1"}]); + let result = parse_test_cases(Some(&input)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("status")); +} + +#[test] +fn tool_validate_stories_empty_project() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_validate_stories(&ctx).unwrap(); + // CRDT is global; other tests may have inserted items. + // Just verify it parses without error. + let _parsed: Vec = serde_json::from_str(&result).unwrap(); +} + +#[test] +fn tool_create_story_and_list_upcoming() { + let tmp = tempfile::tempdir().unwrap(); + // No git repo needed: spike 61 — create_story just writes the file; + // the filesystem watcher handles the commit asynchronously. + let ctx = test_ctx(tmp.path()); + + let result = tool_create_story( + &json!({"name": "Test Story", "acceptance_criteria": ["AC1", "AC2"]}), + &ctx, + ) + .unwrap(); + assert!(result.contains("Created story:")); + + // List should return it (CRDT is global, so filter for our story) + let list = tool_list_upcoming(&ctx).unwrap(); + let parsed: Vec = serde_json::from_str(&list).unwrap(); + assert!( + parsed.iter().any(|s| s["name"] == "Test Story"), + "expected 'Test Story' in upcoming list: {parsed:?}" + ); +} + +#[test] +fn tool_create_story_rejects_empty_name() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_create_story(&json!({"name": "!!!"}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("alphanumeric")); +} + +#[test] +fn tool_create_story_missing_name() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_create_story(&json!({}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Missing required argument")); +} + +// Regression test for bug 509: description was silently dropped. +#[test] +fn tool_create_story_description_is_written_to_file() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + + let result = tool_create_story( + &json!({ + "name": "Story With Description", + "description": "This is the background context." + }), + &ctx, + ) + .unwrap(); + assert!(result.contains("Created story:")); + + let story_id = result + .trim_start_matches("Created story: ") + .trim() + .to_string(); + let content = crate::db::read_content(&story_id).expect("story content should exist"); + assert!( + content.contains("## Description"), + "Description section missing from story: {content}" + ); + assert!( + content.contains("This is the background context."), + "Description text missing from story: {content}" + ); +} + +#[test] +fn tool_get_pipeline_status_returns_structured_response() { + let tmp = tempfile::tempdir().unwrap(); + + crate::db::ensure_content_store(); + for (stage, id, name) in &[ + ("1_backlog", "9910_story_upcoming", "Upcoming Story"), + ("2_current", "9920_story_current", "Current Story"), + ("3_qa", "9930_story_qa", "QA Story"), + ("4_merge", "9940_story_merge", "Merge Story"), + ("5_done", "9950_story_done", "Done Story"), + ] { + crate::db::write_item_with_content(id, stage, &format!("---\nname: \"{name}\"\n---\n")); + } + + let ctx = test_ctx(tmp.path()); + let result = tool_get_pipeline_status(&ctx).unwrap(); + let parsed: Value = serde_json::from_str(&result).unwrap(); + + // Active stages include current, qa, merge, done + let active = parsed["active"].as_array().unwrap(); + let stages: Vec<&str> = active + .iter() + .map(|i| i["stage"].as_str().unwrap()) + .collect(); + assert!(stages.contains(&"current")); + assert!(stages.contains(&"qa")); + assert!(stages.contains(&"merge")); + assert!(stages.contains(&"done")); + + // Backlog should contain our item + let backlog = parsed["backlog"].as_array().unwrap(); + assert!( + backlog + .iter() + .any(|b| b["story_id"] == "9910_story_upcoming"), + "expected 9910_story_upcoming in backlog: {backlog:?}" + ); +} + +#[test] +fn tool_get_pipeline_status_includes_agent_assignment() { + let tmp = tempfile::tempdir().unwrap(); + + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "9921_story_active", + "2_current", + "---\nname: \"Active Story\"\n---\n", + ); + + let ctx = test_ctx(tmp.path()); + ctx.services.agents.inject_test_agent( + "9921_story_active", + "coder-1", + crate::agents::AgentStatus::Running, + ); + + let result = tool_get_pipeline_status(&ctx).unwrap(); + let parsed: Value = serde_json::from_str(&result).unwrap(); + + let active = parsed["active"].as_array().unwrap(); + let item = active + .iter() + .find(|i| i["story_id"] == "9921_story_active") + .expect("expected 9921_story_active in active items"); + assert_eq!(item["stage"], "current"); + assert!(!item["agent"].is_null(), "agent should be present"); + assert_eq!(item["agent"]["agent_name"], "coder-1"); + assert_eq!(item["agent"]["status"], "running"); +} + +#[test] +fn tool_get_story_todos_missing_file() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_get_story_todos(&json!({"story_id": "99_nonexistent"}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); +} + +#[test] +fn tool_get_story_todos_returns_unchecked() { + let tmp = tempfile::tempdir().unwrap(); + + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "9901_test", + "2_current", + "---\nname: Test\n---\n## AC\n- [ ] First\n- [x] Done\n- [ ] Second\n", + ); + + let ctx = test_ctx(tmp.path()); + let result = tool_get_story_todos(&json!({"story_id": "9901_test"}), &ctx).unwrap(); + let parsed: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["todos"].as_array().unwrap().len(), 2); + assert_eq!(parsed["story_name"], "Test"); +} + +#[test] +fn tool_record_tests_and_ensure_acceptance() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + + // Record passing tests + let result = tool_record_tests( + &json!({ + "story_id": "1_test", + "unit": [{"name": "u1", "status": "pass"}], + "integration": [{"name": "i1", "status": "pass"}] + }), + &ctx, + ) + .unwrap(); + assert!(result.contains("recorded")); + + // Should be acceptable + let result = tool_ensure_acceptance(&json!({"story_id": "1_test"}), &ctx).unwrap(); + assert!(result.contains("All gates pass")); +} + +#[test] +fn tool_ensure_acceptance_blocks_on_failures() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + + tool_record_tests( + &json!({ + "story_id": "1_test", + "unit": [{"name": "u1", "status": "fail"}], + "integration": [] + }), + &ctx, + ) + .unwrap(); + + let result = tool_ensure_acceptance(&json!({"story_id": "1_test"}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("blocked")); +} + +fn setup_git_repo_in(dir: &std::path::Path) { + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(dir) + .output() + .unwrap(); +} + +#[test] +fn create_bug_in_tools_list() { + use super::super::handle_tools_list; + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + let tool = tools.iter().find(|t| t["name"] == "create_bug"); + assert!(tool.is_some(), "create_bug missing from tools list"); + let t = tool.unwrap(); + let desc = t["description"].as_str().unwrap(); + assert!( + desc.contains("work/1_backlog/"), + "create_bug description should reference work/1_backlog/, got: {desc}" + ); + assert!( + !desc.contains(".huskies/bugs"), + "create_bug description should not reference nonexistent .huskies/bugs/, got: {desc}" + ); + let required = t["inputSchema"]["required"].as_array().unwrap(); + let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(req_names.contains(&"name")); + assert!(req_names.contains(&"description")); + assert!(req_names.contains(&"steps_to_reproduce")); + assert!(req_names.contains(&"actual_result")); + assert!(req_names.contains(&"expected_result")); +} + +#[test] +fn list_bugs_in_tools_list() { + use super::super::handle_tools_list; + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + let tool = tools.iter().find(|t| t["name"] == "list_bugs"); + assert!(tool.is_some(), "list_bugs missing from tools list"); + let t = tool.unwrap(); + let desc = t["description"].as_str().unwrap(); + assert!( + desc.contains("work/1_backlog/"), + "list_bugs description should reference work/1_backlog/, got: {desc}" + ); + assert!( + !desc.contains(".huskies/bugs"), + "list_bugs description should not reference nonexistent .huskies/bugs/, got: {desc}" + ); +} + +#[test] +fn close_bug_in_tools_list() { + use super::super::handle_tools_list; + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + let tool = tools.iter().find(|t| t["name"] == "close_bug"); + assert!(tool.is_some(), "close_bug missing from tools list"); + let t = tool.unwrap(); + let desc = t["description"].as_str().unwrap(); + assert!( + !desc.contains(".huskies/bugs"), + "close_bug description should not reference nonexistent .huskies/bugs/, got: {desc}" + ); + assert!( + desc.contains("work/5_done/"), + "close_bug description should reference work/5_done/, got: {desc}" + ); + let required = t["inputSchema"]["required"].as_array().unwrap(); + let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(req_names.contains(&"bug_id")); +} + +#[test] +fn tool_create_bug_missing_name() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_create_bug( + &json!({ + "description": "d", + "steps_to_reproduce": "s", + "actual_result": "a", + "expected_result": "e" + }), + &ctx, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("name")); +} + +#[test] +fn tool_create_bug_missing_description() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_create_bug( + &json!({ + "name": "Bug", + "steps_to_reproduce": "s", + "actual_result": "a", + "expected_result": "e" + }), + &ctx, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("description")); +} + +#[test] +fn tool_create_bug_creates_file_and_commits() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo_in(tmp.path()); + let ctx = test_ctx(tmp.path()); + + let result = tool_create_bug( + &json!({ + "name": "Login Crash", + "description": "The app crashes on login.", + "steps_to_reproduce": "1. Open app\n2. Click login", + "actual_result": "500 error", + "expected_result": "Successful login" + }), + &ctx, + ) + .unwrap(); + + assert!( + result.contains("_bug_login_crash"), + "result should contain bug ID: {result}" + ); + // Extract the actual bug ID from the result message (format: "Created bug: "). + let bug_id = result.trim_start_matches("Created bug: ").trim(); + // Bug content should exist in the CRDT content store. + assert!( + crate::db::read_content(bug_id).is_some(), + "expected bug content in CRDT for {bug_id}" + ); +} + +#[test] +fn tool_list_bugs_no_crash_on_empty_root() { + // list_bugs reads from the global CRDT, not the filesystem. + // Verify it returns valid JSON without panicking. + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_list_bugs(&ctx).unwrap(); + // Verify result is valid JSON array (may contain bugs from + // the shared global CRDT populated by other tests). + let _parsed: Vec = serde_json::from_str(&result).unwrap(); +} + +#[test] +fn tool_list_bugs_returns_open_bugs() { + let tmp = tempfile::tempdir().unwrap(); + + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "9902_bug_crash", + "1_backlog", + "---\nname: \"App Crash\"\n---\n# Bug 9902: App Crash\n", + ); + crate::db::write_item_with_content( + "9903_bug_typo", + "1_backlog", + "---\nname: \"Typo in Header\"\n---\n# Bug 9903: Typo in Header\n", + ); + + let ctx = test_ctx(tmp.path()); + let result = tool_list_bugs(&ctx).unwrap(); + let parsed: Vec = serde_json::from_str(&result).unwrap(); + assert!( + parsed + .iter() + .any(|b| b["bug_id"] == "9902_bug_crash" && b["name"] == "App Crash"), + "expected 9902_bug_crash in bugs list: {parsed:?}" + ); + assert!( + parsed + .iter() + .any(|b| b["bug_id"] == "9903_bug_typo" && b["name"] == "Typo in Header"), + "expected 9903_bug_typo in bugs list: {parsed:?}" + ); +} + +#[test] +fn tool_close_bug_missing_bug_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_close_bug(&json!({}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("bug_id")); +} + +#[test] +fn tool_close_bug_moves_to_archive() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo_in(tmp.path()); + let backlog_dir = tmp.path().join(".huskies/work/1_backlog"); + std::fs::create_dir_all(&backlog_dir).unwrap(); + let bug_file = backlog_dir.join("9901_bug_crash.md"); + let content = "# Bug 9901: Crash\n"; + std::fs::write(&bug_file, content).unwrap(); + crate::db::ensure_content_store(); + crate::db::write_content("9901_bug_crash", content); + // Stage the file so it's tracked + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "add bug"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + let ctx = test_ctx(tmp.path()); + let result = tool_close_bug(&json!({"bug_id": "9901_bug_crash"}), &ctx).unwrap(); + assert!(result.contains("9901_bug_crash")); + assert!( + crate::db::read_content("9901_bug_crash").is_some(), + "content store should have the bug after close" + ); +} + +#[test] +fn create_spike_in_tools_list() { + use super::super::handle_tools_list; + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + let tool = tools.iter().find(|t| t["name"] == "create_spike"); + assert!(tool.is_some(), "create_spike missing from tools list"); + let t = tool.unwrap(); + assert!(t["description"].is_string()); + let required = t["inputSchema"]["required"].as_array().unwrap(); + let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(req_names.contains(&"name")); + // description is optional + assert!(!req_names.contains(&"description")); +} + +#[test] +fn tool_create_spike_missing_name() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_create_spike(&json!({}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("name")); +} + +#[test] +fn tool_create_spike_rejects_empty_name() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_create_spike(&json!({"name": "!!!"}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("alphanumeric")); +} + +#[test] +fn tool_create_spike_creates_file() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + + let result = tool_create_spike( + &json!({"name": "Compare Encoders", "description": "Which encoder is fastest?"}), + &ctx, + ) + .unwrap(); + + assert!( + result.contains("_spike_compare_encoders"), + "result should contain spike ID: {result}" + ); + // Extract the actual spike ID from the result message (format: "Created spike: "). + let spike_id = result.trim_start_matches("Created spike: ").trim(); + // Spike content should exist in the CRDT content store. + let contents = crate::db::read_content(spike_id).expect("expected spike content in CRDT"); + assert!(contents.starts_with("---\nname: \"Compare Encoders\"\n---")); + assert!(contents.contains("Which encoder is fastest?")); +} + +#[test] +fn tool_create_spike_creates_file_without_description() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + + let result = tool_create_spike(&json!({"name": "My Spike"}), &ctx).unwrap(); + assert!( + result.contains("_spike_my_spike"), + "result should contain spike ID: {result}" + ); + // Extract the actual spike ID from the result message (format: "Created spike: "). + let spike_id = result.trim_start_matches("Created spike: ").trim(); + + // Spike content should exist in the CRDT content store. + let contents = crate::db::read_content(spike_id).expect("expected spike content in CRDT"); + assert!(contents.starts_with("---\nname: \"My Spike\"\n---")); + assert!(contents.contains("## Question\n\n- TBD\n")); +} + +#[test] +fn tool_record_tests_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_record_tests(&json!({"unit": [], "integration": []}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); +} + +#[test] +fn tool_record_tests_invalid_unit_type_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_record_tests( + &json!({ + "story_id": "1_test", + "unit": "not_an_array", + "integration": [] + }), + &ctx, + ); + assert!(result.is_err()); +} + +#[test] +fn tool_ensure_acceptance_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_ensure_acceptance(&json!({}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); +} + +#[test] +fn tool_validate_stories_with_valid_story() { + let tmp = tempfile::tempdir().unwrap(); + + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "9907_test", + "2_current", + "---\nname: \"Valid Story\"\n---\n## AC\n- [ ] First\n", + ); + + let ctx = test_ctx(tmp.path()); + let result = tool_validate_stories(&ctx).unwrap(); + let parsed: Vec = serde_json::from_str(&result).unwrap(); + let item = parsed + .iter() + .find(|v| v["story_id"] == "9907_test") + .expect("expected 9907_test in validation results"); + assert_eq!(item["valid"], true); +} + +#[test] +fn tool_validate_stories_with_invalid_front_matter() { + let tmp = tempfile::tempdir().unwrap(); + + crate::db::ensure_content_store(); + crate::db::write_item_with_content("9908_test", "2_current", "## No front matter at all\n"); + + let ctx = test_ctx(tmp.path()); + let result = tool_validate_stories(&ctx).unwrap(); + let parsed: Vec = serde_json::from_str(&result).unwrap(); + let item = parsed + .iter() + .find(|v| v["story_id"] == "9908_test") + .expect("expected 9908_test in validation results"); + assert_eq!(item["valid"], false); +} + +#[test] +fn record_tests_persists_to_story_file() { + let tmp = tempfile::tempdir().unwrap(); + + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "9906_story_persist", + "2_current", + "---\nname: Persist\n---\n# Story\n", + ); + + let ctx = test_ctx(tmp.path()); + tool_record_tests( + &json!({ + "story_id": "9906_story_persist", + "unit": [{"name": "u1", "status": "pass"}], + "integration": [] + }), + &ctx, + ) + .unwrap(); + + let contents = + crate::db::read_content("9906_story_persist").expect("story content should exist in CRDT"); + assert!( + contents.contains("## Test Results"), + "content should have Test Results section" + ); + assert!( + contents.contains("huskies-test-results:"), + "content should have JSON marker" + ); + assert!(contents.contains("u1"), "content should contain test name"); +} + +#[test] +fn ensure_acceptance_reads_from_file_when_not_in_memory() { + let tmp = tempfile::tempdir().unwrap(); + + // Write story content to CRDT with a pre-populated Test Results section + let story_content = "---\nname: Persist\n---\n# Story\n\n## Test Results\n\n\n"; + crate::db::ensure_content_store(); + crate::db::write_item_with_content("9905_story_file_only", "2_current", story_content); + + let ctx = test_ctx(tmp.path()); + + // ensure_acceptance should read from content store and succeed + let result = tool_ensure_acceptance(&json!({"story_id": "9905_story_file_only"}), &ctx); + assert!( + result.is_ok(), + "should accept based on content store data, got: {:?}", + result + ); + assert!(result.unwrap().contains("All gates pass")); +} + +#[test] +fn ensure_acceptance_file_with_failures_still_blocks() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".huskies/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + + let story_content = "---\nname: Fail\n---\n# Story\n\n## Test Results\n\n\n"; + fs::write(current.join("3_story_fail.md"), story_content).unwrap(); + + let ctx = test_ctx(tmp.path()); + let result = tool_ensure_acceptance(&json!({"story_id": "3_story_fail"}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("blocked")); +} + +#[tokio::test] +async fn tool_delete_story_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_delete_story(&json!({}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); +} + +#[tokio::test] +async fn tool_delete_story_not_found_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_delete_story(&json!({"story_id": "99_nonexistent"}), &ctx).await; + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .contains("not found in any pipeline stage") + ); +} + +#[tokio::test] +async fn tool_delete_story_deletes_file_from_backlog() { + let tmp = tempfile::tempdir().unwrap(); + let backlog = tmp.path().join(".huskies/work/1_backlog"); + fs::create_dir_all(&backlog).unwrap(); + let story_file = backlog.join("10_story_cleanup.md"); + fs::write(&story_file, "---\nname: Cleanup\n---\n").unwrap(); + + let ctx = test_ctx(tmp.path()); + let result = tool_delete_story(&json!({"story_id": "10_story_cleanup"}), &ctx).await; + assert!(result.is_ok(), "expected ok: {result:?}"); + assert!(!story_file.exists(), "story file should be deleted"); +} + +#[tokio::test] +async fn tool_delete_story_deletes_file_from_current() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".huskies/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let story_file = current.join("11_story_active.md"); + fs::write(&story_file, "---\nname: Active\n---\n").unwrap(); + + let ctx = test_ctx(tmp.path()); + let result = tool_delete_story(&json!({"story_id": "11_story_active"}), &ctx).await; + assert!(result.is_ok(), "expected ok: {result:?}"); + assert!(!story_file.exists(), "story file should be deleted"); +} + +#[test] +fn tool_accept_story_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_accept_story(&json!({}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); +} + +#[test] +fn tool_accept_story_nonexistent_story_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo_in(tmp.path()); + let ctx = test_ctx(tmp.path()); + // No story file in current/ — should fail + let result = tool_accept_story(&json!({"story_id": "99_nonexistent"}), &ctx); + assert!(result.is_err()); +} + +/// Bug 226: accept_story must refuse when the feature branch has unmerged code. +#[test] +fn tool_accept_story_refuses_when_feature_branch_has_unmerged_code() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo_in(tmp.path()); + + // Create a feature branch with code changes. + std::process::Command::new("git") + .args(["checkout", "-b", "feature/story-50_story_test"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::fs::write(tmp.path().join("feature.rs"), "fn main() {}").unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "add feature"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["checkout", "master"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + // Create story file in current/ so move_story_to_done would work. + let current_dir = tmp.path().join(".huskies/work/2_current"); + std::fs::create_dir_all(¤t_dir).unwrap(); + std::fs::write( + current_dir.join("50_story_test.md"), + "---\nname: Test\n---\n", + ) + .unwrap(); + + let ctx = test_ctx(tmp.path()); + let result = tool_accept_story(&json!({"story_id": "50_story_test"}), &ctx); + assert!( + result.is_err(), + "should refuse when feature branch has unmerged code" + ); + let err = result.unwrap_err(); + assert!( + err.contains("unmerged"), + "error should mention unmerged changes: {err}" + ); +} + +/// Bug 226: accept_story succeeds when no feature branch exists (e.g. manual stories). +#[test] +fn tool_accept_story_succeeds_when_no_feature_branch() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo_in(tmp.path()); + + // Create story file in current/ (no feature branch). + let current_dir = tmp.path().join(".huskies/work/2_current"); + std::fs::create_dir_all(¤t_dir).unwrap(); + let content = "---\nname: No Branch\n---\n"; + std::fs::write(current_dir.join("51_story_no_branch.md"), content).unwrap(); + crate::db::ensure_content_store(); + crate::db::write_content("51_story_no_branch", content); + + let ctx = test_ctx(tmp.path()); + let result = tool_accept_story(&json!({"story_id": "51_story_no_branch"}), &ctx); + assert!( + result.is_ok(), + "should succeed when no feature branch: {result:?}" + ); +} + +// ── tool_update_story non-string front matter tests ─────────────────────── + +fn setup_story_for_update(dir: &std::path::Path, story_id: &str, content: &str) { + let current = dir.join(".huskies/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + fs::write(current.join(format!("{story_id}.md")), content).unwrap(); + crate::db::ensure_content_store(); + crate::db::write_content(story_id, content); +} + +#[test] +fn tool_update_story_front_matter_json_bool_written_unquoted() { + let tmp = tempfile::tempdir().unwrap(); + setup_story_for_update( + tmp.path(), + "504_bool_test", + "---\nname: Bool Test\n---\n\nNo sections.\n", + ); + let ctx = test_ctx(tmp.path()); + + let result = tool_update_story( + &json!({"story_id": "504_bool_test", "front_matter": {"blocked": false}}), + &ctx, + ); + assert!(result.is_ok(), "Expected ok: {result:?}"); + + let content = crate::db::read_content("504_bool_test").unwrap(); + assert!( + content.contains("blocked: false"), + "bool should be unquoted: {content}" + ); + assert!( + !content.contains("blocked: \"false\""), + "bool must not be quoted: {content}" + ); +} + +#[test] +fn tool_update_story_front_matter_json_number_written_unquoted() { + let tmp = tempfile::tempdir().unwrap(); + setup_story_for_update( + tmp.path(), + "504_num_test", + "---\nname: Num Test\n---\n\nNo sections.\n", + ); + let ctx = test_ctx(tmp.path()); + + let result = tool_update_story( + &json!({"story_id": "504_num_test", "front_matter": {"retry_count": 3}}), + &ctx, + ); + assert!(result.is_ok(), "Expected ok: {result:?}"); + + let content = crate::db::read_content("504_num_test").unwrap(); + assert!( + content.contains("retry_count: 3"), + "number should be unquoted: {content}" + ); + assert!( + !content.contains("retry_count: \"3\""), + "number must not be quoted: {content}" + ); +} + +#[test] +fn tool_update_story_front_matter_json_array_written_as_yaml_sequence() { + let tmp = tempfile::tempdir().unwrap(); + setup_story_for_update( + tmp.path(), + "504_arr_test", + "---\nname: Array Test\n---\n\nNo sections.\n", + ); + let ctx = test_ctx(tmp.path()); + + let result = tool_update_story( + &json!({"story_id": "504_arr_test", "front_matter": {"depends_on": [490, 491]}}), + &ctx, + ); + assert!(result.is_ok(), "Expected ok: {result:?}"); + + let content = crate::db::read_content("504_arr_test").unwrap(); + // YAML inline sequences use spaces after commas + assert!( + content.contains("depends_on: [490, 491]"), + "array should be unquoted YAML: {content}" + ); + assert!( + !content.contains("depends_on: \""), + "array must not be quoted: {content}" + ); + + // The YAML must be parseable as a vec + let meta = + crate::io::story_metadata::parse_front_matter(&content).expect("front matter should parse"); + assert_eq!(meta.depends_on, Some(vec![490, 491])); +} + +#[test] +fn tool_check_criterion_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_check_criterion(&json!({"criterion_index": 0}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); +} + +#[test] +fn tool_check_criterion_missing_criterion_index() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_check_criterion(&json!({"story_id": "1_test"}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("criterion_index")); +} + +#[test] +fn tool_check_criterion_marks_unchecked_item() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo_in(tmp.path()); + + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "9904_test", + "2_current", + "---\nname: Test\n---\n## AC\n- [ ] First criterion\n- [x] Already done\n", + ); + + let ctx = test_ctx(tmp.path()); + let result = tool_check_criterion( + &json!({"story_id": "9904_test", "criterion_index": 0}), + &ctx, + ); + assert!(result.is_ok(), "Expected ok: {result:?}"); + assert!(result.unwrap().contains("Criterion 0 checked")); +} + +#[test] +fn tool_remove_criterion_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_remove_criterion(&json!({"criterion_index": 0}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); +} + +#[test] +fn tool_remove_criterion_missing_criterion_index() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_remove_criterion(&json!({"story_id": "1_test"}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("criterion_index")); +} + +#[test] +fn tool_remove_criterion_removes_item() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo_in(tmp.path()); + + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "9905_test", + "2_current", + "---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Keep me\n- [ ] Remove me\n", + ); + + let ctx = test_ctx(tmp.path()); + let result = tool_remove_criterion( + &json!({"story_id": "9905_test", "criterion_index": 1}), + &ctx, + ); + assert!(result.is_ok(), "Expected ok: {result:?}"); + assert!(result.unwrap().contains("Removed criterion 1")); +} + +#[test] +fn tool_remove_criterion_out_of_range() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo_in(tmp.path()); + + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "9906_test", + "2_current", + "---\nname: Test\n---\n## Acceptance Criteria\n- [ ] Only one\n", + ); + + let ctx = test_ctx(tmp.path()); + let result = tool_remove_criterion( + &json!({"story_id": "9906_test", "criterion_index": 5}), + &ctx, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("out of range")); +} + +/// Regression test for bug 514: deleting a story must cancel its pending +/// rate-limit retry timer so the tick loop cannot re-spawn an agent. +/// +/// Repro (2026-04-09): `delete_story 478_…` returned success and removed +/// the filesystem shadow, but the timer entry in `.huskies/timers.json` +/// survived. Five minutes later the tick loop fired and re-spawned +/// `coder-1` on the deleted story. +#[tokio::test] +async fn delete_story_cancels_pending_timer() { + use chrono::Utc; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Create a story file in the backlog. + let backlog = root.join(".huskies/work/1_backlog"); + fs::create_dir_all(&backlog).unwrap(); + fs::write( + backlog.join("478_story_rate_limit_repro.md"), + "---\nname: \"Rate Limit Repro\"\n---\n", + ) + .unwrap(); + + let ctx = test_ctx(root); + + // Schedule a rate-limit retry timer for the story (simulates the + // auto-scheduler that fires after a rate-limit event). + let future_time = Utc::now() + chrono::Duration::minutes(5); + ctx.timer_store + .add("478_story_rate_limit_repro".to_string(), future_time) + .unwrap(); + + // Sanity: timer is present before deletion. + assert_eq!(ctx.timer_store.list().len(), 1); + + // Delete the story. + let result = tool_delete_story(&json!({"story_id": "478_story_rate_limit_repro"}), &ctx).await; + assert!(result.is_ok(), "delete_story failed: {result:?}"); + + // Timer must be gone — fast-forwarding past the scheduled time should + // return no entries. + assert!( + ctx.timer_store.list().is_empty(), + "timer was not cancelled by delete_story" + ); + let far_future = Utc::now() + chrono::Duration::hours(1); + let due = ctx.timer_store.take_due(far_future); + assert!( + due.is_empty(), + "take_due returned a timer for the deleted story: {due:?}" + ); + + // Filesystem shadow must also be gone. + assert!( + !backlog.join("478_story_rate_limit_repro.md").exists(), + "filesystem shadow was not removed" + ); +} diff --git a/server/src/http/mcp/tests.rs b/server/src/http/mcp/tests.rs new file mode 100644 index 00000000..a518abfa --- /dev/null +++ b/server/src/http/mcp/tests.rs @@ -0,0 +1,472 @@ +use super::*; +use crate::http::test_helpers::test_ctx; + +#[test] +fn json_rpc_response_serializes_success() { + let resp = JsonRpcResponse::success(Some(json!(1)), json!({"ok": true})); + let s = serde_json::to_string(&resp).unwrap(); + assert!(s.contains("\"result\"")); + assert!(!s.contains("\"error\"")); +} + +#[test] +fn json_rpc_response_serializes_error() { + let resp = JsonRpcResponse::error(Some(json!(1)), -32600, "bad".into()); + let s = serde_json::to_string(&resp).unwrap(); + assert!(s.contains("\"error\"")); + assert!(!s.contains("\"result\"")); +} + +#[test] +fn initialize_returns_capabilities() { + let resp = handle_initialize( + Some(json!(1)), + &json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}), + ); + let result = resp.result.unwrap(); + assert_eq!(result["protocolVersion"], "2025-03-26"); + assert!(result["capabilities"]["tools"].is_object()); + assert_eq!(result["serverInfo"]["name"], "huskies"); +} + +#[test] +fn tools_list_returns_all_tools() { + let resp = handle_tools_list(Some(json!(2))); + let result = resp.result.unwrap(); + let tools = result["tools"].as_array().unwrap(); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + assert!(names.contains(&"create_story")); + assert!(names.contains(&"validate_stories")); + assert!(names.contains(&"list_upcoming")); + assert!(names.contains(&"get_story_todos")); + assert!(names.contains(&"record_tests")); + assert!(names.contains(&"ensure_acceptance")); + assert!(names.contains(&"start_agent")); + assert!(names.contains(&"stop_agent")); + assert!(names.contains(&"list_agents")); + assert!(names.contains(&"get_agent_config")); + assert!(names.contains(&"reload_agent_config")); + assert!(names.contains(&"get_agent_output")); + assert!(names.contains(&"wait_for_agent")); + assert!(names.contains(&"get_agent_remaining_turns_and_budget")); + assert!(names.contains(&"create_worktree")); + assert!(names.contains(&"list_worktrees")); + assert!(names.contains(&"remove_worktree")); + assert!(names.contains(&"get_editor_command")); + assert!(!names.contains(&"report_completion")); + assert!(names.contains(&"accept_story")); + assert!(names.contains(&"check_criterion")); + assert!(names.contains(&"add_criterion")); + assert!(names.contains(&"update_story")); + assert!(names.contains(&"create_spike")); + assert!(names.contains(&"create_bug")); + assert!(names.contains(&"list_bugs")); + assert!(names.contains(&"close_bug")); + assert!(names.contains(&"create_refactor")); + assert!(names.contains(&"list_refactors")); + assert!(names.contains(&"merge_agent_work")); + assert!(names.contains(&"get_merge_status")); + assert!(names.contains(&"move_story_to_merge")); + assert!(names.contains(&"report_merge_failure")); + assert!(names.contains(&"request_qa")); + assert!(names.contains(&"approve_qa")); + assert!(names.contains(&"reject_qa")); + assert!(names.contains(&"launch_qa_app")); + assert!(names.contains(&"get_server_logs")); + assert!(names.contains(&"prompt_permission")); + assert!(names.contains(&"get_pipeline_status")); + assert!(names.contains(&"rebuild_and_restart")); + assert!(names.contains(&"get_token_usage")); + assert!(names.contains(&"move_story")); + assert!(names.contains(&"unblock_story")); + assert!(names.contains(&"delete_story")); + assert!(names.contains(&"run_command")); + assert!(names.contains(&"run_tests")); + assert!(names.contains(&"get_test_result")); + assert!(names.contains(&"run_build")); + assert!(names.contains(&"run_lint")); + assert!(names.contains(&"git_status")); + assert!(names.contains(&"git_diff")); + assert!(names.contains(&"git_add")); + assert!(names.contains(&"git_commit")); + assert!(names.contains(&"git_log")); + assert!(names.contains(&"status")); + assert!(names.contains(&"loc_file")); + assert!(names.contains(&"dump_crdt")); + assert!(names.contains(&"get_version")); + assert!(names.contains(&"remove_criterion")); + assert_eq!(tools.len(), 66); +} + +#[test] +fn tools_list_schemas_have_required_fields() { + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + for tool in &tools { + assert!(tool["name"].is_string(), "tool missing name"); + assert!(tool["description"].is_string(), "tool missing description"); + assert!(tool["inputSchema"].is_object(), "tool missing inputSchema"); + assert_eq!(tool["inputSchema"]["type"], "object"); + } +} + +#[test] +fn handle_tools_call_unknown_tool() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let rt = tokio::runtime::Runtime::new().unwrap(); + let resp = rt.block_on(handle_tools_call( + Some(json!(1)), + &json!({"name": "bogus_tool", "arguments": {}}), + &ctx, + )); + let result = resp.result.unwrap(); + assert_eq!(result["isError"], true); + assert!( + result["content"][0]["text"] + .as_str() + .unwrap() + .contains("Unknown tool") + ); +} + +#[test] +fn to_sse_response_wraps_in_data_prefix() { + let resp = JsonRpcResponse::success(Some(json!(1)), json!({"ok": true})); + let http_resp = to_sse_response(resp); + assert_eq!( + http_resp.headers().get("content-type").unwrap(), + "text/event-stream" + ); +} + +#[test] +fn wants_sse_detects_accept_header() { + // Can't easily construct a Request in tests without TestClient, + // so test the logic indirectly via to_sse_response format + let resp = JsonRpcResponse::success(Some(json!(1)), json!("ok")); + let json_resp = to_json_response(resp); + assert_eq!( + json_resp.headers().get("content-type").unwrap(), + "application/json" + ); +} + +#[test] +fn json_rpc_error_response_builds_json_response() { + let resp = json_rpc_error_response(Some(json!(42)), -32600, "test error".into()); + assert_eq!(resp.status(), poem::http::StatusCode::OK); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "application/json" + ); +} + +// ── HTTP handler tests (TestClient) ─────────────────────────── + +fn test_mcp_app(ctx: std::sync::Arc) -> impl poem::Endpoint { + use poem::EndpointExt; + poem::Route::new() + .at("/mcp", poem::post(mcp_post_handler).get(mcp_get_handler)) + .data(ctx) +} + +async fn read_body_json(resp: poem::test::TestResponse) -> Value { + let body = resp.0.into_body().into_string().await.unwrap(); + serde_json::from_str(&body).unwrap() +} + +async fn post_json_mcp(cli: &poem::test::TestClient, payload: &str) -> Value { + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .body(payload.to_string()) + .send() + .await; + read_body_json(resp).await +} + +#[tokio::test] +async fn mcp_get_handler_returns_405() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli.get("/mcp").send().await; + assert_eq!(resp.0.status(), poem::http::StatusCode::METHOD_NOT_ALLOWED); +} + +#[tokio::test] +async fn mcp_post_invalid_content_type_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "text/plain") + .body("{}") + .send() + .await; + let body = read_body_json(resp).await; + assert!(body.get("error").is_some(), "expected error field: {body}"); +} + +#[tokio::test] +async fn mcp_post_invalid_json_returns_parse_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .body("not-valid-json") + .send() + .await; + let body = read_body_json(resp).await; + assert!(body.get("error").is_some(), "expected error field: {body}"); +} + +#[tokio::test] +async fn mcp_post_wrong_jsonrpc_version_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let body = post_json_mcp( + &cli, + r#"{"jsonrpc":"1.0","id":1,"method":"initialize","params":{}}"#, + ) + .await; + assert!( + body["error"]["message"] + .as_str() + .unwrap_or("") + .contains("version"), + "expected version error: {body}" + ); +} + +#[tokio::test] +async fn mcp_post_notification_with_null_id_returns_accepted() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .body(r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#) + .send() + .await; + assert_eq!(resp.0.status(), poem::http::StatusCode::ACCEPTED); +} + +#[tokio::test] +async fn mcp_post_notification_with_explicit_null_id_returns_accepted() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .body(r#"{"jsonrpc":"2.0","id":null,"method":"notifications/initialized","params":{}}"#) + .send() + .await; + assert_eq!(resp.0.status(), poem::http::StatusCode::ACCEPTED); +} + +#[tokio::test] +async fn mcp_post_missing_id_non_notification_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let body = post_json_mcp( + &cli, + r#"{"jsonrpc":"2.0","method":"initialize","params":{}}"#, + ) + .await; + assert!(body.get("error").is_some(), "expected error: {body}"); +} + +#[tokio::test] +async fn mcp_post_unknown_method_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let body = post_json_mcp( + &cli, + r#"{"jsonrpc":"2.0","id":1,"method":"bogus/method","params":{}}"#, + ) + .await; + assert!( + body["error"]["message"] + .as_str() + .unwrap_or("") + .contains("Unknown method"), + "expected unknown method error: {body}" + ); +} + +#[tokio::test] +async fn mcp_post_initialize_returns_capabilities() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let body = post_json_mcp( + &cli, + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"#, + ) + .await; + assert_eq!(body["result"]["protocolVersion"], "2025-03-26"); + assert_eq!(body["result"]["serverInfo"]["name"], "huskies"); +} + +#[tokio::test] +async fn mcp_post_tools_list_returns_tools() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let body = post_json_mcp( + &cli, + r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#, + ) + .await; + assert!(body["result"]["tools"].is_array()); +} + +#[tokio::test] +async fn mcp_post_sse_returns_event_stream_content_type() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#) + .send() + .await; + assert_eq!( + resp.0.headers().get("content-type").unwrap(), + "text/event-stream" + ); +} + +#[tokio::test] +async fn mcp_post_sse_get_agent_output_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{}}}"#) + .send() + .await; + assert_eq!( + resp.0.headers().get("content-type").unwrap(), + "text/event-stream", + "expected SSE content-type" + ); +} + +#[tokio::test] +async fn mcp_post_sse_get_agent_output_without_agent_name_returns_disk_content() { + // Without agent_name the SSE live-streaming intercept is skipped and + // the disk-based handler runs. The transport still wraps the result in + // SSE format (data: …\n\n) because the client sent Accept: text/event-stream, + // but the content should be a valid JSON-RPC result, not a subscribe error. + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"1_test"}}}"#) + .send() + .await; + let body = resp.0.into_body().into_string().await.unwrap(); + // Body is SSE-wrapped: "data: {…}\n\n" — strip the prefix and verify it's + // a valid JSON-RPC result (not an error about missing agent_name). + let json_part = body + .trim_start_matches("data: ") + .trim_end_matches("\n\n") + .trim(); + let parsed: serde_json::Value = serde_json::from_str(json_part) + .unwrap_or_else(|_| panic!("expected JSON-RPC in SSE body, got: {body}")); + assert!( + parsed.get("result").is_some(), + "expected JSON-RPC result (disk-based handler ran): {parsed}" + ); + // Must NOT be an error about missing agent_name (agent_name is now optional) + assert!( + parsed.get("error").is_none(), + "unexpected error when agent_name omitted: {parsed}" + ); +} + +#[tokio::test] +async fn mcp_post_sse_get_agent_output_no_agent_no_logs_returns_not_found() { + // Agent not in pool and no log files → SSE success with "No log files found" message. + let tmp = tempfile::tempdir().unwrap(); + let ctx = std::sync::Arc::new(test_ctx(tmp.path())); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"99_nope","agent_name":"bot"}}}"#) + .send() + .await; + assert_eq!( + resp.0.headers().get("content-type").unwrap(), + "text/event-stream" + ); + let body = resp.0.into_body().into_string().await.unwrap(); + assert!(body.contains("data:"), "expected SSE data prefix: {body}"); + // Must NOT return isError — should be a success result with "No log files found" + assert!( + !body.contains("isError"), + "expected no isError for missing agent: {body}" + ); + assert!( + body.contains("No log files found"), + "expected not-found message: {body}" + ); +} + +#[tokio::test] +async fn mcp_post_sse_get_agent_output_exited_agent_reads_disk_logs() { + use crate::agent_log::AgentLogWriter; + use crate::agents::AgentEvent; + // Agent has exited (not in pool) but wrote logs to disk. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-sse").unwrap(); + writer + .write_event(&AgentEvent::Output { + story_id: "42_story_foo".to_string(), + agent_name: "coder-1".to_string(), + text: "disk output".to_string(), + }) + .unwrap(); + drop(writer); + + let ctx = std::sync::Arc::new(test_ctx(root)); + let cli = poem::test::TestClient::new(test_mcp_app(ctx)); + let resp = cli + .post("/mcp") + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_output","arguments":{"story_id":"42_story_foo","agent_name":"coder-1"}}}"#) + .send() + .await; + let body = resp.0.into_body().into_string().await.unwrap(); + assert!( + body.contains("disk output"), + "expected disk log content in SSE response: {body}" + ); + assert!( + !body.contains("isError"), + "expected no error for exited agent with logs: {body}" + ); +} diff --git a/server/src/io/fs/scaffold.rs b/server/src/io/fs/scaffold.rs deleted file mode 100644 index d68b3b72..00000000 --- a/server/src/io/fs/scaffold.rs +++ /dev/null @@ -1,2045 +0,0 @@ -//! Project scaffolding — creates the `.huskies/` directory structure and default files. -use std::fs; -use std::path::Path; - -const STORY_KIT_README: &str = include_str!("../../../../.huskies/README.md"); - -const BOT_TOML_MATRIX_EXAMPLE: &str = include_str!("../../../../.huskies/bot.toml.matrix.example"); -const BOT_TOML_WHATSAPP_META_EXAMPLE: &str = - include_str!("../../../../.huskies/bot.toml.whatsapp-meta.example"); -const BOT_TOML_WHATSAPP_TWILIO_EXAMPLE: &str = - include_str!("../../../../.huskies/bot.toml.whatsapp-twilio.example"); -const BOT_TOML_SLACK_EXAMPLE: &str = include_str!("../../../../.huskies/bot.toml.slack.example"); - -const STORY_KIT_CONTEXT: &str = "\n\ -# Project Context\n\ -\n\ -## High-Level Goal\n\ -\n\ -TODO: Describe the high-level goal of this project.\n\ -\n\ -## Core Features\n\ -\n\ -TODO: List the core features of this project.\n\ -\n\ -## Domain Definition\n\ -\n\ -TODO: Define the key domain concepts and entities.\n\ -\n\ -## Glossary\n\ -\n\ -TODO: Define abbreviations and technical terms.\n"; - -const STORY_KIT_STACK: &str = "\n\ -# Tech Stack & Constraints\n\ -\n\ -## Core Stack\n\ -\n\ -TODO: Describe the language, frameworks, and runtimes.\n\ -\n\ -## Coding Standards\n\ -\n\ -TODO: Describe code style, linting rules, and error handling conventions.\n\ -\n\ -## Quality Gates\n\ -\n\ -TODO: List the commands that must pass before merging (e.g., cargo test, npm run build).\n\ -\n\ -## Libraries\n\ -\n\ -TODO: List approved libraries and their purpose.\n"; - -const STORY_KIT_SCRIPT_TEST: &str = "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's test commands here.\n# Story Kit agents invoke this script as the canonical test runner.\n# Exit 0 on success, non-zero on failure.\necho \"No tests configured\"\n"; - -const STORY_KIT_CLAUDE_MD: &str = "\n\ -Never chain shell commands with `&&`, `||`, or `;` in a single Bash call. \ -The permission system validates the entire command string, and chained commands \ -won't match allow rules like `Bash(git *)`. Use separate Bash calls instead — \ -parallel calls work fine.\n\ -\n\ -Read .huskies/README.md to see our dev process.\n\ -\n\ -IMPORTANT: On your first conversation, call `wizard_status` to check if \ -project setup is complete. If not, read .huskies/README.md for the full \ -setup wizard instructions and guide the user through it conversationally.\n"; - -const STORY_KIT_CLAUDE_SETTINGS: &str = r#"{ - "permissions": { - "allow": [ - "Bash(cargo build:*)", - "Bash(cargo check:*)", - "Bash(git *)", - "Bash(ls *)", - "Bash(mkdir *)", - "Bash(mv *)", - "Bash(rm *)", - "Bash(touch *)", - "Bash(echo:*)", - "Bash(pwd *)", - "Bash(grep:*)", - "Bash(find *)", - "Bash(head *)", - "Bash(tail *)", - "Bash(wc *)", - "Bash(cat *)", - "Read", - "Edit", - "Write", - "Glob", - "Grep", - "mcp__huskies__*" - ] - }, - "enabledMcpjsonServers": [ - "huskies" - ] -} -"#; - -const DEFAULT_PROJECT_SETTINGS_TOML: &str = r#"# Project-wide default QA mode: "server", "agent", or "human". -# Per-story `qa` front matter overrides this setting. -default_qa = "server" - -# Maximum number of retries per story per pipeline stage before marking as blocked. -# Set to 0 to disable retry limits. -max_retries = 2 - -# Default model for coder-stage agents (e.g. "sonnet", "opus"). -# When set, only coder agents whose model matches this value are considered for -# auto-assignment, so opus agents are only used when explicitly requested via -# story front matter `agent:` field. -# default_coder_model = "sonnet" - -# Maximum number of concurrent coder-stage agents. -# Stories wait in 2_current/ until a slot frees up. -# max_coders = 3 - -# Override the base branch for worktree creation and merge operations. -# When not set, the system auto-detects the base branch from the current HEAD. -# base_branch = "main" - -# Suppress soft rate-limit warning notifications in chat. -# Hard blocks and story-blocked notifications are always sent. -# rate_limit_notifications = true - -# IANA timezone for timer scheduling (e.g. "Europe/London", "America/New_York"). -# Timer HH:MM inputs are interpreted in this timezone. -# timezone = "America/New_York" -"#; - -const DEFAULT_AGENTS_TOML: &str = r#"[[agent]] -name = "coder-1" -stage = "coder" -role = "Full-stack engineer. Implements features across all components." -model = "sonnet" -max_turns = 50 -max_budget_usd = 5.00 -prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md to understand the dev process. Follow the workflow through implementation and verification. The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop.\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates when your process exits.\n\nIf `script/test` still contains the generic 'No tests configured' stub, update it to run the project's actual test suite before starting implementation." -system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Commit all your work before finishing. Do not accept stories, move them to archived, or merge to master." - -[[agent]] -name = "qa" -stage = "qa" -role = "Reviews coder work: runs quality gates, generates testing plans, and reports findings." -model = "sonnet" -max_turns = 40 -max_budget_usd = 4.00 -prompt = "You are the QA agent for story {{story_id}}. Review the coder's work and produce a structured QA report. Run quality gates (linting, tests), attempt a build, and generate a manual testing plan. Do NOT modify any code." -system_prompt = "You are a QA agent. Your job is read-only: review code quality, run tests, and produce a structured QA report. Do not modify code." - -[[agent]] -name = "mergemaster" -stage = "mergemaster" -role = "Merges completed work into master, runs quality gates, and archives stories." -model = "sonnet" -max_turns = 30 -max_budget_usd = 5.00 -prompt = "You are the mergemaster agent for story {{story_id}}. Call merge_agent_work(story_id='{{story_id}}') to start the merge pipeline. Then poll get_merge_status(story_id='{{story_id}}') every 15 seconds until the status is 'completed' or 'failed'. Report the final result. If the merge fails, call report_merge_failure." -system_prompt = "You are the mergemaster agent. Call merge_agent_work to start the merge, then poll get_merge_status every 15 seconds until done. Never manually move story files. Call report_merge_failure when merges fail." -"#; - -/// Detect the tech stack from the project root and return TOML `[[component]]` entries. -/// -/// Inspects well-known marker files at the project root to identify which -/// tech stacks are present, then emits one `[[component]]` entry per detected -/// stack with sensible default `setup` commands. If no markers are found, a -/// single fallback `app` component with an empty `setup` list is returned so -/// that the pipeline never breaks on an unknown stack. -pub fn detect_components_toml(root: &Path) -> String { - let mut sections = Vec::new(); - - if root.join("Cargo.toml").exists() { - sections.push( - "[[component]]\nname = \"server\"\npath = \".\"\nsetup = [\"cargo check\"]\n" - .to_string(), - ); - } - - if root.join("package.json").exists() { - let setup_cmd = if root.join("pnpm-lock.yaml").exists() { - "pnpm install" - } else { - "npm install" - }; - sections.push(format!( - "[[component]]\nname = \"frontend\"\npath = \".\"\nsetup = [\"{setup_cmd}\"]\n" - )); - } - - if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { - sections.push( - "[[component]]\nname = \"python\"\npath = \".\"\nsetup = [\"pip install -r requirements.txt\"]\n" - .to_string(), - ); - } - - if root.join("go.mod").exists() { - sections.push( - "[[component]]\nname = \"go\"\npath = \".\"\nsetup = [\"go build ./...\"]\n" - .to_string(), - ); - } - - if root.join("Gemfile").exists() { - sections.push( - "[[component]]\nname = \"ruby\"\npath = \".\"\nsetup = [\"bundle install\"]\n" - .to_string(), - ); - } - - if sections.is_empty() { - // No tech stack markers detected — emit a single generic component - // with an empty setup list. The ONBOARDING_PROMPT instructs the chat - // agent to inspect the project and replace this with real definitions. - sections.push("[[component]]\nname = \"app\"\npath = \".\"\nsetup = []\n".to_string()); - } - - sections.join("\n") -} - -/// Detect the appropriate Node.js test command for a directory containing `package.json`. -/// -/// Reads the `package.json` content to identify known test runners (vitest, jest). -/// Falls back to `npm test` or `pnpm test` based on which lock file is present. -fn detect_node_test_cmd(pkg_dir: &Path) -> String { - let has_pnpm = pkg_dir.join("pnpm-lock.yaml").exists(); - let content = std::fs::read_to_string(pkg_dir.join("package.json")).unwrap_or_default(); - - if content.contains("\"vitest\"") { - let pm = if has_pnpm { "pnpm" } else { "npx" }; - return format!("{} vitest run", pm); - } - if content.contains("\"jest\"") { - let pm = if has_pnpm { "pnpm" } else { "npx" }; - return format!("{} jest", pm); - } - - if has_pnpm { - "pnpm test".to_string() - } else { - "npm test".to_string() - } -} - -/// Detect the appropriate Node.js build command for a directory containing `package.json`. -fn detect_node_build_cmd(pkg_dir: &Path) -> String { - if pkg_dir.join("pnpm-lock.yaml").exists() { - "pnpm run build".to_string() - } else { - "npm run build".to_string() - } -} - -/// Detect the appropriate Node.js lint command for a directory containing `package.json`. -/// -/// Reads the `package.json` content to identify eslint. Falls back to -/// `npm run lint` or `pnpm run lint` based on which lock file is present. -fn detect_node_lint_cmd(pkg_dir: &Path) -> String { - let has_pnpm = pkg_dir.join("pnpm-lock.yaml").exists(); - let content = std::fs::read_to_string(pkg_dir.join("package.json")).unwrap_or_default(); - if content.contains("\"eslint\"") { - let pm = if has_pnpm { "pnpm" } else { "npx" }; - return format!("{pm} eslint ."); - } - if has_pnpm { - "pnpm run lint".to_string() - } else { - "npm run lint".to_string() - } -} - -/// Generate `script/build` content for a new project at `root`. -/// -/// Inspects well-known marker files to identify which tech stacks are present -/// and emits the appropriate build commands. Multi-stack projects get combined -/// commands run sequentially. Falls back to a generic stub when no markers -/// are found so the scaffold is always valid. -/// -/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`), -/// the build command is detected from the presence of `pnpm-lock.yaml`. -pub fn detect_script_build(root: &Path) -> String { - let mut commands: Vec = Vec::new(); - - if root.join("Cargo.toml").exists() { - commands.push("cargo build --release".to_string()); - } - - if root.join("package.json").exists() { - commands.push(detect_node_build_cmd(root)); - } - - // Detect frontend in known subdirectories (e.g. frontend/, client/) - for subdir in &["frontend", "client"] { - let sub_path = root.join(subdir); - if sub_path.join("package.json").exists() { - let cmd = detect_node_build_cmd(&sub_path); - commands.push(format!("(cd {} && {})", subdir, cmd)); - } - } - - if root.join("pyproject.toml").exists() { - commands.push("python -m build".to_string()); - } - - if root.join("go.mod").exists() { - commands.push("go build ./...".to_string()); - } - - if commands.is_empty() { - return "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's build commands here.\necho \"No build configured\"\n".to_string(); - } - - let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string(); - for cmd in commands { - script.push_str(&cmd); - script.push('\n'); - } - script -} - -/// Generate `script/lint` content for a new project at `root`. -/// -/// Inspects well-known marker files to identify which linters are present -/// and emits the appropriate lint commands. Multi-stack projects get combined -/// commands run sequentially. Falls back to a generic stub when no markers -/// are found so the scaffold is always valid. -/// -/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`), -/// the lint command is detected from the `package.json` (eslint, npm, pnpm). -pub fn detect_script_lint(root: &Path) -> String { - let mut commands: Vec = Vec::new(); - - if root.join("Cargo.toml").exists() { - commands.push("cargo fmt --all --check".to_string()); - commands.push("cargo clippy -- -D warnings".to_string()); - } - - if root.join("package.json").exists() { - commands.push(detect_node_lint_cmd(root)); - } - - // Detect frontend in known subdirectories (e.g. frontend/, client/) - for subdir in &["frontend", "client"] { - let sub_path = root.join(subdir); - if sub_path.join("package.json").exists() { - let cmd = detect_node_lint_cmd(&sub_path); - commands.push(format!("(cd {} && {})", subdir, cmd)); - } - } - - if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { - let mut content = std::fs::read_to_string(root.join("pyproject.toml")).unwrap_or_default(); - content - .push_str(&std::fs::read_to_string(root.join("requirements.txt")).unwrap_or_default()); - if content.contains("ruff") { - commands.push("ruff check .".to_string()); - } else { - commands.push("flake8 .".to_string()); - } - } - - if root.join("go.mod").exists() { - commands.push("go vet ./...".to_string()); - } - - if commands.is_empty() { - return "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's lint commands here.\necho \"No linters configured\"\n".to_string(); - } - - let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string(); - for cmd in commands { - script.push_str(&cmd); - script.push('\n'); - } - script -} - -/// Generate `script/test` content for a new project at `root`. -/// -/// Inspects well-known marker files to identify which tech stacks are present -/// and emits the appropriate test commands. Multi-stack projects get combined -/// commands run sequentially. Falls back to the generic stub when no markers -/// are found so the scaffold is always valid. -/// -/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`), -/// the test runner is detected from the `package.json` (vitest, jest, npm, pnpm). -pub fn detect_script_test(root: &Path) -> String { - let mut commands: Vec = Vec::new(); - - if root.join("Cargo.toml").exists() { - commands.push("cargo test".to_string()); - } - - if root.join("package.json").exists() { - if root.join("pnpm-lock.yaml").exists() { - commands.push("pnpm test".to_string()); - } else { - commands.push("npm test".to_string()); - } - } - - // Detect frontend in known subdirectories (e.g. frontend/, client/) - for subdir in &["frontend", "client"] { - let sub_path = root.join(subdir); - if sub_path.join("package.json").exists() { - let cmd = detect_node_test_cmd(&sub_path); - commands.push(format!("(cd {} && {})", subdir, cmd)); - } - } - - if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { - commands.push("pytest".to_string()); - } - - if root.join("go.mod").exists() { - commands.push("go test ./...".to_string()); - } - - if commands.is_empty() { - return STORY_KIT_SCRIPT_TEST.to_string(); - } - - let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string(); - for cmd in commands { - script.push_str(&cmd); - script.push('\n'); - } - script -} - -/// Generate a `project.toml` for a new project at `root`. -/// -/// Detects the tech stack via [`detect_components_toml`] and combines the -/// resulting `[[component]]` entries with the default project settings. -/// Agent definitions are written to `agents.toml` separately. -fn generate_project_toml(root: &Path) -> String { - let components = detect_components_toml(root); - format!("{components}\n{DEFAULT_PROJECT_SETTINGS_TOML}") -} - -fn write_file_if_missing(path: &Path, content: &str) -> Result<(), String> { - if path.exists() { - return Ok(()); - } - fs::write(path, content).map_err(|e| format!("Failed to write file: {}", e))?; - Ok(()) -} - -/// Write `content` to `path` if missing, then ensure the file is executable. -fn write_script_if_missing(path: &Path, content: &str) -> Result<(), String> { - write_file_if_missing(path, content)?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(path) - .map_err(|e| format!("Failed to read permissions for {}: {}", path.display(), e))? - .permissions(); - perms.set_mode(0o755); - fs::set_permissions(path, perms) - .map_err(|e| format!("Failed to set permissions on {}: {}", path.display(), e))?; - } - - Ok(()) -} - -/// Write (or idempotently update) `.huskies/.gitignore` with Story Kit–specific -/// ignore patterns for files that live inside the `.huskies/` directory. -/// Patterns are relative to `.huskies/` as git resolves `.gitignore` files -/// relative to the directory that contains them. -fn write_story_kit_gitignore(root: &Path) -> Result<(), String> { - // Entries that belong inside .huskies/.gitignore (relative to .huskies/). - let entries = [ - "bot.toml", - "matrix_store/", - "matrix_device_id", - "matrix_history.json", - "timers.json", - "worktrees/", - "merge_workspace/", - "coverage/", - "work/2_current/", - "work/3_qa/", - "work/4_merge/", - "logs/", - "token_usage.jsonl", - "wizard_state.json", - "store.json", - "pipeline.db", - "*.db", - ]; - - let gitignore_path = root.join(".huskies").join(".gitignore"); - let existing = if gitignore_path.exists() { - fs::read_to_string(&gitignore_path) - .map_err(|e| format!("Failed to read .huskies/.gitignore: {}", e))? - } else { - String::new() - }; - - let missing: Vec<&str> = entries - .iter() - .copied() - .filter(|e| !existing.lines().any(|l| l.trim() == *e)) - .collect(); - - if missing.is_empty() { - return Ok(()); - } - - let mut new_content = existing; - if !new_content.is_empty() && !new_content.ends_with('\n') { - new_content.push('\n'); - } - for entry in missing { - new_content.push_str(entry); - new_content.push('\n'); - } - - fs::write(&gitignore_path, new_content) - .map_err(|e| format!("Failed to write .huskies/.gitignore: {}", e))?; - - Ok(()) -} - -/// Append root-level Story Kit entries to the project `.gitignore`. -/// Only `.huskies_port` and `.mcp.json` remain here because they live at -/// the project root and git does not support `../` patterns in `.gitignore` -/// files, so they cannot be expressed in `.huskies/.gitignore`. -/// `store.json` is excluded via `.huskies/.gitignore` since it now lives -/// inside the `.huskies/` directory. -fn append_root_gitignore_entries(root: &Path) -> Result<(), String> { - let entries = [".huskies_port", ".mcp.json"]; - - let gitignore_path = root.join(".gitignore"); - let existing = if gitignore_path.exists() { - fs::read_to_string(&gitignore_path) - .map_err(|e| format!("Failed to read .gitignore: {}", e))? - } else { - String::new() - }; - - let missing: Vec<&str> = entries - .iter() - .copied() - .filter(|e| !existing.lines().any(|l| l.trim() == *e)) - .collect(); - - if missing.is_empty() { - return Ok(()); - } - - let mut new_content = existing; - if !new_content.is_empty() && !new_content.ends_with('\n') { - new_content.push('\n'); - } - for entry in missing { - new_content.push_str(entry); - new_content.push('\n'); - } - - fs::write(&gitignore_path, new_content) - .map_err(|e| format!("Failed to write .gitignore: {}", e))?; - - Ok(()) -} - -pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> { - let story_kit_root = root.join(".huskies"); - let specs_root = story_kit_root.join("specs"); - let tech_root = specs_root.join("tech"); - let functional_root = specs_root.join("functional"); - let script_root = root.join("script"); - - // Create the work/ pipeline directories, each with a .gitkeep so empty dirs survive git clone - let work_stages = [ - "1_backlog", - "2_current", - "3_qa", - "4_merge", - "5_done", - "6_archived", - ]; - for stage in &work_stages { - let dir = story_kit_root.join("work").join(stage); - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create work/{}: {}", stage, e))?; - write_file_if_missing(&dir.join(".gitkeep"), "")?; - } - - fs::create_dir_all(&tech_root).map_err(|e| format!("Failed to create specs/tech: {}", e))?; - fs::create_dir_all(&functional_root) - .map_err(|e| format!("Failed to create specs/functional: {}", e))?; - fs::create_dir_all(&script_root) - .map_err(|e| format!("Failed to create script/ directory: {}", e))?; - - write_file_if_missing(&story_kit_root.join("README.md"), STORY_KIT_README)?; - let project_toml_content = generate_project_toml(root); - write_file_if_missing(&story_kit_root.join("project.toml"), &project_toml_content)?; - write_file_if_missing(&story_kit_root.join("agents.toml"), DEFAULT_AGENTS_TOML)?; - write_file_if_missing(&specs_root.join("00_CONTEXT.md"), STORY_KIT_CONTEXT)?; - write_file_if_missing(&tech_root.join("STACK.md"), STORY_KIT_STACK)?; - let script_test_content = detect_script_test(root); - write_script_if_missing(&script_root.join("test"), &script_test_content)?; - let script_build_content = detect_script_build(root); - write_script_if_missing(&script_root.join("build"), &script_build_content)?; - let script_lint_content = detect_script_lint(root); - write_script_if_missing(&script_root.join("lint"), &script_lint_content)?; - write_file_if_missing(&root.join("CLAUDE.md"), STORY_KIT_CLAUDE_MD)?; - - // Write per-transport bot.toml example files so users can see all options. - write_file_if_missing( - &story_kit_root.join("bot.toml.matrix.example"), - BOT_TOML_MATRIX_EXAMPLE, - )?; - write_file_if_missing( - &story_kit_root.join("bot.toml.whatsapp-meta.example"), - BOT_TOML_WHATSAPP_META_EXAMPLE, - )?; - write_file_if_missing( - &story_kit_root.join("bot.toml.whatsapp-twilio.example"), - BOT_TOML_WHATSAPP_TWILIO_EXAMPLE, - )?; - write_file_if_missing( - &story_kit_root.join("bot.toml.slack.example"), - BOT_TOML_SLACK_EXAMPLE, - )?; - - // Write .mcp.json at the project root so agents can find the MCP server. - // Only written when missing — never overwrites an existing file, because - // the port is environment-specific and must not clobber a running instance. - let mcp_content = format!( - "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" - ); - write_file_if_missing(&root.join(".mcp.json"), &mcp_content)?; - - // Create .claude/settings.json with sensible permission defaults so that - // Claude Code (both agents and web UI chat) can operate without constant - // permission prompts. - let claude_dir = root.join(".claude"); - fs::create_dir_all(&claude_dir) - .map_err(|e| format!("Failed to create .claude/ directory: {}", e))?; - write_file_if_missing(&claude_dir.join("settings.json"), STORY_KIT_CLAUDE_SETTINGS)?; - - write_story_kit_gitignore(root)?; - append_root_gitignore_entries(root)?; - - // Run `git init` if the directory is not already a git repo, then make an initial commit - if !root.join(".git").exists() { - let init_status = std::process::Command::new("git") - .args(["init"]) - .current_dir(root) - .status() - .map_err(|e| format!("Failed to run git init: {}", e))?; - if !init_status.success() { - return Err("git init failed".to_string()); - } - - let add_output = std::process::Command::new("git") - .args([ - "add", - ".huskies", - "script", - ".gitignore", - "CLAUDE.md", - ".claude", - ]) - .current_dir(root) - .output() - .map_err(|e| format!("Failed to run git add: {}", e))?; - if !add_output.status.success() { - return Err(format!( - "git add failed: {}", - String::from_utf8_lossy(&add_output.stderr) - )); - } - - let commit_output = std::process::Command::new("git") - .args([ - "-c", - "user.email=huskies@localhost", - "-c", - "user.name=Story Kit", - "commit", - "-m", - "Initial Story Kit scaffold", - ]) - .current_dir(root) - .output() - .map_err(|e| format!("Failed to run git commit: {}", e))?; - if !commit_output.status.success() { - return Err(format!( - "git commit failed: {}", - String::from_utf8_lossy(&commit_output.stderr) - )); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - // --- scaffold --- - - #[test] - fn scaffold_story_kit_creates_structure() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - assert!(dir.path().join(".huskies/README.md").exists()); - assert!(dir.path().join(".huskies/project.toml").exists()); - assert!(dir.path().join(".huskies/agents.toml").exists()); - assert!(dir.path().join(".huskies/specs/00_CONTEXT.md").exists()); - assert!(dir.path().join(".huskies/specs/tech/STACK.md").exists()); - // Old stories/ dirs should NOT be created - assert!(!dir.path().join(".huskies/stories").exists()); - assert!(dir.path().join("script/test").exists()); - } - - #[test] - fn scaffold_story_kit_creates_work_pipeline_dirs() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let stages = [ - "1_backlog", - "2_current", - "3_qa", - "4_merge", - "5_done", - "6_archived", - ]; - for stage in &stages { - let path = dir.path().join(".huskies/work").join(stage); - assert!(path.is_dir(), "work/{} should be a directory", stage); - assert!( - path.join(".gitkeep").exists(), - "work/{} should have a .gitkeep file", - stage - ); - } - } - - #[test] - fn scaffold_story_kit_agents_toml_has_coder_qa_mergemaster() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - // Agent definitions go into agents.toml, not project.toml. - let agents = fs::read_to_string(dir.path().join(".huskies/agents.toml")).unwrap(); - assert!(agents.contains("[[agent]]")); - assert!(agents.contains("stage = \"coder\"")); - assert!(agents.contains("stage = \"qa\"")); - assert!(agents.contains("stage = \"mergemaster\"")); - assert!(agents.contains("model = \"sonnet\"")); - - // project.toml should NOT contain [[agent]] blocks. - let project = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); - assert!(!project.contains("[[agent]]")); - } - - #[test] - fn scaffold_project_toml_contains_rate_limit_and_timezone_comments() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); - assert!( - content.contains("rate_limit_notifications"), - "project.toml scaffold should document rate_limit_notifications" - ); - assert!( - content.contains("timezone"), - "project.toml scaffold should document timezone" - ); - } - - #[test] - fn scaffold_project_toml_contains_max_retries_with_default_value() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); - assert!( - content.contains("max_retries = 2"), - "project.toml scaffold should include max_retries with default value 2" - ); - assert!( - content.contains("Maximum number of retries"), - "project.toml scaffold should include a comment explaining max_retries" - ); - } - - #[test] - fn scaffold_project_toml_contains_commented_out_optional_fields() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); - assert!( - content.contains("# default_coder_model"), - "project.toml scaffold should include commented-out default_coder_model" - ); - assert!( - content.contains("# max_coders"), - "project.toml scaffold should include commented-out max_coders" - ); - assert!( - content.contains("# base_branch"), - "project.toml scaffold should include commented-out base_branch" - ); - } - - #[test] - fn scaffold_project_toml_round_trips_through_project_config_load() { - use crate::config::ProjectConfig; - - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - // The generated project.toml must parse without error. - let config = ProjectConfig::load(dir.path()) - .expect("Generated project.toml should parse without error"); - - // Key defaults must survive the round-trip. - assert_eq!(config.default_qa, "server"); - assert_eq!(config.max_retries, 2); - assert!( - config.rate_limit_notifications, - "rate_limit_notifications should default to true" - ); - assert!( - config.default_coder_model.is_none(), - "default_coder_model should be None when commented out" - ); - assert!( - config.max_coders.is_none(), - "max_coders should be None when commented out" - ); - assert!( - config.base_branch.is_none(), - "base_branch should be None when commented out" - ); - assert!( - config.timezone.is_none(), - "timezone should be None when commented out" - ); - } - - #[test] - fn scaffold_context_is_blank_template_not_story_kit_content() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let content = fs::read_to_string(dir.path().join(".huskies/specs/00_CONTEXT.md")).unwrap(); - assert!(content.contains("")); - assert!(content.contains("## High-Level Goal")); - assert!(content.contains("## Core Features")); - assert!(content.contains("## Domain Definition")); - assert!(content.contains("## Glossary")); - // Must NOT contain Story Kit-specific content - assert!(!content.contains("Agentic AI Code Assistant")); - assert!(!content.contains("Poem HTTP server")); - } - - #[test] - fn scaffold_stack_is_blank_template_not_story_kit_content() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let content = fs::read_to_string(dir.path().join(".huskies/specs/tech/STACK.md")).unwrap(); - assert!(content.contains("")); - assert!(content.contains("## Core Stack")); - assert!(content.contains("## Coding Standards")); - assert!(content.contains("## Quality Gates")); - assert!(content.contains("## Libraries")); - // Must NOT contain Story Kit-specific content - assert!(!content.contains("Poem HTTP server")); - assert!(!content.contains("TypeScript + React")); - } - - #[cfg(unix)] - #[test] - fn scaffold_story_kit_creates_executable_script_test() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let script_test = dir.path().join("script/test"); - assert!(script_test.exists(), "script/test should be created"); - let perms = fs::metadata(&script_test).unwrap().permissions(); - assert!( - perms.mode() & 0o111 != 0, - "script/test should be executable" - ); - } - - #[test] - fn scaffold_story_kit_does_not_overwrite_existing() { - let dir = tempdir().unwrap(); - let readme = dir.path().join(".huskies/README.md"); - fs::create_dir_all(readme.parent().unwrap()).unwrap(); - fs::write(&readme, "custom content").unwrap(); - - scaffold_story_kit(dir.path(), 3001).unwrap(); - - assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content"); - } - - #[test] - fn scaffold_story_kit_is_idempotent() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let readme_content = fs::read_to_string(dir.path().join(".huskies/README.md")).unwrap(); - let toml_content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); - - // Run again — must not change content or add duplicate .gitignore entries - scaffold_story_kit(dir.path(), 3001).unwrap(); - - assert_eq!( - fs::read_to_string(dir.path().join(".huskies/README.md")).unwrap(), - readme_content - ); - assert_eq!( - fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(), - toml_content - ); - - let story_kit_gitignore = - fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap(); - let count = story_kit_gitignore - .lines() - .filter(|l| l.trim() == "worktrees/") - .count(); - assert_eq!( - count, 1, - ".huskies/.gitignore should not have duplicate entries" - ); - } - - #[test] - fn scaffold_story_kit_existing_git_repo_no_commit() { - let dir = tempdir().unwrap(); - - // Initialize a git repo before scaffold - std::process::Command::new("git") - .args(["init"]) - .current_dir(dir.path()) - .status() - .unwrap(); - std::process::Command::new("git") - .args([ - "-c", - "user.email=test@test.com", - "-c", - "user.name=Test", - "commit", - "--allow-empty", - "-m", - "pre-scaffold", - ]) - .current_dir(dir.path()) - .status() - .unwrap(); - - scaffold_story_kit(dir.path(), 3001).unwrap(); - - // Only 1 commit should exist — scaffold must not commit into an existing repo - let log_output = std::process::Command::new("git") - .args(["log", "--oneline"]) - .current_dir(dir.path()) - .output() - .unwrap(); - let log = String::from_utf8_lossy(&log_output.stdout); - let commit_count = log.lines().count(); - assert_eq!( - commit_count, 1, - "scaffold should not create a commit in an existing git repo" - ); - } - - #[test] - fn scaffold_creates_story_kit_gitignore_with_relative_entries() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - // .huskies/.gitignore must contain relative patterns for files under .huskies/ - let sk_content = fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap(); - assert!(sk_content.contains("worktrees/")); - assert!(sk_content.contains("merge_workspace/")); - assert!(sk_content.contains("coverage/")); - assert!(sk_content.contains("matrix_history.json")); - assert!(sk_content.contains("timers.json")); - // Must NOT contain absolute .huskies/ prefixed paths - assert!(!sk_content.contains(".huskies/")); - - // Root .gitignore must contain root-level huskies entries - let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); - assert!(root_content.contains(".huskies_port")); - // store.json now lives inside .huskies/ and must NOT appear in root .gitignore - assert!(!root_content.contains("store.json")); - // Root .gitignore must NOT contain .huskies/ sub-directory patterns - assert!(!root_content.contains(".huskies/worktrees/")); - assert!(!root_content.contains(".huskies/merge_workspace/")); - assert!(!root_content.contains(".huskies/coverage/")); - // store.json must be in .huskies/.gitignore instead - assert!(sk_content.contains("store.json")); - // Database files must be ignored so novice users don't accidentally commit them - assert!(sk_content.contains("pipeline.db")); - assert!(sk_content.contains("*.db")); - } - - #[test] - fn scaffold_story_kit_gitignore_does_not_duplicate_existing_entries() { - let dir = tempdir().unwrap(); - // Pre-create .huskies dir and .gitignore with some entries already present - fs::create_dir_all(dir.path().join(".huskies")).unwrap(); - fs::write( - dir.path().join(".huskies/.gitignore"), - "worktrees/\ncoverage/\n", - ) - .unwrap(); - - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let content = fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap(); - let worktrees_count = content.lines().filter(|l| l.trim() == "worktrees/").count(); - assert_eq!(worktrees_count, 1, "worktrees/ should not be duplicated"); - let coverage_count = content.lines().filter(|l| l.trim() == "coverage/").count(); - assert_eq!(coverage_count, 1, "coverage/ should not be duplicated"); - // The missing entry must have been added - assert!(content.contains("merge_workspace/")); - } - - // --- CLAUDE.md scaffold --- - - #[test] - fn scaffold_creates_claude_md_at_project_root() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let claude_md = dir.path().join("CLAUDE.md"); - assert!( - claude_md.exists(), - "CLAUDE.md should be created at project root" - ); - - let content = fs::read_to_string(&claude_md).unwrap(); - assert!( - content.contains(""), - "CLAUDE.md should contain the scaffold sentinel" - ); - assert!( - content.contains("Read .huskies/README.md"), - "CLAUDE.md should include directive to read .huskies/README.md" - ); - assert!( - content.contains("Never chain shell commands"), - "CLAUDE.md should include command chaining rule" - ); - assert!( - content.contains("wizard_status"), - "CLAUDE.md should instruct Claude to call wizard_status on first conversation" - ); - } - - #[test] - fn scaffold_does_not_overwrite_existing_claude_md() { - let dir = tempdir().unwrap(); - let claude_md = dir.path().join("CLAUDE.md"); - fs::write(&claude_md, "custom CLAUDE.md content").unwrap(); - - scaffold_story_kit(dir.path(), 3001).unwrap(); - - assert_eq!( - fs::read_to_string(&claude_md).unwrap(), - "custom CLAUDE.md content", - "scaffold should not overwrite an existing CLAUDE.md" - ); - } - - #[test] - fn scaffold_story_kit_writes_mcp_json_with_port() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 4242).unwrap(); - - let mcp_path = dir.path().join(".mcp.json"); - assert!(mcp_path.exists(), ".mcp.json should be created by scaffold"); - let content = fs::read_to_string(&mcp_path).unwrap(); - assert!( - content.contains("4242"), - ".mcp.json should reference the given port" - ); - assert!( - content.contains("localhost"), - ".mcp.json should reference localhost" - ); - assert!( - content.contains("huskies"), - ".mcp.json should name the huskies server" - ); - } - - #[test] - fn scaffold_story_kit_does_not_overwrite_existing_mcp_json() { - let dir = tempdir().unwrap(); - let mcp_path = dir.path().join(".mcp.json"); - fs::write(&mcp_path, "{\"custom\": true}").unwrap(); - - scaffold_story_kit(dir.path(), 3001).unwrap(); - - assert_eq!( - fs::read_to_string(&mcp_path).unwrap(), - "{\"custom\": true}", - "scaffold should not overwrite an existing .mcp.json" - ); - } - - #[test] - fn scaffold_gitignore_includes_mcp_json() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let root_gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); - assert!( - root_gitignore.contains(".mcp.json"), - "root .gitignore should include .mcp.json (port is environment-specific)" - ); - } - - // --- detect_components_toml --- - - #[test] - fn detect_no_markers_returns_fallback_components() { - let dir = tempdir().unwrap(); - let toml = detect_components_toml(dir.path()); - // At least one [[component]] entry should always be present - assert!( - toml.contains("[[component]]"), - "should always emit at least one component" - ); - // Fallback should use a generic app component with empty setup - assert!( - toml.contains("name = \"app\""), - "fallback should use generic 'app' component name" - ); - assert!( - toml.contains("setup = []"), - "fallback should have empty setup list" - ); - // Must not contain Rust-specific commands in a non-Rust project - assert!( - !toml.contains("cargo"), - "fallback must not contain Rust-specific commands" - ); - } - - #[test] - fn detect_cargo_toml_generates_rust_component() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Cargo.toml"), - "[package]\nname = \"test\"\n", - ) - .unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"server\"")); - assert!(toml.contains("setup = [\"cargo check\"]")); - } - - #[test] - fn detect_package_json_with_pnpm_lock_generates_pnpm_component() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"frontend\"")); - assert!(toml.contains("setup = [\"pnpm install\"]")); - } - - #[test] - fn detect_package_json_without_pnpm_lock_generates_npm_component() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"frontend\"")); - assert!(toml.contains("setup = [\"npm install\"]")); - } - - #[test] - fn detect_pyproject_toml_generates_python_component() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("pyproject.toml"), - "[project]\nname = \"test\"\n", - ) - .unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"python\"")); - assert!(toml.contains("pip install")); - } - - #[test] - fn detect_requirements_txt_generates_python_component() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"python\"")); - assert!(toml.contains("pip install")); - } - - #[test] - fn detect_go_mod_generates_go_component() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"go\"")); - assert!(toml.contains("setup = [\"go build ./...\"]")); - } - - #[test] - fn detect_gemfile_generates_ruby_component() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Gemfile"), - "source \"https://rubygems.org\"\n", - ) - .unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"ruby\"")); - assert!(toml.contains("setup = [\"bundle install\"]")); - } - - // --- Bug 375: no Rust-specific commands for non-Rust projects --- - - #[test] - fn no_rust_commands_in_go_project() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!( - !toml.contains("cargo"), - "go project must not contain cargo commands" - ); - assert!(toml.contains("go build"), "go project must use Go tooling"); - } - - #[test] - fn no_rust_commands_in_node_project() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!( - !toml.contains("cargo"), - "node project must not contain cargo commands" - ); - assert!( - toml.contains("npm install"), - "node project must use npm tooling" - ); - } - - #[test] - fn no_rust_commands_when_no_stack_detected() { - let dir = tempdir().unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!( - !toml.contains("cargo"), - "unknown stack must not contain cargo commands" - ); - // setup list must be empty - assert!( - toml.contains("setup = []"), - "unknown stack must have empty setup list" - ); - } - - #[test] - fn detect_multiple_markers_generates_multiple_components() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Cargo.toml"), - "[package]\nname = \"server\"\n", - ) - .unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let toml = detect_components_toml(dir.path()); - assert!(toml.contains("name = \"server\"")); - assert!(toml.contains("name = \"frontend\"")); - // Both component entries should be present - let component_count = toml.matches("[[component]]").count(); - assert_eq!(component_count, 2); - } - - #[test] - fn detect_no_fallback_when_markers_found() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); - - let toml = detect_components_toml(dir.path()); - // The fallback "app" component should NOT appear when a real stack is detected - assert!(!toml.contains("name = \"app\"")); - } - - // --- detect_script_test --- - - #[test] - fn detect_script_test_no_markers_returns_stub() { - let dir = tempdir().unwrap(); - let script = detect_script_test(dir.path()); - assert!( - script.contains("No tests configured"), - "fallback should contain the generic stub message" - ); - assert!(script.starts_with("#!/usr/bin/env bash")); - } - - #[test] - fn detect_script_test_cargo_toml_adds_cargo_test() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("cargo test"), - "Rust project should run cargo test" - ); - assert!(!script.contains("No tests configured")); - } - - #[test] - fn detect_script_test_package_json_npm_adds_npm_test() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("npm test"), - "Node project without pnpm-lock should run npm test" - ); - assert!(!script.contains("No tests configured")); - } - - #[test] - fn detect_script_test_package_json_pnpm_adds_pnpm_test() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("pnpm test"), - "Node project with pnpm-lock should run pnpm test" - ); - // "pnpm test" is a substring of itself; verify there's no bare "npm test" line - assert!( - !script.lines().any(|l| l.trim() == "npm test"), - "should not use npm when pnpm-lock.yaml is present" - ); - } - - #[test] - fn detect_script_test_pyproject_toml_adds_pytest() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("pyproject.toml"), - "[project]\nname = \"x\"\n", - ) - .unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("pytest"), - "Python project should run pytest" - ); - assert!(!script.contains("No tests configured")); - } - - #[test] - fn detect_script_test_requirements_txt_adds_pytest() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("pytest"), - "Python project (requirements.txt) should run pytest" - ); - } - - #[test] - fn detect_script_test_go_mod_adds_go_test() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("go test ./..."), - "Go project should run go test ./..." - ); - assert!(!script.contains("No tests configured")); - } - - #[test] - fn detect_script_test_multi_stack_combines_commands() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("go test ./..."), - "multi-stack should include Go test command" - ); - assert!( - script.contains("npm test"), - "multi-stack should include Node test command" - ); - } - - #[test] - fn detect_script_test_frontend_subdir_with_vitest_uses_npx_vitest() { - let dir = tempdir().unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write( - frontend.join("package.json"), - r#"{"devDependencies":{"vitest":"^1.0.0"},"scripts":{"test":"vitest run"}}"#, - ) - .unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("vitest run"), - "frontend with vitest should emit vitest run" - ); - assert!( - script.contains("cd frontend"), - "should cd into the frontend directory" - ); - assert!( - !script.contains("No tests configured"), - "should not use stub when frontend is detected" - ); - } - - #[test] - fn detect_script_test_frontend_subdir_with_jest_uses_npx_jest() { - let dir = tempdir().unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write( - frontend.join("package.json"), - r#"{"devDependencies":{"jest":"^29.0.0"},"scripts":{"test":"jest"}}"#, - ) - .unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("jest"), - "frontend with jest should emit jest" - ); - assert!( - script.contains("cd frontend"), - "should cd into the frontend directory" - ); - } - - #[test] - fn detect_script_test_frontend_subdir_no_known_runner_uses_npm_test() { - let dir = tempdir().unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write( - frontend.join("package.json"), - r#"{"scripts":{"test":"mocha"}}"#, - ) - .unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("npm test"), - "frontend without known runner should fall back to npm test" - ); - assert!(script.contains("cd frontend")); - } - - #[test] - fn detect_script_test_frontend_subdir_pnpm_uses_pnpm_vitest() { - let dir = tempdir().unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write( - frontend.join("package.json"), - r#"{"devDependencies":{"vitest":"^1.0.0"}}"#, - ) - .unwrap(); - fs::write(frontend.join("pnpm-lock.yaml"), "").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("pnpm vitest run"), - "pnpm frontend with vitest should use pnpm vitest run" - ); - } - - #[test] - fn detect_script_test_rust_plus_frontend_subdir_both_included() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Cargo.toml"), - "[package]\nname = \"server\"\n", - ) - .unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write( - frontend.join("package.json"), - r#"{"devDependencies":{"vitest":"^1.0.0"}}"#, - ) - .unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("cargo test"), - "Rust + frontend should include cargo test" - ); - assert!( - script.contains("vitest run"), - "Rust + frontend should include vitest run" - ); - assert!( - script.contains("cd frontend"), - "Rust + frontend should cd into frontend" - ); - } - - #[test] - fn detect_script_test_client_subdir_detected() { - let dir = tempdir().unwrap(); - let client = dir.path().join("client"); - fs::create_dir_all(&client).unwrap(); - fs::write( - client.join("package.json"), - r#"{"scripts":{"test":"jest"}}"#, - ) - .unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.contains("cd client"), - "client/ subdir should also be detected" - ); - } - - #[test] - fn detect_script_test_output_starts_with_shebang() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); - - let script = detect_script_test(dir.path()); - assert!( - script.starts_with("#!/usr/bin/env bash\nset -euo pipefail\n"), - "generated script should start with bash shebang and set -euo pipefail" - ); - } - - #[test] - fn scaffold_script_test_contains_detected_commands_for_rust() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Cargo.toml"), - "[package]\nname = \"myapp\"\n", - ) - .unwrap(); - - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let content = fs::read_to_string(dir.path().join("script/test")).unwrap(); - assert!( - content.contains("cargo test"), - "Rust project scaffold should set cargo test in script/test" - ); - assert!( - !content.contains("No tests configured"), - "should not use stub when stack is detected" - ); - } - - #[test] - fn scaffold_script_test_fallback_stub_when_no_stack() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let content = fs::read_to_string(dir.path().join("script/test")).unwrap(); - assert!( - content.contains("No tests configured"), - "unknown stack should use the generic stub" - ); - } - - // --- detect_script_build --- - - #[test] - fn detect_script_build_no_markers_returns_stub() { - let dir = tempdir().unwrap(); - let script = detect_script_build(dir.path()); - assert!( - script.contains("No build configured"), - "fallback should contain the generic stub message" - ); - assert!(script.starts_with("#!/usr/bin/env bash")); - } - - #[test] - fn detect_script_build_cargo_toml_adds_cargo_build_release() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); - - let script = detect_script_build(dir.path()); - assert!( - script.contains("cargo build --release"), - "Rust project should run cargo build --release" - ); - assert!(!script.contains("No build configured")); - } - - #[test] - fn detect_script_build_package_json_npm_adds_npm_run_build() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let script = detect_script_build(dir.path()); - assert!( - script.contains("npm run build"), - "Node project without pnpm-lock should run npm run build" - ); - } - - #[test] - fn detect_script_build_package_json_pnpm_adds_pnpm_run_build() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); - - let script = detect_script_build(dir.path()); - assert!( - script.contains("pnpm run build"), - "Node project with pnpm-lock should run pnpm run build" - ); - assert!( - !script.lines().any(|l| l.trim() == "npm run build"), - "should not use npm when pnpm-lock.yaml is present" - ); - } - - #[test] - fn detect_script_build_go_mod_adds_go_build() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); - - let script = detect_script_build(dir.path()); - assert!( - script.contains("go build ./..."), - "Go project should run go build ./..." - ); - } - - #[test] - fn detect_script_build_pyproject_toml_adds_python_build() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("pyproject.toml"), - "[project]\nname = \"x\"\n", - ) - .unwrap(); - - let script = detect_script_build(dir.path()); - assert!( - script.contains("python -m build"), - "Python project should run python -m build" - ); - } - - #[test] - fn detect_script_build_frontend_subdir_detected() { - let dir = tempdir().unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write(frontend.join("package.json"), "{}").unwrap(); - - let script = detect_script_build(dir.path()); - assert!( - script.contains("cd frontend"), - "frontend subdir should be detected for build" - ); - assert!(script.contains("npm run build")); - } - - #[test] - fn detect_script_build_rust_plus_frontend_subdir_both_included() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Cargo.toml"), - "[package]\nname = \"server\"\n", - ) - .unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write(frontend.join("package.json"), "{}").unwrap(); - - let script = detect_script_build(dir.path()); - assert!(script.contains("cargo build --release")); - assert!(script.contains("cd frontend")); - assert!(script.contains("npm run build")); - } - - // --- detect_script_lint --- - - #[test] - fn detect_script_lint_no_markers_returns_stub() { - let dir = tempdir().unwrap(); - let script = detect_script_lint(dir.path()); - assert!( - script.contains("No linters configured"), - "fallback should contain the generic stub message" - ); - assert!(script.starts_with("#!/usr/bin/env bash")); - } - - #[test] - fn detect_script_lint_cargo_toml_adds_fmt_and_clippy() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("cargo fmt --all --check"), - "Rust project should check formatting" - ); - assert!( - script.contains("cargo clippy -- -D warnings"), - "Rust project should run clippy" - ); - assert!(!script.contains("No linters configured")); - } - - #[test] - fn detect_script_lint_package_json_without_eslint_uses_npm_run_lint() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("npm run lint"), - "Node project without eslint dep should fall back to npm run lint" - ); - } - - #[test] - fn detect_script_lint_package_json_with_eslint_uses_npx_eslint() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"devDependencies":{"eslint":"^8.0.0"}}"#, - ) - .unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("npx eslint ."), - "Node project with eslint should use npx eslint ." - ); - } - - #[test] - fn detect_script_lint_pnpm_with_eslint_uses_pnpm_eslint() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("package.json"), - r#"{"devDependencies":{"eslint":"^8.0.0"}}"#, - ) - .unwrap(); - fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("pnpm eslint ."), - "pnpm project with eslint should use pnpm eslint ." - ); - } - - #[test] - fn detect_script_lint_python_requirements_uses_flake8() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("flake8 ."), - "Python project without ruff should use flake8" - ); - } - - #[test] - fn detect_script_lint_python_with_ruff_uses_ruff() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("pyproject.toml"), - "[project]\nname = \"x\"\n\n[tool.ruff]\n", - ) - .unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("ruff check ."), - "Python project with ruff configured should use ruff" - ); - assert!( - !script.contains("flake8"), - "should not use flake8 when ruff is configured" - ); - } - - #[test] - fn detect_script_lint_go_mod_adds_go_vet() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("go vet ./..."), - "Go project should run go vet ./..." - ); - } - - #[test] - fn detect_script_lint_frontend_subdir_detected() { - let dir = tempdir().unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write(frontend.join("package.json"), "{}").unwrap(); - - let script = detect_script_lint(dir.path()); - assert!( - script.contains("cd frontend"), - "frontend subdir should be detected for lint" - ); - } - - #[test] - fn detect_script_lint_rust_plus_frontend_subdir_both_included() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Cargo.toml"), - "[package]\nname = \"server\"\n", - ) - .unwrap(); - let frontend = dir.path().join("frontend"); - fs::create_dir_all(&frontend).unwrap(); - fs::write(frontend.join("package.json"), "{}").unwrap(); - - let script = detect_script_lint(dir.path()); - assert!(script.contains("cargo fmt --all --check")); - assert!(script.contains("cargo clippy -- -D warnings")); - assert!(script.contains("cd frontend")); - } - - #[test] - fn scaffold_story_kit_creates_script_build_and_lint() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - assert!( - dir.path().join("script/build").exists(), - "script/build should be created by scaffold" - ); - assert!( - dir.path().join("script/lint").exists(), - "script/lint should be created by scaffold" - ); - } - - #[cfg(unix)] - #[test] - fn scaffold_story_kit_creates_executable_script_build_and_lint() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - for name in &["build", "lint"] { - let path = dir.path().join("script").join(name); - assert!(path.exists(), "script/{name} should be created"); - let perms = fs::metadata(&path).unwrap().permissions(); - assert!( - perms.mode() & 0o111 != 0, - "script/{name} should be executable" - ); - } - } - - #[test] - fn scaffold_script_build_contains_detected_commands_for_rust() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Cargo.toml"), - "[package]\nname = \"myapp\"\n", - ) - .unwrap(); - - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let content = fs::read_to_string(dir.path().join("script/build")).unwrap(); - assert!( - content.contains("cargo build --release"), - "Rust project scaffold should set cargo build --release in script/build" - ); - } - - #[test] - fn scaffold_script_lint_contains_detected_commands_for_rust() { - let dir = tempdir().unwrap(); - fs::write( - dir.path().join("Cargo.toml"), - "[package]\nname = \"myapp\"\n", - ) - .unwrap(); - - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let content = fs::read_to_string(dir.path().join("script/lint")).unwrap(); - assert!( - content.contains("cargo fmt --all --check"), - "Rust project scaffold should include fmt check in script/lint" - ); - assert!( - content.contains("cargo clippy -- -D warnings"), - "Rust project scaffold should include clippy in script/lint" - ); - } - - // --- generate_project_toml --- - - #[test] - fn generate_project_toml_includes_components_but_not_agents() { - let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); - - let toml = generate_project_toml(dir.path()); - // Component section should be present - assert!(toml.contains("[[component]]")); - assert!(toml.contains("name = \"server\"")); - // Agent sections must NOT be in project.toml — they go in agents.toml - assert!(!toml.contains("[[agent]]")); - } - - #[test] - fn default_agents_toml_has_coder_qa_mergemaster() { - assert!(DEFAULT_AGENTS_TOML.contains("[[agent]]")); - assert!(DEFAULT_AGENTS_TOML.contains("stage = \"coder\"")); - assert!(DEFAULT_AGENTS_TOML.contains("stage = \"qa\"")); - assert!(DEFAULT_AGENTS_TOML.contains("stage = \"mergemaster\"")); - } - - #[test] - fn scaffold_project_toml_contains_detected_components() { - let dir = tempdir().unwrap(); - // Place a Cargo.toml in the project root before scaffolding - fs::write( - dir.path().join("Cargo.toml"), - "[package]\nname = \"myapp\"\n", - ) - .unwrap(); - - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); - assert!( - content.contains("[[component]]"), - "project.toml should contain a component entry" - ); - assert!( - content.contains("name = \"server\""), - "Rust project should have a 'server' component" - ); - assert!( - content.contains("cargo check"), - "Rust component should have cargo check setup" - ); - } - - #[test] - fn scaffold_project_toml_fallback_when_no_stack_detected() { - let dir = tempdir().unwrap(); - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); - assert!( - content.contains("[[component]]"), - "project.toml should always have at least one component" - ); - // Fallback uses generic app component with empty setup — no Rust-specific commands - assert!( - content.contains("name = \"app\""), - "fallback should use generic 'app' component name" - ); - assert!( - !content.contains("cargo"), - "fallback must not contain Rust-specific commands for non-Rust projects" - ); - } - - #[test] - fn scaffold_does_not_overwrite_existing_project_toml_with_components() { - let dir = tempdir().unwrap(); - let sk_dir = dir.path().join(".huskies"); - fs::create_dir_all(&sk_dir).unwrap(); - let existing = "[[component]]\nname = \"custom\"\npath = \".\"\nsetup = [\"make build\"]\n"; - fs::write(sk_dir.join("project.toml"), existing).unwrap(); - - scaffold_story_kit(dir.path(), 3001).unwrap(); - - let content = fs::read_to_string(sk_dir.join("project.toml")).unwrap(); - assert_eq!( - content, existing, - "scaffold should not overwrite existing project.toml" - ); - } -} diff --git a/server/src/io/fs/scaffold/mod.rs b/server/src/io/fs/scaffold/mod.rs new file mode 100644 index 00000000..46cd5bb4 --- /dev/null +++ b/server/src/io/fs/scaffold/mod.rs @@ -0,0 +1,702 @@ +//! Project scaffolding — creates the `.huskies/` directory structure and default files. +use std::fs; +use std::path::Path; + +const STORY_KIT_README: &str = include_str!("../../../../../.huskies/README.md"); + +const BOT_TOML_MATRIX_EXAMPLE: &str = + include_str!("../../../../../.huskies/bot.toml.matrix.example"); +const BOT_TOML_WHATSAPP_META_EXAMPLE: &str = + include_str!("../../../../../.huskies/bot.toml.whatsapp-meta.example"); +const BOT_TOML_WHATSAPP_TWILIO_EXAMPLE: &str = + include_str!("../../../../../.huskies/bot.toml.whatsapp-twilio.example"); +const BOT_TOML_SLACK_EXAMPLE: &str = include_str!("../../../../../.huskies/bot.toml.slack.example"); + +const STORY_KIT_CONTEXT: &str = "\n\ +# Project Context\n\ +\n\ +## High-Level Goal\n\ +\n\ +TODO: Describe the high-level goal of this project.\n\ +\n\ +## Core Features\n\ +\n\ +TODO: List the core features of this project.\n\ +\n\ +## Domain Definition\n\ +\n\ +TODO: Define the key domain concepts and entities.\n\ +\n\ +## Glossary\n\ +\n\ +TODO: Define abbreviations and technical terms.\n"; + +const STORY_KIT_STACK: &str = "\n\ +# Tech Stack & Constraints\n\ +\n\ +## Core Stack\n\ +\n\ +TODO: Describe the language, frameworks, and runtimes.\n\ +\n\ +## Coding Standards\n\ +\n\ +TODO: Describe code style, linting rules, and error handling conventions.\n\ +\n\ +## Quality Gates\n\ +\n\ +TODO: List the commands that must pass before merging (e.g., cargo test, npm run build).\n\ +\n\ +## Libraries\n\ +\n\ +TODO: List approved libraries and their purpose.\n"; + +const STORY_KIT_SCRIPT_TEST: &str = "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's test commands here.\n# Story Kit agents invoke this script as the canonical test runner.\n# Exit 0 on success, non-zero on failure.\necho \"No tests configured\"\n"; + +const STORY_KIT_CLAUDE_MD: &str = "\n\ +Never chain shell commands with `&&`, `||`, or `;` in a single Bash call. \ +The permission system validates the entire command string, and chained commands \ +won't match allow rules like `Bash(git *)`. Use separate Bash calls instead — \ +parallel calls work fine.\n\ +\n\ +Read .huskies/README.md to see our dev process.\n\ +\n\ +IMPORTANT: On your first conversation, call `wizard_status` to check if \ +project setup is complete. If not, read .huskies/README.md for the full \ +setup wizard instructions and guide the user through it conversationally.\n"; + +const STORY_KIT_CLAUDE_SETTINGS: &str = r#"{ + "permissions": { + "allow": [ + "Bash(cargo build:*)", + "Bash(cargo check:*)", + "Bash(git *)", + "Bash(ls *)", + "Bash(mkdir *)", + "Bash(mv *)", + "Bash(rm *)", + "Bash(touch *)", + "Bash(echo:*)", + "Bash(pwd *)", + "Bash(grep:*)", + "Bash(find *)", + "Bash(head *)", + "Bash(tail *)", + "Bash(wc *)", + "Bash(cat *)", + "Read", + "Edit", + "Write", + "Glob", + "Grep", + "mcp__huskies__*" + ] + }, + "enabledMcpjsonServers": [ + "huskies" + ] +} +"#; + +const DEFAULT_PROJECT_SETTINGS_TOML: &str = r#"# Project-wide default QA mode: "server", "agent", or "human". +# Per-story `qa` front matter overrides this setting. +default_qa = "server" + +# Maximum number of retries per story per pipeline stage before marking as blocked. +# Set to 0 to disable retry limits. +max_retries = 2 + +# Default model for coder-stage agents (e.g. "sonnet", "opus"). +# When set, only coder agents whose model matches this value are considered for +# auto-assignment, so opus agents are only used when explicitly requested via +# story front matter `agent:` field. +# default_coder_model = "sonnet" + +# Maximum number of concurrent coder-stage agents. +# Stories wait in 2_current/ until a slot frees up. +# max_coders = 3 + +# Override the base branch for worktree creation and merge operations. +# When not set, the system auto-detects the base branch from the current HEAD. +# base_branch = "main" + +# Suppress soft rate-limit warning notifications in chat. +# Hard blocks and story-blocked notifications are always sent. +# rate_limit_notifications = true + +# IANA timezone for timer scheduling (e.g. "Europe/London", "America/New_York"). +# Timer HH:MM inputs are interpreted in this timezone. +# timezone = "America/New_York" +"#; + +const DEFAULT_AGENTS_TOML: &str = r#"[[agent]] +name = "coder-1" +stage = "coder" +role = "Full-stack engineer. Implements features across all components." +model = "sonnet" +max_turns = 50 +max_budget_usd = 5.00 +prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md to understand the dev process. Follow the workflow through implementation and verification. The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop.\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates when your process exits.\n\nIf `script/test` still contains the generic 'No tests configured' stub, update it to run the project's actual test suite before starting implementation." +system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Commit all your work before finishing. Do not accept stories, move them to archived, or merge to master." + +[[agent]] +name = "qa" +stage = "qa" +role = "Reviews coder work: runs quality gates, generates testing plans, and reports findings." +model = "sonnet" +max_turns = 40 +max_budget_usd = 4.00 +prompt = "You are the QA agent for story {{story_id}}. Review the coder's work and produce a structured QA report. Run quality gates (linting, tests), attempt a build, and generate a manual testing plan. Do NOT modify any code." +system_prompt = "You are a QA agent. Your job is read-only: review code quality, run tests, and produce a structured QA report. Do not modify code." + +[[agent]] +name = "mergemaster" +stage = "mergemaster" +role = "Merges completed work into master, runs quality gates, and archives stories." +model = "sonnet" +max_turns = 30 +max_budget_usd = 5.00 +prompt = "You are the mergemaster agent for story {{story_id}}. Call merge_agent_work(story_id='{{story_id}}') to start the merge pipeline. Then poll get_merge_status(story_id='{{story_id}}') every 15 seconds until the status is 'completed' or 'failed'. Report the final result. If the merge fails, call report_merge_failure." +system_prompt = "You are the mergemaster agent. Call merge_agent_work to start the merge, then poll get_merge_status every 15 seconds until done. Never manually move story files. Call report_merge_failure when merges fail." +"#; + +/// Detect the tech stack from the project root and return TOML `[[component]]` entries. +/// +/// Inspects well-known marker files at the project root to identify which +/// tech stacks are present, then emits one `[[component]]` entry per detected +/// stack with sensible default `setup` commands. If no markers are found, a +/// single fallback `app` component with an empty `setup` list is returned so +/// that the pipeline never breaks on an unknown stack. +pub fn detect_components_toml(root: &Path) -> String { + let mut sections = Vec::new(); + + if root.join("Cargo.toml").exists() { + sections.push( + "[[component]]\nname = \"server\"\npath = \".\"\nsetup = [\"cargo check\"]\n" + .to_string(), + ); + } + + if root.join("package.json").exists() { + let setup_cmd = if root.join("pnpm-lock.yaml").exists() { + "pnpm install" + } else { + "npm install" + }; + sections.push(format!( + "[[component]]\nname = \"frontend\"\npath = \".\"\nsetup = [\"{setup_cmd}\"]\n" + )); + } + + if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { + sections.push( + "[[component]]\nname = \"python\"\npath = \".\"\nsetup = [\"pip install -r requirements.txt\"]\n" + .to_string(), + ); + } + + if root.join("go.mod").exists() { + sections.push( + "[[component]]\nname = \"go\"\npath = \".\"\nsetup = [\"go build ./...\"]\n" + .to_string(), + ); + } + + if root.join("Gemfile").exists() { + sections.push( + "[[component]]\nname = \"ruby\"\npath = \".\"\nsetup = [\"bundle install\"]\n" + .to_string(), + ); + } + + if sections.is_empty() { + // No tech stack markers detected — emit a single generic component + // with an empty setup list. The ONBOARDING_PROMPT instructs the chat + // agent to inspect the project and replace this with real definitions. + sections.push("[[component]]\nname = \"app\"\npath = \".\"\nsetup = []\n".to_string()); + } + + sections.join("\n") +} + +/// Detect the appropriate Node.js test command for a directory containing `package.json`. +/// +/// Reads the `package.json` content to identify known test runners (vitest, jest). +/// Falls back to `npm test` or `pnpm test` based on which lock file is present. +fn detect_node_test_cmd(pkg_dir: &Path) -> String { + let has_pnpm = pkg_dir.join("pnpm-lock.yaml").exists(); + let content = std::fs::read_to_string(pkg_dir.join("package.json")).unwrap_or_default(); + + if content.contains("\"vitest\"") { + let pm = if has_pnpm { "pnpm" } else { "npx" }; + return format!("{} vitest run", pm); + } + if content.contains("\"jest\"") { + let pm = if has_pnpm { "pnpm" } else { "npx" }; + return format!("{} jest", pm); + } + + if has_pnpm { + "pnpm test".to_string() + } else { + "npm test".to_string() + } +} + +/// Detect the appropriate Node.js build command for a directory containing `package.json`. +fn detect_node_build_cmd(pkg_dir: &Path) -> String { + if pkg_dir.join("pnpm-lock.yaml").exists() { + "pnpm run build".to_string() + } else { + "npm run build".to_string() + } +} + +/// Detect the appropriate Node.js lint command for a directory containing `package.json`. +/// +/// Reads the `package.json` content to identify eslint. Falls back to +/// `npm run lint` or `pnpm run lint` based on which lock file is present. +fn detect_node_lint_cmd(pkg_dir: &Path) -> String { + let has_pnpm = pkg_dir.join("pnpm-lock.yaml").exists(); + let content = std::fs::read_to_string(pkg_dir.join("package.json")).unwrap_or_default(); + if content.contains("\"eslint\"") { + let pm = if has_pnpm { "pnpm" } else { "npx" }; + return format!("{pm} eslint ."); + } + if has_pnpm { + "pnpm run lint".to_string() + } else { + "npm run lint".to_string() + } +} + +/// Generate `script/build` content for a new project at `root`. +/// +/// Inspects well-known marker files to identify which tech stacks are present +/// and emits the appropriate build commands. Multi-stack projects get combined +/// commands run sequentially. Falls back to a generic stub when no markers +/// are found so the scaffold is always valid. +/// +/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`), +/// the build command is detected from the presence of `pnpm-lock.yaml`. +pub fn detect_script_build(root: &Path) -> String { + let mut commands: Vec = Vec::new(); + + if root.join("Cargo.toml").exists() { + commands.push("cargo build --release".to_string()); + } + + if root.join("package.json").exists() { + commands.push(detect_node_build_cmd(root)); + } + + // Detect frontend in known subdirectories (e.g. frontend/, client/) + for subdir in &["frontend", "client"] { + let sub_path = root.join(subdir); + if sub_path.join("package.json").exists() { + let cmd = detect_node_build_cmd(&sub_path); + commands.push(format!("(cd {} && {})", subdir, cmd)); + } + } + + if root.join("pyproject.toml").exists() { + commands.push("python -m build".to_string()); + } + + if root.join("go.mod").exists() { + commands.push("go build ./...".to_string()); + } + + if commands.is_empty() { + return "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's build commands here.\necho \"No build configured\"\n".to_string(); + } + + let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string(); + for cmd in commands { + script.push_str(&cmd); + script.push('\n'); + } + script +} + +/// Generate `script/lint` content for a new project at `root`. +/// +/// Inspects well-known marker files to identify which linters are present +/// and emits the appropriate lint commands. Multi-stack projects get combined +/// commands run sequentially. Falls back to a generic stub when no markers +/// are found so the scaffold is always valid. +/// +/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`), +/// the lint command is detected from the `package.json` (eslint, npm, pnpm). +pub fn detect_script_lint(root: &Path) -> String { + let mut commands: Vec = Vec::new(); + + if root.join("Cargo.toml").exists() { + commands.push("cargo fmt --all --check".to_string()); + commands.push("cargo clippy -- -D warnings".to_string()); + } + + if root.join("package.json").exists() { + commands.push(detect_node_lint_cmd(root)); + } + + // Detect frontend in known subdirectories (e.g. frontend/, client/) + for subdir in &["frontend", "client"] { + let sub_path = root.join(subdir); + if sub_path.join("package.json").exists() { + let cmd = detect_node_lint_cmd(&sub_path); + commands.push(format!("(cd {} && {})", subdir, cmd)); + } + } + + if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { + let mut content = std::fs::read_to_string(root.join("pyproject.toml")).unwrap_or_default(); + content + .push_str(&std::fs::read_to_string(root.join("requirements.txt")).unwrap_or_default()); + if content.contains("ruff") { + commands.push("ruff check .".to_string()); + } else { + commands.push("flake8 .".to_string()); + } + } + + if root.join("go.mod").exists() { + commands.push("go vet ./...".to_string()); + } + + if commands.is_empty() { + return "#!/usr/bin/env bash\nset -euo pipefail\n\n# Add your project's lint commands here.\necho \"No linters configured\"\n".to_string(); + } + + let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string(); + for cmd in commands { + script.push_str(&cmd); + script.push('\n'); + } + script +} + +/// Generate `script/test` content for a new project at `root`. +/// +/// Inspects well-known marker files to identify which tech stacks are present +/// and emits the appropriate test commands. Multi-stack projects get combined +/// commands run sequentially. Falls back to the generic stub when no markers +/// are found so the scaffold is always valid. +/// +/// For projects with a frontend in a known subdirectory (`frontend/`, `client/`), +/// the test runner is detected from the `package.json` (vitest, jest, npm, pnpm). +pub fn detect_script_test(root: &Path) -> String { + let mut commands: Vec = Vec::new(); + + if root.join("Cargo.toml").exists() { + commands.push("cargo test".to_string()); + } + + if root.join("package.json").exists() { + if root.join("pnpm-lock.yaml").exists() { + commands.push("pnpm test".to_string()); + } else { + commands.push("npm test".to_string()); + } + } + + // Detect frontend in known subdirectories (e.g. frontend/, client/) + for subdir in &["frontend", "client"] { + let sub_path = root.join(subdir); + if sub_path.join("package.json").exists() { + let cmd = detect_node_test_cmd(&sub_path); + commands.push(format!("(cd {} && {})", subdir, cmd)); + } + } + + if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() { + commands.push("pytest".to_string()); + } + + if root.join("go.mod").exists() { + commands.push("go test ./...".to_string()); + } + + if commands.is_empty() { + return STORY_KIT_SCRIPT_TEST.to_string(); + } + + let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string(); + for cmd in commands { + script.push_str(&cmd); + script.push('\n'); + } + script +} + +/// Generate a `project.toml` for a new project at `root`. +/// +/// Detects the tech stack via [`detect_components_toml`] and combines the +/// resulting `[[component]]` entries with the default project settings. +/// Agent definitions are written to `agents.toml` separately. +fn generate_project_toml(root: &Path) -> String { + let components = detect_components_toml(root); + format!("{components}\n{DEFAULT_PROJECT_SETTINGS_TOML}") +} + +fn write_file_if_missing(path: &Path, content: &str) -> Result<(), String> { + if path.exists() { + return Ok(()); + } + fs::write(path, content).map_err(|e| format!("Failed to write file: {}", e))?; + Ok(()) +} + +/// Write `content` to `path` if missing, then ensure the file is executable. +fn write_script_if_missing(path: &Path, content: &str) -> Result<(), String> { + write_file_if_missing(path, content)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path) + .map_err(|e| format!("Failed to read permissions for {}: {}", path.display(), e))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms) + .map_err(|e| format!("Failed to set permissions on {}: {}", path.display(), e))?; + } + + Ok(()) +} + +/// Write (or idempotently update) `.huskies/.gitignore` with Story Kit–specific +/// ignore patterns for files that live inside the `.huskies/` directory. +/// Patterns are relative to `.huskies/` as git resolves `.gitignore` files +/// relative to the directory that contains them. +fn write_story_kit_gitignore(root: &Path) -> Result<(), String> { + // Entries that belong inside .huskies/.gitignore (relative to .huskies/). + let entries = [ + "bot.toml", + "matrix_store/", + "matrix_device_id", + "matrix_history.json", + "timers.json", + "worktrees/", + "merge_workspace/", + "coverage/", + "work/2_current/", + "work/3_qa/", + "work/4_merge/", + "logs/", + "token_usage.jsonl", + "wizard_state.json", + "store.json", + "pipeline.db", + "*.db", + ]; + + let gitignore_path = root.join(".huskies").join(".gitignore"); + let existing = if gitignore_path.exists() { + fs::read_to_string(&gitignore_path) + .map_err(|e| format!("Failed to read .huskies/.gitignore: {}", e))? + } else { + String::new() + }; + + let missing: Vec<&str> = entries + .iter() + .copied() + .filter(|e| !existing.lines().any(|l| l.trim() == *e)) + .collect(); + + if missing.is_empty() { + return Ok(()); + } + + let mut new_content = existing; + if !new_content.is_empty() && !new_content.ends_with('\n') { + new_content.push('\n'); + } + for entry in missing { + new_content.push_str(entry); + new_content.push('\n'); + } + + fs::write(&gitignore_path, new_content) + .map_err(|e| format!("Failed to write .huskies/.gitignore: {}", e))?; + + Ok(()) +} + +/// Append root-level Story Kit entries to the project `.gitignore`. +/// Only `.huskies_port` and `.mcp.json` remain here because they live at +/// the project root and git does not support `../` patterns in `.gitignore` +/// files, so they cannot be expressed in `.huskies/.gitignore`. +/// `store.json` is excluded via `.huskies/.gitignore` since it now lives +/// inside the `.huskies/` directory. +fn append_root_gitignore_entries(root: &Path) -> Result<(), String> { + let entries = [".huskies_port", ".mcp.json"]; + + let gitignore_path = root.join(".gitignore"); + let existing = if gitignore_path.exists() { + fs::read_to_string(&gitignore_path) + .map_err(|e| format!("Failed to read .gitignore: {}", e))? + } else { + String::new() + }; + + let missing: Vec<&str> = entries + .iter() + .copied() + .filter(|e| !existing.lines().any(|l| l.trim() == *e)) + .collect(); + + if missing.is_empty() { + return Ok(()); + } + + let mut new_content = existing; + if !new_content.is_empty() && !new_content.ends_with('\n') { + new_content.push('\n'); + } + for entry in missing { + new_content.push_str(entry); + new_content.push('\n'); + } + + fs::write(&gitignore_path, new_content) + .map_err(|e| format!("Failed to write .gitignore: {}", e))?; + + Ok(()) +} + +pub(crate) fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> { + let story_kit_root = root.join(".huskies"); + let specs_root = story_kit_root.join("specs"); + let tech_root = specs_root.join("tech"); + let functional_root = specs_root.join("functional"); + let script_root = root.join("script"); + + // Create the work/ pipeline directories, each with a .gitkeep so empty dirs survive git clone + let work_stages = [ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", + ]; + for stage in &work_stages { + let dir = story_kit_root.join("work").join(stage); + fs::create_dir_all(&dir).map_err(|e| format!("Failed to create work/{}: {}", stage, e))?; + write_file_if_missing(&dir.join(".gitkeep"), "")?; + } + + fs::create_dir_all(&tech_root).map_err(|e| format!("Failed to create specs/tech: {}", e))?; + fs::create_dir_all(&functional_root) + .map_err(|e| format!("Failed to create specs/functional: {}", e))?; + fs::create_dir_all(&script_root) + .map_err(|e| format!("Failed to create script/ directory: {}", e))?; + + write_file_if_missing(&story_kit_root.join("README.md"), STORY_KIT_README)?; + let project_toml_content = generate_project_toml(root); + write_file_if_missing(&story_kit_root.join("project.toml"), &project_toml_content)?; + write_file_if_missing(&story_kit_root.join("agents.toml"), DEFAULT_AGENTS_TOML)?; + write_file_if_missing(&specs_root.join("00_CONTEXT.md"), STORY_KIT_CONTEXT)?; + write_file_if_missing(&tech_root.join("STACK.md"), STORY_KIT_STACK)?; + let script_test_content = detect_script_test(root); + write_script_if_missing(&script_root.join("test"), &script_test_content)?; + let script_build_content = detect_script_build(root); + write_script_if_missing(&script_root.join("build"), &script_build_content)?; + let script_lint_content = detect_script_lint(root); + write_script_if_missing(&script_root.join("lint"), &script_lint_content)?; + write_file_if_missing(&root.join("CLAUDE.md"), STORY_KIT_CLAUDE_MD)?; + + // Write per-transport bot.toml example files so users can see all options. + write_file_if_missing( + &story_kit_root.join("bot.toml.matrix.example"), + BOT_TOML_MATRIX_EXAMPLE, + )?; + write_file_if_missing( + &story_kit_root.join("bot.toml.whatsapp-meta.example"), + BOT_TOML_WHATSAPP_META_EXAMPLE, + )?; + write_file_if_missing( + &story_kit_root.join("bot.toml.whatsapp-twilio.example"), + BOT_TOML_WHATSAPP_TWILIO_EXAMPLE, + )?; + write_file_if_missing( + &story_kit_root.join("bot.toml.slack.example"), + BOT_TOML_SLACK_EXAMPLE, + )?; + + // Write .mcp.json at the project root so agents can find the MCP server. + // Only written when missing — never overwrites an existing file, because + // the port is environment-specific and must not clobber a running instance. + let mcp_content = format!( + "{{\n \"mcpServers\": {{\n \"huskies\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n" + ); + write_file_if_missing(&root.join(".mcp.json"), &mcp_content)?; + + // Create .claude/settings.json with sensible permission defaults so that + // Claude Code (both agents and web UI chat) can operate without constant + // permission prompts. + let claude_dir = root.join(".claude"); + fs::create_dir_all(&claude_dir) + .map_err(|e| format!("Failed to create .claude/ directory: {}", e))?; + write_file_if_missing(&claude_dir.join("settings.json"), STORY_KIT_CLAUDE_SETTINGS)?; + + write_story_kit_gitignore(root)?; + append_root_gitignore_entries(root)?; + + // Run `git init` if the directory is not already a git repo, then make an initial commit + if !root.join(".git").exists() { + let init_status = std::process::Command::new("git") + .args(["init"]) + .current_dir(root) + .status() + .map_err(|e| format!("Failed to run git init: {}", e))?; + if !init_status.success() { + return Err("git init failed".to_string()); + } + + let add_output = std::process::Command::new("git") + .args([ + "add", + ".huskies", + "script", + ".gitignore", + "CLAUDE.md", + ".claude", + ]) + .current_dir(root) + .output() + .map_err(|e| format!("Failed to run git add: {}", e))?; + if !add_output.status.success() { + return Err(format!( + "git add failed: {}", + String::from_utf8_lossy(&add_output.stderr) + )); + } + + let commit_output = std::process::Command::new("git") + .args([ + "-c", + "user.email=huskies@localhost", + "-c", + "user.name=Story Kit", + "commit", + "-m", + "Initial Story Kit scaffold", + ]) + .current_dir(root) + .output() + .map_err(|e| format!("Failed to run git commit: {}", e))?; + if !commit_output.status.success() { + return Err(format!( + "git commit failed: {}", + String::from_utf8_lossy(&commit_output.stderr) + )); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/server/src/io/fs/scaffold/tests.rs b/server/src/io/fs/scaffold/tests.rs new file mode 100644 index 00000000..d6683bf6 --- /dev/null +++ b/server/src/io/fs/scaffold/tests.rs @@ -0,0 +1,1342 @@ +use super::*; +use tempfile::tempdir; + +// --- scaffold --- + +#[test] +fn scaffold_story_kit_creates_structure() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + assert!(dir.path().join(".huskies/README.md").exists()); + assert!(dir.path().join(".huskies/project.toml").exists()); + assert!(dir.path().join(".huskies/agents.toml").exists()); + assert!(dir.path().join(".huskies/specs/00_CONTEXT.md").exists()); + assert!(dir.path().join(".huskies/specs/tech/STACK.md").exists()); + // Old stories/ dirs should NOT be created + assert!(!dir.path().join(".huskies/stories").exists()); + assert!(dir.path().join("script/test").exists()); +} + +#[test] +fn scaffold_story_kit_creates_work_pipeline_dirs() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let stages = [ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", + ]; + for stage in &stages { + let path = dir.path().join(".huskies/work").join(stage); + assert!(path.is_dir(), "work/{} should be a directory", stage); + assert!( + path.join(".gitkeep").exists(), + "work/{} should have a .gitkeep file", + stage + ); + } +} + +#[test] +fn scaffold_story_kit_agents_toml_has_coder_qa_mergemaster() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + // Agent definitions go into agents.toml, not project.toml. + let agents = fs::read_to_string(dir.path().join(".huskies/agents.toml")).unwrap(); + assert!(agents.contains("[[agent]]")); + assert!(agents.contains("stage = \"coder\"")); + assert!(agents.contains("stage = \"qa\"")); + assert!(agents.contains("stage = \"mergemaster\"")); + assert!(agents.contains("model = \"sonnet\"")); + + // project.toml should NOT contain [[agent]] blocks. + let project = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); + assert!(!project.contains("[[agent]]")); +} + +#[test] +fn scaffold_project_toml_contains_rate_limit_and_timezone_comments() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); + assert!( + content.contains("rate_limit_notifications"), + "project.toml scaffold should document rate_limit_notifications" + ); + assert!( + content.contains("timezone"), + "project.toml scaffold should document timezone" + ); +} + +#[test] +fn scaffold_project_toml_contains_max_retries_with_default_value() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); + assert!( + content.contains("max_retries = 2"), + "project.toml scaffold should include max_retries with default value 2" + ); + assert!( + content.contains("Maximum number of retries"), + "project.toml scaffold should include a comment explaining max_retries" + ); +} + +#[test] +fn scaffold_project_toml_contains_commented_out_optional_fields() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); + assert!( + content.contains("# default_coder_model"), + "project.toml scaffold should include commented-out default_coder_model" + ); + assert!( + content.contains("# max_coders"), + "project.toml scaffold should include commented-out max_coders" + ); + assert!( + content.contains("# base_branch"), + "project.toml scaffold should include commented-out base_branch" + ); +} + +#[test] +fn scaffold_project_toml_round_trips_through_project_config_load() { + use crate::config::ProjectConfig; + + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + // The generated project.toml must parse without error. + let config = + ProjectConfig::load(dir.path()).expect("Generated project.toml should parse without error"); + + // Key defaults must survive the round-trip. + assert_eq!(config.default_qa, "server"); + assert_eq!(config.max_retries, 2); + assert!( + config.rate_limit_notifications, + "rate_limit_notifications should default to true" + ); + assert!( + config.default_coder_model.is_none(), + "default_coder_model should be None when commented out" + ); + assert!( + config.max_coders.is_none(), + "max_coders should be None when commented out" + ); + assert!( + config.base_branch.is_none(), + "base_branch should be None when commented out" + ); + assert!( + config.timezone.is_none(), + "timezone should be None when commented out" + ); +} + +#[test] +fn scaffold_context_is_blank_template_not_story_kit_content() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let content = fs::read_to_string(dir.path().join(".huskies/specs/00_CONTEXT.md")).unwrap(); + assert!(content.contains("")); + assert!(content.contains("## High-Level Goal")); + assert!(content.contains("## Core Features")); + assert!(content.contains("## Domain Definition")); + assert!(content.contains("## Glossary")); + // Must NOT contain Story Kit-specific content + assert!(!content.contains("Agentic AI Code Assistant")); + assert!(!content.contains("Poem HTTP server")); +} + +#[test] +fn scaffold_stack_is_blank_template_not_story_kit_content() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let content = fs::read_to_string(dir.path().join(".huskies/specs/tech/STACK.md")).unwrap(); + assert!(content.contains("")); + assert!(content.contains("## Core Stack")); + assert!(content.contains("## Coding Standards")); + assert!(content.contains("## Quality Gates")); + assert!(content.contains("## Libraries")); + // Must NOT contain Story Kit-specific content + assert!(!content.contains("Poem HTTP server")); + assert!(!content.contains("TypeScript + React")); +} + +#[cfg(unix)] +#[test] +fn scaffold_story_kit_creates_executable_script_test() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let script_test = dir.path().join("script/test"); + assert!(script_test.exists(), "script/test should be created"); + let perms = fs::metadata(&script_test).unwrap().permissions(); + assert!( + perms.mode() & 0o111 != 0, + "script/test should be executable" + ); +} + +#[test] +fn scaffold_story_kit_does_not_overwrite_existing() { + let dir = tempdir().unwrap(); + let readme = dir.path().join(".huskies/README.md"); + fs::create_dir_all(readme.parent().unwrap()).unwrap(); + fs::write(&readme, "custom content").unwrap(); + + scaffold_story_kit(dir.path(), 3001).unwrap(); + + assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content"); +} + +#[test] +fn scaffold_story_kit_is_idempotent() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let readme_content = fs::read_to_string(dir.path().join(".huskies/README.md")).unwrap(); + let toml_content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); + + // Run again — must not change content or add duplicate .gitignore entries + scaffold_story_kit(dir.path(), 3001).unwrap(); + + assert_eq!( + fs::read_to_string(dir.path().join(".huskies/README.md")).unwrap(), + readme_content + ); + assert_eq!( + fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(), + toml_content + ); + + let story_kit_gitignore = fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap(); + let count = story_kit_gitignore + .lines() + .filter(|l| l.trim() == "worktrees/") + .count(); + assert_eq!( + count, 1, + ".huskies/.gitignore should not have duplicate entries" + ); +} + +#[test] +fn scaffold_story_kit_existing_git_repo_no_commit() { + let dir = tempdir().unwrap(); + + // Initialize a git repo before scaffold + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .status() + .unwrap(); + std::process::Command::new("git") + .args([ + "-c", + "user.email=test@test.com", + "-c", + "user.name=Test", + "commit", + "--allow-empty", + "-m", + "pre-scaffold", + ]) + .current_dir(dir.path()) + .status() + .unwrap(); + + scaffold_story_kit(dir.path(), 3001).unwrap(); + + // Only 1 commit should exist — scaffold must not commit into an existing repo + let log_output = std::process::Command::new("git") + .args(["log", "--oneline"]) + .current_dir(dir.path()) + .output() + .unwrap(); + let log = String::from_utf8_lossy(&log_output.stdout); + let commit_count = log.lines().count(); + assert_eq!( + commit_count, 1, + "scaffold should not create a commit in an existing git repo" + ); +} + +#[test] +fn scaffold_creates_story_kit_gitignore_with_relative_entries() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + // .huskies/.gitignore must contain relative patterns for files under .huskies/ + let sk_content = fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap(); + assert!(sk_content.contains("worktrees/")); + assert!(sk_content.contains("merge_workspace/")); + assert!(sk_content.contains("coverage/")); + assert!(sk_content.contains("matrix_history.json")); + assert!(sk_content.contains("timers.json")); + // Must NOT contain absolute .huskies/ prefixed paths + assert!(!sk_content.contains(".huskies/")); + + // Root .gitignore must contain root-level huskies entries + let root_content = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!(root_content.contains(".huskies_port")); + // store.json now lives inside .huskies/ and must NOT appear in root .gitignore + assert!(!root_content.contains("store.json")); + // Root .gitignore must NOT contain .huskies/ sub-directory patterns + assert!(!root_content.contains(".huskies/worktrees/")); + assert!(!root_content.contains(".huskies/merge_workspace/")); + assert!(!root_content.contains(".huskies/coverage/")); + // store.json must be in .huskies/.gitignore instead + assert!(sk_content.contains("store.json")); + // Database files must be ignored so novice users don't accidentally commit them + assert!(sk_content.contains("pipeline.db")); + assert!(sk_content.contains("*.db")); +} + +#[test] +fn scaffold_story_kit_gitignore_does_not_duplicate_existing_entries() { + let dir = tempdir().unwrap(); + // Pre-create .huskies dir and .gitignore with some entries already present + fs::create_dir_all(dir.path().join(".huskies")).unwrap(); + fs::write( + dir.path().join(".huskies/.gitignore"), + "worktrees/\ncoverage/\n", + ) + .unwrap(); + + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let content = fs::read_to_string(dir.path().join(".huskies/.gitignore")).unwrap(); + let worktrees_count = content.lines().filter(|l| l.trim() == "worktrees/").count(); + assert_eq!(worktrees_count, 1, "worktrees/ should not be duplicated"); + let coverage_count = content.lines().filter(|l| l.trim() == "coverage/").count(); + assert_eq!(coverage_count, 1, "coverage/ should not be duplicated"); + // The missing entry must have been added + assert!(content.contains("merge_workspace/")); +} + +// --- CLAUDE.md scaffold --- + +#[test] +fn scaffold_creates_claude_md_at_project_root() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let claude_md = dir.path().join("CLAUDE.md"); + assert!( + claude_md.exists(), + "CLAUDE.md should be created at project root" + ); + + let content = fs::read_to_string(&claude_md).unwrap(); + assert!( + content.contains(""), + "CLAUDE.md should contain the scaffold sentinel" + ); + assert!( + content.contains("Read .huskies/README.md"), + "CLAUDE.md should include directive to read .huskies/README.md" + ); + assert!( + content.contains("Never chain shell commands"), + "CLAUDE.md should include command chaining rule" + ); + assert!( + content.contains("wizard_status"), + "CLAUDE.md should instruct Claude to call wizard_status on first conversation" + ); +} + +#[test] +fn scaffold_does_not_overwrite_existing_claude_md() { + let dir = tempdir().unwrap(); + let claude_md = dir.path().join("CLAUDE.md"); + fs::write(&claude_md, "custom CLAUDE.md content").unwrap(); + + scaffold_story_kit(dir.path(), 3001).unwrap(); + + assert_eq!( + fs::read_to_string(&claude_md).unwrap(), + "custom CLAUDE.md content", + "scaffold should not overwrite an existing CLAUDE.md" + ); +} + +#[test] +fn scaffold_story_kit_writes_mcp_json_with_port() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 4242).unwrap(); + + let mcp_path = dir.path().join(".mcp.json"); + assert!(mcp_path.exists(), ".mcp.json should be created by scaffold"); + let content = fs::read_to_string(&mcp_path).unwrap(); + assert!( + content.contains("4242"), + ".mcp.json should reference the given port" + ); + assert!( + content.contains("localhost"), + ".mcp.json should reference localhost" + ); + assert!( + content.contains("huskies"), + ".mcp.json should name the huskies server" + ); +} + +#[test] +fn scaffold_story_kit_does_not_overwrite_existing_mcp_json() { + let dir = tempdir().unwrap(); + let mcp_path = dir.path().join(".mcp.json"); + fs::write(&mcp_path, "{\"custom\": true}").unwrap(); + + scaffold_story_kit(dir.path(), 3001).unwrap(); + + assert_eq!( + fs::read_to_string(&mcp_path).unwrap(), + "{\"custom\": true}", + "scaffold should not overwrite an existing .mcp.json" + ); +} + +#[test] +fn scaffold_gitignore_includes_mcp_json() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let root_gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!( + root_gitignore.contains(".mcp.json"), + "root .gitignore should include .mcp.json (port is environment-specific)" + ); +} + +// --- detect_components_toml --- + +#[test] +fn detect_no_markers_returns_fallback_components() { + let dir = tempdir().unwrap(); + let toml = detect_components_toml(dir.path()); + // At least one [[component]] entry should always be present + assert!( + toml.contains("[[component]]"), + "should always emit at least one component" + ); + // Fallback should use a generic app component with empty setup + assert!( + toml.contains("name = \"app\""), + "fallback should use generic 'app' component name" + ); + assert!( + toml.contains("setup = []"), + "fallback should have empty setup list" + ); + // Must not contain Rust-specific commands in a non-Rust project + assert!( + !toml.contains("cargo"), + "fallback must not contain Rust-specific commands" + ); +} + +#[test] +fn detect_cargo_toml_generates_rust_component() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"test\"\n", + ) + .unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"server\"")); + assert!(toml.contains("setup = [\"cargo check\"]")); +} + +#[test] +fn detect_package_json_with_pnpm_lock_generates_pnpm_component() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"frontend\"")); + assert!(toml.contains("setup = [\"pnpm install\"]")); +} + +#[test] +fn detect_package_json_without_pnpm_lock_generates_npm_component() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"frontend\"")); + assert!(toml.contains("setup = [\"npm install\"]")); +} + +#[test] +fn detect_pyproject_toml_generates_python_component() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"test\"\n", + ) + .unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"python\"")); + assert!(toml.contains("pip install")); +} + +#[test] +fn detect_requirements_txt_generates_python_component() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"python\"")); + assert!(toml.contains("pip install")); +} + +#[test] +fn detect_go_mod_generates_go_component() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"go\"")); + assert!(toml.contains("setup = [\"go build ./...\"]")); +} + +#[test] +fn detect_gemfile_generates_ruby_component() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Gemfile"), + "source \"https://rubygems.org\"\n", + ) + .unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"ruby\"")); + assert!(toml.contains("setup = [\"bundle install\"]")); +} + +// --- Bug 375: no Rust-specific commands for non-Rust projects --- + +#[test] +fn no_rust_commands_in_go_project() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!( + !toml.contains("cargo"), + "go project must not contain cargo commands" + ); + assert!(toml.contains("go build"), "go project must use Go tooling"); +} + +#[test] +fn no_rust_commands_in_node_project() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!( + !toml.contains("cargo"), + "node project must not contain cargo commands" + ); + assert!( + toml.contains("npm install"), + "node project must use npm tooling" + ); +} + +#[test] +fn no_rust_commands_when_no_stack_detected() { + let dir = tempdir().unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!( + !toml.contains("cargo"), + "unknown stack must not contain cargo commands" + ); + // setup list must be empty + assert!( + toml.contains("setup = []"), + "unknown stack must have empty setup list" + ); +} + +#[test] +fn detect_multiple_markers_generates_multiple_components() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"server\"\n", + ) + .unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let toml = detect_components_toml(dir.path()); + assert!(toml.contains("name = \"server\"")); + assert!(toml.contains("name = \"frontend\"")); + // Both component entries should be present + let component_count = toml.matches("[[component]]").count(); + assert_eq!(component_count, 2); +} + +#[test] +fn detect_no_fallback_when_markers_found() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); + + let toml = detect_components_toml(dir.path()); + // The fallback "app" component should NOT appear when a real stack is detected + assert!(!toml.contains("name = \"app\"")); +} + +// --- detect_script_test --- + +#[test] +fn detect_script_test_no_markers_returns_stub() { + let dir = tempdir().unwrap(); + let script = detect_script_test(dir.path()); + assert!( + script.contains("No tests configured"), + "fallback should contain the generic stub message" + ); + assert!(script.starts_with("#!/usr/bin/env bash")); +} + +#[test] +fn detect_script_test_cargo_toml_adds_cargo_test() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("cargo test"), + "Rust project should run cargo test" + ); + assert!(!script.contains("No tests configured")); +} + +#[test] +fn detect_script_test_package_json_npm_adds_npm_test() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("npm test"), + "Node project without pnpm-lock should run npm test" + ); + assert!(!script.contains("No tests configured")); +} + +#[test] +fn detect_script_test_package_json_pnpm_adds_pnpm_test() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("pnpm test"), + "Node project with pnpm-lock should run pnpm test" + ); + // "pnpm test" is a substring of itself; verify there's no bare "npm test" line + assert!( + !script.lines().any(|l| l.trim() == "npm test"), + "should not use npm when pnpm-lock.yaml is present" + ); +} + +#[test] +fn detect_script_test_pyproject_toml_adds_pytest() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"x\"\n", + ) + .unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("pytest"), + "Python project should run pytest" + ); + assert!(!script.contains("No tests configured")); +} + +#[test] +fn detect_script_test_requirements_txt_adds_pytest() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("pytest"), + "Python project (requirements.txt) should run pytest" + ); +} + +#[test] +fn detect_script_test_go_mod_adds_go_test() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("go test ./..."), + "Go project should run go test ./..." + ); + assert!(!script.contains("No tests configured")); +} + +#[test] +fn detect_script_test_multi_stack_combines_commands() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("go test ./..."), + "multi-stack should include Go test command" + ); + assert!( + script.contains("npm test"), + "multi-stack should include Node test command" + ); +} + +#[test] +fn detect_script_test_frontend_subdir_with_vitest_uses_npx_vitest() { + let dir = tempdir().unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write( + frontend.join("package.json"), + r#"{"devDependencies":{"vitest":"^1.0.0"},"scripts":{"test":"vitest run"}}"#, + ) + .unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("vitest run"), + "frontend with vitest should emit vitest run" + ); + assert!( + script.contains("cd frontend"), + "should cd into the frontend directory" + ); + assert!( + !script.contains("No tests configured"), + "should not use stub when frontend is detected" + ); +} + +#[test] +fn detect_script_test_frontend_subdir_with_jest_uses_npx_jest() { + let dir = tempdir().unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write( + frontend.join("package.json"), + r#"{"devDependencies":{"jest":"^29.0.0"},"scripts":{"test":"jest"}}"#, + ) + .unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("jest"), + "frontend with jest should emit jest" + ); + assert!( + script.contains("cd frontend"), + "should cd into the frontend directory" + ); +} + +#[test] +fn detect_script_test_frontend_subdir_no_known_runner_uses_npm_test() { + let dir = tempdir().unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write( + frontend.join("package.json"), + r#"{"scripts":{"test":"mocha"}}"#, + ) + .unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("npm test"), + "frontend without known runner should fall back to npm test" + ); + assert!(script.contains("cd frontend")); +} + +#[test] +fn detect_script_test_frontend_subdir_pnpm_uses_pnpm_vitest() { + let dir = tempdir().unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write( + frontend.join("package.json"), + r#"{"devDependencies":{"vitest":"^1.0.0"}}"#, + ) + .unwrap(); + fs::write(frontend.join("pnpm-lock.yaml"), "").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("pnpm vitest run"), + "pnpm frontend with vitest should use pnpm vitest run" + ); +} + +#[test] +fn detect_script_test_rust_plus_frontend_subdir_both_included() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"server\"\n", + ) + .unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write( + frontend.join("package.json"), + r#"{"devDependencies":{"vitest":"^1.0.0"}}"#, + ) + .unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("cargo test"), + "Rust + frontend should include cargo test" + ); + assert!( + script.contains("vitest run"), + "Rust + frontend should include vitest run" + ); + assert!( + script.contains("cd frontend"), + "Rust + frontend should cd into frontend" + ); +} + +#[test] +fn detect_script_test_client_subdir_detected() { + let dir = tempdir().unwrap(); + let client = dir.path().join("client"); + fs::create_dir_all(&client).unwrap(); + fs::write( + client.join("package.json"), + r#"{"scripts":{"test":"jest"}}"#, + ) + .unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.contains("cd client"), + "client/ subdir should also be detected" + ); +} + +#[test] +fn detect_script_test_output_starts_with_shebang() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); + + let script = detect_script_test(dir.path()); + assert!( + script.starts_with("#!/usr/bin/env bash\nset -euo pipefail\n"), + "generated script should start with bash shebang and set -euo pipefail" + ); +} + +#[test] +fn scaffold_script_test_contains_detected_commands_for_rust() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"myapp\"\n", + ) + .unwrap(); + + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let content = fs::read_to_string(dir.path().join("script/test")).unwrap(); + assert!( + content.contains("cargo test"), + "Rust project scaffold should set cargo test in script/test" + ); + assert!( + !content.contains("No tests configured"), + "should not use stub when stack is detected" + ); +} + +#[test] +fn scaffold_script_test_fallback_stub_when_no_stack() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let content = fs::read_to_string(dir.path().join("script/test")).unwrap(); + assert!( + content.contains("No tests configured"), + "unknown stack should use the generic stub" + ); +} + +// --- detect_script_build --- + +#[test] +fn detect_script_build_no_markers_returns_stub() { + let dir = tempdir().unwrap(); + let script = detect_script_build(dir.path()); + assert!( + script.contains("No build configured"), + "fallback should contain the generic stub message" + ); + assert!(script.starts_with("#!/usr/bin/env bash")); +} + +#[test] +fn detect_script_build_cargo_toml_adds_cargo_build_release() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); + + let script = detect_script_build(dir.path()); + assert!( + script.contains("cargo build --release"), + "Rust project should run cargo build --release" + ); + assert!(!script.contains("No build configured")); +} + +#[test] +fn detect_script_build_package_json_npm_adds_npm_run_build() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let script = detect_script_build(dir.path()); + assert!( + script.contains("npm run build"), + "Node project without pnpm-lock should run npm run build" + ); +} + +#[test] +fn detect_script_build_package_json_pnpm_adds_pnpm_run_build() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); + + let script = detect_script_build(dir.path()); + assert!( + script.contains("pnpm run build"), + "Node project with pnpm-lock should run pnpm run build" + ); + assert!( + !script.lines().any(|l| l.trim() == "npm run build"), + "should not use npm when pnpm-lock.yaml is present" + ); +} + +#[test] +fn detect_script_build_go_mod_adds_go_build() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); + + let script = detect_script_build(dir.path()); + assert!( + script.contains("go build ./..."), + "Go project should run go build ./..." + ); +} + +#[test] +fn detect_script_build_pyproject_toml_adds_python_build() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"x\"\n", + ) + .unwrap(); + + let script = detect_script_build(dir.path()); + assert!( + script.contains("python -m build"), + "Python project should run python -m build" + ); +} + +#[test] +fn detect_script_build_frontend_subdir_detected() { + let dir = tempdir().unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write(frontend.join("package.json"), "{}").unwrap(); + + let script = detect_script_build(dir.path()); + assert!( + script.contains("cd frontend"), + "frontend subdir should be detected for build" + ); + assert!(script.contains("npm run build")); +} + +#[test] +fn detect_script_build_rust_plus_frontend_subdir_both_included() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"server\"\n", + ) + .unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write(frontend.join("package.json"), "{}").unwrap(); + + let script = detect_script_build(dir.path()); + assert!(script.contains("cargo build --release")); + assert!(script.contains("cd frontend")); + assert!(script.contains("npm run build")); +} + +// --- detect_script_lint --- + +#[test] +fn detect_script_lint_no_markers_returns_stub() { + let dir = tempdir().unwrap(); + let script = detect_script_lint(dir.path()); + assert!( + script.contains("No linters configured"), + "fallback should contain the generic stub message" + ); + assert!(script.starts_with("#!/usr/bin/env bash")); +} + +#[test] +fn detect_script_lint_cargo_toml_adds_fmt_and_clippy() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("cargo fmt --all --check"), + "Rust project should check formatting" + ); + assert!( + script.contains("cargo clippy -- -D warnings"), + "Rust project should run clippy" + ); + assert!(!script.contains("No linters configured")); +} + +#[test] +fn detect_script_lint_package_json_without_eslint_uses_npm_run_lint() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("npm run lint"), + "Node project without eslint dep should fall back to npm run lint" + ); +} + +#[test] +fn detect_script_lint_package_json_with_eslint_uses_npx_eslint() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("package.json"), + r#"{"devDependencies":{"eslint":"^8.0.0"}}"#, + ) + .unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("npx eslint ."), + "Node project with eslint should use npx eslint ." + ); +} + +#[test] +fn detect_script_lint_pnpm_with_eslint_uses_pnpm_eslint() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("package.json"), + r#"{"devDependencies":{"eslint":"^8.0.0"}}"#, + ) + .unwrap(); + fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("pnpm eslint ."), + "pnpm project with eslint should use pnpm eslint ." + ); +} + +#[test] +fn detect_script_lint_python_requirements_uses_flake8() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("flake8 ."), + "Python project without ruff should use flake8" + ); +} + +#[test] +fn detect_script_lint_python_with_ruff_uses_ruff() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("pyproject.toml"), + "[project]\nname = \"x\"\n\n[tool.ruff]\n", + ) + .unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("ruff check ."), + "Python project with ruff configured should use ruff" + ); + assert!( + !script.contains("flake8"), + "should not use flake8 when ruff is configured" + ); +} + +#[test] +fn detect_script_lint_go_mod_adds_go_vet() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("go vet ./..."), + "Go project should run go vet ./..." + ); +} + +#[test] +fn detect_script_lint_frontend_subdir_detected() { + let dir = tempdir().unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write(frontend.join("package.json"), "{}").unwrap(); + + let script = detect_script_lint(dir.path()); + assert!( + script.contains("cd frontend"), + "frontend subdir should be detected for lint" + ); +} + +#[test] +fn detect_script_lint_rust_plus_frontend_subdir_both_included() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"server\"\n", + ) + .unwrap(); + let frontend = dir.path().join("frontend"); + fs::create_dir_all(&frontend).unwrap(); + fs::write(frontend.join("package.json"), "{}").unwrap(); + + let script = detect_script_lint(dir.path()); + assert!(script.contains("cargo fmt --all --check")); + assert!(script.contains("cargo clippy -- -D warnings")); + assert!(script.contains("cd frontend")); +} + +#[test] +fn scaffold_story_kit_creates_script_build_and_lint() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + assert!( + dir.path().join("script/build").exists(), + "script/build should be created by scaffold" + ); + assert!( + dir.path().join("script/lint").exists(), + "script/lint should be created by scaffold" + ); +} + +#[cfg(unix)] +#[test] +fn scaffold_story_kit_creates_executable_script_build_and_lint() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + for name in &["build", "lint"] { + let path = dir.path().join("script").join(name); + assert!(path.exists(), "script/{name} should be created"); + let perms = fs::metadata(&path).unwrap().permissions(); + assert!( + perms.mode() & 0o111 != 0, + "script/{name} should be executable" + ); + } +} + +#[test] +fn scaffold_script_build_contains_detected_commands_for_rust() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"myapp\"\n", + ) + .unwrap(); + + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let content = fs::read_to_string(dir.path().join("script/build")).unwrap(); + assert!( + content.contains("cargo build --release"), + "Rust project scaffold should set cargo build --release in script/build" + ); +} + +#[test] +fn scaffold_script_lint_contains_detected_commands_for_rust() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"myapp\"\n", + ) + .unwrap(); + + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let content = fs::read_to_string(dir.path().join("script/lint")).unwrap(); + assert!( + content.contains("cargo fmt --all --check"), + "Rust project scaffold should include fmt check in script/lint" + ); + assert!( + content.contains("cargo clippy -- -D warnings"), + "Rust project scaffold should include clippy in script/lint" + ); +} + +// --- generate_project_toml --- + +#[test] +fn generate_project_toml_includes_components_but_not_agents() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap(); + + let toml = generate_project_toml(dir.path()); + // Component section should be present + assert!(toml.contains("[[component]]")); + assert!(toml.contains("name = \"server\"")); + // Agent sections must NOT be in project.toml — they go in agents.toml + assert!(!toml.contains("[[agent]]")); +} + +#[test] +fn default_agents_toml_has_coder_qa_mergemaster() { + assert!(DEFAULT_AGENTS_TOML.contains("[[agent]]")); + assert!(DEFAULT_AGENTS_TOML.contains("stage = \"coder\"")); + assert!(DEFAULT_AGENTS_TOML.contains("stage = \"qa\"")); + assert!(DEFAULT_AGENTS_TOML.contains("stage = \"mergemaster\"")); +} + +#[test] +fn scaffold_project_toml_contains_detected_components() { + let dir = tempdir().unwrap(); + // Place a Cargo.toml in the project root before scaffolding + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"myapp\"\n", + ) + .unwrap(); + + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); + assert!( + content.contains("[[component]]"), + "project.toml should contain a component entry" + ); + assert!( + content.contains("name = \"server\""), + "Rust project should have a 'server' component" + ); + assert!( + content.contains("cargo check"), + "Rust component should have cargo check setup" + ); +} + +#[test] +fn scaffold_project_toml_fallback_when_no_stack_detected() { + let dir = tempdir().unwrap(); + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let content = fs::read_to_string(dir.path().join(".huskies/project.toml")).unwrap(); + assert!( + content.contains("[[component]]"), + "project.toml should always have at least one component" + ); + // Fallback uses generic app component with empty setup — no Rust-specific commands + assert!( + content.contains("name = \"app\""), + "fallback should use generic 'app' component name" + ); + assert!( + !content.contains("cargo"), + "fallback must not contain Rust-specific commands for non-Rust projects" + ); +} + +#[test] +fn scaffold_does_not_overwrite_existing_project_toml_with_components() { + let dir = tempdir().unwrap(); + let sk_dir = dir.path().join(".huskies"); + fs::create_dir_all(&sk_dir).unwrap(); + let existing = "[[component]]\nname = \"custom\"\npath = \".\"\nsetup = [\"make build\"]\n"; + fs::write(sk_dir.join("project.toml"), existing).unwrap(); + + scaffold_story_kit(dir.path(), 3001).unwrap(); + + let content = fs::read_to_string(sk_dir.join("project.toml")).unwrap(); + assert_eq!( + content, existing, + "scaffold should not overwrite existing project.toml" + ); +}