huskies: merge 869
This commit is contained in:
@@ -50,14 +50,29 @@ pub(crate) fn spawn_pipeline_advance(
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolve QA mode from the content store.
|
||||
/// Resolve QA mode for a story.
|
||||
///
|
||||
/// Checks the typed `qa_mode` CRDT register first. If the register holds a
|
||||
/// recognised value, returns it immediately without touching the content store.
|
||||
/// Otherwise reads the story content and falls back to YAML front-matter
|
||||
/// parsing via [`crate::io::story_metadata::resolve_qa_mode_from_content`].
|
||||
pub(super) fn resolve_qa_mode_from_store(
|
||||
_project_root: &Path,
|
||||
story_id: &str,
|
||||
default: crate::io::story_metadata::QaMode,
|
||||
) -> crate::io::story_metadata::QaMode {
|
||||
// CRDT register is the authoritative source; check it before the content store.
|
||||
if let Some(view) = crate::crdt_state::read_item(story_id)
|
||||
&& let Some(ref s) = view.qa_mode
|
||||
&& let Some(mode) = crate::io::story_metadata::QaMode::from_str(s)
|
||||
{
|
||||
return mode;
|
||||
}
|
||||
// Fall back to YAML front matter for backward compatibility.
|
||||
if let Some(contents) = crate::db::read_content(story_id) {
|
||||
return crate::io::story_metadata::resolve_qa_mode_from_content(&contents, default);
|
||||
return crate::io::story_metadata::resolve_qa_mode_from_content(
|
||||
story_id, &contents, default,
|
||||
);
|
||||
}
|
||||
default
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ pub use types::{
|
||||
TestJobCrdt, TestJobView, TokenUsageCrdt, TokenUsageView, subscribe,
|
||||
};
|
||||
pub use write::{
|
||||
migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id, write_item,
|
||||
migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id, set_qa_mode,
|
||||
write_item,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -315,6 +315,11 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let qa_mode = match item.qa_mode.view() {
|
||||
JsonValue::String(s) if !s.is_empty() => Some(s),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Some(PipelineItemView {
|
||||
story_id,
|
||||
stage,
|
||||
@@ -326,6 +331,7 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
|
||||
claimed_by,
|
||||
claimed_at,
|
||||
merged_at,
|
||||
qa_mode,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,9 @@ pub struct PipelineItemCrdt {
|
||||
/// Written once when the item transitions to `5_done`. Used by the
|
||||
/// sweep loop to determine when to promote to `6_archived`.
|
||||
pub merged_at: LwwRegisterCrdt<f64>,
|
||||
/// QA mode override for this item: `"server"`, `"agent"`, or `"human"`.
|
||||
/// Empty string means "use the project default".
|
||||
pub qa_mode: LwwRegisterCrdt<String>,
|
||||
}
|
||||
|
||||
/// CRDT node that holds a single peer's presence entry.
|
||||
@@ -122,6 +125,9 @@ pub struct PipelineItemView {
|
||||
/// Unix timestamp (seconds) when the item was merged to master.
|
||||
/// `None` for items that were never in `5_done` or for legacy items.
|
||||
pub merged_at: Option<f64>,
|
||||
/// QA mode override from the CRDT register: `"server"`, `"agent"`, or `"human"`.
|
||||
/// `None` means the register is unset (use project default).
|
||||
pub qa_mode: Option<String>,
|
||||
}
|
||||
|
||||
/// A snapshot of a single node presence entry derived from the CRDT document.
|
||||
|
||||
@@ -8,6 +8,7 @@ use serde_json::json;
|
||||
|
||||
use super::state::{apply_and_persist, emit_event, get_crdt, rebuild_index};
|
||||
use super::types::{CrdtEvent, PipelineDoc, PipelineItemCrdt};
|
||||
use crate::io::story_metadata::QaMode;
|
||||
use crate::slog;
|
||||
|
||||
// ── Name migration helpers ────────────────────────────────────────────
|
||||
@@ -185,6 +186,29 @@ pub fn migrate_names_from_slugs() {
|
||||
slog!("[crdt] Migrated names for {count} items from story ID slugs");
|
||||
}
|
||||
|
||||
/// Set the typed `qa_mode` CRDT register for a pipeline item.
|
||||
///
|
||||
/// Passing `Some(mode)` writes the mode string (e.g. `"server"`, `"agent"`, `"human"`)
|
||||
/// to the item's `qa_mode` register and persists a signed op.
|
||||
/// Passing `None` clears the register to an empty string, which means
|
||||
/// "use the project default" (same as if the field was never set).
|
||||
///
|
||||
/// Returns `true` if the item was found and the op was applied, `false` otherwise.
|
||||
pub fn set_qa_mode(story_id: &str, mode: Option<QaMode>) -> bool {
|
||||
let Some(state_mutex) = get_crdt() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(mut state) = state_mutex.lock() else {
|
||||
return false;
|
||||
};
|
||||
let Some(&idx) = state.index.get(story_id) else {
|
||||
return false;
|
||||
};
|
||||
let value = mode.map(|m| m.as_str().to_string()).unwrap_or_default();
|
||||
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].qa_mode.set(value));
|
||||
true
|
||||
}
|
||||
|
||||
/// Write a pipeline item state through CRDT operations.
|
||||
///
|
||||
/// If the item exists, updates its registers. If not, inserts a new item
|
||||
@@ -287,6 +311,7 @@ pub fn write_item(
|
||||
"claimed_by": claimed_by.unwrap_or(""),
|
||||
"claimed_at": claimed_at.unwrap_or(0.0),
|
||||
"merged_at": merged_at.unwrap_or(0.0),
|
||||
"qa_mode": "",
|
||||
})
|
||||
.into();
|
||||
|
||||
@@ -312,6 +337,7 @@ pub fn write_item(
|
||||
item.claimed_by.advance_seq(floor);
|
||||
item.claimed_at.advance_seq(floor);
|
||||
item.merged_at.advance_seq(floor);
|
||||
item.qa_mode.advance_seq(floor);
|
||||
}
|
||||
|
||||
// Broadcast a CrdtEvent for the new item.
|
||||
@@ -613,6 +639,64 @@ mod tests {
|
||||
// We call it here just to confirm no panic.
|
||||
migrate_names_from_slugs();
|
||||
}
|
||||
|
||||
// ── set_qa_mode regression tests ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_qa_mode_round_trip_server_then_human() {
|
||||
use crate::io::story_metadata::QaMode;
|
||||
init_for_test();
|
||||
|
||||
write_item(
|
||||
"869_story_qa_roundtrip",
|
||||
"1_backlog",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Set qa=server via typed path and assert CRDT register reflects it.
|
||||
let ok = set_qa_mode("869_story_qa_roundtrip", Some(QaMode::Server));
|
||||
assert!(ok, "set_qa_mode should return true for known item");
|
||||
let view = read_item("869_story_qa_roundtrip").unwrap();
|
||||
assert_eq!(
|
||||
view.qa_mode.as_deref(),
|
||||
Some("server"),
|
||||
"CRDT register should hold \"server\""
|
||||
);
|
||||
|
||||
// Set qa=human via typed path and assert CRDT register is updated.
|
||||
let ok = set_qa_mode("869_story_qa_roundtrip", Some(QaMode::Human));
|
||||
assert!(ok, "set_qa_mode should return true for known item");
|
||||
let view = read_item("869_story_qa_roundtrip").unwrap();
|
||||
assert_eq!(
|
||||
view.qa_mode.as_deref(),
|
||||
Some("human"),
|
||||
"CRDT register should hold \"human\""
|
||||
);
|
||||
|
||||
// Clear via None — register goes back to unset.
|
||||
let ok = set_qa_mode("869_story_qa_roundtrip", None);
|
||||
assert!(ok, "set_qa_mode(None) should return true");
|
||||
let view = read_item("869_story_qa_roundtrip").unwrap();
|
||||
assert_eq!(
|
||||
view.qa_mode, None,
|
||||
"clearing qa_mode should leave register unset"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_qa_mode_returns_false_for_unknown_story() {
|
||||
init_for_test();
|
||||
use crate::io::story_metadata::QaMode;
|
||||
let ok = set_qa_mode("nonexistent_story_qa", Some(QaMode::Server));
|
||||
assert!(!ok, "set_qa_mode should return false for unknown story_id");
|
||||
}
|
||||
use bft_json_crdt::json_crdt::OpState;
|
||||
use bft_json_crdt::keypair::make_keypair;
|
||||
use bft_json_crdt::op::ROOT_ID;
|
||||
|
||||
@@ -30,6 +30,14 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
|
||||
front_matter.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
// Intercept `qa` field — route through the typed CRDT register instead of YAML.
|
||||
if let Some(qa_val) = front_matter.remove("qa") {
|
||||
let mode = qa_val
|
||||
.as_str()
|
||||
.and_then(crate::io::story_metadata::QaMode::from_str);
|
||||
crate::crdt_state::set_qa_mode(story_id, mode);
|
||||
}
|
||||
|
||||
let front_matter_opt = if front_matter.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -37,7 +45,11 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
|
||||
};
|
||||
|
||||
let root = ctx.state.get_project_root()?;
|
||||
update_story_in_file(&root, story_id, user_story, description, front_matter_opt)?;
|
||||
|
||||
// Only call update_story_in_file when there is something left to write.
|
||||
if user_story.is_some() || description.is_some() || front_matter_opt.is_some() {
|
||||
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)
|
||||
|
||||
@@ -108,11 +108,21 @@ pub fn resolve_qa_mode(path: &Path, default: QaMode) -> QaMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the effective QA mode from story content (no filesystem access).
|
||||
/// Resolve the effective QA mode for a story by story ID.
|
||||
///
|
||||
/// Parses front matter from `contents` and returns the `qa` field if present,
|
||||
/// otherwise returns `default`.
|
||||
pub fn resolve_qa_mode_from_content(contents: &str, default: QaMode) -> QaMode {
|
||||
/// Checks the typed `qa_mode` CRDT register first. If the register holds a
|
||||
/// recognised value (`"server"`, `"agent"`, or `"human"`), returns it.
|
||||
/// Otherwise falls back to parsing the `qa` YAML front-matter field from
|
||||
/// `contents`. If neither source provides a value, returns `default`.
|
||||
pub fn resolve_qa_mode_from_content(story_id: &str, contents: &str, default: QaMode) -> QaMode {
|
||||
// CRDT register takes precedence over YAML front matter.
|
||||
if let Some(view) = crate::crdt_state::read_item(story_id)
|
||||
&& let Some(ref s) = view.qa_mode
|
||||
&& let Some(mode) = QaMode::from_str(s)
|
||||
{
|
||||
return mode;
|
||||
}
|
||||
// Fall back to YAML front matter for backward compatibility.
|
||||
match parse_front_matter(contents) {
|
||||
Ok(meta) => meta.qa.unwrap_or(default),
|
||||
Err(_) => default,
|
||||
|
||||
@@ -205,6 +205,7 @@ mod tests {
|
||||
claimed_by: None,
|
||||
claimed_at: None,
|
||||
merged_at: None,
|
||||
qa_mode: None,
|
||||
};
|
||||
let item = PipelineItem::try_from(&view).unwrap();
|
||||
assert_eq!(item.story_id, StoryId("42_story_test".to_string()));
|
||||
@@ -227,6 +228,7 @@ mod tests {
|
||||
claimed_by: None,
|
||||
claimed_at: None,
|
||||
merged_at: None,
|
||||
qa_mode: None,
|
||||
};
|
||||
let item = PipelineItem::try_from(&view).unwrap();
|
||||
assert!(matches!(item.stage, Stage::Coding));
|
||||
@@ -246,6 +248,7 @@ mod tests {
|
||||
claimed_by: None,
|
||||
claimed_at: None,
|
||||
merged_at: None,
|
||||
qa_mode: None,
|
||||
};
|
||||
let item = PipelineItem::try_from(&view).unwrap();
|
||||
assert!(matches!(item.stage, Stage::Merge { .. }));
|
||||
@@ -272,6 +275,7 @@ mod tests {
|
||||
claimed_by: None,
|
||||
claimed_at: None,
|
||||
merged_at: None,
|
||||
qa_mode: None,
|
||||
};
|
||||
let item = PipelineItem::try_from(&view).unwrap();
|
||||
assert!(matches!(
|
||||
@@ -296,6 +300,7 @@ mod tests {
|
||||
claimed_by: None,
|
||||
claimed_at: None,
|
||||
merged_at: None,
|
||||
qa_mode: None,
|
||||
};
|
||||
let item = PipelineItem::try_from(&view).unwrap();
|
||||
assert!(matches!(
|
||||
@@ -320,6 +325,7 @@ mod tests {
|
||||
claimed_by: None,
|
||||
claimed_at: None,
|
||||
merged_at: None,
|
||||
qa_mode: None,
|
||||
};
|
||||
let result = PipelineItem::try_from(&view);
|
||||
assert!(matches!(
|
||||
|
||||
Reference in New Issue
Block a user