Files
clawmates/migrations/0047_missions.sql
T
Omar SobhandClaude Opus 4.7 fbefc67878
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 9s
ci / frontend (push) Successful in 25s
ci / e2e (push) Skipped
ci / publish (push) Skipped
slice 1: missions data model + migration
Introduce the unified `missions` tier that will replace the current
research_topics + loops split. This slice ships the data model +
backfill + skeleton REST surface; the old wizards keep working in
parallel until Slice 9's big-bang cutover.

Migration 0047 adds:
  - missions              (top-level workflow: template_kind + team +
                           schedule + status + config)
  - mission_phases        (ordered {research|coding|benchmark|
                           security_scan} phases per mission)
  - mission_tasks         (typed units of work, e.g. INT-XX cards,
                           UPSERT-keyed on (phase_id, external_id))
  - mission_artifacts     (MD/PDF/benchmark/security/diff files with
                           a pending queue for the PDF renderer worker)
  - benchmark_snapshots   (before/after pairs per iteration)

Backfill copies existing research_topics + loops rows into the new
tables as one-shot missions with the appropriate template_kind, so
Slice 2's UI can render the full history immediately.

New Rust surface:
  - cm_domain: MissionId, MissionPhaseId, MissionTaskId, MissionArtifactId
  - cm_db::repo::missions: Mission/MissionPhase/MissionTask/
    MissionArtifact structs + insert (txn-wrapped)/get/list/set_status/
    phases_for/set_phase_status/upsert_task/tasks_for/register_artifact/
    artifacts_for/next_pdf_pending/set_pdf_result
  - cm_api::routes::missions: skeleton list/create/get/set_status
    routes registered at /api/missions/*

Follow-up slices layer richer behavior (template dispatch, phase
execution, task parsing, artifact rendering) on this foundation.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-07-19 11:40:51 -07:00

293 lines
12 KiB
SQL

-- Slice 1 of the missions unification (see design notes).
--
-- A `mission` is a single top-level user-driven workflow. It replaces
-- the current two-headed split between `research_topics` and `loops`:
-- both of those become special cases of a mission composed of phases.
--
-- This migration ONLY adds the new tables + backfills existing rows.
-- Old tables (research_topics, loops, research_topic_agents,
-- research_outcomes, ...) stay live and writable until Slice 9's
-- big-bang cutover deletes them. Reads route through the compat
-- shims added in Slice 2; the old wizards keep working unchanged.
--
-- Discriminators — kept as TEXT (not enums) so new templates + phase
-- kinds ship as PRs without schema migrations.
-- missions.template_kind ∈
-- {research_only, research_and_code, security_hardening,
-- refactor, benchmark, custom}
-- mission_phases.kind ∈
-- {research, coding, benchmark, security_scan}
-- mission_tasks.status ∈
-- {created, working, validating, complete, failed}
-- mission_artifacts.kind ∈
-- {md, pdf, benchmark_result, security_report, code_diff, index}
CREATE TABLE missions (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL,
title TEXT NOT NULL,
template_kind TEXT NOT NULL,
-- team_id is nullable so a mission can start without a bound team
-- (auto-provision materializes one during phase startup).
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
-- team_template_id captures template lineage — level-up promotions
-- diff against the template version the team was minted from.
team_template_id UUID,
repo_id UUID REFERENCES repos(id) ON DELETE SET NULL,
-- schedule JSONB carries the trigger config (cron | one_shot |
-- on_event). Kept as JSONB so we can grow the schedule surface
-- without a table alter each time.
schedule JSONB NOT NULL DEFAULT '{"kind":"one_shot"}'::jsonb,
-- Mission-level status. Rolls up phase statuses per lifecycle rules
-- enforced in cm-api::routes::missions.
-- draft → running → (completed | failed | cancelled)
status TEXT NOT NULL DEFAULT 'draft',
-- Human-facing description + optional target subject. Both are
-- shown in the canvas overview and passed to the LLM as context.
description TEXT,
-- Free-form config for the specific workflow template (task
-- template overrides, benchmark commands, LLM overrides for PDF
-- rendering, etc.). Every template contributes its own keys.
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
CREATE INDEX missions_workspace_idx
ON missions (workspace_id, created_at DESC);
CREATE INDEX missions_team_idx
ON missions (team_id) WHERE team_id IS NOT NULL;
CREATE INDEX missions_status_idx
ON missions (status, updated_at DESC);
-- A phase is one segment of a mission's plan. Ordering by order_idx.
-- Multiple phases of the same kind are allowed (e.g. two coding
-- passes bracketing a benchmark phase).
CREATE TABLE mission_phases (
id UUID PRIMARY KEY,
mission_id UUID NOT NULL REFERENCES missions(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
order_idx INT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
-- Phase-scoped config (e.g. commit_policy, loop bounds, benchmark
-- cmd) merged over the template's phase spec at run time.
config JSONB NOT NULL DEFAULT '{}'::jsonb,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
UNIQUE (mission_id, order_idx)
);
CREATE INDEX mission_phases_mission_idx
ON mission_phases (mission_id, order_idx);
CREATE INDEX mission_phases_status_idx
ON mission_phases (status) WHERE status IN ('pending', 'running');
-- A task is one addressable unit inside a phase. For coding phases
-- that consume an integrations artifact, tasks correspond 1:1 with
-- INT-XX items parsed from run output (Slice 5).
-- Research phases materialize one task per "outcome iteration".
-- Security phases materialize one task per finding.
CREATE TABLE mission_tasks (
id UUID PRIMARY KEY,
mission_id UUID NOT NULL REFERENCES missions(id) ON DELETE CASCADE,
phase_id UUID NOT NULL REFERENCES mission_phases(id) ON DELETE CASCADE,
-- external_id: e.g. "INT-05", "CVE-2024-1234", "OUTCOME-v3".
-- Nullable so ad-hoc tasks work without a parseable marker.
external_id TEXT,
title TEXT NOT NULL,
-- Assigned agent (nullable — some tasks are team-wide).
assigned_agent_id UUID REFERENCES agents(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'created',
-- The topology run that produced this task's most recent state
-- update. Follows the run's lifecycle for observability.
run_id UUID REFERENCES topology_runs(id) ON DELETE SET NULL,
-- Paths to the artifacts this task produced (relative to the
-- mission's artifact root). One task can have many artifacts;
-- kept as an array for cheap lookup without a join table.
artifact_paths TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
CREATE INDEX mission_tasks_mission_idx
ON mission_tasks (mission_id, created_at);
CREATE INDEX mission_tasks_phase_idx
ON mission_tasks (phase_id, status);
CREATE INDEX mission_tasks_agent_idx
ON mission_tasks (assigned_agent_id) WHERE assigned_agent_id IS NOT NULL;
-- Uniqueness on (phase_id, external_id) so the task-card parser (Slice
-- 5) can UPSERT on marker match without duplicating rows.
CREATE UNIQUE INDEX mission_tasks_external_uniq
ON mission_tasks (phase_id, external_id)
WHERE external_id IS NOT NULL;
-- Artifacts land on the filesystem under a per-mission root
-- (see cm-api::missions::artifact_root). We index them here for
-- typed discovery from the UI without walking the filesystem.
CREATE TABLE mission_artifacts (
id UUID PRIMARY KEY,
mission_id UUID NOT NULL REFERENCES missions(id) ON DELETE CASCADE,
phase_id UUID REFERENCES mission_phases(id) ON DELETE SET NULL,
-- Repo-relative path from the mission root, e.g.
-- "research/v3/spec.md" or "benchmarks/before.json".
path TEXT NOT NULL,
kind TEXT NOT NULL,
mime TEXT,
-- Optional back-pointer to the run that produced this artifact.
generated_by_run UUID REFERENCES topology_runs(id) ON DELETE SET NULL,
-- Sidecar for the PDF renderer (Slice 6). NULL until rendered.
rendered_pdf_path TEXT,
render_pdf_status TEXT NOT NULL DEFAULT 'skip',
-- ↑ skip | pending | rendering | done | failed
render_pdf_error TEXT,
-- Human-shown title; falls back to filename when NULL.
title TEXT,
-- Free-form metadata (word count, sha256, source model, etc.).
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (mission_id, path)
);
CREATE INDEX mission_artifacts_mission_idx
ON mission_artifacts (mission_id, created_at DESC);
-- PDF renderer picks up its queue via this partial index.
CREATE INDEX mission_artifacts_pdf_queue_idx
ON mission_artifacts (created_at)
WHERE render_pdf_status = 'pending';
-- Benchmark snapshots — one row per benchmark phase per iteration.
-- Stored as JSONB pairs so the shape can evolve per template
-- (criterion, cargo bench, k6, wrk, custom scripts) without alters.
CREATE TABLE benchmark_snapshots (
id UUID PRIMARY KEY,
mission_id UUID NOT NULL REFERENCES missions(id) ON DELETE CASCADE,
phase_id UUID NOT NULL REFERENCES mission_phases(id) ON DELETE CASCADE,
-- iteration = 0 for the pre-coding baseline; ≥1 for post-iteration
-- snapshots. Uniqueness enforced so re-runs overwrite in place.
iteration INT NOT NULL,
before_metrics JSONB,
after_metrics JSONB,
delta JSONB,
-- Optional descriptor of the benchmark command / driver.
driver TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (phase_id, iteration)
);
CREATE INDEX benchmark_snapshots_mission_idx
ON benchmark_snapshots (mission_id, created_at DESC);
-- ── Backfill ─────────────────────────────────────────────────────
--
-- Every existing research_topic becomes a mission with a single
-- research phase. Every existing loop with source_research_topic_id
-- becomes a research_and_code mission with two phases pointing at the
-- same underlying topic + loop rows through the compat shim.
--
-- Standalone loops (no source topic) become refactor missions with a
-- single coding phase.
--
-- This is a one-shot copy — old rows are NOT deleted (Slice 9). The
-- compat shims read from `missions` first, fall through to the
-- legacy tables when nothing landed, so both surfaces stay coherent
-- during the transition.
-- 1. Research-only backfill.
INSERT INTO missions (
id, workspace_id, title, template_kind, team_id, repo_id,
schedule, status, description, config, created_at, updated_at
)
SELECT
id,
workspace_id,
title,
'research_only',
team_id,
repo_id,
'{"kind":"one_shot"}'::jsonb,
CASE
WHEN status IN ('publishing','complete','archived') THEN 'completed'
WHEN status = 'processing' THEN 'running'
WHEN status = 'failed' THEN 'failed'
ELSE 'draft'
END,
description,
jsonb_build_object(
'legacy_topic_id', id::text,
'outcome_kind', outcome_kind,
'topology_kind', topology_kind
),
created_at,
updated_at
FROM research_topics
WHERE NOT EXISTS (SELECT 1 FROM missions m WHERE m.id = research_topics.id);
INSERT INTO mission_phases (id, mission_id, kind, order_idx, status, config)
SELECT
gen_random_uuid(),
id,
'research',
0,
CASE
WHEN status IN ('publishing','complete','archived') THEN 'completed'
WHEN status = 'processing' THEN 'running'
WHEN status = 'failed' THEN 'failed'
ELSE 'pending'
END,
jsonb_build_object('legacy_topic_id', id::text)
FROM research_topics
WHERE NOT EXISTS (
SELECT 1 FROM mission_phases mp
WHERE mp.mission_id = research_topics.id AND mp.kind = 'research'
);
-- 2. Loop backfill — one mission per loop, template_kind chosen by
-- whether it's paired to a research topic.
INSERT INTO missions (
id, workspace_id, title, template_kind, team_id, repo_id,
schedule, status, description, config, created_at, updated_at
)
SELECT
l.id,
l.workspace_id,
l.title,
CASE
WHEN l.source_research_topic_id IS NOT NULL THEN 'research_and_code'
ELSE 'refactor'
END,
l.team_id,
NULL,
jsonb_build_object(
'kind', CASE WHEN l.enabled THEN 'cron' ELSE 'one_shot' END,
'triggers', l.triggers
),
'running',
l.description,
jsonb_build_object(
'legacy_loop_id', l.id::text,
'source_research_topic', l.source_research_topic_id::text,
'loop_kind', l.kind
),
l.created_at,
l.updated_at
FROM loops l
WHERE NOT EXISTS (SELECT 1 FROM missions m WHERE m.id = l.id);
-- 2a. Research-then-code loops get two phases (research phase already
-- exists as the paired topic mission; we add a coding phase to this
-- mission that points at the same loop_id via config).
INSERT INTO mission_phases (id, mission_id, kind, order_idx, status, config)
SELECT
gen_random_uuid(),
l.id,
'coding',
0,
'pending',
jsonb_build_object(
'legacy_loop_id', l.id::text,
'source_research_topic_id', l.source_research_topic_id::text
)
FROM loops l
WHERE NOT EXISTS (
SELECT 1 FROM mission_phases mp
WHERE mp.mission_id = l.id AND mp.kind = 'coding'
);