huskies: merge 891
This commit is contained in:
@@ -103,11 +103,11 @@ mod tests {
|
|||||||
// Confirm the stale claim is in place.
|
// Confirm the stale claim is in place.
|
||||||
let before = read_item(story_id).expect("item should exist");
|
let before = read_item(story_id).expect("item should exist");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
before.claimed_by.as_deref(),
|
before.claimed_by(),
|
||||||
Some(stale_holder),
|
Some(stale_holder),
|
||||||
"pre-condition: item should be claimed by the stale holder"
|
"pre-condition: item should be claimed by the stale holder"
|
||||||
);
|
);
|
||||||
let age = chrono::Utc::now().timestamp() as f64 - before.claimed_at.unwrap_or(0.0);
|
let age = chrono::Utc::now().timestamp() as f64 - before.claimed_at().unwrap_or(0.0);
|
||||||
assert!(
|
assert!(
|
||||||
age >= CLAIM_TIMEOUT_SECS,
|
age >= CLAIM_TIMEOUT_SECS,
|
||||||
"pre-condition: claim age ({age}s) must exceed TTL ({CLAIM_TIMEOUT_SECS}s)"
|
"pre-condition: claim age ({age}s) must exceed TTL ({CLAIM_TIMEOUT_SECS}s)"
|
||||||
@@ -134,12 +134,12 @@ mod tests {
|
|||||||
let our_id = our_node_id().expect("node id should be available after init_for_test");
|
let our_id = our_node_id().expect("node id should be available after init_for_test");
|
||||||
let after = read_item(story_id).expect("item should still exist");
|
let after = read_item(story_id).expect("item should still exist");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
after.claimed_by.as_deref(),
|
after.claimed_by(),
|
||||||
Some(our_id.as_str()),
|
Some(our_id.as_str()),
|
||||||
"new claim should have displaced the stale holder"
|
"new claim should have displaced the stale holder"
|
||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
after.claimed_by.as_deref(),
|
after.claimed_by(),
|
||||||
Some(stale_holder),
|
Some(stale_holder),
|
||||||
"stale holder must no longer own the claim"
|
"stale holder must no longer own the claim"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,27 +42,28 @@ pub(super) async fn scan_and_claim(
|
|||||||
|
|
||||||
for item in &items {
|
for item in &items {
|
||||||
// Only claim stories in active stages.
|
// Only claim stories in active stages.
|
||||||
if !crate::pipeline_state::Stage::from_dir(&item.stage).is_some_and(|s| s.is_active()) {
|
if !crate::pipeline_state::Stage::from_dir(item.stage_str()).is_some_and(|s| s.is_active())
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip blocked stories.
|
// Skip blocked stories.
|
||||||
if item.blocked == Some(true) {
|
if item.blocked() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If already claimed by us, skip.
|
// If already claimed by us, skip.
|
||||||
if item.claimed_by.as_deref() == Some(&our_node) {
|
if item.claimed_by() == Some(our_node.as_str()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If claimed by another node, respect the claim while it is fresh.
|
// If claimed by another node, respect the claim while it is fresh.
|
||||||
// Once the TTL expires the claim is considered stale regardless of
|
// Once the TTL expires the claim is considered stale regardless of
|
||||||
// whether the holder appears alive — displacement is purely TTL-driven.
|
// whether the holder appears alive — displacement is purely TTL-driven.
|
||||||
if let Some(ref claimer) = item.claimed_by
|
if let Some(claimer) = item.claimed_by()
|
||||||
&& !claimer.is_empty()
|
&& !claimer.is_empty()
|
||||||
&& claimer != &our_node
|
&& claimer != our_node.as_str()
|
||||||
&& let Some(claimed_at) = item.claimed_at
|
&& let Some(claimed_at) = item.claimed_at()
|
||||||
{
|
{
|
||||||
let now = chrono::Utc::now().timestamp() as f64;
|
let now = chrono::Utc::now().timestamp() as f64;
|
||||||
let age = now - claimed_at;
|
let age = now - claimed_at;
|
||||||
@@ -74,7 +75,7 @@ pub(super) async fn scan_and_claim(
|
|||||||
slog!(
|
slog!(
|
||||||
"[agent-mode] Displacing stale claim on '{}' held by {:.12}… \
|
"[agent-mode] Displacing stale claim on '{}' held by {:.12}… \
|
||||||
(age {}s > TTL {}s)",
|
(age {}s > TTL {}s)",
|
||||||
item.story_id,
|
item.story_id(),
|
||||||
claimer,
|
claimer,
|
||||||
age as u64,
|
age as u64,
|
||||||
CLAIM_TIMEOUT_SECS as u64,
|
CLAIM_TIMEOUT_SECS as u64,
|
||||||
@@ -97,10 +98,10 @@ pub(super) async fn scan_and_claim(
|
|||||||
})
|
})
|
||||||
.map(|n| n.node_id)
|
.map(|n| n.node_id)
|
||||||
.collect();
|
.collect();
|
||||||
if !should_self_claim(&our_node, &item.story_id, &alive_peers) {
|
if !should_self_claim(&our_node, item.story_id(), &alive_peers) {
|
||||||
slog!(
|
slog!(
|
||||||
"[agent-mode] Hash tie-break: deferring claim on '{}' to lower-hash peer",
|
"[agent-mode] Hash tie-break: deferring claim on '{}' to lower-hash peer",
|
||||||
item.story_id
|
item.story_id()
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -108,11 +109,11 @@ pub(super) async fn scan_and_claim(
|
|||||||
// Try to claim this story.
|
// Try to claim this story.
|
||||||
slog!(
|
slog!(
|
||||||
"[agent-mode] Claiming story '{}' for this node",
|
"[agent-mode] Claiming story '{}' for this node",
|
||||||
item.story_id
|
item.story_id()
|
||||||
);
|
);
|
||||||
if crdt_state::write_claim(&item.story_id) {
|
if crdt_state::write_claim(item.story_id()) {
|
||||||
let now = chrono::Utc::now().timestamp() as f64;
|
let now = chrono::Utc::now().timestamp() as f64;
|
||||||
our_claims.insert(item.story_id.clone(), now);
|
our_claims.insert(item.story_id().to_string(), now);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,27 +166,28 @@ pub(super) fn reclaim_timed_out_work(_project_root: &Path) {
|
|||||||
let now = chrono::Utc::now().timestamp() as f64;
|
let now = chrono::Utc::now().timestamp() as f64;
|
||||||
|
|
||||||
for item in &items {
|
for item in &items {
|
||||||
if !crate::pipeline_state::Stage::from_dir(&item.stage).is_some_and(|s| s.is_active()) {
|
if !crate::pipeline_state::Stage::from_dir(item.stage_str()).is_some_and(|s| s.is_active())
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release the claim if the TTL has expired — regardless of whether the
|
// Release the claim if the TTL has expired — regardless of whether the
|
||||||
// holder is still alive. A node actively working should refresh its
|
// holder is still alive. A node actively working should refresh its
|
||||||
// claim before the TTL window closes.
|
// claim before the TTL window closes.
|
||||||
if let Some(ref claimer) = item.claimed_by {
|
if let Some(claimer) = item.claimed_by() {
|
||||||
if claimer.is_empty() {
|
if claimer.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(claimed_at) = item.claimed_at
|
if let Some(claimed_at) = item.claimed_at()
|
||||||
&& now - claimed_at >= CLAIM_TIMEOUT_SECS
|
&& now - claimed_at >= CLAIM_TIMEOUT_SECS
|
||||||
{
|
{
|
||||||
slog!(
|
slog!(
|
||||||
"[agent-mode] Releasing stale claim on '{}' held by {:.12}… (age {}s)",
|
"[agent-mode] Releasing stale claim on '{}' held by {:.12}… (age {}s)",
|
||||||
item.story_id,
|
item.story_id(),
|
||||||
claimer,
|
claimer,
|
||||||
(now - claimed_at) as u64,
|
(now - claimed_at) as u64,
|
||||||
);
|
);
|
||||||
crdt_state::release_claim(&item.story_id);
|
crdt_state::release_claim(item.story_id());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ pub(super) fn read_story_front_matter_agent(
|
|||||||
story_id: &str,
|
story_id: &str,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
if let Some(view) = crate::crdt_state::read_item(story_id)
|
if let Some(view) = crate::crdt_state::read_item(story_id)
|
||||||
&& let Some(agent) = view.agent.as_ref()
|
&& let Some(agent) = view.agent()
|
||||||
&& !agent.is_empty()
|
&& !agent.is_empty()
|
||||||
{
|
{
|
||||||
return Some(agent.clone());
|
return Some(agent.to_string());
|
||||||
}
|
}
|
||||||
use crate::db::yaml_legacy::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
let contents = read_story_contents(project_root, story_id)?;
|
let contents = read_story_contents(project_root, story_id)?;
|
||||||
@@ -101,7 +101,7 @@ pub(super) fn has_mergemaster_attempted(
|
|||||||
story_id: &str,
|
story_id: &str,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
crate::crdt_state::read_item(story_id)
|
crate::crdt_state::read_item(story_id)
|
||||||
.and_then(|view| view.mergemaster_attempted)
|
.map(|view| view.mergemaster_attempted())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -274,9 +274,10 @@ max_turns = 10
|
|||||||
let item = crate::crdt_state::read_item(story_id)
|
let item = crate::crdt_state::read_item(story_id)
|
||||||
.expect("story must be in CRDT after watchdog termination");
|
.expect("story must be in CRDT after watchdog termination");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
item.stage, "2_blocked",
|
item.stage_str(),
|
||||||
|
"2_blocked",
|
||||||
"story stage must be 2_blocked after limit termination with max_retries=1 — got: {}",
|
"story stage must be 2_blocked after limit termination with max_retries=1 — got: {}",
|
||||||
item.stage
|
item.stage_str()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sanity: the agent itself is also Failed with the right reason.
|
// Sanity: the agent itself is also Failed with the right reason.
|
||||||
@@ -415,7 +416,8 @@ max_turns = 10
|
|||||||
let item = crate::crdt_state::read_item(story_id)
|
let item = crate::crdt_state::read_item(story_id)
|
||||||
.expect("story must be in CRDT after per-session overrun");
|
.expect("story must be in CRDT after per-session overrun");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
item.stage, "2_blocked",
|
item.stage_str(),
|
||||||
|
"2_blocked",
|
||||||
"story stage must be 2_blocked after per-session overrun with max_retries=1"
|
"story stage must be 2_blocked after per-session overrun with max_retries=1"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -471,12 +473,12 @@ max_turns = 10
|
|||||||
|
|
||||||
let item = crate::crdt_state::read_item(story_id).expect("story must be in CRDT");
|
let item = crate::crdt_state::read_item(story_id).expect("story must be in CRDT");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
item.retry_count,
|
item.retry_count(),
|
||||||
Some(1),
|
1,
|
||||||
"after session 1, retry_count should be 1 in CRDT"
|
"after session 1, retry_count should be 1 in CRDT"
|
||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
item.stage.as_str(),
|
item.stage_str(),
|
||||||
"2_blocked",
|
"2_blocked",
|
||||||
"story should NOT be blocked after session 1"
|
"story should NOT be blocked after session 1"
|
||||||
);
|
);
|
||||||
@@ -491,12 +493,12 @@ max_turns = 10
|
|||||||
|
|
||||||
let item = crate::crdt_state::read_item(story_id).expect("story must be in CRDT");
|
let item = crate::crdt_state::read_item(story_id).expect("story must be in CRDT");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
item.retry_count,
|
item.retry_count(),
|
||||||
Some(2),
|
2,
|
||||||
"after session 2, retry_count should be 2 in CRDT"
|
"after session 2, retry_count should be 2 in CRDT"
|
||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
item.stage.as_str(),
|
item.stage_str(),
|
||||||
"2_blocked",
|
"2_blocked",
|
||||||
"story should NOT be blocked after session 2"
|
"story should NOT be blocked after session 2"
|
||||||
);
|
);
|
||||||
@@ -511,15 +513,16 @@ max_turns = 10
|
|||||||
|
|
||||||
let item = crate::crdt_state::read_item(story_id).expect("story must be in CRDT");
|
let item = crate::crdt_state::read_item(story_id).expect("story must be in CRDT");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
item.stage, "2_blocked",
|
item.stage_str(),
|
||||||
|
"2_blocked",
|
||||||
"story must be blocked after session 3 (retry_count=3 >= max_retries=3) — got: {}",
|
"story must be blocked after session 3 (retry_count=3 >= max_retries=3) — got: {}",
|
||||||
item.stage
|
item.stage_str()
|
||||||
);
|
);
|
||||||
// retry_count resets to 0 on stage transition (Bug 780) — the fact
|
// retry_count resets to 0 on stage transition (Bug 780) — the fact
|
||||||
// that the story reached 2_blocked proves the retry limit was hit.
|
// that the story reached 2_blocked proves the retry limit was hit.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
item.retry_count,
|
item.retry_count(),
|
||||||
Some(0),
|
0,
|
||||||
"retry_count should reset to 0 after stage transition to blocked"
|
"retry_count should reset to 0 after stage transition to blocked"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ pub(super) fn resolve_qa_mode_from_store(
|
|||||||
) -> crate::io::story_metadata::QaMode {
|
) -> crate::io::story_metadata::QaMode {
|
||||||
// CRDT register is the authoritative source; check it before the content store.
|
// CRDT register is the authoritative source; check it before the content store.
|
||||||
if let Some(view) = crate::crdt_state::read_item(story_id)
|
if let Some(view) = crate::crdt_state::read_item(story_id)
|
||||||
&& let Some(ref s) = view.qa_mode
|
&& let Some(s) = view.qa_mode()
|
||||||
&& let Some(mode) = crate::io::story_metadata::QaMode::from_str(s)
|
&& let Some(mode) = crate::io::story_metadata::QaMode::from_str(s)
|
||||||
{
|
{
|
||||||
return mode;
|
return mode;
|
||||||
|
|||||||
@@ -867,8 +867,8 @@ stage = "coder"
|
|||||||
let item =
|
let item =
|
||||||
crate::crdt_state::read_item("9950_story_warm_resume").expect("story must be in CRDT");
|
crate::crdt_state::read_item("9950_story_warm_resume").expect("story must be in CRDT");
|
||||||
assert!(
|
assert!(
|
||||||
item.retry_count.is_some_and(|rc| rc > 0),
|
item.retry_count() > 0,
|
||||||
"retry_count must be incremented after warm-resume: got {:?}",
|
"retry_count must be incremented after warm-resume: got {}",
|
||||||
item.retry_count
|
item.retry_count()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ fn inject_gate_failure_section(args: &mut Vec<String>, gate_output: &str) {
|
|||||||
/// prior failure context, even when session-resuming (story 881).
|
/// prior failure context, even when session-resuming (story 881).
|
||||||
pub(super) fn maybe_inject_gate_failure(args: &mut Vec<String>, story_id: &str) {
|
pub(super) fn maybe_inject_gate_failure(args: &mut Vec<String>, story_id: &str) {
|
||||||
let retry_count = crate::crdt_state::read_item(story_id)
|
let retry_count = crate::crdt_state::read_item(story_id)
|
||||||
.and_then(|item| item.retry_count)
|
.map(|item| item.retry_count())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
if retry_count > 0
|
if retry_count > 0
|
||||||
&& let Some(gate_output) = crate::db::read_content(&format!("{story_id}:gate_output"))
|
&& let Some(gate_output) = crate::db::read_content(&format!("{story_id}:gate_output"))
|
||||||
@@ -767,8 +767,7 @@ mod tests {
|
|||||||
|
|
||||||
// retry_count must remain 0 — the abort path never calls bump_retry_count.
|
// retry_count must remain 0 — the abort path never calls bump_retry_count.
|
||||||
let retry_count = crate::crdt_state::read_item(story_id)
|
let retry_count = crate::crdt_state::read_item(story_id)
|
||||||
.and_then(|item| item.retry_count)
|
.map(|item| item.retry_count())
|
||||||
.map(|r| r as u32)
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
retry_count, 0,
|
retry_count, 0,
|
||||||
|
|||||||
@@ -64,10 +64,10 @@ pub(super) fn read_front_matter_agent(story_id: &str, agent_name: Option<&str>)
|
|||||||
// to legacy YAML parsing for stories whose CRDT entry doesn't yet have
|
// to legacy YAML parsing for stories whose CRDT entry doesn't yet have
|
||||||
// the field populated.
|
// the field populated.
|
||||||
if let Some(view) = crate::crdt_state::read_item(story_id)
|
if let Some(view) = crate::crdt_state::read_item(story_id)
|
||||||
&& let Some(agent) = view.agent.as_ref()
|
&& let Some(agent) = view.agent()
|
||||||
&& !agent.is_empty()
|
&& !agent.is_empty()
|
||||||
{
|
{
|
||||||
return Some(agent.clone());
|
return Some(agent.to_string());
|
||||||
}
|
}
|
||||||
crate::db::read_content(story_id).and_then(|contents| {
|
crate::db::read_content(story_id).and_then(|contents| {
|
||||||
crate::db::yaml_legacy::parse_front_matter(&contents)
|
crate::db::yaml_legacy::parse_front_matter(&contents)
|
||||||
|
|||||||
@@ -192,8 +192,8 @@ mod tests {
|
|||||||
// CRDT register must hold the deps.
|
// CRDT register must hold the deps.
|
||||||
let view = crate::crdt_state::read_item("9910_story_foo").expect("CRDT should have story");
|
let view = crate::crdt_state::read_item("9910_story_foo").expect("CRDT should have story");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
view.depends_on,
|
view.depends_on(),
|
||||||
Some(vec![477, 478]),
|
&[477, 478],
|
||||||
"CRDT register should hold [477, 478]: {view:?}"
|
"CRDT register should hold [477, 478]: {view:?}"
|
||||||
);
|
);
|
||||||
// Content store YAML must NOT be mutated with depends_on.
|
// Content store YAML must NOT be mutated with depends_on.
|
||||||
@@ -223,8 +223,8 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// CRDT register must be empty after clear.
|
// CRDT register must be empty after clear.
|
||||||
let view = crate::crdt_state::read_item("9911_story_bar").expect("CRDT should have story");
|
let view = crate::crdt_state::read_item("9911_story_bar").expect("CRDT should have story");
|
||||||
assert_eq!(
|
assert!(
|
||||||
view.depends_on, None,
|
view.depends_on().is_empty(),
|
||||||
"CRDT register should be empty after clearing: {view:?}"
|
"CRDT register should be empty after clearing: {view:?}"
|
||||||
);
|
);
|
||||||
// Content store YAML must not be mutated.
|
// Content store YAML must not be mutated.
|
||||||
@@ -260,8 +260,8 @@ mod tests {
|
|||||||
let view =
|
let view =
|
||||||
crate::crdt_state::read_item("8790_story_chat_dep").expect("CRDT must have chat story");
|
crate::crdt_state::read_item("8790_story_chat_dep").expect("CRDT must have chat story");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
view.depends_on,
|
view.depends_on(),
|
||||||
Some(vec![500, 501]),
|
&[500, 501],
|
||||||
"CRDT must hold [500, 501]: {view:?}"
|
"CRDT must hold [500, 501]: {view:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -290,8 +290,8 @@ mod tests {
|
|||||||
assert!(out.contains("1"), "response should mention dep 1: {out}");
|
assert!(out.contains("1"), "response should mention dep 1: {out}");
|
||||||
let view = crate::crdt_state::read_item("9920_story_scr").expect("CRDT must have story");
|
let view = crate::crdt_state::read_item("9920_story_scr").expect("CRDT must have story");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
view.depends_on,
|
view.depends_on(),
|
||||||
Some(vec![1, 2, 3]),
|
&[1, 2, 3],
|
||||||
"CRDT should hold [1,2,3]: {view:?}"
|
"CRDT should hold [1,2,3]: {view:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -299,9 +299,9 @@ mod tests {
|
|||||||
let out = depends_cmd_with_root(tmp.path(), "9920").unwrap();
|
let out = depends_cmd_with_root(tmp.path(), "9920").unwrap();
|
||||||
assert!(out.contains("Cleared"), "clear should confirm: {out}");
|
assert!(out.contains("Cleared"), "clear should confirm: {out}");
|
||||||
let view = crate::crdt_state::read_item("9920_story_scr").expect("CRDT must have story");
|
let view = crate::crdt_state::read_item("9920_story_scr").expect("CRDT must have story");
|
||||||
assert_eq!(
|
assert!(
|
||||||
view.depends_on, None,
|
view.depends_on().is_empty(),
|
||||||
"CRDT should be None after clear: {view:?}"
|
"CRDT should be empty after clear: {view:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Replace with [4, 5] — must not append to old list.
|
// Replace with [4, 5] — must not append to old list.
|
||||||
@@ -309,8 +309,8 @@ mod tests {
|
|||||||
assert!(out.contains("4"), "response should mention dep 4: {out}");
|
assert!(out.contains("4"), "response should mention dep 4: {out}");
|
||||||
let view = crate::crdt_state::read_item("9920_story_scr").expect("CRDT must have story");
|
let view = crate::crdt_state::read_item("9920_story_scr").expect("CRDT must have story");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
view.depends_on,
|
view.depends_on(),
|
||||||
Some(vec![4, 5]),
|
&[4, 5],
|
||||||
"CRDT should hold exactly [4,5] after replace: {view:?}"
|
"CRDT should hold exactly [4,5] after replace: {view:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ fn unblock_by_story_id(story_id: &str) -> String {
|
|||||||
let crdt_item = crate::crdt_state::read_item(story_id);
|
let crdt_item = crate::crdt_state::read_item(story_id);
|
||||||
let story_name = crdt_item
|
let story_name = crdt_item
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|i| i.name.clone())
|
.and_then(|i| i.name().map(str::to_string))
|
||||||
.unwrap_or_else(|| story_id.to_string());
|
.unwrap_or_else(|| story_id.to_string());
|
||||||
|
|
||||||
// Canonical "is this story blocked?" comes from the typed pipeline state.
|
// Canonical "is this story blocked?" comes from the typed pipeline state.
|
||||||
@@ -69,7 +69,7 @@ fn unblock_by_story_id(story_id: &str) -> String {
|
|||||||
Some(crate::pipeline_state::Stage::MergeFailure { .. })
|
Some(crate::pipeline_state::Stage::MergeFailure { .. })
|
||||||
);
|
);
|
||||||
// CRDT register fallback for items not yet projected into typed state.
|
// CRDT register fallback for items not yet projected into typed state.
|
||||||
let crdt_blocked = crdt_item.as_ref().and_then(|i| i.blocked).unwrap_or(false);
|
let crdt_blocked = crdt_item.as_ref().is_some_and(|i| i.blocked());
|
||||||
|
|
||||||
if !typed_blocked && !crdt_blocked {
|
if !typed_blocked && !crdt_blocked {
|
||||||
return format!("**{story_name}** ({story_id}) is not blocked. Nothing to unblock.");
|
return format!("**{story_name}** ({story_id}) is not blocked. Nothing to unblock.");
|
||||||
@@ -271,8 +271,8 @@ mod tests {
|
|||||||
let item = crate::crdt_state::read_item("9903_story_stuck")
|
let item = crate::crdt_state::read_item("9903_story_stuck")
|
||||||
.expect("story should be in CRDT after unblock");
|
.expect("story should be in CRDT after unblock");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
item.retry_count,
|
item.retry_count(),
|
||||||
Some(0),
|
0,
|
||||||
"retry_count should be reset to 0 in CRDT after unblock"
|
"retry_count should be reset to 0 in CRDT after unblock"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -334,14 +334,13 @@ mod tests {
|
|||||||
let item = crate::crdt_state::read_item(story_id)
|
let item = crate::crdt_state::read_item(story_id)
|
||||||
.expect("story should still be in CRDT after unblock");
|
.expect("story should still be in CRDT after unblock");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
item.retry_count,
|
item.retry_count(),
|
||||||
Some(0),
|
0,
|
||||||
"retry_count must be reset to 0 in CRDT after unblock"
|
"retry_count must be reset to 0 in CRDT after unblock"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!item.blocked.unwrap_or(false),
|
!item.blocked(),
|
||||||
"blocked flag must be cleared in CRDT after unblock: {:?}",
|
"blocked flag must be cleared in CRDT after unblock"
|
||||||
item.blocked
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,14 +32,19 @@ pub(crate) fn find_story_by_number(
|
|||||||
// initialised (e.g. in unit tests or very early startup).
|
// initialised (e.g. in unit tests or very early startup).
|
||||||
if let Some(items) = crate::crdt_state::read_all_items() {
|
if let Some(items) = crate::crdt_state::read_all_items() {
|
||||||
for item in items {
|
for item in items {
|
||||||
if item.story_id.split('_').next().unwrap_or("") == number {
|
if item.story_id().split('_').next().unwrap_or("") == number {
|
||||||
let path = project_root
|
let path = project_root
|
||||||
.join(".huskies")
|
.join(".huskies")
|
||||||
.join("work")
|
.join("work")
|
||||||
.join(&item.stage)
|
.join(item.stage_str())
|
||||||
.join(format!("{}.md", item.story_id));
|
.join(format!("{}.md", item.story_id()));
|
||||||
let content = crate::db::read_content(&item.story_id);
|
let content = crate::db::read_content(item.story_id());
|
||||||
return Some((item.story_id, item.stage, path, content));
|
return Some((
|
||||||
|
item.story_id().to_string(),
|
||||||
|
item.stage_str().to_string(),
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +58,7 @@ pub(crate) fn find_story_by_number(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let stage_dir = crate::crdt_state::read_item(&id)
|
let stage_dir = crate::crdt_state::read_item(&id)
|
||||||
.map(|v| v.stage)
|
.map(|v| v.stage_str().to_string())
|
||||||
.unwrap_or_else(|| "1_backlog".to_string());
|
.unwrap_or_else(|| "1_backlog".to_string());
|
||||||
let path = project_root
|
let path = project_root
|
||||||
.join(".huskies")
|
.join(".huskies")
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ pub use state::init;
|
|||||||
pub use types::{
|
pub use types::{
|
||||||
ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, CrdtEvent,
|
ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, CrdtEvent,
|
||||||
GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, MergeJobCrdt, MergeJobView,
|
GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, MergeJobCrdt, MergeJobView,
|
||||||
NodePresenceCrdt, NodePresenceView, PipelineDoc, PipelineItemCrdt, PipelineItemView,
|
NodePresenceCrdt, NodePresenceView, PipelineDoc, PipelineItemCrdt, PipelineItemView, Stage,
|
||||||
TestJobCrdt, TestJobView, TokenUsageCrdt, TokenUsageView, subscribe,
|
TestJobCrdt, TestJobView, TokenUsageCrdt, TokenUsageView, WorkItem, subscribe,
|
||||||
};
|
};
|
||||||
pub use write::{
|
pub use write::{
|
||||||
bump_retry_count, migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id,
|
bump_retry_count, migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id,
|
||||||
|
|||||||
@@ -406,10 +406,10 @@ pub fn check_unmet_deps_crdt(story_id: &str) -> Vec<u32> {
|
|||||||
Some(i) => i,
|
Some(i) => i,
|
||||||
None => return Vec::new(),
|
None => return Vec::new(),
|
||||||
};
|
};
|
||||||
let deps = match item.depends_on {
|
let deps = item.depends_on().to_vec();
|
||||||
Some(d) => d,
|
if deps.is_empty() {
|
||||||
None => return Vec::new(),
|
return Vec::new();
|
||||||
};
|
}
|
||||||
deps.into_iter()
|
deps.into_iter()
|
||||||
.filter(|&dep| !dep_is_done_crdt(dep))
|
.filter(|&dep| !dep_is_done_crdt(dep))
|
||||||
.collect()
|
.collect()
|
||||||
@@ -425,10 +425,10 @@ pub fn check_archived_deps_crdt(story_id: &str) -> Vec<u32> {
|
|||||||
Some(i) => i,
|
Some(i) => i,
|
||||||
None => return Vec::new(),
|
None => return Vec::new(),
|
||||||
};
|
};
|
||||||
let deps = match item.depends_on {
|
let deps = item.depends_on().to_vec();
|
||||||
Some(d) => d,
|
if deps.is_empty() {
|
||||||
None => return Vec::new(),
|
return Vec::new();
|
||||||
};
|
}
|
||||||
deps.into_iter()
|
deps.into_iter()
|
||||||
.filter(|&dep| dep_is_archived_crdt(dep))
|
.filter(|&dep| dep_is_archived_crdt(dep))
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
+201
-23
@@ -112,31 +112,209 @@ pub struct NodePresenceCrdt {
|
|||||||
|
|
||||||
// ── Read-side view types ─────────────────────────────────────────────
|
// ── Read-side view types ─────────────────────────────────────────────
|
||||||
|
|
||||||
/// A snapshot of a single pipeline item derived from the CRDT document.
|
/// Pipeline stage inferred from the CRDT `stage` register.
|
||||||
#[derive(Clone, Debug)]
|
///
|
||||||
pub struct PipelineItemView {
|
/// This is the low-level typed stage for [`WorkItem`] accessors. For rich
|
||||||
pub story_id: String,
|
/// transition metadata (merge commits, timestamps, etc.) project via
|
||||||
pub stage: String,
|
/// `pipeline_state::Stage` instead.
|
||||||
pub name: Option<String>,
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub agent: Option<String>,
|
pub enum Stage {
|
||||||
pub retry_count: Option<i64>,
|
/// Story created but not yet triaged (`0_upcoming`).
|
||||||
pub blocked: Option<bool>,
|
Upcoming,
|
||||||
pub depends_on: Option<Vec<u32>>,
|
/// Waiting for dependencies or auto-assign (`1_backlog`).
|
||||||
/// Node ID of the node that claimed this item (hex-encoded Ed25519 pubkey).
|
Backlog,
|
||||||
pub claimed_by: Option<String>,
|
/// Actively being coded (`2_current`).
|
||||||
/// Unix timestamp when the item was claimed.
|
Coding,
|
||||||
pub claimed_at: Option<f64>,
|
/// Blocked awaiting human resolution (`2_blocked`).
|
||||||
/// Unix timestamp (seconds) when the item was merged to master.
|
Blocked,
|
||||||
/// `None` for items that were never in `5_done` or for legacy items.
|
/// Coder done; gates running (`3_qa`).
|
||||||
pub merged_at: Option<f64>,
|
Qa,
|
||||||
/// QA mode override from the CRDT register: `"server"`, `"agent"`, or `"human"`.
|
/// Gates passed; ready to merge (`4_merge`).
|
||||||
/// `None` means the register is unset (use project default).
|
Merge,
|
||||||
pub qa_mode: Option<String>,
|
/// Merge failed; awaiting intervention (`4_merge_failure`).
|
||||||
/// Whether the auto-assigner has already spawned a mergemaster session for
|
MergeFailure,
|
||||||
/// this item. `None` means the register has never been set (treat as false).
|
/// Merged to master (`5_done`).
|
||||||
pub mergemaster_attempted: Option<bool>,
|
Done,
|
||||||
|
/// Out of the active flow (`6_archived`).
|
||||||
|
Archived,
|
||||||
|
/// Frozen, awaiting human review (`7_frozen`).
|
||||||
|
Frozen,
|
||||||
|
/// An unrecognised stage string — forward-compatible catch-all.
|
||||||
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Stage {
|
||||||
|
/// Parse a stage directory string into the typed enum.
|
||||||
|
pub fn from_dir(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"0_upcoming" => Stage::Upcoming,
|
||||||
|
"1_backlog" => Stage::Backlog,
|
||||||
|
"2_current" => Stage::Coding,
|
||||||
|
"2_blocked" => Stage::Blocked,
|
||||||
|
"3_qa" => Stage::Qa,
|
||||||
|
"4_merge" => Stage::Merge,
|
||||||
|
"4_merge_failure" => Stage::MergeFailure,
|
||||||
|
"5_done" => Stage::Done,
|
||||||
|
"6_archived" => Stage::Archived,
|
||||||
|
"7_frozen" => Stage::Frozen,
|
||||||
|
other => Stage::Unknown(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert back to the filesystem directory name string.
|
||||||
|
pub fn as_dir(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Stage::Upcoming => "0_upcoming",
|
||||||
|
Stage::Backlog => "1_backlog",
|
||||||
|
Stage::Coding => "2_current",
|
||||||
|
Stage::Blocked => "2_blocked",
|
||||||
|
Stage::Qa => "3_qa",
|
||||||
|
Stage::Merge => "4_merge",
|
||||||
|
Stage::MergeFailure => "4_merge_failure",
|
||||||
|
Stage::Done => "5_done",
|
||||||
|
Stage::Archived => "6_archived",
|
||||||
|
Stage::Frozen => "7_frozen",
|
||||||
|
Stage::Unknown(s) => s.as_str(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A typed snapshot of a single pipeline work item derived from the CRDT document.
|
||||||
|
///
|
||||||
|
/// Access fields exclusively through the typed accessor methods — raw field access is
|
||||||
|
/// restricted to the `crdt_state` module tree. All `JsonValue` interpretation is
|
||||||
|
/// confined to `crdt_state::read::extract_item_view`, so no `JsonValue` escapes into
|
||||||
|
/// the public API.
|
||||||
|
///
|
||||||
|
/// Adding a new field here without also reading it in an accessor produces an
|
||||||
|
/// `unused field` compiler warning, enforcing the read-side contract at compile time.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct WorkItem {
|
||||||
|
pub(super) story_id: String,
|
||||||
|
pub(super) stage: String,
|
||||||
|
pub(super) name: Option<String>,
|
||||||
|
pub(super) agent: Option<String>,
|
||||||
|
pub(super) retry_count: Option<i64>,
|
||||||
|
pub(super) blocked: Option<bool>,
|
||||||
|
pub(super) depends_on: Option<Vec<u32>>,
|
||||||
|
/// Node ID of the node that claimed this item (hex-encoded Ed25519 pubkey).
|
||||||
|
pub(super) claimed_by: Option<String>,
|
||||||
|
/// Unix timestamp (seconds) when the claim was written.
|
||||||
|
pub(super) claimed_at: Option<f64>,
|
||||||
|
/// Unix timestamp (seconds) when the item was merged to master.
|
||||||
|
pub(super) merged_at: Option<f64>,
|
||||||
|
/// QA mode override: `"server"`, `"agent"`, or `"human"`.
|
||||||
|
pub(super) qa_mode: Option<String>,
|
||||||
|
/// Whether the auto-assigner has already attempted a mergemaster spawn.
|
||||||
|
pub(super) mergemaster_attempted: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkItem {
|
||||||
|
/// The story identifier (e.g. `"42"` or `"42_story_my_feature"`).
|
||||||
|
pub fn story_id(&self) -> &str {
|
||||||
|
&self.story_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pipeline stage as a typed enum.
|
||||||
|
pub fn stage(&self) -> Stage {
|
||||||
|
Stage::from_dir(&self.stage)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw stage directory string (e.g. `"2_current"`).
|
||||||
|
pub fn stage_str(&self) -> &str {
|
||||||
|
&self.stage
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable story name, or `None` when unset.
|
||||||
|
pub fn name(&self) -> Option<&str> {
|
||||||
|
self.name.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agent name pinned to this item, or `None` when unset.
|
||||||
|
pub fn agent(&self) -> Option<&str> {
|
||||||
|
self.agent.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the item is blocked. Returns `false` when the register is unset.
|
||||||
|
pub fn blocked(&self) -> bool {
|
||||||
|
self.blocked.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retry counter. Returns `0` when the register is unset.
|
||||||
|
pub fn retry_count(&self) -> u32 {
|
||||||
|
self.retry_count.unwrap_or(0).max(0) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dependency story numbers. Returns an empty slice when unset.
|
||||||
|
pub fn depends_on(&self) -> &[u32] {
|
||||||
|
self.depends_on.as_deref().unwrap_or(&[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Node ID of the current claim holder, or `None` when unclaimed.
|
||||||
|
pub fn claimed_by(&self) -> Option<&str> {
|
||||||
|
self.claimed_by.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unix timestamp (seconds) when the current claim was written, or `None`.
|
||||||
|
pub fn claimed_at(&self) -> Option<f64> {
|
||||||
|
self.claimed_at
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unix timestamp (seconds) when the item was merged to master, or `None`.
|
||||||
|
pub fn merged_at(&self) -> Option<f64> {
|
||||||
|
self.merged_at
|
||||||
|
}
|
||||||
|
|
||||||
|
/// QA mode override (`"server"`, `"agent"`, or `"human"`), or `None` when unset.
|
||||||
|
pub fn qa_mode(&self) -> Option<&str> {
|
||||||
|
self.qa_mode.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a mergemaster spawn has already been attempted. Returns `false` when unset.
|
||||||
|
pub fn mergemaster_attempted(&self) -> bool {
|
||||||
|
self.mergemaster_attempted.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct a `WorkItem` for use in tests outside `crdt_state::*`.
|
||||||
|
///
|
||||||
|
/// Within `crdt_state` use a struct literal directly (fields are `pub(super)`).
|
||||||
|
/// Each field must be supplied — adding a new field to `WorkItem` without updating
|
||||||
|
/// this constructor produces a compile error, enforcing the read-side contract.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn for_test(
|
||||||
|
story_id: impl Into<String>,
|
||||||
|
stage: impl Into<String>,
|
||||||
|
name: Option<String>,
|
||||||
|
agent: Option<String>,
|
||||||
|
retry_count: Option<i64>,
|
||||||
|
blocked: Option<bool>,
|
||||||
|
depends_on: Option<Vec<u32>>,
|
||||||
|
claimed_by: Option<String>,
|
||||||
|
claimed_at: Option<f64>,
|
||||||
|
merged_at: Option<f64>,
|
||||||
|
qa_mode: Option<String>,
|
||||||
|
mergemaster_attempted: Option<bool>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
story_id: story_id.into(),
|
||||||
|
stage: stage.into(),
|
||||||
|
name,
|
||||||
|
agent,
|
||||||
|
retry_count,
|
||||||
|
blocked,
|
||||||
|
depends_on,
|
||||||
|
claimed_by,
|
||||||
|
claimed_at,
|
||||||
|
merged_at,
|
||||||
|
qa_mode,
|
||||||
|
mergemaster_attempted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backward-compatibility alias; prefer [`WorkItem`].
|
||||||
|
pub type PipelineItemView = WorkItem;
|
||||||
|
|
||||||
/// A snapshot of a single node presence entry derived from the CRDT document.
|
/// A snapshot of a single node presence entry derived from the CRDT document.
|
||||||
#[derive(Clone, Debug, serde::Serialize)]
|
#[derive(Clone, Debug, serde::Serialize)]
|
||||||
pub struct NodePresenceView {
|
pub struct NodePresenceView {
|
||||||
|
|||||||
+11
-9
@@ -346,12 +346,12 @@ mod tests {
|
|||||||
write_item_with_content(story_id, "2_current", content, meta);
|
write_item_with_content(story_id, "2_current", content, meta);
|
||||||
|
|
||||||
let view = crate::crdt_state::read_item(story_id).expect("story exists in CRDT");
|
let view = crate::crdt_state::read_item(story_id).expect("story exists in CRDT");
|
||||||
assert_eq!(view.stage, "2_current");
|
assert_eq!(view.stage_str(), "2_current");
|
||||||
assert_eq!(view.name.as_deref(), Some("Typed Name"));
|
assert_eq!(view.name(), Some("Typed Name"));
|
||||||
assert_eq!(view.agent.as_deref(), Some("coder-1"));
|
assert_eq!(view.agent(), Some("coder-1"));
|
||||||
assert_eq!(view.retry_count, Some(2));
|
assert_eq!(view.retry_count(), 2);
|
||||||
assert_eq!(view.blocked, Some(true));
|
assert!(view.blocked());
|
||||||
assert_eq!(view.depends_on, Some(vec![100, 200]));
|
assert_eq!(view.depends_on(), &[100, 200]);
|
||||||
|
|
||||||
// Content is stored verbatim (no parsing, no rewrite).
|
// Content is stored verbatim (no parsing, no rewrite).
|
||||||
assert_eq!(read_content(story_id).as_deref(), Some(content));
|
assert_eq!(read_content(story_id).as_deref(), Some(content));
|
||||||
@@ -371,13 +371,15 @@ mod tests {
|
|||||||
write_item_with_content(story_id, "2_current", content, ItemMeta::default());
|
write_item_with_content(story_id, "2_current", content, ItemMeta::default());
|
||||||
|
|
||||||
let view = crate::crdt_state::read_item(story_id).expect("story exists in CRDT");
|
let view = crate::crdt_state::read_item(story_id).expect("story exists in CRDT");
|
||||||
assert_eq!(view.stage, "2_current");
|
assert_eq!(view.stage_str(), "2_current");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
view.name, None,
|
view.name(),
|
||||||
|
None,
|
||||||
"name must come from typed meta, not parsed YAML"
|
"name must come from typed meta, not parsed YAML"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
view.agent, None,
|
view.agent(),
|
||||||
|
None,
|
||||||
"agent must come from typed meta, not parsed YAML"
|
"agent must come from typed meta, not parsed YAML"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ pub(super) async fn tool_merge_agent_work(
|
|||||||
// Check CRDT stage before attempting merge — if already done or archived,
|
// Check CRDT stage before attempting merge — if already done or archived,
|
||||||
// return success immediately to avoid spurious error notifications.
|
// return success immediately to avoid spurious error notifications.
|
||||||
if let Some(item) = crate::crdt_state::read_item(story_id)
|
if let Some(item) = crate::crdt_state::read_item(story_id)
|
||||||
&& crate::pipeline_state::Stage::from_dir(&item.stage).is_some_and(|s| {
|
&& crate::pipeline_state::Stage::from_dir(item.stage_str()).is_some_and(|s| {
|
||||||
matches!(
|
matches!(
|
||||||
s,
|
s,
|
||||||
crate::pipeline_state::Stage::Done { .. }
|
crate::pipeline_state::Stage::Done { .. }
|
||||||
@@ -31,7 +31,7 @@ pub(super) async fn tool_merge_agent_work(
|
|||||||
"success": true,
|
"success": true,
|
||||||
"message": format!(
|
"message": format!(
|
||||||
"Story '{}' is already in '{}' — no merge needed.",
|
"Story '{}' is already in '{}' — no merge needed.",
|
||||||
story_id, item.stage
|
story_id, item.stage_str()
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
.map_err(|e| format!("Serialization error: {e}"));
|
.map_err(|e| format!("Serialization error: {e}"));
|
||||||
|
|||||||
@@ -215,12 +215,12 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
|
|||||||
// pass the read_typed / 2_current check above, but the code is present
|
// pass the read_typed / 2_current check above, but the code is present
|
||||||
// for completeness and future-proofing).
|
// for completeness and future-proofing).
|
||||||
if let Some(view) = crate::crdt_state::read_item(story_id) {
|
if let Some(view) = crate::crdt_state::read_item(story_id) {
|
||||||
if let Some(cb) = &view.claimed_by
|
if let Some(cb) = view.claimed_by()
|
||||||
&& !cb.is_empty()
|
&& !cb.is_empty()
|
||||||
{
|
{
|
||||||
front_matter.insert("claimed_by".to_string(), json!(cb));
|
front_matter.insert("claimed_by".to_string(), json!(cb));
|
||||||
}
|
}
|
||||||
if let Some(ca) = view.claimed_at
|
if let Some(ca) = view.claimed_at()
|
||||||
&& ca > 0.0
|
&& ca > 0.0
|
||||||
{
|
{
|
||||||
front_matter.insert("claimed_at".to_string(), json!(ca));
|
front_matter.insert("claimed_at".to_string(), json!(ca));
|
||||||
|
|||||||
@@ -233,8 +233,8 @@ mod tests {
|
|||||||
assert!(r.is_ok(), "set [1,2,3] should succeed: {r:?}");
|
assert!(r.is_ok(), "set [1,2,3] should succeed: {r:?}");
|
||||||
let view = crate::crdt_state::read_item("888_deps_scr").expect("CRDT must have story");
|
let view = crate::crdt_state::read_item("888_deps_scr").expect("CRDT must have story");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
view.depends_on,
|
view.depends_on(),
|
||||||
Some(vec![1, 2, 3]),
|
&[1, 2, 3],
|
||||||
"CRDT should hold [1,2,3] after set"
|
"CRDT should hold [1,2,3] after set"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -245,9 +245,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(r.is_ok(), "clear [] should succeed: {r:?}");
|
assert!(r.is_ok(), "clear [] should succeed: {r:?}");
|
||||||
let view = crate::crdt_state::read_item("888_deps_scr").expect("CRDT must have story");
|
let view = crate::crdt_state::read_item("888_deps_scr").expect("CRDT must have story");
|
||||||
assert_eq!(
|
assert!(
|
||||||
view.depends_on, None,
|
view.depends_on().is_empty(),
|
||||||
"CRDT should be None after clearing to []"
|
"CRDT should be empty after clearing to []"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Replace with [4, 5] — must not append to previous [1,2,3].
|
// Replace with [4, 5] — must not append to previous [1,2,3].
|
||||||
@@ -258,8 +258,8 @@ mod tests {
|
|||||||
assert!(r.is_ok(), "replace [4,5] should succeed: {r:?}");
|
assert!(r.is_ok(), "replace [4,5] should succeed: {r:?}");
|
||||||
let view = crate::crdt_state::read_item("888_deps_scr").expect("CRDT must have story");
|
let view = crate::crdt_state::read_item("888_deps_scr").expect("CRDT must have story");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
view.depends_on,
|
view.depends_on(),
|
||||||
Some(vec![4, 5]),
|
&[4, 5],
|
||||||
"CRDT should hold exactly [4,5] after replace (not [1,2,3,4,5])"
|
"CRDT should hold exactly [4,5] after replace (not [1,2,3,4,5])"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -290,7 +290,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(r.is_ok(), "clear should succeed: {r:?}");
|
assert!(r.is_ok(), "clear should succeed: {r:?}");
|
||||||
let view = crate::crdt_state::read_item("888_deps_persist").expect("CRDT must have story");
|
let view = crate::crdt_state::read_item("888_deps_persist").expect("CRDT must have story");
|
||||||
assert_eq!(view.depends_on, None, "CRDT should be None after clear");
|
assert!(
|
||||||
|
view.depends_on().is_empty(),
|
||||||
|
"CRDT should be empty after clear"
|
||||||
|
);
|
||||||
|
|
||||||
// Now update a different field — this triggers write_story_content with
|
// Now update a different field — this triggers write_story_content with
|
||||||
// the stale YAML (which still has depends_on: [100, 200]).
|
// the stale YAML (which still has depends_on: [100, 200]).
|
||||||
@@ -300,11 +303,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(r.is_ok(), "subsequent name update should succeed: {r:?}");
|
assert!(r.is_ok(), "subsequent name update should succeed: {r:?}");
|
||||||
|
|
||||||
// The CRDT must still be None — the YAML value must not have been restored.
|
// The CRDT must still be empty — the YAML value must not have been restored.
|
||||||
let view = crate::crdt_state::read_item("888_deps_persist").expect("CRDT must have story");
|
let view = crate::crdt_state::read_item("888_deps_persist").expect("CRDT must have story");
|
||||||
assert_eq!(
|
assert!(
|
||||||
view.depends_on, None,
|
view.depends_on().is_empty(),
|
||||||
"CRDT depends_on must remain None after unrelated update (write_story_content must not restore YAML value)"
|
"CRDT depends_on must remain empty after unrelated update (write_story_content must not restore YAML value)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,8 +330,8 @@ mod tests {
|
|||||||
// CRDT register must hold the deps.
|
// CRDT register must hold the deps.
|
||||||
let view = crate::crdt_state::read_item("504_arr_test").expect("CRDT must have the story");
|
let view = crate::crdt_state::read_item("504_arr_test").expect("CRDT must have the story");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
view.depends_on,
|
view.depends_on(),
|
||||||
Some(vec![490, 491]),
|
&[490, 491],
|
||||||
"CRDT register should hold [490, 491]: {view:?}"
|
"CRDT register should hold [490, 491]: {view:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
|
|||||||
/// spikes themselves.
|
/// spikes themselves.
|
||||||
pub fn resolve_qa_mode(story_id: &str, default: QaMode) -> QaMode {
|
pub fn resolve_qa_mode(story_id: &str, default: QaMode) -> QaMode {
|
||||||
crate::crdt_state::read_item(story_id)
|
crate::crdt_state::read_item(story_id)
|
||||||
.and_then(|view| view.qa_mode)
|
.and_then(|view| view.qa_mode().map(str::to_string))
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(QaMode::from_str)
|
.and_then(QaMode::from_str)
|
||||||
.unwrap_or(default)
|
.unwrap_or(default)
|
||||||
|
|||||||
@@ -36,22 +36,22 @@ impl fmt::Display for ProjectionError {
|
|||||||
|
|
||||||
impl std::error::Error for ProjectionError {}
|
impl std::error::Error for ProjectionError {}
|
||||||
|
|
||||||
// ── Projection: PipelineItemView → PipelineItem ─────────────────────────────
|
// ── Projection: WorkItem → PipelineItem ─────────────────────────────────────
|
||||||
|
|
||||||
impl TryFrom<&PipelineItemView> for PipelineItem {
|
impl TryFrom<&PipelineItemView> for PipelineItem {
|
||||||
type Error = ProjectionError;
|
type Error = ProjectionError;
|
||||||
|
|
||||||
fn try_from(view: &PipelineItemView) -> Result<Self, ProjectionError> {
|
fn try_from(view: &PipelineItemView) -> Result<Self, ProjectionError> {
|
||||||
let story_id = StoryId(view.story_id.clone());
|
let story_id = StoryId(view.story_id().to_string());
|
||||||
let name = view.name.clone().unwrap_or_default();
|
let name = view.name().unwrap_or("").to_string();
|
||||||
|
|
||||||
let depends_on: Vec<StoryId> = view
|
let depends_on: Vec<StoryId> = view
|
||||||
.depends_on
|
.depends_on()
|
||||||
.as_ref()
|
.iter()
|
||||||
.map(|deps| deps.iter().map(|d| StoryId(d.to_string())).collect())
|
.map(|d| StoryId(d.to_string()))
|
||||||
.unwrap_or_default();
|
.collect();
|
||||||
|
|
||||||
let retry_count = view.retry_count.unwrap_or(0).max(0) as u32;
|
let retry_count = view.retry_count();
|
||||||
|
|
||||||
let stage = project_stage(view)?;
|
let stage = project_stage(view)?;
|
||||||
|
|
||||||
@@ -65,11 +65,11 @@ impl TryFrom<&PipelineItemView> for PipelineItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Project the stage string + associated fields from a PipelineItemView into
|
/// Project the stage string + associated fields from a WorkItem into
|
||||||
/// a typed Stage enum. This is the one carefully-controlled boundary where
|
/// a typed Stage enum. This is the one carefully-controlled boundary where
|
||||||
/// loose CRDT data becomes typed.
|
/// loose CRDT data becomes typed.
|
||||||
pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError> {
|
pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError> {
|
||||||
match view.stage.as_str() {
|
match view.stage_str() {
|
||||||
"0_upcoming" => Ok(Stage::Upcoming),
|
"0_upcoming" => Ok(Stage::Upcoming),
|
||||||
"1_backlog" => Ok(Stage::Backlog),
|
"1_backlog" => Ok(Stage::Backlog),
|
||||||
"2_blocked" => Ok(Stage::Blocked {
|
"2_blocked" => Ok(Stage::Blocked {
|
||||||
@@ -82,7 +82,7 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
|
|||||||
// commits_ahead — those are computed at transition time. For
|
// commits_ahead — those are computed at transition time. For
|
||||||
// projection from existing CRDT data, we synthesize defaults.
|
// projection from existing CRDT data, we synthesize defaults.
|
||||||
// The feature branch follows the naming convention.
|
// The feature branch follows the naming convention.
|
||||||
let branch = format!("feature/story-{}", view.story_id);
|
let branch = format!("feature/story-{}", view.story_id());
|
||||||
// Existing CRDT data doesn't track commits_ahead, so we use 1 as
|
// Existing CRDT data doesn't track commits_ahead, so we use 1 as
|
||||||
// a safe non-zero default (the item is in merge, so there must be
|
// a safe non-zero default (the item is in merge, so there must be
|
||||||
// at least one commit).
|
// at least one commit).
|
||||||
@@ -105,7 +105,7 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
|
|||||||
// to UNIX_EPOCH, which makes them older than any retention window
|
// to UNIX_EPOCH, which makes them older than any retention window
|
||||||
// and therefore eligible for immediate sweep to 6_archived.
|
// and therefore eligible for immediate sweep to 6_archived.
|
||||||
let merged_at = view
|
let merged_at = view
|
||||||
.merged_at
|
.merged_at()
|
||||||
.map(|ts| {
|
.map(|ts| {
|
||||||
DateTime::from_timestamp(ts as i64, 0).unwrap_or(DateTime::<Utc>::UNIX_EPOCH)
|
DateTime::from_timestamp(ts as i64, 0).unwrap_or(DateTime::<Utc>::UNIX_EPOCH)
|
||||||
})
|
})
|
||||||
@@ -117,7 +117,7 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
|
|||||||
}
|
}
|
||||||
"6_archived" => {
|
"6_archived" => {
|
||||||
// Determine the archive reason from the CRDT fields.
|
// Determine the archive reason from the CRDT fields.
|
||||||
let reason = if view.blocked == Some(true) {
|
let reason = if view.blocked() {
|
||||||
ArchiveReason::Blocked {
|
ArchiveReason::Blocked {
|
||||||
reason: "migrated from legacy blocked field".to_string(),
|
reason: "migrated from legacy blocked field".to_string(),
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
|
|||||||
"7_frozen" => {
|
"7_frozen" => {
|
||||||
// The stage to resume to is stored in front matter as `resume_to_stage`.
|
// The stage to resume to is stored in front matter as `resume_to_stage`.
|
||||||
// Fall back to Coding if the field is absent (e.g. legacy frozen items).
|
// Fall back to Coding if the field is absent (e.g. legacy frozen items).
|
||||||
let resume_to = crate::db::read_content(&view.story_id)
|
let resume_to = crate::db::read_content(view.story_id())
|
||||||
.and_then(|content| {
|
.and_then(|content| {
|
||||||
crate::db::yaml_legacy::parse_front_matter(&content)
|
crate::db::yaml_legacy::parse_front_matter(&content)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -186,7 +186,7 @@ pub fn read_all_typed() -> Vec<PipelineItem> {
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
crate::slog!(
|
crate::slog!(
|
||||||
"[pipeline_state] projection error for '{}': {e}",
|
"[pipeline_state] projection error for '{}': {e}",
|
||||||
v.story_id
|
v.story_id()
|
||||||
);
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -221,42 +221,46 @@ mod tests {
|
|||||||
StoryId(s.to_string())
|
StoryId(s.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn make_view(story_id: &str, stage: &str, name: Option<&str>) -> PipelineItemView {
|
||||||
|
PipelineItemView::for_test(
|
||||||
|
story_id,
|
||||||
|
stage,
|
||||||
|
name.map(str::to_string),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_upcoming_item() {
|
fn project_upcoming_item() {
|
||||||
let view = PipelineItemView {
|
let view = make_view("42_story_test", "0_upcoming", Some("Test Story"));
|
||||||
story_id: "42_story_test".to_string(),
|
|
||||||
stage: "0_upcoming".to_string(),
|
|
||||||
name: Some("Test Story".to_string()),
|
|
||||||
agent: None,
|
|
||||||
retry_count: None,
|
|
||||||
blocked: None,
|
|
||||||
depends_on: None,
|
|
||||||
claimed_by: None,
|
|
||||||
claimed_at: None,
|
|
||||||
merged_at: None,
|
|
||||||
qa_mode: None,
|
|
||||||
mergemaster_attempted: None,
|
|
||||||
};
|
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert!(matches!(item.stage, Stage::Upcoming));
|
assert!(matches!(item.stage, Stage::Upcoming));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_backlog_item() {
|
fn project_backlog_item() {
|
||||||
let view = PipelineItemView {
|
let view = PipelineItemView::for_test(
|
||||||
story_id: "42_story_test".to_string(),
|
"42_story_test",
|
||||||
stage: "1_backlog".to_string(),
|
"1_backlog",
|
||||||
name: Some("Test Story".to_string()),
|
Some("Test Story".to_string()),
|
||||||
agent: None,
|
None,
|
||||||
retry_count: None,
|
None,
|
||||||
blocked: None,
|
None,
|
||||||
depends_on: Some(vec![10, 20]),
|
Some(vec![10, 20]),
|
||||||
claimed_by: None,
|
None,
|
||||||
claimed_at: None,
|
None,
|
||||||
merged_at: None,
|
None,
|
||||||
qa_mode: None,
|
None,
|
||||||
mergemaster_attempted: None,
|
None,
|
||||||
};
|
);
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert_eq!(item.story_id, StoryId("42_story_test".to_string()));
|
assert_eq!(item.story_id, StoryId("42_story_test".to_string()));
|
||||||
assert_eq!(item.name, "Test Story");
|
assert_eq!(item.name, "Test Story");
|
||||||
@@ -267,20 +271,20 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_current_item() {
|
fn project_current_item() {
|
||||||
let view = PipelineItemView {
|
let view = PipelineItemView::for_test(
|
||||||
story_id: "42_story_test".to_string(),
|
"42_story_test",
|
||||||
stage: "2_current".to_string(),
|
"2_current",
|
||||||
name: Some("Test".to_string()),
|
Some("Test".to_string()),
|
||||||
agent: Some("coder-1".to_string()),
|
Some("coder-1".to_string()),
|
||||||
retry_count: Some(2),
|
Some(2),
|
||||||
blocked: None,
|
None,
|
||||||
depends_on: None,
|
None,
|
||||||
claimed_by: None,
|
None,
|
||||||
claimed_at: None,
|
None,
|
||||||
merged_at: None,
|
None,
|
||||||
qa_mode: None,
|
None,
|
||||||
mergemaster_attempted: None,
|
None,
|
||||||
};
|
);
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert!(matches!(item.stage, Stage::Coding));
|
assert!(matches!(item.stage, Stage::Coding));
|
||||||
assert_eq!(item.retry_count, 2);
|
assert_eq!(item.retry_count, 2);
|
||||||
@@ -288,20 +292,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_merge_item() {
|
fn project_merge_item() {
|
||||||
let view = PipelineItemView {
|
let view = make_view("42_story_test", "4_merge", Some("Test"));
|
||||||
story_id: "42_story_test".to_string(),
|
|
||||||
stage: "4_merge".to_string(),
|
|
||||||
name: Some("Test".to_string()),
|
|
||||||
agent: None,
|
|
||||||
retry_count: None,
|
|
||||||
blocked: None,
|
|
||||||
depends_on: None,
|
|
||||||
claimed_by: None,
|
|
||||||
claimed_at: None,
|
|
||||||
merged_at: None,
|
|
||||||
qa_mode: None,
|
|
||||||
mergemaster_attempted: None,
|
|
||||||
};
|
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert!(matches!(item.stage, Stage::Merge { .. }));
|
assert!(matches!(item.stage, Stage::Merge { .. }));
|
||||||
if let Stage::Merge {
|
if let Stage::Merge {
|
||||||
@@ -316,40 +307,27 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_blocked_item() {
|
fn project_blocked_item() {
|
||||||
let view = PipelineItemView {
|
let view = make_view("42_story_test", "2_blocked", Some("Test"));
|
||||||
story_id: "42_story_test".to_string(),
|
|
||||||
stage: "2_blocked".to_string(),
|
|
||||||
name: Some("Test".to_string()),
|
|
||||||
agent: None,
|
|
||||||
retry_count: None,
|
|
||||||
blocked: None,
|
|
||||||
depends_on: None,
|
|
||||||
claimed_by: None,
|
|
||||||
claimed_at: None,
|
|
||||||
merged_at: None,
|
|
||||||
qa_mode: None,
|
|
||||||
mergemaster_attempted: None,
|
|
||||||
};
|
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert!(matches!(item.stage, Stage::Blocked { .. }));
|
assert!(matches!(item.stage, Stage::Blocked { .. }));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_archived_blocked_item() {
|
fn project_archived_blocked_item() {
|
||||||
let view = PipelineItemView {
|
let view = PipelineItemView::for_test(
|
||||||
story_id: "42_story_test".to_string(),
|
"42_story_test",
|
||||||
stage: "6_archived".to_string(),
|
"6_archived",
|
||||||
name: Some("Test".to_string()),
|
Some("Test".to_string()),
|
||||||
agent: None,
|
None,
|
||||||
retry_count: None,
|
None,
|
||||||
blocked: Some(true),
|
Some(true),
|
||||||
depends_on: None,
|
None,
|
||||||
claimed_by: None,
|
None,
|
||||||
claimed_at: None,
|
None,
|
||||||
merged_at: None,
|
None,
|
||||||
qa_mode: None,
|
None,
|
||||||
mergemaster_attempted: None,
|
None,
|
||||||
};
|
);
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
item.stage,
|
item.stage,
|
||||||
@@ -362,20 +340,20 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_archived_completed_item() {
|
fn project_archived_completed_item() {
|
||||||
let view = PipelineItemView {
|
let view = PipelineItemView::for_test(
|
||||||
story_id: "42_story_test".to_string(),
|
"42_story_test",
|
||||||
stage: "6_archived".to_string(),
|
"6_archived",
|
||||||
name: Some("Test".to_string()),
|
Some("Test".to_string()),
|
||||||
agent: None,
|
None,
|
||||||
retry_count: None,
|
None,
|
||||||
blocked: Some(false),
|
Some(false),
|
||||||
depends_on: None,
|
None,
|
||||||
claimed_by: None,
|
None,
|
||||||
claimed_at: None,
|
None,
|
||||||
merged_at: None,
|
None,
|
||||||
qa_mode: None,
|
None,
|
||||||
mergemaster_attempted: None,
|
None,
|
||||||
};
|
);
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
item.stage,
|
item.stage,
|
||||||
@@ -388,20 +366,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn project_unknown_stage_returns_error() {
|
fn project_unknown_stage_returns_error() {
|
||||||
let view = PipelineItemView {
|
let view = make_view("42_story_test", "9_invalid", Some("Test"));
|
||||||
story_id: "42_story_test".to_string(),
|
|
||||||
stage: "9_invalid".to_string(),
|
|
||||||
name: Some("Test".to_string()),
|
|
||||||
agent: None,
|
|
||||||
retry_count: None,
|
|
||||||
blocked: None,
|
|
||||||
depends_on: None,
|
|
||||||
claimed_by: None,
|
|
||||||
claimed_at: None,
|
|
||||||
merged_at: None,
|
|
||||||
qa_mode: None,
|
|
||||||
mergemaster_attempted: None,
|
|
||||||
};
|
|
||||||
let result = PipelineItem::try_from(&view);
|
let result = PipelineItem::try_from(&view);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result,
|
result,
|
||||||
|
|||||||
Reference in New Issue
Block a user