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]>
293 lines
12 KiB
SQL
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'
|
|
);
|