herdr phase 1a: missions runtime_kind + target_node schema
First slice of the second-runtime path. Missions now carry
runtime_kind ('zeroclaw' | 'local_herdr') + target_node_id (FK to
nodes) so the mission_orchestrator + phase executors can dispatch
differently depending on where the operator wants execution.
Migration:
- 0055_missions_runtime_kind.sql — adds runtime_kind (NOT NULL
DEFAULT 'zeroclaw' + CHECK), target_node_id (nullable FK ON
DELETE SET NULL). All existing missions backfill to 'zeroclaw'
so behavior is unchanged.
- topology_runs also grows herdr_workspace_id / herdr_tab_id /
herdr_pane_id text columns so a resumed run can reattach to the
same Herdr pane instead of spawning a duplicate.
Code:
- cm-db::repo::missions — Mission + NewMission carry the two new
fields; all SELECTs updated; INSERT COALESCE-defaults
runtime_kind to 'zeroclaw' when unspecified.
- routes::missions::create — validates runtime_kind and requires
target_node_id when kind='local_herdr' (400 otherwise).
- lib/api/missions.ts — RuntimeKind type; Mission carries both;
CreateMissionRequest optional fields.
Behavior is opt-in: no path exists yet to actually create a
local_herdr mission — that lands in Phase 1c (wizard picker). This
commit just makes the schema + validation in place so Phase 1b's
fleet_herdr dispatch module can key on it.
Tests: mission_orchestrator integration test still green.
This commit is contained in:
@@ -35,6 +35,9 @@ pub struct CreateMissionRequest {
|
|||||||
pub config: Value,
|
pub config: Value,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub phases: Vec<PhaseSpec>,
|
pub phases: Vec<PhaseSpec>,
|
||||||
|
/// Defaults to "zeroclaw". "local_herdr" requires target_node_id.
|
||||||
|
pub runtime_kind: Option<String>,
|
||||||
|
pub target_node_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_schedule() -> Value {
|
fn default_schedule() -> Value {
|
||||||
@@ -120,6 +123,18 @@ pub async fn create(
|
|||||||
if body.title.trim().is_empty() {
|
if body.title.trim().is_empty() {
|
||||||
return Err(ApiError::BadRequest);
|
return Err(ApiError::BadRequest);
|
||||||
}
|
}
|
||||||
|
// Validate runtime_kind + require target_node when local_herdr.
|
||||||
|
let runtime_kind = body.runtime_kind.as_deref().unwrap_or("zeroclaw");
|
||||||
|
match runtime_kind {
|
||||||
|
"zeroclaw" => {}
|
||||||
|
"local_herdr" => {
|
||||||
|
if body.target_node_id.is_none() {
|
||||||
|
return Err(ApiError::BadRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return Err(ApiError::BadRequest),
|
||||||
|
}
|
||||||
|
|
||||||
let new = NewMission {
|
let new = NewMission {
|
||||||
workspace_id: user.workspace_id.as_uuid(),
|
workspace_id: user.workspace_id.as_uuid(),
|
||||||
title: body.title.trim(),
|
title: body.title.trim(),
|
||||||
@@ -130,6 +145,8 @@ pub async fn create(
|
|||||||
schedule: body.schedule,
|
schedule: body.schedule,
|
||||||
description: body.description.as_deref(),
|
description: body.description.as_deref(),
|
||||||
config: body.config,
|
config: body.config,
|
||||||
|
runtime_kind: Some(runtime_kind),
|
||||||
|
target_node_id: body.target_node_id,
|
||||||
phases: body
|
phases: body
|
||||||
.phases
|
.phases
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ pub struct Mission {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub config: Value,
|
pub config: Value,
|
||||||
|
/// 'zeroclaw' (default, headless) | 'local_herdr' (attended, fleet node)
|
||||||
|
pub runtime_kind: String,
|
||||||
|
/// FK → nodes(id); only relevant when runtime_kind = 'local_herdr'
|
||||||
|
pub target_node_id: Option<Uuid>,
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
pub created_at: OffsetDateTime,
|
pub created_at: OffsetDateTime,
|
||||||
#[serde(with = "time::serde::rfc3339")]
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
@@ -106,6 +110,9 @@ pub struct NewMission<'a> {
|
|||||||
pub schedule: Value,
|
pub schedule: Value,
|
||||||
pub description: Option<&'a str>,
|
pub description: Option<&'a str>,
|
||||||
pub config: Value,
|
pub config: Value,
|
||||||
|
/// Defaults to 'zeroclaw' when None.
|
||||||
|
pub runtime_kind: Option<&'a str>,
|
||||||
|
pub target_node_id: Option<Uuid>,
|
||||||
pub phases: Vec<NewMissionPhase>,
|
pub phases: Vec<NewMissionPhase>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,8 +134,10 @@ pub async fn insert(pool: &PgPool, m: NewMission<'_>) -> Result<Uuid, DbError> {
|
|||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO missions
|
"INSERT INTO missions
|
||||||
(id, workspace_id, title, template_kind, team_id,
|
(id, workspace_id, title, template_kind, team_id,
|
||||||
team_template_id, repo_id, schedule, status, description, config)
|
team_template_id, repo_id, schedule, status, description, config,
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'draft',$9,$10)",
|
runtime_kind, target_node_id)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'draft',$9,$10,
|
||||||
|
COALESCE($11,'zeroclaw'),$12)",
|
||||||
)
|
)
|
||||||
.bind(mission_id)
|
.bind(mission_id)
|
||||||
.bind(m.workspace_id)
|
.bind(m.workspace_id)
|
||||||
@@ -140,6 +149,8 @@ pub async fn insert(pool: &PgPool, m: NewMission<'_>) -> Result<Uuid, DbError> {
|
|||||||
.bind(&m.schedule)
|
.bind(&m.schedule)
|
||||||
.bind(m.description)
|
.bind(m.description)
|
||||||
.bind(&m.config)
|
.bind(&m.config)
|
||||||
|
.bind(m.runtime_kind)
|
||||||
|
.bind(m.target_node_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -169,7 +180,8 @@ pub async fn get(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<Option<M
|
|||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT id, workspace_id, title, template_kind, team_id,
|
"SELECT id, workspace_id, title, template_kind, team_id,
|
||||||
team_template_id, repo_id, schedule, status,
|
team_template_id, repo_id, schedule, status,
|
||||||
description, config, created_at, updated_at, completed_at
|
description, config, runtime_kind, target_node_id,
|
||||||
|
created_at, updated_at, completed_at
|
||||||
FROM missions WHERE id = $1 AND workspace_id = $2",
|
FROM missions WHERE id = $1 AND workspace_id = $2",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -188,6 +200,8 @@ pub async fn get(pool: &PgPool, id: Uuid, workspace_id: Uuid) -> Result<Option<M
|
|||||||
status: r.get("status"),
|
status: r.get("status"),
|
||||||
description: r.get("description"),
|
description: r.get("description"),
|
||||||
config: r.get("config"),
|
config: r.get("config"),
|
||||||
|
runtime_kind: r.get("runtime_kind"),
|
||||||
|
target_node_id: r.get("target_node_id"),
|
||||||
created_at: r.get("created_at"),
|
created_at: r.get("created_at"),
|
||||||
updated_at: r.get("updated_at"),
|
updated_at: r.get("updated_at"),
|
||||||
completed_at: r.get("completed_at"),
|
completed_at: r.get("completed_at"),
|
||||||
@@ -204,7 +218,8 @@ pub async fn list_by_workspace(
|
|||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT id, workspace_id, title, template_kind, team_id,
|
"SELECT id, workspace_id, title, template_kind, team_id,
|
||||||
team_template_id, repo_id, schedule, status,
|
team_template_id, repo_id, schedule, status,
|
||||||
description, config, created_at, updated_at, completed_at
|
description, config, runtime_kind, target_node_id,
|
||||||
|
created_at, updated_at, completed_at
|
||||||
FROM missions WHERE workspace_id = $1
|
FROM missions WHERE workspace_id = $1
|
||||||
ORDER BY created_at DESC LIMIT $2",
|
ORDER BY created_at DESC LIMIT $2",
|
||||||
)
|
)
|
||||||
@@ -226,6 +241,8 @@ pub async fn list_by_workspace(
|
|||||||
status: r.get("status"),
|
status: r.get("status"),
|
||||||
description: r.get("description"),
|
description: r.get("description"),
|
||||||
config: r.get("config"),
|
config: r.get("config"),
|
||||||
|
runtime_kind: r.get("runtime_kind"),
|
||||||
|
target_node_id: r.get("target_node_id"),
|
||||||
created_at: r.get("created_at"),
|
created_at: r.get("created_at"),
|
||||||
updated_at: r.get("updated_at"),
|
updated_at: r.get("updated_at"),
|
||||||
completed_at: r.get("completed_at"),
|
completed_at: r.get("completed_at"),
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export interface Schedule {
|
|||||||
event?: string;
|
event?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RuntimeKind = "zeroclaw" | "local_herdr";
|
||||||
|
|
||||||
export interface Mission {
|
export interface Mission {
|
||||||
id: string;
|
id: string;
|
||||||
workspace_id: string;
|
workspace_id: string;
|
||||||
@@ -60,6 +62,8 @@ export interface Mission {
|
|||||||
status: MissionStatus;
|
status: MissionStatus;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
|
runtime_kind: RuntimeKind;
|
||||||
|
target_node_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
@@ -165,6 +169,8 @@ export interface CreateMissionRequest {
|
|||||||
description?: string;
|
description?: string;
|
||||||
config?: Record<string, unknown>;
|
config?: Record<string, unknown>;
|
||||||
phases?: PhaseSpec[];
|
phases?: PhaseSpec[];
|
||||||
|
runtime_kind?: RuntimeKind;
|
||||||
|
target_node_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- Herdr second-runtime path (Phase 1a).
|
||||||
|
--
|
||||||
|
-- Missions can now execute either through the shared ZeroClaw daemon
|
||||||
|
-- (default, headless, browser-driven) OR through a Herdr session on
|
||||||
|
-- a specific fleet node (attended, operator-visible, uses that node's
|
||||||
|
-- local CLIs). Frontend picks in the wizard; mission_orchestrator +
|
||||||
|
-- phase executors dispatch on runtime_kind.
|
||||||
|
--
|
||||||
|
-- topology_runs grows a nullable (workspace, tab, pane) triple so a
|
||||||
|
-- run resumed after a server restart can reattach to the same Herdr
|
||||||
|
-- pane instead of spawning a duplicate.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE missions
|
||||||
|
ADD COLUMN runtime_kind TEXT NOT NULL DEFAULT 'zeroclaw'
|
||||||
|
CHECK (runtime_kind IN ('zeroclaw', 'local_herdr')),
|
||||||
|
ADD COLUMN target_node_id UUID REFERENCES nodes(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX missions_target_node_idx
|
||||||
|
ON missions (target_node_id)
|
||||||
|
WHERE target_node_id IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE topology_runs
|
||||||
|
ADD COLUMN herdr_workspace_id TEXT,
|
||||||
|
ADD COLUMN herdr_tab_id TEXT,
|
||||||
|
ADD COLUMN herdr_pane_id TEXT;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Reference in New Issue
Block a user