migrations: 0030 research_topics + 0031 loops schema
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 24s
ci / rust (push) Successful in 3m44s
ci / publish (push) Successful in 3m6s
ci / e2e (push) Failing after 29m54s

First commit of the Research + Loops feature arc. Schema only — routes,
runtime hooks, and the UI arrive in subsequent commits.

0030 — research_topics
  Container entity with a small state machine (standby → processing →
  reviewing → publishing → published). Owns runs via a nullable
  topology_runs.research_topic_id FK, so all existing SSE/audit/gated-
  approval plumbing surfaces without changes. Many-to-many join table
  captures the role_slot the wizard assigns each agent ("lead", "critic",
  "writer") so the canvas can group avatars sensibly.

0031 — loops
  Durable recurring topology execution. graph + task_template pair with a
  triggers JSONB (any of cron / on_completion / webhook, all can be on
  simultaneously) and a repeat_policy (infinite / N iters / until). Every
  iteration writes a topology_runs row with loop_id, iteration (1-indexed),
  and parent_run_id chained back to N-1 — cross-iteration context comes
  from that hop, no extra state store needed. Webhook auth is HMAC-SHA256
  keyed by webhook_signing_key. Missed cron windows fire once and skip the
  backlog (see comment header).

Both migrations only ADD tables/columns and use ON DELETE SET NULL for the
back-refs, so they're safe to run against prod without downtime. The
existing indexes on topology_runs keep serving legacy (non-research,
non-loop) runs unchanged.

Publish approval extension (approvals.kind for the reviewing → publishing
gate) comes as a separate migration in the publish-gate commit.
This commit is contained in:
Omar Sobh
2026-07-06 04:14:06 -07:00
parent 2527a888a2
commit 258113e4d8
2 changed files with 133 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
-- Research topics: user-scoped inquiry containers that group a set of agents
-- around a shared question and drive them toward a named outcome (spec,
-- prod_plan, roadmap, or paper). The status field is a small state machine
-- driven by the orchestrator + a manual publish gate (approvals; see 0032).
--
-- standby — created, agents assigned, not started
-- processing — running; each iteration writes to topology_runs
-- (with research_topic_id back-ref)
-- reviewing — orchestrator flipped it here on last run_completed;
-- a human approver still has to click Publish
-- publishing — approval landed; artifact assembly + release in flight
-- published — terminal
--
-- A topic OWNS runs (topology_runs.research_topic_id). Runs carry all the
-- durable execution state — the topic row is just the container + status.
CREATE TABLE research_topics (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
title TEXT NOT NULL,
-- LLM-refined description from the wizard: structured markdown with the
-- topic prompt + refined framing + key questions + success criteria.
description TEXT NOT NULL,
outcome_kind TEXT NOT NULL
CHECK (outcome_kind IN ('spec', 'prod_plan', 'roadmap', 'paper')),
status TEXT NOT NULL DEFAULT 'standby'
CHECK (status IN ('standby', 'processing', 'reviewing', 'publishing', 'published')),
created_by UUID NOT NULL REFERENCES users (id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Set on the reviewing→publishing transition (after the publish approval
-- lands). Used by list queries that surface recent publishes.
published_at TIMESTAMPTZ
);
CREATE INDEX research_topics_workspace_idx
ON research_topics (workspace_id, updated_at DESC);
-- Speeds up the "topics in each status" sidebar bucket count.
CREATE INDEX research_topics_status_idx
ON research_topics (workspace_id, status);
-- Assigned agents (many-to-many). role_slot is a free-form label the wizard
-- captures ("lead", "critic", "writer") so the canvas can group avatars
-- when the topic has multiple agents playing different parts.
CREATE TABLE research_topic_agents (
topic_id UUID NOT NULL REFERENCES research_topics (id) ON DELETE CASCADE,
agent_id UUID NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
role_slot TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (topic_id, agent_id)
);
CREATE INDEX research_topic_agents_agent_idx
ON research_topic_agents (agent_id);
-- Back-ref from durable runs to the topic they belong to. Nullable — runs
-- outside a research topic (chat, ad-hoc topology exec) leave this NULL and
-- the existing indexes keep serving them.
ALTER TABLE topology_runs
ADD COLUMN research_topic_id UUID REFERENCES research_topics (id) ON DELETE SET NULL;
CREATE INDEX topology_runs_research_topic_idx
ON topology_runs (research_topic_id, created_at DESC)
WHERE research_topic_id IS NOT NULL;
+71
View File
@@ -0,0 +1,71 @@
-- Loops: durable recurring topology executions. A loop pairs a stored
-- topology graph + task template with a trigger configuration (any of
-- cron / on_completion / webhook) and a repeat policy (infinite / N iters /
-- until condition). Every iteration writes a normal topology_runs row with
-- loop_id + iteration + parent_run_id set so the existing SSE observer,
-- audit log, and gated approvals surface transparently.
--
-- Cross-iteration state: iteration N+1's run driver reads parent_run_id and
-- injects the parent's final assistant message into the first system prompt
-- as prior-iteration context (refine-over-time by default).
--
-- Missed schedules: if the loop scheduler wakes and finds next_fire_at in
-- the past (a restart or long stall), it fires ONCE and computes the next
-- fire from the current cron expression. Missed backlog is not drained.
CREATE TABLE loops (
id UUID PRIMARY KEY,
workspace_id UUID NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL,
-- Same shape as topology_runs.graph: JSON-encoded agent/node connectivity
-- from the wizard's topology builder (or a template pick).
graph JSONB NOT NULL,
-- Text prompt used as the task for every iteration. Templating is out of
-- scope for v1 — the same string runs each time (with prior-iteration
-- output prepended by the run driver for refine-over-time).
task_template TEXT NOT NULL,
-- {cron?: '0 */6 * * *', on_completion?: bool, webhook_enabled?: bool}
-- All three can be enabled simultaneously; whichever fires first wins.
triggers JSONB NOT NULL DEFAULT '{}'::JSONB,
-- {kind: 'infinite' | 'iters' | 'until', spec: <kind-specific>}
-- iters: {n: 10} — stop after N iterations
-- until: {event: 'ok', within_iters: 20} — stop when N events match
repeat_policy JSONB NOT NULL DEFAULT '{"kind":"infinite"}'::JSONB,
enabled BOOLEAN NOT NULL DEFAULT true,
-- Computed by the scheduler from `triggers.cron` after each fire. NULL
-- when no cron trigger is set (loop runs only on webhook / on_completion
-- / manual).
next_fire_at TIMESTAMPTZ,
last_run_id UUID REFERENCES topology_runs (id) ON DELETE SET NULL,
-- Opaque secret ID for the webhook endpoint. HMAC-SHA256 verification of
-- the request body uses webhook_signing_key. Both are NULL when the
-- webhook trigger is disabled; both are set when it's on. Rotation:
-- disable trigger, re-enable to regenerate both.
webhook_token TEXT UNIQUE,
webhook_signing_key TEXT,
created_by UUID NOT NULL REFERENCES users (id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX loops_workspace_idx
ON loops (workspace_id, updated_at DESC);
-- Scheduler tick query: SELECT ... WHERE enabled AND next_fire_at <= now().
-- Partial index keeps the scanner tight; disabled + no-cron loops stay out.
CREATE INDEX loops_next_fire_idx
ON loops (next_fire_at)
WHERE enabled AND next_fire_at IS NOT NULL;
-- Back-refs from durable runs. loop_id groups all iterations of a loop;
-- iteration is 1-indexed per loop; parent_run_id chains iteration N+1 back
-- to N so the run driver can pull prior context in one hop.
ALTER TABLE topology_runs
ADD COLUMN loop_id UUID REFERENCES loops (id) ON DELETE SET NULL,
ADD COLUMN iteration INT,
ADD COLUMN parent_run_id UUID REFERENCES topology_runs (id) ON DELETE SET NULL;
-- Listing "recent iterations of loop X" hits this.
CREATE INDEX topology_runs_loop_idx
ON topology_runs (loop_id, iteration DESC)
WHERE loop_id IS NOT NULL;