-- 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' );