feat(missions): wire the workflow registry so phase config reaches the database
workflow_registry.rs had zero call sites -- lib.rs declared the module and
nothing ever called load() or get(). So templates/workflows/*.toml was never
read, and because the client's TEMPLATE_PRESETS carries only {kind, order_idx}
with no config, PhaseSpec.config defaulted to Value::Null and every
wizard-created mission stored a null mission_phases.config.
Every per-phase setting was therefore inert. `loop = "until_no_more_int_items"`
and `commit_policy = "on_green_tests"` described a scheduler that does not
exist AND had no path to the database. benchmark_runner and security_scan
already read phase_config(); they were reading from null.
- Mission create derives phases from the recipe when none are sent, and
backfills config per phase (matched on kind+order_idx, then kind) when the
caller sends shape without config. An explicit config always wins.
- phases_for_create takes Option<&WorkflowRecipe> rather than reaching for the
global, because the registry resolves its directory relative to the process
cwd -- which under cargo test is the crate root, not the repo root.
- GET /api/workflows serves the catalog; the wizard fetches it and falls back
to TEMPLATE_PRESETS. Adding a TOML now adds a template with no FE change.
- load() runs at boot so a malformed recipe appears in the boot log instead of
silently producing a mission with no phase config.
Also fixes a latent bug in all five recipes: `default_team_template` was
written below the first [[phases]] block, and TOML scopes a bare key after a
table header INTO that table -- so it parsed as
phases[last].config.default_team_template and the real field was always None.
Invisible while the registry was dead code. Moved above the phases, with a
test asserting it neither returns None nor leaks into a phase config.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d94487d3ba
commit
49bcf53b84
@@ -288,6 +288,13 @@ async fn run() -> Result<(), String> {
|
|||||||
// for INT-XX markers in event payloads and upserts mission_tasks
|
// for INT-XX markers in event payloads and upserts mission_tasks
|
||||||
// rows so the canvas renders a live status timeline.
|
// rows so the canvas renders a live status timeline.
|
||||||
cm_api::task_card_worker::spawn(pool.clone());
|
cm_api::task_card_worker::spawn(pool.clone());
|
||||||
|
// Load the workflow recipes now rather than lazily on first mission
|
||||||
|
// create, so a malformed TOML shows up in the boot log instead of
|
||||||
|
// silently yielding a mission with no phase config.
|
||||||
|
{
|
||||||
|
let recipes = cm_api::workflow_registry::load();
|
||||||
|
eprintln!("workflow_registry: {} recipe(s) available", recipes.len());
|
||||||
|
}
|
||||||
cm_api::phase_runner::spawn(pool.clone());
|
cm_api::phase_runner::spawn(pool.clone());
|
||||||
// Per-mission runtime container sweeper (C3): tears down mission
|
// Per-mission runtime container sweeper (C3): tears down mission
|
||||||
// runtime containers 30 min after the mission reaches a terminal
|
// runtime containers 30 min after the mission reaches a terminal
|
||||||
|
|||||||
@@ -443,6 +443,9 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/missions",
|
"/api/missions",
|
||||||
get(routes::missions::list).post(routes::missions::create),
|
get(routes::missions::list).post(routes::missions::create),
|
||||||
)
|
)
|
||||||
|
// The workflow recipe catalog (templates/workflows/*.toml). Serving it
|
||||||
|
// lets the client stop mirroring the phase composition table inline.
|
||||||
|
.route("/api/workflows", get(routes::missions::list_workflows))
|
||||||
.route(
|
.route(
|
||||||
"/api/missions/{id}",
|
"/api/missions/{id}",
|
||||||
get(routes::missions::get)
|
get(routes::missions::get)
|
||||||
|
|||||||
@@ -148,6 +148,72 @@ pub async fn list(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the phase list for a new mission, filling in each phase's `config`
|
||||||
|
/// from the workflow recipe when the caller didn't supply one.
|
||||||
|
///
|
||||||
|
/// `mission_phases.config` is where per-phase settings live (`done_when`,
|
||||||
|
/// `max_iterations`, `harness`, `tools`). The client's phase list historically
|
||||||
|
/// carried only `{kind, order_idx}`, so every wizard-created mission landed
|
||||||
|
/// with a null config and every recipe setting was silently inert. Callers can
|
||||||
|
/// still override by sending a non-null config per phase.
|
||||||
|
fn phases_for_create(
|
||||||
|
recipe: Option<&crate::workflow_registry::WorkflowRecipe>,
|
||||||
|
requested: Vec<PhaseSpec>,
|
||||||
|
) -> Vec<NewMissionPhase> {
|
||||||
|
// No phases requested: take the recipe's wholesale.
|
||||||
|
if requested.is_empty() {
|
||||||
|
return recipe
|
||||||
|
.map(|r| {
|
||||||
|
r.phases
|
||||||
|
.iter()
|
||||||
|
.map(|p| NewMissionPhase {
|
||||||
|
kind: p.kind.clone(),
|
||||||
|
order_idx: p.order_idx,
|
||||||
|
config: p.config.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phases requested: honour them, but backfill config from the matching
|
||||||
|
// recipe phase (by kind + order_idx, falling back to kind alone) so a
|
||||||
|
// client that only knows the shape still gets the recipe's settings.
|
||||||
|
requested
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| {
|
||||||
|
let config = if p.config.is_null() {
|
||||||
|
recipe
|
||||||
|
.and_then(|r| {
|
||||||
|
r.phases
|
||||||
|
.iter()
|
||||||
|
.find(|rp| rp.kind == p.kind && rp.order_idx == p.order_idx)
|
||||||
|
.or_else(|| r.phases.iter().find(|rp| rp.kind == p.kind))
|
||||||
|
})
|
||||||
|
.map(|rp| rp.config.clone())
|
||||||
|
.unwrap_or(Value::Null)
|
||||||
|
} else {
|
||||||
|
p.config
|
||||||
|
};
|
||||||
|
NewMissionPhase {
|
||||||
|
kind: p.kind,
|
||||||
|
order_idx: p.order_idx,
|
||||||
|
config,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/workflows` — the workflow recipe catalog.
|
||||||
|
///
|
||||||
|
/// Serves `templates/workflows/*.toml` so the client can drop its inline
|
||||||
|
/// mirror of the phase composition table.
|
||||||
|
pub async fn list_workflows(
|
||||||
|
Authed(_user): Authed,
|
||||||
|
) -> Json<&'static [crate::workflow_registry::WorkflowRecipe]> {
|
||||||
|
Json(crate::workflow_registry::load())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create(
|
pub async fn create(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Authed(user): Authed,
|
Authed(user): Authed,
|
||||||
@@ -180,15 +246,10 @@ pub async fn create(
|
|||||||
config: body.config,
|
config: body.config,
|
||||||
runtime_kind: Some(runtime_kind),
|
runtime_kind: Some(runtime_kind),
|
||||||
target_node_id: body.target_node_id,
|
target_node_id: body.target_node_id,
|
||||||
phases: body
|
phases: phases_for_create(
|
||||||
.phases
|
crate::workflow_registry::get(body.template_kind.trim()),
|
||||||
.into_iter()
|
body.phases,
|
||||||
.map(|p| NewMissionPhase {
|
),
|
||||||
kind: p.kind,
|
|
||||||
order_idx: p.order_idx,
|
|
||||||
config: p.config,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
};
|
};
|
||||||
let id = cm_db::repo::missions::insert(&state.pool, new).await?;
|
let id = cm_db::repo::missions::insert(&state.pool, new).await?;
|
||||||
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
let mission = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||||
@@ -918,6 +979,99 @@ pub async fn get_document(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The whole point of wiring the registry: a client that sends only the
|
||||||
|
/// phase shape must still get the recipe's config, because that is where
|
||||||
|
/// per-phase settings are read from at run time. Before this, every
|
||||||
|
/// wizard-created mission stored a null config and every recipe setting
|
||||||
|
/// was inert.
|
||||||
|
#[test]
|
||||||
|
fn phase_config_is_backfilled_from_the_recipe() {
|
||||||
|
let recipe = test_recipe();
|
||||||
|
let requested = vec![
|
||||||
|
PhaseSpec {
|
||||||
|
kind: "research".into(),
|
||||||
|
order_idx: 0,
|
||||||
|
config: Value::Null,
|
||||||
|
},
|
||||||
|
PhaseSpec {
|
||||||
|
kind: "coding".into(),
|
||||||
|
order_idx: 1,
|
||||||
|
config: Value::Null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let phases = phases_for_create(Some(&recipe), requested);
|
||||||
|
assert_eq!(phases.len(), 2);
|
||||||
|
assert!(
|
||||||
|
phases.iter().all(|p| !p.config.is_null()),
|
||||||
|
"recipe config was not backfilled: {phases:?}"
|
||||||
|
);
|
||||||
|
// The coding phase's loop policy is the setting the loop work depends on.
|
||||||
|
let coding = phases.iter().find(|p| p.kind == "coding").expect("coding");
|
||||||
|
assert_eq!(
|
||||||
|
coding.config.get("loop").and_then(|v| v.as_str()),
|
||||||
|
Some("until_no_more_int_items")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Omitting phases entirely takes the recipe's list wholesale.
|
||||||
|
#[test]
|
||||||
|
fn phases_default_to_the_recipe() {
|
||||||
|
let phases = phases_for_create(Some(&test_recipe()), vec![]);
|
||||||
|
assert_eq!(phases.len(), 2);
|
||||||
|
assert_eq!(phases[0].kind, "research");
|
||||||
|
assert_eq!(phases[1].kind, "coding");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An explicit config always wins over the recipe's.
|
||||||
|
#[test]
|
||||||
|
fn explicit_phase_config_is_not_overwritten() {
|
||||||
|
let requested = vec![PhaseSpec {
|
||||||
|
kind: "coding".into(),
|
||||||
|
order_idx: 1,
|
||||||
|
config: serde_json::json!({"loop": "single_pass"}),
|
||||||
|
}];
|
||||||
|
let phases = phases_for_create(Some(&test_recipe()), requested);
|
||||||
|
assert_eq!(
|
||||||
|
phases[0].config.get("loop").and_then(|v| v.as_str()),
|
||||||
|
Some("single_pass")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unknown template must not fabricate phases or panic.
|
||||||
|
#[test]
|
||||||
|
fn unknown_template_yields_no_phases() {
|
||||||
|
assert!(phases_for_create(None, vec![]).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors `templates/workflows/research_and_code.toml`. Built inline
|
||||||
|
/// rather than loaded from disk because the registry resolves its
|
||||||
|
/// directory relative to the process cwd, which under `cargo test` is the
|
||||||
|
/// crate root, not the repo root.
|
||||||
|
fn test_recipe() -> crate::workflow_registry::WorkflowRecipe {
|
||||||
|
crate::workflow_registry::WorkflowRecipe {
|
||||||
|
key: "research_and_code".into(),
|
||||||
|
title: "Research + Coding Loop".into(),
|
||||||
|
blurb: String::new(),
|
||||||
|
requires_repo: true,
|
||||||
|
default_team_template: Some("rust_sdlc".into()),
|
||||||
|
phases: vec![
|
||||||
|
crate::workflow_registry::WorkflowPhase {
|
||||||
|
kind: "research".into(),
|
||||||
|
order_idx: 0,
|
||||||
|
config: serde_json::json!({"produces": ["md", "pdf"]}),
|
||||||
|
},
|
||||||
|
crate::workflow_registry::WorkflowPhase {
|
||||||
|
kind: "coding".into(),
|
||||||
|
order_idx: 1,
|
||||||
|
config: serde_json::json!({
|
||||||
|
"loop": "until_no_more_int_items",
|
||||||
|
"commit_policy": "on_green_tests"
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn title_prefers_first_markdown_heading() {
|
fn title_prefers_first_markdown_heading() {
|
||||||
let body = "I'll start by exploring.\n\n# ClawHDF5 Research Report\n\ntext";
|
let body = "I'll start by exploring.\n\n# ClawHDF5 Research Report\n\ntext";
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
//! Read-only registry of workflow template recipes loaded from
|
//! Read-only registry of workflow template recipes loaded from
|
||||||
//! `templates/workflows/*.toml` at server boot. Slice 4.
|
//! `templates/workflows/*.toml` at server boot. Slice 4.
|
||||||
//!
|
//!
|
||||||
//! Recipes are immutable reference data — no DB row per recipe.
|
//! Recipes are immutable reference data — no DB row per recipe. They are
|
||||||
//! Slice 2's client-side `TEMPLATE_PRESETS` is a mirror of what
|
//! served over `GET /api/workflows` so the client doesn't need its own copy
|
||||||
//! ends up here; a follow-up serves this registry over an API so
|
//! of the phase composition table.
|
||||||
//! the client can drop its inline mirror.
|
//!
|
||||||
|
//! **These recipes are the only place a phase's `config` comes from.** Mission
|
||||||
|
//! creation copies `phases[].config` into `mission_phases.config`, which is
|
||||||
|
//! where per-phase settings (`done_when`, `max_iterations`, `harness`, `tools`)
|
||||||
|
//! are read from at run time. A mission created with an explicit `phases` list
|
||||||
|
//! and no config gets an empty config — that is the caller's choice, not a
|
||||||
|
//! default.
|
||||||
|
//!
|
||||||
|
//! TOML gotcha worth remembering: a bare top-level key written *after* a
|
||||||
|
//! `[[phases]]` block is scoped into that block's table, not the document
|
||||||
|
//! root. Every recipe here once had `default_team_template` below its phases,
|
||||||
|
//! so it silently parsed as `phases[last].config.default_team_template` and
|
||||||
|
//! the real field was always `None`. Keep top-level keys above the first
|
||||||
|
//! `[[phases]]`.
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct WorkflowRecipe {
|
pub struct WorkflowRecipe {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -23,7 +36,7 @@ pub struct WorkflowRecipe {
|
|||||||
pub default_team_template: Option<String>,
|
pub default_team_template: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct WorkflowPhase {
|
pub struct WorkflowPhase {
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
pub order_idx: i32,
|
pub order_idx: i32,
|
||||||
@@ -89,3 +102,63 @@ fn load_one(path: &std::path::Path) -> Result<WorkflowRecipe, String> {
|
|||||||
pub fn get(key: &str) -> Option<&'static WorkflowRecipe> {
|
pub fn get(key: &str) -> Option<&'static WorkflowRecipe> {
|
||||||
load().iter().find(|r| r.key == key)
|
load().iter().find(|r| r.key == key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn recipes() -> Vec<WorkflowRecipe> {
|
||||||
|
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../templates/workflows")
|
||||||
|
.canonicalize()
|
||||||
|
.expect("templates/workflows resolves");
|
||||||
|
std::fs::read_dir(&dir)
|
||||||
|
.expect("workflows dir readable")
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("toml"))
|
||||||
|
.map(|p| load_one(&p).unwrap_or_else(|e| panic!("{e}")))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every shipped recipe parses and declares the fields mission creation
|
||||||
|
/// depends on.
|
||||||
|
#[test]
|
||||||
|
fn shipped_recipes_parse() {
|
||||||
|
let all = recipes();
|
||||||
|
assert!(!all.is_empty(), "no recipes found");
|
||||||
|
for r in &all {
|
||||||
|
assert!(!r.key.is_empty(), "recipe missing key");
|
||||||
|
assert!(!r.phases.is_empty(), "{} has no phases", r.key);
|
||||||
|
for p in &r.phases {
|
||||||
|
assert!(!p.kind.is_empty(), "{} has a phase with no kind", r.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bare top-level key written after a `[[phases]]` block is scoped INTO
|
||||||
|
/// that block by TOML, not the document root. Every recipe shipped with
|
||||||
|
/// `default_team_template` below its phases, so it parsed as
|
||||||
|
/// `phases[last].config.default_team_template` and the real field was
|
||||||
|
/// always `None` — invisible while the registry was unused.
|
||||||
|
#[test]
|
||||||
|
fn top_level_keys_are_not_swallowed_by_phase_tables() {
|
||||||
|
for r in recipes() {
|
||||||
|
assert!(
|
||||||
|
r.default_team_template.is_some(),
|
||||||
|
"{}: default_team_template is None — it is probably written below \
|
||||||
|
the first [[phases]] block and got scoped into a phase config",
|
||||||
|
r.key
|
||||||
|
);
|
||||||
|
for p in &r.phases {
|
||||||
|
assert!(
|
||||||
|
p.config.get("default_team_template").is_none(),
|
||||||
|
"{}: phase {:?} config contains default_team_template — a \
|
||||||
|
top-level key leaked into the phase table",
|
||||||
|
r.key,
|
||||||
|
p.kind
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { X } from "lucide-react";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createMission,
|
createMission,
|
||||||
presetForKind,
|
listWorkflows,
|
||||||
|
recipeToPreset,
|
||||||
TEMPLATE_PRESETS,
|
TEMPLATE_PRESETS,
|
||||||
type Schedule,
|
type Schedule,
|
||||||
type TemplateKind,
|
type TemplateKind,
|
||||||
@@ -87,9 +88,29 @@ export function MissionWizard({
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Workflow recipes come from the server (templates/workflows/*.toml) so a
|
||||||
|
// new TOML shows up here without a frontend change. TEMPLATE_PRESETS is the
|
||||||
|
// fallback when the request fails or hasn't landed yet.
|
||||||
|
const [recipes, setRecipes] = useState<TemplatePreset[] | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const rs = await listWorkflows();
|
||||||
|
if (live && rs.length > 0) setRecipes(rs.map(recipeToPreset));
|
||||||
|
} catch {
|
||||||
|
// Fallback table already covers this.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
const templates: TemplatePreset[] = recipes ?? TEMPLATE_PRESETS;
|
||||||
|
|
||||||
const preset: TemplatePreset = useMemo(
|
const preset: TemplatePreset = useMemo(
|
||||||
() => presetForKind(templateKind) ?? TEMPLATE_PRESETS[0],
|
() => templates.find((p) => p.kind === templateKind) ?? templates[0],
|
||||||
[templateKind],
|
[templates, templateKind],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Which panels to show on step 3 (research / dev) depends on which
|
// Which panels to show on step 3 (research / dev) depends on which
|
||||||
@@ -244,7 +265,7 @@ export function MissionWizard({
|
|||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{TEMPLATE_PRESETS.map((p) => {
|
{templates.map((p) => {
|
||||||
const active = p.kind === templateKind;
|
const active = p.kind === templateKind;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -433,3 +433,42 @@ export const TEMPLATE_PRESETS: TemplatePreset[] = [
|
|||||||
|
|
||||||
export const presetForKind = (k: TemplateKind): TemplatePreset | undefined =>
|
export const presetForKind = (k: TemplateKind): TemplatePreset | undefined =>
|
||||||
TEMPLATE_PRESETS.find((p) => p.kind === k);
|
TEMPLATE_PRESETS.find((p) => p.kind === k);
|
||||||
|
|
||||||
|
// ── Server-side recipes ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// `GET /api/workflows` serves `templates/workflows/*.toml`, which is the
|
||||||
|
// authoritative source of phase composition — including each phase's `config`,
|
||||||
|
// where per-phase settings live. The table above stays as the offline
|
||||||
|
// fallback and to keep the wizard rendering if the request fails.
|
||||||
|
//
|
||||||
|
// Note the server backfills phase config from the recipe on create, so the
|
||||||
|
// wizard does NOT need to send config; posting `{kind, order_idx}` is enough.
|
||||||
|
|
||||||
|
export interface WorkflowRecipePhase {
|
||||||
|
kind: PhaseKind;
|
||||||
|
order_idx: number;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowRecipe {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
blurb: string;
|
||||||
|
requires_repo: boolean;
|
||||||
|
phases: WorkflowRecipePhase[];
|
||||||
|
default_team_template?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listWorkflows = () => api<WorkflowRecipe[]>("/api/workflows");
|
||||||
|
|
||||||
|
/** Shape a server recipe like the local preset table so callers are uniform. */
|
||||||
|
export const recipeToPreset = (r: WorkflowRecipe): TemplatePreset => ({
|
||||||
|
kind: r.key as TemplateKind,
|
||||||
|
title: r.title,
|
||||||
|
blurb: r.blurb,
|
||||||
|
requiresRepo: r.requires_repo,
|
||||||
|
phases: r.phases
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.order_idx - b.order_idx)
|
||||||
|
.map((p) => ({ kind: p.kind, order_idx: p.order_idx })),
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ title = "Benchmark"
|
|||||||
blurb = "Author + baseline benchmarks so subsequent refactors can be measured before/after."
|
blurb = "Author + baseline benchmarks so subsequent refactors can be measured before/after."
|
||||||
requires_repo = true
|
requires_repo = true
|
||||||
|
|
||||||
|
default_team_template = "rust_sdlc"
|
||||||
|
|
||||||
[[phases]]
|
[[phases]]
|
||||||
kind = "benchmark"
|
kind = "benchmark"
|
||||||
order_idx = 0
|
order_idx = 0
|
||||||
@@ -16,5 +18,3 @@ mode = "author_and_baseline"
|
|||||||
# ts/js → vitest --bench / mitata
|
# ts/js → vitest --bench / mitata
|
||||||
# py → pytest-benchmark
|
# py → pytest-benchmark
|
||||||
harness = "auto"
|
harness = "auto"
|
||||||
|
|
||||||
default_team_template = "rust_sdlc"
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ title = "Refactor"
|
|||||||
blurb = "Audit dependencies + versions, propose API/SDK adaptations, apply the changes."
|
blurb = "Audit dependencies + versions, propose API/SDK adaptations, apply the changes."
|
||||||
requires_repo = true
|
requires_repo = true
|
||||||
|
|
||||||
|
default_team_template = "rust_sdlc"
|
||||||
|
|
||||||
[[phases]]
|
[[phases]]
|
||||||
kind = "coding"
|
kind = "coding"
|
||||||
order_idx = 0
|
order_idx = 0
|
||||||
@@ -13,5 +15,3 @@ loop = "single_pass"
|
|||||||
commit_policy = "on_green_tests"
|
commit_policy = "on_green_tests"
|
||||||
# Bench before + after the pass so we can measure impact.
|
# Bench before + after the pass so we can measure impact.
|
||||||
benchmark = { mode = "before_after" }
|
benchmark = { mode = "before_after" }
|
||||||
|
|
||||||
default_team_template = "rust_sdlc"
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ title = "Research + Coding Loop"
|
|||||||
blurb = "Research a topic against a repo, then loop the coding team through the produced INT-XX items until done."
|
blurb = "Research a topic against a repo, then loop the coding team through the produced INT-XX items until done."
|
||||||
requires_repo = true
|
requires_repo = true
|
||||||
|
|
||||||
|
default_team_template = "rust_sdlc"
|
||||||
|
|
||||||
[[phases]]
|
[[phases]]
|
||||||
kind = "research"
|
kind = "research"
|
||||||
order_idx = 0
|
order_idx = 0
|
||||||
@@ -25,5 +27,3 @@ loop = "until_no_more_int_items"
|
|||||||
# marker before the committer runs. If a coding role wants to bypass
|
# marker before the committer runs. If a coding role wants to bypass
|
||||||
# (rare — pure docs commit), it emits COMMIT_POLICY_OVERRIDE: <reason>.
|
# (rare — pure docs commit), it emits COMMIT_POLICY_OVERRIDE: <reason>.
|
||||||
commit_policy = "on_green_tests"
|
commit_policy = "on_green_tests"
|
||||||
|
|
||||||
default_team_template = "rust_sdlc"
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ requires_repo = false
|
|||||||
|
|
||||||
# Phases run in order. Each entry gets a `mission_phases` row on
|
# Phases run in order. Each entry gets a `mission_phases` row on
|
||||||
# mission create; the orchestrator dispatches per-kind executors.
|
# mission create; the orchestrator dispatches per-kind executors.
|
||||||
|
default_team_template = "rust_sdlc"
|
||||||
|
|
||||||
[[phases]]
|
[[phases]]
|
||||||
kind = "research"
|
kind = "research"
|
||||||
order_idx = 0
|
order_idx = 0
|
||||||
@@ -15,4 +17,3 @@ default_topology = "hub_spoke"
|
|||||||
|
|
||||||
# Which team template is the "sensible default" for the picker when
|
# Which team template is the "sensible default" for the picker when
|
||||||
# the user hasn't explicitly picked one. UI honors this.
|
# the user hasn't explicitly picked one. UI honors this.
|
||||||
default_team_template = "rust_sdlc"
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ title = "Security Hardening"
|
|||||||
blurb = "Scan the repo for vulnerabilities, research patches, then apply + verify."
|
blurb = "Scan the repo for vulnerabilities, research patches, then apply + verify."
|
||||||
requires_repo = true
|
requires_repo = true
|
||||||
|
|
||||||
|
default_team_template = "rust_sdlc"
|
||||||
|
|
||||||
[[phases]]
|
[[phases]]
|
||||||
kind = "security_scan"
|
kind = "security_scan"
|
||||||
order_idx = 0
|
order_idx = 0
|
||||||
@@ -29,5 +31,3 @@ loop = "until_all_findings_closed"
|
|||||||
# Security requires reviewer approval on top of green tests.
|
# Security requires reviewer approval on top of green tests.
|
||||||
commit_policy = "on_reviewer_approval"
|
commit_policy = "on_reviewer_approval"
|
||||||
mcp_bundles = ["clawmates_door", "clawmates_skills", "gitea_forge", "security_scan"]
|
mcp_bundles = ["clawmates_door", "clawmates_skills", "gitea_forge", "security_scan"]
|
||||||
|
|
||||||
default_team_template = "rust_sdlc"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user