From 9405da8a4561aa982af9a2f2497c3752708441e4 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 7 Jul 2026 14:51:19 -0500 Subject: [PATCH] feat(convoys): convoy lifecycle MCP and state machine (Epic 4) Adds server-enforced convoy transitions with gate actor guards, convoy_events logging, and six MCP tools (create, brief, transition, log, query, reconcile). Co-authored-by: Cursor --- apps/mcp-server/package.json | 1 + apps/mcp-server/src/drizzle.ts | 2 + apps/mcp-server/src/index.ts | 2 +- apps/mcp-server/src/lib/convoy-files.ts | 147 + apps/mcp-server/src/lib/convoy-objects.ts | 94 + apps/mcp-server/src/tools/create-brief.ts | 76 + apps/mcp-server/src/tools/create-convoy.ts | 85 + apps/mcp-server/src/tools/index.ts | 18 + apps/mcp-server/src/tools/log-convoy-event.ts | 82 + .../src/tools/query-manifest-status.ts | 74 + .../src/tools/reconcile-from-files.ts | 155 + .../src/tools/transition-convoy-status.ts | 102 + docs/convoy-lifecycle.md | 38 + .../database/migrations/0013_crazy_joseph.sql | 28 + .../migrations/meta/0013_snapshot.json | 3750 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/src/schema/convoy_events.ts | 57 + packages/database/src/schema/index.ts | 1 + packages/database/src/schema/relations.ts | 19 + packages/shared/package.json | 1 + packages/shared/src/convoy-lifecycle/index.ts | 28 + .../shared/src/convoy-lifecycle/schemas.ts | 85 + .../convoy-lifecycle/state-machine.test.ts | 78 + .../src/convoy-lifecycle/state-machine.ts | 211 + packages/shared/src/index.ts | 1 + .../Epic-convoy-lifecycle-mcp.md | 12 +- .../Task-db-convoy-events-migration.md | 22 + .../Task-mcp-convoy-lifecycle-tools.md | 22 + .../Task-shared-convoy-state-machine.md | 22 + .../Plan-automation-foundation.md | 2 +- pnpm-lock.yaml | 3 + 31 files changed, 5218 insertions(+), 7 deletions(-) create mode 100644 apps/mcp-server/src/lib/convoy-files.ts create mode 100644 apps/mcp-server/src/lib/convoy-objects.ts create mode 100644 apps/mcp-server/src/tools/create-brief.ts create mode 100644 apps/mcp-server/src/tools/create-convoy.ts create mode 100644 apps/mcp-server/src/tools/log-convoy-event.ts create mode 100644 apps/mcp-server/src/tools/query-manifest-status.ts create mode 100644 apps/mcp-server/src/tools/reconcile-from-files.ts create mode 100644 apps/mcp-server/src/tools/transition-convoy-status.ts create mode 100644 docs/convoy-lifecycle.md create mode 100644 packages/database/migrations/0013_crazy_joseph.sql create mode 100644 packages/database/migrations/meta/0013_snapshot.json create mode 100644 packages/database/src/schema/convoy_events.ts create mode 100644 packages/shared/src/convoy-lifecycle/index.ts create mode 100644 packages/shared/src/convoy-lifecycle/schemas.ts create mode 100644 packages/shared/src/convoy-lifecycle/state-machine.test.ts create mode 100644 packages/shared/src/convoy-lifecycle/state-machine.ts create mode 100644 plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-db-convoy-events-migration.md create mode 100644 plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-mcp-convoy-lifecycle-tools.md create mode 100644 plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-shared-convoy-state-machine.md diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index 042e11c..34f0856 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -14,6 +14,7 @@ "@modelcontextprotocol/sdk": "^1.4.0", "@tasks/database": "workspace:*", "@tasks/shared": "workspace:*", + "yaml": "^2.8.3", "zod": "^3.24.0" }, "devDependencies": { diff --git a/apps/mcp-server/src/drizzle.ts b/apps/mcp-server/src/drizzle.ts index 2106053..cbf4b9a 100644 --- a/apps/mcp-server/src/drizzle.ts +++ b/apps/mcp-server/src/drizzle.ts @@ -15,8 +15,10 @@ const d: any = require(drizzlePath); export const and = d.and; export const asc = d.asc; +export const desc = d.desc; export const eq = d.eq; export const ilike = d.ilike; export const inArray = d.inArray; export const isNull = d.isNull; export const or = d.or; +export const sql = d.sql; diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index 8d5b0f6..72b4116 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -10,7 +10,7 @@ const mcp = new McpServer( }, { instructions: - "Tools and resources for the Tasks project management app: objects, workspaces, views, and templates.", + "Tools and resources for Echodo: objects, workspaces, backlog claim/complete, and convoy lifecycle (create_convoy, transition_convoy_status, reconcile_from_files).", }, ); diff --git a/apps/mcp-server/src/lib/convoy-files.ts b/apps/mcp-server/src/lib/convoy-files.ts new file mode 100644 index 0000000..1b1d115 --- /dev/null +++ b/apps/mcp-server/src/lib/convoy-files.ts @@ -0,0 +1,147 @@ +// @ts-nocheck — Node built-ins; MCP tsconfig may omit @types/node in some paths. +import { existsSync, readdirSync, readFileSync, appendFileSync, writeFileSync } from "node:fs"; +import { basename, join } from "node:path"; +import { parse as parseYaml } from "yaml"; + +import type { ConvoyClassification, ConvoyFileStatus } from "@tasks/shared/convoy-lifecycle"; + +const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; + +export type ParsedConvoyFile = { + filePath: string; + slug: string; + classification: ConvoyClassification | null; + successMetric: string | null; + skipFlags: string[]; + fileStatus: ConvoyFileStatus | null; + bodyMarkdown: string; + modelPolicy: Record | null; +}; + +export type PendingMcpSyncEntry = { + ts: string; + tool: string; + args: Record; + error: string; +}; + +function readString(fm: Record, key: string): string | null { + const value = fm[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function readStringArray(fm: Record, key: string): string[] { + const value = fm[key]; + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === "string"); + } + return []; +} + +export function parseConvoyMarkdownFile( + filePath: string, + raw: string, +): ParsedConvoyFile | null { + const fmMatch = raw.match(FRONTMATTER); + let frontmatter: Record = {}; + let bodyMarkdown = raw; + if (fmMatch) { + try { + const parsed = parseYaml(fmMatch[1]); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + frontmatter = parsed as Record; + } + } catch { + return null; + } + bodyMarkdown = raw.slice(fmMatch[0].length); + } + + const slug = + readString(frontmatter, "name") ?? + basename(filePath).replace(/\.md$/i, ""); + + const classification = readString(frontmatter, "classification"); + const fileStatus = readString(frontmatter, "status"); + const modelPolicyRaw = frontmatter.model_policy; + const modelPolicy = + modelPolicyRaw && typeof modelPolicyRaw === "object" && !Array.isArray(modelPolicyRaw) + ? (modelPolicyRaw as Record) + : null; + + return { + filePath, + slug, + classification: classification as ConvoyClassification | null, + successMetric: readString(frontmatter, "success_metric"), + skipFlags: readStringArray(frontmatter, "skip"), + fileStatus: fileStatus as ConvoyFileStatus | null, + bodyMarkdown, + modelPolicy, + }; +} + +export function listConvoyMarkdownFiles(repoRoot: string): ParsedConvoyFile[] { + const convoysDir = join(repoRoot, ".convoys"); + if (!existsSync(convoysDir)) { + return []; + } + + const results: ParsedConvoyFile[] = []; + for (const entry of readdirSync(convoysDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "README.md") { + continue; + } + const filePath = join(convoysDir, entry.name); + const raw = readFileSync(filePath, "utf8"); + const parsed = parseConvoyMarkdownFile(filePath, raw); + if (parsed) { + results.push(parsed); + } + } + return results; +} + +export function pendingSyncPath(repoRoot: string): string { + return join(repoRoot, ".convoys", ".pending-mcp-sync.jsonl"); +} + +export function appendPendingSync( + repoRoot: string, + entry: Omit & { ts?: string }, +): void { + const path = pendingSyncPath(repoRoot); + const line = JSON.stringify({ + ts: entry.ts ?? new Date().toISOString(), + tool: entry.tool, + args: entry.args, + error: entry.error, + }); + appendFileSync(path, `${line}\n`, "utf8"); +} + +export function readPendingSync(repoRoot: string): PendingMcpSyncEntry[] { + const path = pendingSyncPath(repoRoot); + if (!existsSync(path)) { + return []; + } + const raw = readFileSync(path, "utf8"); + const entries: PendingMcpSyncEntry[] = []; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line) as PendingMcpSyncEntry; + entries.push(parsed); + } catch { + // skip corrupt lines + } + } + return entries; +} + +export function clearPendingSync(repoRoot: string): void { + const path = pendingSyncPath(repoRoot); + if (existsSync(path)) { + writeFileSync(path, "", "utf8"); + } +} diff --git a/apps/mcp-server/src/lib/convoy-objects.ts b/apps/mcp-server/src/lib/convoy-objects.ts new file mode 100644 index 0000000..e6ab485 --- /dev/null +++ b/apps/mcp-server/src/lib/convoy-objects.ts @@ -0,0 +1,94 @@ +import { and, eq, sql } from "../drizzle.js"; +import { db } from "../db.js"; +import { convoyEvents, objects } from "../schema.js"; +import { + convoyObjectContentSchema, + type ConvoyObjectContent, + type ConvoyStatus, +} from "@tasks/shared/convoy-lifecycle"; + +export async function findConvoyBySlug(workspaceId: string, slug: string) { + const [row] = await db + .select() + .from(objects) + .where( + and( + eq(objects.workspaceId, workspaceId), + eq(objects.type, "project"), + sql`${objects.content} ->> 'convoySlug' = ${slug}`, + ), + ) + .limit(1); + return row ?? null; +} + +export async function loadConvoyProject(convoyId: string, workspaceId: string) { + const [row] = await db + .select() + .from(objects) + .where( + and( + eq(objects.id, convoyId), + eq(objects.workspaceId, workspaceId), + eq(objects.type, "project"), + ), + ) + .limit(1); + return row ?? null; +} + +export function parseConvoyContent(content: unknown): ConvoyObjectContent | null { + const parsed = convoyObjectContentSchema.safeParse(content); + return parsed.success ? parsed.data : null; +} + +export async function insertConvoyEvent(input: { + workspaceId: string; + convoyId: string; + briefId?: string | null; + role: string; + actor?: string | null; + eventType: string; + classification?: string | null; + skipFlags?: string[] | null; + durationS?: number | null; + outcome?: string | null; + repo?: string | null; + multitaskGroup?: string | null; + modelAlias?: string | null; + providerType?: string | null; + modelId?: string | null; + modelTier?: string | null; + estimatedCostUsd?: string | null; + metadata?: Record | null; +}) { + const [row] = await db + .insert(convoyEvents) + .values({ + workspaceId: input.workspaceId, + convoyId: input.convoyId, + briefId: input.briefId ?? null, + role: input.role, + actor: input.actor ?? null, + eventType: input.eventType, + classification: input.classification ?? null, + skipFlags: input.skipFlags ?? null, + durationS: input.durationS ?? null, + outcome: input.outcome ?? null, + repo: input.repo ?? null, + multitaskGroup: input.multitaskGroup ?? null, + modelAlias: input.modelAlias ?? null, + providerType: input.providerType ?? null, + modelId: input.modelId ?? null, + modelTier: input.modelTier ?? null, + estimatedCostUsd: input.estimatedCostUsd ?? null, + metadata: input.metadata ?? null, + }) + .returning(); + return row; +} + +export function currentConvoyStatus(row: { status: string | null }): ConvoyStatus { + const status = row.status ?? "draft"; + return status as ConvoyStatus; +} diff --git a/apps/mcp-server/src/tools/create-brief.ts b/apps/mcp-server/src/tools/create-brief.ts new file mode 100644 index 0000000..d712842 --- /dev/null +++ b/apps/mcp-server/src/tools/create-brief.ts @@ -0,0 +1,76 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { briefObjectContentSchema } from "@tasks/shared/convoy-lifecycle"; + +import { db } from "../db.js"; +import { objects } from "../schema.js"; +import { loadConvoyProject } from "../lib/convoy-objects.js"; +import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; +import { toolCatch, toolErr, toolOk } from "./tool-result.js"; + +const createBriefInputSchema = z.object({ + workspace: z.string().min(1), + convoyId: z.string().uuid(), + briefNumber: z.number().int().positive(), + title: z.string().min(1).max(500), + files: z.array(z.string()).default([]), + sliceDependencies: z.array(z.number().int().positive()).default([]), + acceptance: z.string().optional(), + bodyMarkdown: z.string().optional(), +}); + +export function registerCreateBriefTool(mcp: McpServer): void { + mcp.registerTool( + "create_brief", + { + description: + "Create an implementer brief as a task object child of a convoy project.", + inputSchema: createBriefInputSchema, + }, + async (args) => { + try { + const input = createBriefInputSchema.parse(args); + const ws = await resolveWorkspaceHandle(input.workspace); + + const convoy = await loadConvoyProject(input.convoyId, ws.id); + if (!convoy) { + return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`); + } + + const content = briefObjectContentSchema.parse({ + briefNumber: input.briefNumber, + files: input.files, + sliceDependencies: input.sliceDependencies, + acceptance: input.acceptance, + }); + + const [created] = await db + .insert(objects) + .values({ + type: "task", + title: input.title, + parentId: convoy.id, + workspaceId: ws.id, + description: input.bodyMarkdown ?? input.acceptance ?? null, + status: "ready", + content, + sortOrder: input.briefNumber, + }) + .returning(); + + if (!created) { + return toolErr("Failed to create brief"); + } + + return toolOk({ + workspace: { id: ws.id, slug: ws.slug, name: ws.name }, + convoyId: convoy.id, + brief: created, + }); + } catch (e) { + return toolCatch(e); + } + }, + ); +} diff --git a/apps/mcp-server/src/tools/create-convoy.ts b/apps/mcp-server/src/tools/create-convoy.ts new file mode 100644 index 0000000..30cb24e --- /dev/null +++ b/apps/mcp-server/src/tools/create-convoy.ts @@ -0,0 +1,85 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { + convoyClassificationSchema, + convoyObjectContentSchema, +} from "@tasks/shared/convoy-lifecycle"; + +import { db } from "../db.js"; +import { objects } from "../schema.js"; +import { findConvoyBySlug } from "../lib/convoy-objects.js"; +import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; +import { toolCatch, toolErr, toolOk } from "./tool-result.js"; + +const createConvoyInputSchema = z.object({ + workspace: z.string().min(1), + slug: z + .string() + .min(1) + .max(120) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "slug must be kebab-case"), + classification: convoyClassificationSchema, + skipFlags: z.array(z.string()).default([]), + successMetric: z.string().min(1).max(2_000), + ideaMarkdown: z.string().min(1), + modelPolicy: z + .object({ + roles: z.record(z.string()).optional(), + escalate_to: z.string().optional(), + }) + .optional(), +}); + +export function registerCreateConvoyTool(mcp: McpServer): void { + mcp.registerTool( + "create_convoy", + { + description: + "Create a convoy project object (type=project, status=draft) in the workspace convoys-* tenant.", + inputSchema: createConvoyInputSchema, + }, + async (args) => { + try { + const input = createConvoyInputSchema.parse(args); + const ws = await resolveWorkspaceHandle(input.workspace); + + const existing = await findConvoyBySlug(ws.id, input.slug); + if (existing) { + return toolErr(`Convoy slug '${input.slug}' already exists in workspace ${ws.slug}`); + } + + const content = convoyObjectContentSchema.parse({ + convoySlug: input.slug, + classification: input.classification, + skipFlags: input.skipFlags, + successMetric: input.successMetric, + modelPolicy: input.modelPolicy, + }); + + const [created] = await db + .insert(objects) + .values({ + type: "project", + title: input.slug, + workspaceId: ws.id, + description: input.ideaMarkdown, + status: "draft", + content, + }) + .returning(); + + if (!created) { + return toolErr("Failed to create convoy"); + } + + return toolOk({ + workspace: { id: ws.id, slug: ws.slug, name: ws.name }, + convoy: created, + }); + } catch (e) { + return toolCatch(e); + } + }, + ); +} diff --git a/apps/mcp-server/src/tools/index.ts b/apps/mcp-server/src/tools/index.ts index 5c77780..ce29082 100644 --- a/apps/mcp-server/src/tools/index.ts +++ b/apps/mcp-server/src/tools/index.ts @@ -6,6 +6,12 @@ import { registerSearchObjectsTool } from "./search-objects.js"; import { registerUpdateObjectTool } from "./update-object.js"; import { registerClaimTaskTool } from "./claim-task.js"; import { registerCompleteTaskTool } from "./complete-task.js"; +import { registerCreateConvoyTool } from "./create-convoy.js"; +import { registerCreateBriefTool } from "./create-brief.js"; +import { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js"; +import { registerLogConvoyEventTool } from "./log-convoy-event.js"; +import { registerQueryManifestStatusTool } from "./query-manifest-status.js"; +import { registerReconcileFromFilesTool } from "./reconcile-from-files.js"; export function registerTools(mcp: McpServer): void { registerCreateObjectTool(mcp); @@ -15,6 +21,12 @@ export function registerTools(mcp: McpServer): void { registerManageObjectTool(mcp); registerClaimTaskTool(mcp); registerCompleteTaskTool(mcp); + registerCreateConvoyTool(mcp); + registerCreateBriefTool(mcp); + registerTransitionConvoyStatusTool(mcp); + registerLogConvoyEventTool(mcp); + registerQueryManifestStatusTool(mcp); + registerReconcileFromFilesTool(mcp); } export { registerCreateObjectTool } from "./create-object.js"; @@ -24,3 +36,9 @@ export { registerListObjectsTool } from "./list-objects.js"; export { registerManageObjectTool } from "./manage-object.js"; export { registerClaimTaskTool } from "./claim-task.js"; export { registerCompleteTaskTool } from "./complete-task.js"; +export { registerCreateConvoyTool } from "./create-convoy.js"; +export { registerCreateBriefTool } from "./create-brief.js"; +export { registerTransitionConvoyStatusTool } from "./transition-convoy-status.js"; +export { registerLogConvoyEventTool } from "./log-convoy-event.js"; +export { registerQueryManifestStatusTool } from "./query-manifest-status.js"; +export { registerReconcileFromFilesTool } from "./reconcile-from-files.js"; diff --git a/apps/mcp-server/src/tools/log-convoy-event.ts b/apps/mcp-server/src/tools/log-convoy-event.ts new file mode 100644 index 0000000..c9dc686 --- /dev/null +++ b/apps/mcp-server/src/tools/log-convoy-event.ts @@ -0,0 +1,82 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { convoyEventTypeSchema } from "@tasks/shared/convoy-lifecycle"; + +import { + insertConvoyEvent, + loadConvoyProject, +} from "../lib/convoy-objects.js"; +import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; +import { toolCatch, toolErr, toolOk } from "./tool-result.js"; + +const logConvoyEventInputSchema = z.object({ + workspace: z.string().min(1), + convoyId: z.string().uuid(), + role: z.string().min(1).max(64), + eventType: convoyEventTypeSchema.default("role_complete"), + briefId: z.string().uuid().optional(), + actor: z.string().min(1).max(64).optional(), + classification: z.string().optional(), + skipFlags: z.array(z.string()).optional(), + durationS: z.number().int().nonnegative().optional(), + outcome: z.string().optional(), + repo: z.string().optional(), + multitaskGroup: z.string().optional(), + modelAlias: z.string().optional(), + providerType: z.string().optional(), + modelId: z.string().optional(), + modelTier: z.string().optional(), + estimatedCostUsd: z.number().nonnegative().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export function registerLogConvoyEventTool(mcp: McpServer): void { + mcp.registerTool( + "log_convoy_event", + { + description: + "Append a convoy_events row (role metrics, status notes) for analytics and mission control.", + inputSchema: logConvoyEventInputSchema, + }, + async (args) => { + try { + const input = logConvoyEventInputSchema.parse(args); + const ws = await resolveWorkspaceHandle(input.workspace); + + const convoy = await loadConvoyProject(input.convoyId, ws.id); + if (!convoy) { + return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`); + } + + const event = await insertConvoyEvent({ + workspaceId: ws.id, + convoyId: convoy.id, + briefId: input.briefId ?? null, + role: input.role, + actor: input.actor ?? input.role, + eventType: input.eventType, + classification: input.classification ?? null, + skipFlags: input.skipFlags ?? null, + durationS: input.durationS ?? null, + outcome: input.outcome ?? null, + repo: input.repo ?? null, + multitaskGroup: input.multitaskGroup ?? null, + modelAlias: input.modelAlias ?? null, + providerType: input.providerType ?? null, + modelId: input.modelId ?? null, + modelTier: input.modelTier ?? null, + estimatedCostUsd: + input.estimatedCostUsd !== undefined + ? String(input.estimatedCostUsd) + : null, + metadata: input.metadata ?? null, + }); + + return toolOk({ event }); + } catch (e) { + return toolCatch(e); + } + }, + ); +} diff --git a/apps/mcp-server/src/tools/query-manifest-status.ts b/apps/mcp-server/src/tools/query-manifest-status.ts new file mode 100644 index 0000000..da9c3bb --- /dev/null +++ b/apps/mcp-server/src/tools/query-manifest-status.ts @@ -0,0 +1,74 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { and, desc, eq } from "../drizzle.js"; +import { db } from "../db.js"; +import { convoyEvents, objects } from "../schema.js"; +import { + currentConvoyStatus, + loadConvoyProject, + parseConvoyContent, +} from "../lib/convoy-objects.js"; +import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; +import { toolCatch, toolErr, toolOk } from "./tool-result.js"; + +const queryManifestInputSchema = z.object({ + workspace: z.string().min(1), + convoyId: z.string().uuid(), + eventLimit: z.number().int().min(1).max(100).default(20), +}); + +export function registerQueryManifestStatusTool(mcp: McpServer): void { + mcp.registerTool( + "query_manifest_status", + { + description: + "Return convoy project status, metadata, child brief count, and recent convoy_events.", + inputSchema: queryManifestInputSchema, + }, + async (args) => { + try { + const input = queryManifestInputSchema.parse(args); + const ws = await resolveWorkspaceHandle(input.workspace); + + const convoy = await loadConvoyProject(input.convoyId, ws.id); + if (!convoy) { + return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`); + } + + const briefs = await db + .select({ id: objects.id, title: objects.title, status: objects.status }) + .from(objects) + .where( + and(eq(objects.parentId, convoy.id), eq(objects.workspaceId, ws.id)), + ); + + const events = await db + .select() + .from(convoyEvents) + .where( + and( + eq(convoyEvents.convoyId, convoy.id), + eq(convoyEvents.workspaceId, ws.id), + ), + ) + .orderBy(desc(convoyEvents.occurredAt)) + .limit(input.eventLimit); + + return toolOk({ + convoy: { + id: convoy.id, + title: convoy.title, + status: currentConvoyStatus(convoy), + content: parseConvoyContent(convoy.content), + updatedAt: convoy.updatedAt, + }, + briefs, + recentEvents: events, + }); + } catch (e) { + return toolCatch(e); + } + }, + ); +} diff --git a/apps/mcp-server/src/tools/reconcile-from-files.ts b/apps/mcp-server/src/tools/reconcile-from-files.ts new file mode 100644 index 0000000..0d90329 --- /dev/null +++ b/apps/mcp-server/src/tools/reconcile-from-files.ts @@ -0,0 +1,155 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { basename } from "node:path"; + +import { + convoyClassificationSchema, + convoyObjectContentSchema, + mapFileStatusToServer, +} from "@tasks/shared/convoy-lifecycle"; + +import { eq } from "../drizzle.js"; +import { db } from "../db.js"; +import { objects } from "../schema.js"; +import { + clearPendingSync, + listConvoyMarkdownFiles, + readPendingSync, +} from "../lib/convoy-files.js"; +import { + findConvoyBySlug, + insertConvoyEvent, +} from "../lib/convoy-objects.js"; +import { resolveRepoRoot } from "../lib/repo-root.js"; +import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; +import { toolCatch, toolErr, toolOk } from "./tool-result.js"; + +const reconcileInputSchema = z.object({ + workspace: z.string().min(1), + repoPath: z.string().optional(), +}); + +export function registerReconcileFromFilesTool(mcp: McpServer): void { + mcp.registerTool( + "reconcile_from_files", + { + description: + "Sync `.convoys/*.md` files into Echodo project objects and report pending MCP outbox entries.", + inputSchema: reconcileInputSchema, + }, + async (args) => { + try { + const input = reconcileInputSchema.parse(args); + const ws = await resolveWorkspaceHandle(input.workspace); + const repoRoot = input.repoPath?.trim() || resolveRepoRoot(); + if (!repoRoot) { + return toolErr( + "Repo root not found. Set MARKDOWN_BACKLOG_REPO_ROOT or pass repoPath.", + ); + } + + const files = listConvoyMarkdownFiles(repoRoot); + const pending = readPendingSync(repoRoot); + const synced: Array<{ slug: string; convoyId: string; created: boolean }> = []; + const skipped: Array<{ slug: string; reason: string }> = []; + + for (const file of files) { + if (!file.classification || !file.successMetric) { + skipped.push({ + slug: file.slug, + reason: "missing classification or success_metric in frontmatter", + }); + continue; + } + + const classification = convoyClassificationSchema.safeParse(file.classification); + if (!classification.success) { + skipped.push({ slug: file.slug, reason: "invalid classification" }); + continue; + } + + const content = convoyObjectContentSchema.parse({ + convoySlug: file.slug, + classification: classification.data, + skipFlags: file.skipFlags, + successMetric: file.successMetric, + modelPolicy: file.modelPolicy ?? undefined, + }); + + let convoy = await findConvoyBySlug(ws.id, file.slug); + let created = false; + + if (!convoy) { + const [inserted] = await db + .insert(objects) + .values({ + type: "project", + title: file.slug, + workspaceId: ws.id, + description: file.bodyMarkdown.trim() || null, + status: mapFileStatusToServer(file.fileStatus ?? "open") ?? "draft", + content, + }) + .returning(); + if (!inserted) { + skipped.push({ slug: file.slug, reason: "insert failed" }); + continue; + } + convoy = inserted; + created = true; + } else { + const mappedStatus = file.fileStatus + ? mapFileStatusToServer(file.fileStatus) + : null; + const [updated] = await db + .update(objects) + .set({ + description: file.bodyMarkdown.trim() || convoy.description, + content, + ...(mappedStatus ? { status: mappedStatus } : {}), + updatedAt: new Date(), + }) + .where(eq(objects.id, convoy.id)) + .returning(); + convoy = updated ?? convoy; + } + + await insertConvoyEvent({ + workspaceId: ws.id, + convoyId: convoy.id, + role: "reconcile_from_files", + actor: "automation", + eventType: "reconcile", + repo: basename(repoRoot), + metadata: { + filePath: file.filePath, + fileStatus: file.fileStatus, + created, + }, + }); + + synced.push({ slug: file.slug, convoyId: convoy.id, created }); + } + + if (pending.length > 0) { + clearPendingSync(repoRoot); + } + + return toolOk({ + workspace: { id: ws.id, slug: ws.slug, name: ws.name }, + repoRoot, + synced, + skipped, + clearedPendingEntries: pending.length, + pendingReplayNote: + pending.length > 0 + ? "Pending outbox entries were cleared after reporting; replay tooling is deferred." + : null, + pending, + }); + } catch (e) { + return toolCatch(e); + } + }, + ); +} diff --git a/apps/mcp-server/src/tools/transition-convoy-status.ts b/apps/mcp-server/src/tools/transition-convoy-status.ts new file mode 100644 index 0000000..5d23c9c --- /dev/null +++ b/apps/mcp-server/src/tools/transition-convoy-status.ts @@ -0,0 +1,102 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +import { + ConvoyTransitionError, + convoyStatusSchema, + validateConvoyTransition, +} from "@tasks/shared/convoy-lifecycle"; + +import { eq } from "../drizzle.js"; +import { db } from "../db.js"; +import { objects } from "../schema.js"; +import { + currentConvoyStatus, + insertConvoyEvent, + loadConvoyProject, +} from "../lib/convoy-objects.js"; +import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; +import { toolCatch, toolErr, toolOk } from "./tool-result.js"; + +const transitionInputSchema = z.object({ + workspace: z.string().min(1), + convoyId: z.string().uuid(), + toStatus: convoyStatusSchema, + actor: z.string().min(1).max(64), + reason: z.string().min(1).max(2_000).optional(), +}); + +export function registerTransitionConvoyStatusTool(mcp: McpServer): void { + mcp.registerTool( + "transition_convoy_status", + { + description: + "Transition a convoy project through the server-enforced lifecycle state machine.", + inputSchema: transitionInputSchema, + }, + async (args) => { + try { + const input = transitionInputSchema.parse(args); + const ws = await resolveWorkspaceHandle(input.workspace); + + const convoy = await loadConvoyProject(input.convoyId, ws.id); + if (!convoy) { + return toolErr(`Convoy ${input.convoyId} not found in workspace ${ws.slug}`); + } + + const fromStatus = currentConvoyStatus(convoy); + + try { + validateConvoyTransition({ + from: fromStatus, + to: input.toStatus, + actor: input.actor, + reason: input.reason, + }); + } catch (error) { + if (error instanceof ConvoyTransitionError) { + return toolErr( + JSON.stringify({ + code: error.code, + message: error.message, + from: fromStatus, + to: input.toStatus, + actor: input.actor, + }), + ); + } + throw error; + } + + const [updated] = await db + .update(objects) + .set({ + status: input.toStatus, + updatedAt: new Date(), + }) + .where(eq(objects.id, convoy.id)) + .returning(); + + await insertConvoyEvent({ + workspaceId: ws.id, + convoyId: convoy.id, + role: input.actor, + actor: input.actor, + eventType: "status_change", + metadata: { + from: fromStatus, + to: input.toStatus, + reason: input.reason ?? null, + }, + }); + + return toolOk({ + convoy: updated, + transition: { from: fromStatus, to: input.toStatus, actor: input.actor }, + }); + } catch (e) { + return toolCatch(e); + } + }, + ); +} diff --git a/docs/convoy-lifecycle.md b/docs/convoy-lifecycle.md new file mode 100644 index 0000000..37a0d9c --- /dev/null +++ b/docs/convoy-lifecycle.md @@ -0,0 +1,38 @@ +# Convoy lifecycle + +Server-enforced convoy state machine for the agent pipeline (Epic 4). Convoys map to Echodo `project` objects; briefs map to child `task` objects. + +## Status enum + +`draft` → `convoy_review` → `ready` → `in_progress` → `pr_review` → `merged` → `released` + +Terminal / exceptional: `blocked`, `cancelled` + +## MCP tools + +| Tool | Purpose | +|------|---------| +| `create_convoy` | Create `project` at `draft` with convoy metadata in `content` | +| `create_brief` | Create child `task` brief under a convoy | +| `transition_convoy_status` | Enforce actor + transition rules; writes `convoy_events` | +| `log_convoy_event` | Append role metrics / analytics row | +| `query_manifest_status` | Convoy status, briefs, recent events | +| `reconcile_from_files` | Import `.convoys/*.md` into Echodo; report pending outbox | + +## Gate actors + +- `human-gate-1` — plan approval (`convoy_review` ↔ `ready` / `draft`) +- `human-gate-2` — PR merge gate (`pr_review` ↔ `merged` / `in_progress`) +- `human-gate-3` — prod promote (`merged` → `released`) + +Pipeline roles (`role-architect`, `role-implementer`, …) cannot perform gate transitions. + +## Local-first + +Convoy markdown lives in `.convoys/` (see `.convoys/README.md`). Failed MCP calls should append to `.convoys/.pending-mcp-sync.jsonl`; `reconcile_from_files` reports and clears the outbox after sync. + +## Implementation + +- State machine: `packages/shared/src/convoy-lifecycle/` +- Events table: `packages/database/src/schema/convoy_events.ts` +- MCP: `apps/mcp-server/src/tools/*convoy*` diff --git a/packages/database/migrations/0013_crazy_joseph.sql b/packages/database/migrations/0013_crazy_joseph.sql new file mode 100644 index 0000000..0432daf --- /dev/null +++ b/packages/database/migrations/0013_crazy_joseph.sql @@ -0,0 +1,28 @@ +CREATE TABLE "convoy_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "convoy_id" uuid NOT NULL, + "brief_id" uuid, + "role" varchar(64) NOT NULL, + "actor" varchar(64), + "event_type" varchar(32) NOT NULL, + "classification" varchar(32), + "skip_flags" jsonb, + "duration_s" integer, + "outcome" varchar(20), + "repo" varchar(120), + "multitask_group" varchar(64), + "model_alias" varchar(120), + "provider_type" varchar(40), + "model_id" varchar(200), + "model_tier" varchar(20), + "estimated_cost_usd" numeric(12, 6), + "metadata" jsonb, + "occurred_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "convoy_events" ADD CONSTRAINT "convoy_events_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "convoy_events" ADD CONSTRAINT "convoy_events_convoy_id_objects_id_fk" FOREIGN KEY ("convoy_id") REFERENCES "public"."objects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "convoy_events" ADD CONSTRAINT "convoy_events_brief_id_objects_id_fk" FOREIGN KEY ("brief_id") REFERENCES "public"."objects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "convoy_events_workspace_id_occurred_at_idx" ON "convoy_events" USING btree ("workspace_id","occurred_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "convoy_events_convoy_id_occurred_at_idx" ON "convoy_events" USING btree ("convoy_id","occurred_at" DESC NULLS LAST); \ No newline at end of file diff --git a/packages/database/migrations/meta/0013_snapshot.json b/packages/database/migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..f443cd3 --- /dev/null +++ b/packages/database/migrations/meta/0013_snapshot.json @@ -0,0 +1,3750 @@ +{ + "id": "8cff8c6e-3f70-44db-a9f7-048f1716e3f1", + "prevId": "003cc1b8-19b2-4605-9d3a-db1990b06002", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.workspace_invites": { + "name": "workspace_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now() + interval '14 days'" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invites_workspace_id_idx": { + "name": "workspace_invites_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_token_unique": { + "name": "workspace_invites_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invites_open_email_unique": { + "name": "workspace_invites_open_email_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_invites\".\"accepted_at\" IS NULL AND \"workspace_invites\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_workspaces_id_fk": { + "name": "workspace_invites_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invites_invited_by_user_id_users_id_fk": { + "name": "workspace_invites_invited_by_user_id_users_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan_tier": { + "name": "plan_tier", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_user_id_idx": { + "name": "workspaces_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_user_id_users_id_fk": { + "name": "workspaces_owner_user_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_assignees": { + "name": "object_assignees", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'assignee'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_assignees_object_id_user_id_unique": { + "name": "object_assignees_object_id_user_id_unique", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_assignees_object_id_idx": { + "name": "object_assignees_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_assignees_user_id_idx": { + "name": "object_assignees_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_assignees_object_id_objects_id_fk": { + "name": "object_assignees_object_id_objects_id_fk", + "tableFrom": "object_assignees", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "object_assignees_user_id_users_id_fk": { + "name": "object_assignees_user_id_users_id_fk", + "tableFrom": "object_assignees", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.objects": { + "name": "objects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "objects_parent_id_idx": { + "name": "objects_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_type_idx": { + "name": "objects_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_workspace_id_idx": { + "name": "objects_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_template_id_idx": { + "name": "objects_template_id_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_created_by_idx": { + "name": "objects_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "objects_type_workspace_id_idx": { + "name": "objects_type_workspace_id_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "objects_workspace_id_workspaces_id_fk": { + "name": "objects_workspace_id_workspaces_id_fk", + "tableFrom": "objects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "objects_created_by_users_id_fk": { + "name": "objects_created_by_users_id_fk", + "tableFrom": "objects", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "objects_parent_id_objects_id_fk": { + "name": "objects_parent_id_objects_id_fk", + "tableFrom": "objects", + "tableTo": "objects", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "objects_template_id_templates_id_fk": { + "name": "objects_template_id_templates_id_fk", + "tableFrom": "objects", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_members": { + "name": "workspace_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_workspace_id_user_id_unique": { + "name": "workspace_members_workspace_id_user_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_workspace_id_idx": { + "name": "workspace_members_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_users_id_fk": { + "name": "workspace_members_user_id_users_id_fk", + "tableFrom": "workspace_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_type_defs": { + "name": "object_type_defs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "layout": { + "name": "layout", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "default_properties": { + "name": "default_properties", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_type_defs_workspace_id_idx": { + "name": "object_type_defs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_type_defs_slug_idx": { + "name": "object_type_defs_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_type_defs_workspace_id_workspaces_id_fk": { + "name": "object_type_defs_workspace_id_workspaces_id_fk", + "tableFrom": "object_type_defs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_definitions": { + "name": "property_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "property_definitions_workspace_id_idx": { + "name": "property_definitions_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_definitions_workspace_id_name_idx": { + "name": "property_definitions_workspace_id_name_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_definitions_workspace_id_workspaces_id_fk": { + "name": "property_definitions_workspace_id_workspaces_id_fk", + "tableFrom": "property_definitions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.property_values": { + "name": "property_values", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "property_def_id": { + "name": "property_def_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "property_values_object_id_property_def_id_unique": { + "name": "property_values_object_id_property_def_id_unique", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "property_def_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_values_object_id_idx": { + "name": "property_values_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "property_values_property_def_id_idx": { + "name": "property_values_property_def_id_idx", + "columns": [ + { + "expression": "property_def_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "property_values_object_id_objects_id_fk": { + "name": "property_values_object_id_objects_id_fk", + "tableFrom": "property_values", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "property_values_property_def_id_property_definitions_id_fk": { + "name": "property_values_property_def_id_property_definitions_id_fk", + "tableFrom": "property_values", + "tableTo": "property_definitions", + "columnsFrom": [ + "property_def_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.views": { + "name": "views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "view_type": { + "name": "view_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "views_object_id_idx": { + "name": "views_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "views_object_id_objects_id_fk": { + "name": "views_object_id_objects_id_fk", + "tableFrom": "views", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "accounts_provider_provider_account_id_unique": { + "name": "accounts_provider_provider_account_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "session_token": { + "name": "session_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_session_token_unique": { + "name": "sessions_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "session_token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_email_identities": { + "name": "user_email_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_identities_user_id_idx": { + "name": "user_email_identities_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_email_idx": { + "name": "user_email_identities_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_user_id_email_unique": { + "name": "user_email_identities_user_id_email_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_email_identities_verified_email_unique": { + "name": "user_email_identities_verified_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_email_identities\".\"verified_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_email_identities_user_id_users_id_fk": { + "name": "user_email_identities_user_id_users_id_fk", + "tableFrom": "user_email_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_lower_unique": { + "name": "users_email_lower_unique", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier_token_pk": { + "name": "verification_tokens_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.object_relations": { + "name": "object_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "relation_type": { + "name": "relation_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "object_relations_source_id_idx": { + "name": "object_relations_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_target_id_idx": { + "name": "object_relations_target_id_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_relation_type_idx": { + "name": "object_relations_relation_type_idx", + "columns": [ + { + "expression": "relation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "object_relations_source_target_type_unique": { + "name": "object_relations_source_target_type_unique", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relation_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "object_relations_source_id_objects_id_fk": { + "name": "object_relations_source_id_objects_id_fk", + "tableFrom": "object_relations", + "tableTo": "objects", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "object_relations_target_id_objects_id_fk": { + "name": "object_relations_target_id_objects_id_fk", + "tableFrom": "object_relations", + "tableTo": "objects", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.templates": { + "name": "templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "templates_workspace_id_idx": { + "name": "templates_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templates_workspace_id_workspaces_id_fk": { + "name": "templates_workspace_id_workspaces_id_fk", + "tableFrom": "templates", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.form_responses": { + "name": "form_responses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "form_id": { + "name": "form_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "respondent_id": { + "name": "respondent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_object_id": { + "name": "created_object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "form_responses_form_id_idx": { + "name": "form_responses_form_id_idx", + "columns": [ + { + "expression": "form_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "form_responses_respondent_id_idx": { + "name": "form_responses_respondent_id_idx", + "columns": [ + { + "expression": "respondent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "form_responses_form_id_forms_id_fk": { + "name": "form_responses_form_id_forms_id_fk", + "tableFrom": "form_responses", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "form_responses_respondent_id_users_id_fk": { + "name": "form_responses_respondent_id_users_id_fk", + "tableFrom": "form_responses", + "tableTo": "users", + "columnsFrom": [ + "respondent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "form_responses_created_object_id_objects_id_fk": { + "name": "form_responses_created_object_id_objects_id_fk", + "tableFrom": "form_responses", + "tableTo": "objects", + "columnsFrom": [ + "created_object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.forms": { + "name": "forms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "forms_workspace_id_idx": { + "name": "forms_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "forms_object_id_idx": { + "name": "forms_object_id_idx", + "columns": [ + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "forms_workspace_id_workspaces_id_fk": { + "name": "forms_workspace_id_workspaces_id_fk", + "tableFrom": "forms", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "forms_object_id_objects_id_fk": { + "name": "forms_object_id_objects_id_fk", + "tableFrom": "forms", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "forms_created_by_users_id_fk": { + "name": "forms_created_by_users_id_fk", + "tableFrom": "forms", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_favorites": { + "name": "user_favorites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "object_id": { + "name": "object_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_favorites_user_object_idx": { + "name": "user_favorites_user_object_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_favorites_user_id_users_id_fk": { + "name": "user_favorites_user_id_users_id_fk", + "tableFrom": "user_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_favorites_object_id_objects_id_fk": { + "name": "user_favorites_object_id_objects_id_fk", + "tableFrom": "user_favorites", + "tableTo": "objects", + "columnsFrom": [ + "object_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.markdown_backlog_items": { + "name": "markdown_backlog_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "plan_slug": { + "name": "plan_slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "epic_slug": { + "name": "epic_slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frontmatter": { + "name": "frontmatter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "body_markdown": { + "name": "body_markdown", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workflow_prompt": { + "name": "workflow_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "markdown_backlog_workspace_repo_path_unique": { + "name": "markdown_backlog_workspace_repo_path_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_workspace_plan_idx": { + "name": "markdown_backlog_workspace_plan_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plan_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_parent_id_idx": { + "name": "markdown_backlog_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "markdown_backlog_workspace_kind_idx": { + "name": "markdown_backlog_workspace_kind_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "markdown_backlog_items_workspace_id_workspaces_id_fk": { + "name": "markdown_backlog_items_workspace_id_workspaces_id_fk", + "tableFrom": "markdown_backlog_items", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk": { + "name": "markdown_backlog_items_parent_id_markdown_backlog_items_id_fk", + "tableFrom": "markdown_backlog_items", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cursor_sync_mappings": { + "name": "cursor_sync_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "backlog_item_id": { + "name": "backlog_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cursor_plan_id": { + "name": "cursor_plan_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "cursor_item_id": { + "name": "cursor_item_id", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "last_pulled_at": { + "name": "last_pulled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_pushed_at": { + "name": "last_pushed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_content_hash": { + "name": "sync_content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cursor_sync_mappings_backlog_item_id_unique": { + "name": "cursor_sync_mappings_backlog_item_id_unique", + "columns": [ + { + "expression": "backlog_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cursor_sync_mappings_workspace_id_idx": { + "name": "cursor_sync_mappings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cursor_sync_mappings_workspace_id_workspaces_id_fk": { + "name": "cursor_sync_mappings_workspace_id_workspaces_id_fk", + "tableFrom": "cursor_sync_mappings", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk": { + "name": "cursor_sync_mappings_backlog_item_id_markdown_backlog_items_id_fk", + "tableFrom": "cursor_sync_mappings", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "backlog_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_id_created_at_idx": { + "name": "audit_log_workspace_id_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_user_id_idx": { + "name": "audit_log_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspaces_id_fk": { + "name": "audit_log_workspace_id_workspaces_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_runs": { + "name": "agent_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "backlog_item_id": { + "name": "backlog_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_input": { + "name": "tokens_input", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_output": { + "name": "tokens_output", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_total": { + "name": "tokens_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model_alias": { + "name": "model_alias", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "model_tier": { + "name": "model_tier", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_runs_workspace_id_started_at_idx": { + "name": "agent_runs_workspace_id_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_runs_backlog_item_id_started_at_idx": { + "name": "agent_runs_backlog_item_id_started_at_idx", + "columns": [ + { + "expression": "backlog_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_runs_workspace_id_workspaces_id_fk": { + "name": "agent_runs_workspace_id_workspaces_id_fk", + "tableFrom": "agent_runs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_runs_backlog_item_id_markdown_backlog_items_id_fk": { + "name": "agent_runs_backlog_item_id_markdown_backlog_items_id_fk", + "tableFrom": "agent_runs", + "tableTo": "markdown_backlog_items", + "columnsFrom": [ + "backlog_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_runs_actor_user_id_users_id_fk": { + "name": "agent_runs_actor_user_id_users_id_fk", + "tableFrom": "agent_runs", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_providers": { + "name": "model_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "alias": { + "name": "alias", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_env_var": { + "name": "api_key_env_var", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "extra_headers": { + "name": "extra_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "default_params": { + "name": "default_params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "model_providers_workspace_id_idx": { + "name": "model_providers_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "model_providers_workspace_id_alias_unique": { + "name": "model_providers_workspace_id_alias_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "alias", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_providers_workspace_id_workspaces_id_fk": { + "name": "model_providers_workspace_id_workspaces_id_fk", + "tableFrom": "model_providers", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_model_routes": { + "name": "role_model_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "provider_alias_id": { + "name": "provider_alias_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "role_model_routes_workspace_id_idx": { + "name": "role_model_routes_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "role_model_routes_workspace_id_role_id_unique": { + "name": "role_model_routes_workspace_id_role_id_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "role_model_routes_provider_alias_id_idx": { + "name": "role_model_routes_provider_alias_id_idx", + "columns": [ + { + "expression": "provider_alias_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_model_routes_workspace_id_workspaces_id_fk": { + "name": "role_model_routes_workspace_id_workspaces_id_fk", + "tableFrom": "role_model_routes", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_model_routes_provider_alias_id_model_providers_id_fk": { + "name": "role_model_routes_provider_alias_id_model_providers_id_fk", + "tableFrom": "role_model_routes", + "tableTo": "model_providers", + "columnsFrom": [ + "provider_alias_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connected_repos": { + "name": "connected_repos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "git_remote_url": { + "name": "git_remote_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "forge_type": { + "name": "forge_type", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "api_base_url": { + "name": "api_base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "token_env_var": { + "name": "token_env_var", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "connected_repos_workspace_id_idx": { + "name": "connected_repos_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connected_repos_workspace_repo_unique": { + "name": "connected_repos_workspace_repo_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "api_base_url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connected_repos_workspace_id_workspaces_id_fk": { + "name": "connected_repos_workspace_id_workspaces_id_fk", + "tableFrom": "connected_repos", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.convoy_events": { + "name": "convoy_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "convoy_id": { + "name": "convoy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "brief_id": { + "name": "brief_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "actor": { + "name": "actor", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "classification": { + "name": "classification", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "skip_flags": { + "name": "skip_flags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "duration_s": { + "name": "duration_s", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "repo": { + "name": "repo", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "multitask_group": { + "name": "multitask_group", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "model_alias": { + "name": "model_alias", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "model_tier": { + "name": "model_tier", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "estimated_cost_usd": { + "name": "estimated_cost_usd", + "type": "numeric(12, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "convoy_events_workspace_id_occurred_at_idx": { + "name": "convoy_events_workspace_id_occurred_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "convoy_events_convoy_id_occurred_at_idx": { + "name": "convoy_events_convoy_id_occurred_at_idx", + "columns": [ + { + "expression": "convoy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "convoy_events_workspace_id_workspaces_id_fk": { + "name": "convoy_events_workspace_id_workspaces_id_fk", + "tableFrom": "convoy_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "convoy_events_convoy_id_objects_id_fk": { + "name": "convoy_events_convoy_id_objects_id_fk", + "tableFrom": "convoy_events", + "tableTo": "objects", + "columnsFrom": [ + "convoy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "convoy_events_brief_id_objects_id_fk": { + "name": "convoy_events_brief_id_objects_id_fk", + "tableFrom": "convoy_events", + "tableTo": "objects", + "columnsFrom": [ + "brief_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index f6c76f7..4973f45 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1783451965716, "tag": "0012_uneven_captain_stacy", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1783453846679, + "tag": "0013_crazy_joseph", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/schema/convoy_events.ts b/packages/database/src/schema/convoy_events.ts new file mode 100644 index 0000000..872b31d --- /dev/null +++ b/packages/database/src/schema/convoy_events.ts @@ -0,0 +1,57 @@ +import { + index, + integer, + jsonb, + numeric, + pgTable, + text, + timestamp, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +import { objects } from "./objects"; +import { workspaces } from "./workspaces"; + +/** Append-only convoy lifecycle and role metrics log (replaces local JSONL when MCP is up). */ +export const convoyEvents = pgTable( + "convoy_events", + { + id: uuid("id").primaryKey().defaultRandom(), + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspaces.id, { onDelete: "cascade" }), + convoyId: uuid("convoy_id") + .notNull() + .references(() => objects.id, { onDelete: "cascade" }), + briefId: uuid("brief_id").references(() => objects.id, { onDelete: "set null" }), + role: varchar("role", { length: 64 }).notNull(), + actor: varchar("actor", { length: 64 }), + eventType: varchar("event_type", { length: 32 }).notNull(), + classification: varchar("classification", { length: 32 }), + skipFlags: jsonb("skip_flags").$type(), + durationS: integer("duration_s"), + outcome: varchar("outcome", { length: 20 }), + repo: varchar("repo", { length: 120 }), + multitaskGroup: varchar("multitask_group", { length: 64 }), + modelAlias: varchar("model_alias", { length: 120 }), + providerType: varchar("provider_type", { length: 40 }), + modelId: varchar("model_id", { length: 200 }), + modelTier: varchar("model_tier", { length: 20 }), + estimatedCostUsd: numeric("estimated_cost_usd", { precision: 12, scale: 6 }), + metadata: jsonb("metadata").$type | null>(), + occurredAt: timestamp("occurred_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => ({ + workspaceOccurredIdx: index("convoy_events_workspace_id_occurred_at_idx").on( + table.workspaceId, + table.occurredAt.desc(), + ), + convoyOccurredIdx: index("convoy_events_convoy_id_occurred_at_idx").on( + table.convoyId, + table.occurredAt.desc(), + ), + }), +); diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index efbc5ea..9f10a1c 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -16,3 +16,4 @@ export * from "./agent_runs"; export * from "./model_providers"; export * from "./role_model_routes"; export * from "./connected_repos"; +export * from "./convoy_events"; diff --git a/packages/database/src/schema/relations.ts b/packages/database/src/schema/relations.ts index b0cbaaa..4290100 100644 --- a/packages/database/src/schema/relations.ts +++ b/packages/database/src/schema/relations.ts @@ -22,6 +22,7 @@ import { agentRuns } from "./agent_runs"; import { modelProviders } from "./model_providers"; import { roleModelRoutes } from "./role_model_routes"; import { connectedRepos } from "./connected_repos"; +import { convoyEvents } from "./convoy_events"; export const objectRelations = pgTable( "object_relations", @@ -122,6 +123,7 @@ export const workspacesRelations = relations(workspaces, ({ one, many }) => ({ modelProviders: many(modelProviders), roleModelRoutes: many(roleModelRoutes), connectedRepos: many(connectedRepos), + convoyEvents: many(convoyEvents), })); export const objectsRelations = relations(objects, ({ one, many }) => ({ @@ -279,6 +281,23 @@ export const modelProvidersRelations = relations(modelProviders, ({ one, many }) roleRoutes: many(roleModelRoutes), })); +export const convoyEventsRelations = relations(convoyEvents, ({ one }) => ({ + workspace: one(workspaces, { + fields: [convoyEvents.workspaceId], + references: [workspaces.id], + }), + convoy: one(objects, { + fields: [convoyEvents.convoyId], + references: [objects.id], + relationName: "convoyEventConvoy", + }), + brief: one(objects, { + fields: [convoyEvents.briefId], + references: [objects.id], + relationName: "convoyEventBrief", + }), +})); + export const connectedReposRelations = relations(connectedRepos, ({ one }) => ({ workspace: one(workspaces, { fields: [connectedRepos.workspaceId], diff --git a/packages/shared/package.json b/packages/shared/package.json index 496b86c..62ffe87 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -8,6 +8,7 @@ "exports": { ".": "./src/index.ts", "./connected-repos": "./src/connected-repos/index.ts", + "./convoy-lifecycle": "./src/convoy-lifecycle/index.ts", "./model-routing": "./src/model-routing/index.ts", "./types": "./src/types/index.ts", "./utils": "./src/utils/index.ts" diff --git a/packages/shared/src/convoy-lifecycle/index.ts b/packages/shared/src/convoy-lifecycle/index.ts new file mode 100644 index 0000000..d609ed6 --- /dev/null +++ b/packages/shared/src/convoy-lifecycle/index.ts @@ -0,0 +1,28 @@ +export { + briefObjectContentSchema, + convoyActorSchema, + convoyClassificationSchema, + convoyEventTypeSchema, + convoyFileStatusSchema, + convoyObjectContentSchema, + convoyStatusSchema, + convoyTransitionErrorCodeSchema, + transitionConvoyInputSchema, + type BriefObjectContent, + type ConvoyClassification, + type ConvoyEventType, + type ConvoyFileStatus, + type ConvoyObjectContent, + type ConvoyStatus, + type ConvoyTransitionErrorCode, + type TransitionConvoyInput, +} from "./schemas"; + +export { + ALLOWED_TRANSITIONS, + ConvoyTransitionError, + FILE_STATUS_TO_SERVER, + VALID_TRANSITIONS, + mapFileStatusToServer, + validateConvoyTransition, +} from "./state-machine"; diff --git a/packages/shared/src/convoy-lifecycle/schemas.ts b/packages/shared/src/convoy-lifecycle/schemas.ts new file mode 100644 index 0000000..50b2b8f --- /dev/null +++ b/packages/shared/src/convoy-lifecycle/schemas.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import { convoyModelPolicySchema } from "../model-routing/schemas"; + +export const convoyStatusSchema = z.enum([ + "draft", + "convoy_review", + "ready", + "in_progress", + "pr_review", + "merged", + "released", + "blocked", + "cancelled", +]); + +export type ConvoyStatus = z.infer; + +export const convoyClassificationSchema = z.enum([ + "feature", + "hotfix", + "docs-only", + "infra-only", + "server-only", + "config-only", +]); + +export type ConvoyClassification = z.infer; + +export const convoyFileStatusSchema = z.enum([ + "open", + "in-progress", + "merged", + "shipped", + "abandoned", +]); + +export type ConvoyFileStatus = z.infer; + +export const convoyActorSchema = z.string().min(1).max(64); + +export const convoyEventTypeSchema = z.enum([ + "role_complete", + "status_change", + "brief_created", + "reconcile", +]); + +export type ConvoyEventType = z.infer; + +export const convoyObjectContentSchema = z.object({ + convoySlug: z.string().min(1).max(120), + classification: convoyClassificationSchema, + skipFlags: z.array(z.string()).default([]), + successMetric: z.string().min(1), + modelPolicy: convoyModelPolicySchema.optional(), +}); + +export type ConvoyObjectContent = z.infer; + +export const briefObjectContentSchema = z.object({ + briefNumber: z.number().int().positive(), + files: z.array(z.string()).default([]), + sliceDependencies: z.array(z.number().int().positive()).default([]), + acceptance: z.string().optional(), +}); + +export type BriefObjectContent = z.infer; + +export const transitionConvoyInputSchema = z.object({ + convoyId: z.string().uuid(), + toStatus: convoyStatusSchema, + actor: convoyActorSchema, + reason: z.string().min(1).max(2_000).optional(), +}); + +export type TransitionConvoyInput = z.infer; + +export const convoyTransitionErrorCodeSchema = z.enum([ + "INVALID_TRANSITION", + "FORBIDDEN_ACTOR", + "REASON_REQUIRED", +]); + +export type ConvoyTransitionErrorCode = z.infer; diff --git a/packages/shared/src/convoy-lifecycle/state-machine.test.ts b/packages/shared/src/convoy-lifecycle/state-machine.test.ts new file mode 100644 index 0000000..da81bb2 --- /dev/null +++ b/packages/shared/src/convoy-lifecycle/state-machine.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { ConvoyTransitionError, validateConvoyTransition } from "./state-machine"; + +describe("validateConvoyTransition", () => { + it("rejects draft → released", () => { + expect(() => + validateConvoyTransition({ + from: "draft", + to: "released", + actor: "human-gate-1", + }), + ).toThrow(ConvoyTransitionError); + + try { + validateConvoyTransition({ + from: "draft", + to: "released", + actor: "human-gate-1", + }); + } catch (error) { + expect(error).toBeInstanceOf(ConvoyTransitionError); + expect((error as ConvoyTransitionError).code).toBe("INVALID_TRANSITION"); + } + }); + + it("rejects role-architect on human-gate-1 transition", () => { + expect(() => + validateConvoyTransition({ + from: "convoy_review", + to: "ready", + actor: "role-architect", + }), + ).toThrow(ConvoyTransitionError); + + try { + validateConvoyTransition({ + from: "convoy_review", + to: "ready", + actor: "role-architect", + }); + } catch (error) { + expect((error as ConvoyTransitionError).code).toBe("FORBIDDEN_ACTOR"); + } + }); + + it("allows human-gate-1 to approve plan", () => { + expect(() => + validateConvoyTransition({ + from: "convoy_review", + to: "ready", + actor: "human-gate-1", + }), + ).not.toThrow(); + }); + + it("allows role-architect to submit plan for review", () => { + expect(() => + validateConvoyTransition({ + from: "draft", + to: "convoy_review", + actor: "role-architect", + }), + ).not.toThrow(); + }); + + it("requires reason for blocked transitions", () => { + try { + validateConvoyTransition({ + from: "ready", + to: "blocked", + actor: "role-implementer", + }); + } catch (error) { + expect((error as ConvoyTransitionError).code).toBe("REASON_REQUIRED"); + } + }); +}); diff --git a/packages/shared/src/convoy-lifecycle/state-machine.ts b/packages/shared/src/convoy-lifecycle/state-machine.ts new file mode 100644 index 0000000..5cf9dcc --- /dev/null +++ b/packages/shared/src/convoy-lifecycle/state-machine.ts @@ -0,0 +1,211 @@ +import type { ConvoyStatus, ConvoyTransitionErrorCode } from "./schemas"; + +export const VALID_TRANSITIONS: Record = { + draft: ["convoy_review", "blocked", "cancelled"], + convoy_review: ["ready", "draft", "blocked", "cancelled"], + ready: ["in_progress", "blocked", "cancelled"], + in_progress: ["pr_review", "blocked", "cancelled"], + pr_review: ["merged", "in_progress", "blocked", "cancelled"], + merged: ["released", "blocked"], + released: [], + blocked: [ + "draft", + "convoy_review", + "ready", + "in_progress", + "pr_review", + "merged", + "cancelled", + ], + cancelled: [], +}; + +type TransitionRule = { + from: ConvoyStatus; + to: ConvoyStatus; + actors: readonly string[]; + requiresReason?: boolean; +}; + +/** Explicit (from, to, actor) allow-list — gate actors are never implied by pipeline roles. */ +export const ALLOWED_TRANSITIONS: readonly TransitionRule[] = [ + { from: "draft", to: "convoy_review", actors: ["role-architect"] }, + { + from: "draft", + to: "blocked", + actors: ["role-architect", "role-conductor", "human-gate-1", "automation"], + requiresReason: true, + }, + { + from: "draft", + to: "cancelled", + actors: ["human-gate-1", "role-conductor"], + requiresReason: true, + }, + { from: "convoy_review", to: "ready", actors: ["human-gate-1"] }, + { from: "convoy_review", to: "draft", actors: ["human-gate-1"] }, + { + from: "convoy_review", + to: "blocked", + actors: ["human-gate-1", "role-architect", "automation"], + requiresReason: true, + }, + { + from: "convoy_review", + to: "cancelled", + actors: ["human-gate-1"], + requiresReason: true, + }, + { from: "ready", to: "in_progress", actors: ["role-implementer"] }, + { + from: "ready", + to: "blocked", + actors: ["role-implementer", "human-gate-1", "automation"], + requiresReason: true, + }, + { + from: "ready", + to: "cancelled", + actors: ["human-gate-1", "role-conductor"], + requiresReason: true, + }, + { from: "in_progress", to: "pr_review", actors: ["role-implementer", "automation"] }, + { + from: "in_progress", + to: "blocked", + actors: ["role-implementer", "human-gate-2", "automation"], + requiresReason: true, + }, + { + from: "in_progress", + to: "cancelled", + actors: ["human-gate-1", "role-conductor"], + requiresReason: true, + }, + { from: "pr_review", to: "merged", actors: ["human-gate-2"] }, + { from: "pr_review", to: "in_progress", actors: ["human-gate-2", "role-implementer"] }, + { + from: "pr_review", + to: "blocked", + actors: ["human-gate-2", "automation"], + requiresReason: true, + }, + { + from: "pr_review", + to: "cancelled", + actors: ["human-gate-1", "human-gate-2"], + requiresReason: true, + }, + { from: "merged", to: "released", actors: ["human-gate-3"] }, + { + from: "merged", + to: "blocked", + actors: ["human-gate-3", "automation"], + requiresReason: true, + }, + { + from: "blocked", + to: "draft", + actors: ["human-gate-1", "role-conductor", "automation"], + requiresReason: true, + }, + { + from: "blocked", + to: "convoy_review", + actors: ["human-gate-1", "role-conductor", "automation"], + requiresReason: true, + }, + { + from: "blocked", + to: "ready", + actors: ["human-gate-1", "role-conductor", "automation"], + requiresReason: true, + }, + { + from: "blocked", + to: "in_progress", + actors: ["human-gate-2", "role-conductor", "automation"], + requiresReason: true, + }, + { + from: "blocked", + to: "pr_review", + actors: ["human-gate-2", "role-conductor", "automation"], + requiresReason: true, + }, + { + from: "blocked", + to: "merged", + actors: ["human-gate-2", "role-conductor", "automation"], + requiresReason: true, + }, + { + from: "blocked", + to: "cancelled", + actors: ["human-gate-1", "human-gate-2", "human-gate-3", "role-conductor"], + requiresReason: true, + }, +]; + +export class ConvoyTransitionError extends Error { + readonly code: ConvoyTransitionErrorCode; + + constructor(message: string, code: ConvoyTransitionErrorCode) { + super(message); + this.name = "ConvoyTransitionError"; + this.code = code; + } +} + +export function validateConvoyTransition(input: { + from: ConvoyStatus; + to: ConvoyStatus; + actor: string; + reason?: string; +}): void { + const allowedTargets = VALID_TRANSITIONS[input.from]; + if (!allowedTargets.includes(input.to)) { + throw new ConvoyTransitionError( + `Invalid transition ${input.from} → ${input.to}`, + "INVALID_TRANSITION", + ); + } + + const rule = ALLOWED_TRANSITIONS.find( + (candidate) => candidate.from === input.from && candidate.to === input.to, + ); + + if (!rule) { + throw new ConvoyTransitionError( + `No actor may perform transition ${input.from} → ${input.to}`, + "FORBIDDEN_ACTOR", + ); + } + + if (!rule.actors.includes(input.actor)) { + throw new ConvoyTransitionError( + `Actor ${input.actor} cannot perform transition ${input.from} → ${input.to}`, + "FORBIDDEN_ACTOR", + ); + } + + if (rule.requiresReason && !input.reason?.trim()) { + throw new ConvoyTransitionError( + `Transition ${input.from} → ${input.to} requires a reason`, + "REASON_REQUIRED", + ); + } +} + +/** Map local `.convoys/*.md` frontmatter status to server enum for reconcile. */ +export const FILE_STATUS_TO_SERVER: Record = { + open: "draft", + "in-progress": "in_progress", + merged: "merged", + shipped: "released", + abandoned: "cancelled", +}; + +export function mapFileStatusToServer(fileStatus: string): ConvoyStatus | null { + return FILE_STATUS_TO_SERVER[fileStatus] ?? null; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e5f6343..565af3f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export * from "./connected-repos"; +export * from "./convoy-lifecycle"; export * from "./model-routing"; export * from "./types"; export * from "./utils"; diff --git a/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md b/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md index 23c2440..49ad1ff 100644 --- a/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md +++ b/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md @@ -3,7 +3,7 @@ kind: epic slug: convoy-lifecycle-mcp title: Convoy lifecycle MCP — state machine and convoy_events plan_slug: automation-foundation -status: draft +status: done priority: P1 tenant_id: global cursor_epic_id: null @@ -43,10 +43,12 @@ Reference: agent-pipeline v0.4 plan §7; local-first `.convoys/` with `.pending- ## Acceptance criteria -- [ ] Invalid transition `draft → released` rejected with structured error. -- [ ] `role-architect` cannot call `human-gate-1` transition. -- [ ] Offline convoy edit reconciles via `reconcile_from_files`. +- [x] Invalid transition `draft → released` rejected with structured error. +- [x] `role-architect` cannot call `human-gate-1` transition. +- [x] Offline convoy edit reconciles via `reconcile_from_files`. ## Tasks -_To be decomposed when epic graduates to `ready`._ +- [x] [Task-shared-convoy-state-machine](./Task-shared-convoy-state-machine.md) +- [x] [Task-db-convoy-events-migration](./Task-db-convoy-events-migration.md) +- [x] [Task-mcp-convoy-lifecycle-tools](./Task-mcp-convoy-lifecycle-tools.md) diff --git a/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-db-convoy-events-migration.md b/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-db-convoy-events-migration.md new file mode 100644 index 0000000..d3cd711 --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-db-convoy-events-migration.md @@ -0,0 +1,22 @@ +--- +kind: task +slug: db-convoy-events-migration +title: Drizzle convoy_events table and migration +plan_slug: automation-foundation +epic_slug: convoy-lifecycle-mcp +status: done +priority: P1 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-07-07" +--- + +# Task summary + +Append-only `convoy_events` with workspace + convoy indexes and ADR model fields. + +## Acceptance criteria + +- [x] Migration `0013` generated via `pnpm db:generate` +- [x] FK to `objects` for convoy and optional brief diff --git a/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-mcp-convoy-lifecycle-tools.md b/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-mcp-convoy-lifecycle-tools.md new file mode 100644 index 0000000..d43dab6 --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-mcp-convoy-lifecycle-tools.md @@ -0,0 +1,22 @@ +--- +kind: task +slug: mcp-convoy-lifecycle-tools +title: MCP tools for convoy create, transition, log, query, reconcile +plan_slug: automation-foundation +epic_slug: convoy-lifecycle-mcp +status: done +priority: P1 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-07-07" +--- + +# Task summary + +Six MCP tools in `apps/mcp-server` with zod-validated args and workspace scoping. + +## Acceptance criteria + +- [x] `create_convoy`, `create_brief`, `transition_convoy_status`, `log_convoy_event`, `query_manifest_status`, `reconcile_from_files` +- [x] `docs/convoy-lifecycle.md` documents status enum and gate actors diff --git a/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-shared-convoy-state-machine.md b/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-shared-convoy-state-machine.md new file mode 100644 index 0000000..82325c8 --- /dev/null +++ b/plans/Plan-automation-foundation/Epic-convoy-lifecycle-mcp/Task-shared-convoy-state-machine.md @@ -0,0 +1,22 @@ +--- +kind: task +slug: shared-convoy-state-machine +title: Shared convoy status enum, VALID_TRANSITIONS, and actor guards +plan_slug: automation-foundation +epic_slug: convoy-lifecycle-mcp +status: done +priority: P1 +tenant_id: global +owner: unassigned +cursor_todo_id: null +updated_at: "2026-07-07" +--- + +# Task summary + +Pure `validateConvoyTransition()` with gate actor enforcement and vitest coverage. + +## Acceptance criteria + +- [x] `draft → released` rejected (`INVALID_TRANSITION`) +- [x] `role-architect` cannot perform `convoy_review → ready` (`FORBIDDEN_ACTOR`) diff --git a/plans/Plan-automation-foundation/Plan-automation-foundation.md b/plans/Plan-automation-foundation/Plan-automation-foundation.md index ef3a621..1785cdf 100644 --- a/plans/Plan-automation-foundation/Plan-automation-foundation.md +++ b/plans/Plan-automation-foundation/Plan-automation-foundation.md @@ -165,7 +165,7 @@ flowchart LR | 1 | [Model provider registry](./Epic-model-provider-registry/Epic-model-provider-registry.md) | **ready** (6 tasks) | `resolveModel()`, Settings AI pages, run model logging | — | | 2 | [Pipeline convoy scaffolding](./Epic-pipeline-convoy-scaffolding/Epic-pipeline-convoy-scaffolding.md) | done | `.convoys/`, `log-convoy-event.sh`, `convoy-planning.mdc` — dogfood metrics | — (parallel with 1) | | 3 | [Connected repos](./Epic-connected-repos/Epic-connected-repos.md) | done | `repo_links` table, “paste remote URL”, forge adapter interface | — | -| 4 | [Convoy lifecycle MCP](./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md) | draft | `create_convoy`, `transition_convoy_status`, state machine §7, `convoy_events` | 2, 3 | +| 4 | [Convoy lifecycle MCP](./Epic-convoy-lifecycle-mcp/Epic-convoy-lifecycle-mcp.md) | done | `create_convoy`, `transition_convoy_status`, state machine §7, `convoy_events` | 2, 3 | | 5 | [Mission control gates UI](./Epic-mission-control-gates-ui/Epic-mission-control-gates-ui.md) | draft | Convoy kanban, gate inbox, approve/deny/revise, gate co-pilot chat | 4 | | 6 | [Orchestrator](./Epic-apps-orchestrator/Epic-apps-orchestrator.md) | draft | `apps/orchestrator`, Redis queue, role dispatch, pause at gates | 1, 3, 4 | | 7 | [Worker v1](./Epic-apps-worker-v1/Epic-apps-worker-v1.md) | draft | `apps/worker` minimal tool loop, worktree-per-brief, MCP claim/complete | 1, 3, 6 | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15696d0..6fd4ff7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@tasks/shared': specifier: workspace:* version: link:../../packages/shared + yaml: + specifier: ^2.8.3 + version: 2.8.3 zod: specifier: ^3.24.0 version: 3.25.76