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:
Omar Sobh
2026-07-20 09:45:15 -07:00
parent 1f0117e35a
commit d6dbd044c8
4 changed files with 73 additions and 4 deletions
+17
View File
@@ -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()
+21 -4
View File
@@ -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"),
+6
View File
@@ -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> {
+29
View File
@@ -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;