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 <cursoragent@cursor.com>
This commit is contained in:
parent
2a9be5d4e1
commit
9405da8a45
31 changed files with 5218 additions and 7 deletions
|
|
@ -14,6 +14,7 @@
|
||||||
"@modelcontextprotocol/sdk": "^1.4.0",
|
"@modelcontextprotocol/sdk": "^1.4.0",
|
||||||
"@tasks/database": "workspace:*",
|
"@tasks/database": "workspace:*",
|
||||||
"@tasks/shared": "workspace:*",
|
"@tasks/shared": "workspace:*",
|
||||||
|
"yaml": "^2.8.3",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,10 @@ const d: any = require(drizzlePath);
|
||||||
|
|
||||||
export const and = d.and;
|
export const and = d.and;
|
||||||
export const asc = d.asc;
|
export const asc = d.asc;
|
||||||
|
export const desc = d.desc;
|
||||||
export const eq = d.eq;
|
export const eq = d.eq;
|
||||||
export const ilike = d.ilike;
|
export const ilike = d.ilike;
|
||||||
export const inArray = d.inArray;
|
export const inArray = d.inArray;
|
||||||
export const isNull = d.isNull;
|
export const isNull = d.isNull;
|
||||||
export const or = d.or;
|
export const or = d.or;
|
||||||
|
export const sql = d.sql;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const mcp = new McpServer(
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
instructions:
|
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).",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
147
apps/mcp-server/src/lib/convoy-files.ts
Normal file
147
apps/mcp-server/src/lib/convoy-files.ts
Normal file
|
|
@ -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<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PendingMcpSyncEntry = {
|
||||||
|
ts: string;
|
||||||
|
tool: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readString(fm: Record<string, unknown>, key: string): string | null {
|
||||||
|
const value = fm[key];
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringArray(fm: Record<string, unknown>, 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<string, unknown> = {};
|
||||||
|
let bodyMarkdown = raw;
|
||||||
|
if (fmMatch) {
|
||||||
|
try {
|
||||||
|
const parsed = parseYaml(fmMatch[1]);
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||||
|
frontmatter = parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
} 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<string, unknown>)
|
||||||
|
: 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<PendingMcpSyncEntry, "ts"> & { 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
94
apps/mcp-server/src/lib/convoy-objects.ts
Normal file
94
apps/mcp-server/src/lib/convoy-objects.ts
Normal file
|
|
@ -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<string, unknown> | 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;
|
||||||
|
}
|
||||||
76
apps/mcp-server/src/tools/create-brief.ts
Normal file
76
apps/mcp-server/src/tools/create-brief.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
85
apps/mcp-server/src/tools/create-convoy.ts
Normal file
85
apps/mcp-server/src/tools/create-convoy.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,12 @@ import { registerSearchObjectsTool } from "./search-objects.js";
|
||||||
import { registerUpdateObjectTool } from "./update-object.js";
|
import { registerUpdateObjectTool } from "./update-object.js";
|
||||||
import { registerClaimTaskTool } from "./claim-task.js";
|
import { registerClaimTaskTool } from "./claim-task.js";
|
||||||
import { registerCompleteTaskTool } from "./complete-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 {
|
export function registerTools(mcp: McpServer): void {
|
||||||
registerCreateObjectTool(mcp);
|
registerCreateObjectTool(mcp);
|
||||||
|
|
@ -15,6 +21,12 @@ export function registerTools(mcp: McpServer): void {
|
||||||
registerManageObjectTool(mcp);
|
registerManageObjectTool(mcp);
|
||||||
registerClaimTaskTool(mcp);
|
registerClaimTaskTool(mcp);
|
||||||
registerCompleteTaskTool(mcp);
|
registerCompleteTaskTool(mcp);
|
||||||
|
registerCreateConvoyTool(mcp);
|
||||||
|
registerCreateBriefTool(mcp);
|
||||||
|
registerTransitionConvoyStatusTool(mcp);
|
||||||
|
registerLogConvoyEventTool(mcp);
|
||||||
|
registerQueryManifestStatusTool(mcp);
|
||||||
|
registerReconcileFromFilesTool(mcp);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { registerCreateObjectTool } from "./create-object.js";
|
export { registerCreateObjectTool } from "./create-object.js";
|
||||||
|
|
@ -24,3 +36,9 @@ export { registerListObjectsTool } from "./list-objects.js";
|
||||||
export { registerManageObjectTool } from "./manage-object.js";
|
export { registerManageObjectTool } from "./manage-object.js";
|
||||||
export { registerClaimTaskTool } from "./claim-task.js";
|
export { registerClaimTaskTool } from "./claim-task.js";
|
||||||
export { registerCompleteTaskTool } from "./complete-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";
|
||||||
|
|
|
||||||
82
apps/mcp-server/src/tools/log-convoy-event.ts
Normal file
82
apps/mcp-server/src/tools/log-convoy-event.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/mcp-server/src/tools/query-manifest-status.ts
Normal file
74
apps/mcp-server/src/tools/query-manifest-status.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
155
apps/mcp-server/src/tools/reconcile-from-files.ts
Normal file
155
apps/mcp-server/src/tools/reconcile-from-files.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
102
apps/mcp-server/src/tools/transition-convoy-status.ts
Normal file
102
apps/mcp-server/src/tools/transition-convoy-status.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
38
docs/convoy-lifecycle.md
Normal file
38
docs/convoy-lifecycle.md
Normal file
|
|
@ -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*`
|
||||||
28
packages/database/migrations/0013_crazy_joseph.sql
Normal file
28
packages/database/migrations/0013_crazy_joseph.sql
Normal file
|
|
@ -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);
|
||||||
3750
packages/database/migrations/meta/0013_snapshot.json
Normal file
3750
packages/database/migrations/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -92,6 +92,13 @@
|
||||||
"when": 1783451965716,
|
"when": 1783451965716,
|
||||||
"tag": "0012_uneven_captain_stacy",
|
"tag": "0012_uneven_captain_stacy",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783453846679,
|
||||||
|
"tag": "0013_crazy_joseph",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
57
packages/database/src/schema/convoy_events.ts
Normal file
57
packages/database/src/schema/convoy_events.ts
Normal file
|
|
@ -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<string[] | null>(),
|
||||||
|
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<Record<string, unknown> | 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(),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -16,3 +16,4 @@ export * from "./agent_runs";
|
||||||
export * from "./model_providers";
|
export * from "./model_providers";
|
||||||
export * from "./role_model_routes";
|
export * from "./role_model_routes";
|
||||||
export * from "./connected_repos";
|
export * from "./connected_repos";
|
||||||
|
export * from "./convoy_events";
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { agentRuns } from "./agent_runs";
|
||||||
import { modelProviders } from "./model_providers";
|
import { modelProviders } from "./model_providers";
|
||||||
import { roleModelRoutes } from "./role_model_routes";
|
import { roleModelRoutes } from "./role_model_routes";
|
||||||
import { connectedRepos } from "./connected_repos";
|
import { connectedRepos } from "./connected_repos";
|
||||||
|
import { convoyEvents } from "./convoy_events";
|
||||||
|
|
||||||
export const objectRelations = pgTable(
|
export const objectRelations = pgTable(
|
||||||
"object_relations",
|
"object_relations",
|
||||||
|
|
@ -122,6 +123,7 @@ export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
|
||||||
modelProviders: many(modelProviders),
|
modelProviders: many(modelProviders),
|
||||||
roleModelRoutes: many(roleModelRoutes),
|
roleModelRoutes: many(roleModelRoutes),
|
||||||
connectedRepos: many(connectedRepos),
|
connectedRepos: many(connectedRepos),
|
||||||
|
convoyEvents: many(convoyEvents),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const objectsRelations = relations(objects, ({ one, many }) => ({
|
export const objectsRelations = relations(objects, ({ one, many }) => ({
|
||||||
|
|
@ -279,6 +281,23 @@ export const modelProvidersRelations = relations(modelProviders, ({ one, many })
|
||||||
roleRoutes: many(roleModelRoutes),
|
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 }) => ({
|
export const connectedReposRelations = relations(connectedRepos, ({ one }) => ({
|
||||||
workspace: one(workspaces, {
|
workspace: one(workspaces, {
|
||||||
fields: [connectedRepos.workspaceId],
|
fields: [connectedRepos.workspaceId],
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./connected-repos": "./src/connected-repos/index.ts",
|
"./connected-repos": "./src/connected-repos/index.ts",
|
||||||
|
"./convoy-lifecycle": "./src/convoy-lifecycle/index.ts",
|
||||||
"./model-routing": "./src/model-routing/index.ts",
|
"./model-routing": "./src/model-routing/index.ts",
|
||||||
"./types": "./src/types/index.ts",
|
"./types": "./src/types/index.ts",
|
||||||
"./utils": "./src/utils/index.ts"
|
"./utils": "./src/utils/index.ts"
|
||||||
|
|
|
||||||
28
packages/shared/src/convoy-lifecycle/index.ts
Normal file
28
packages/shared/src/convoy-lifecycle/index.ts
Normal file
|
|
@ -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";
|
||||||
85
packages/shared/src/convoy-lifecycle/schemas.ts
Normal file
85
packages/shared/src/convoy-lifecycle/schemas.ts
Normal file
|
|
@ -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<typeof convoyStatusSchema>;
|
||||||
|
|
||||||
|
export const convoyClassificationSchema = z.enum([
|
||||||
|
"feature",
|
||||||
|
"hotfix",
|
||||||
|
"docs-only",
|
||||||
|
"infra-only",
|
||||||
|
"server-only",
|
||||||
|
"config-only",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ConvoyClassification = z.infer<typeof convoyClassificationSchema>;
|
||||||
|
|
||||||
|
export const convoyFileStatusSchema = z.enum([
|
||||||
|
"open",
|
||||||
|
"in-progress",
|
||||||
|
"merged",
|
||||||
|
"shipped",
|
||||||
|
"abandoned",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ConvoyFileStatus = z.infer<typeof convoyFileStatusSchema>;
|
||||||
|
|
||||||
|
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<typeof convoyEventTypeSchema>;
|
||||||
|
|
||||||
|
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<typeof convoyObjectContentSchema>;
|
||||||
|
|
||||||
|
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<typeof briefObjectContentSchema>;
|
||||||
|
|
||||||
|
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<typeof transitionConvoyInputSchema>;
|
||||||
|
|
||||||
|
export const convoyTransitionErrorCodeSchema = z.enum([
|
||||||
|
"INVALID_TRANSITION",
|
||||||
|
"FORBIDDEN_ACTOR",
|
||||||
|
"REASON_REQUIRED",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ConvoyTransitionErrorCode = z.infer<typeof convoyTransitionErrorCodeSchema>;
|
||||||
78
packages/shared/src/convoy-lifecycle/state-machine.test.ts
Normal file
78
packages/shared/src/convoy-lifecycle/state-machine.test.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
211
packages/shared/src/convoy-lifecycle/state-machine.ts
Normal file
211
packages/shared/src/convoy-lifecycle/state-machine.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import type { ConvoyStatus, ConvoyTransitionErrorCode } from "./schemas";
|
||||||
|
|
||||||
|
export const VALID_TRANSITIONS: Record<ConvoyStatus, ConvoyStatus[]> = {
|
||||||
|
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<string, ConvoyStatus> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export * from "./connected-repos";
|
export * from "./connected-repos";
|
||||||
|
export * from "./convoy-lifecycle";
|
||||||
export * from "./model-routing";
|
export * from "./model-routing";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
export * from "./utils";
|
export * from "./utils";
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ kind: epic
|
||||||
slug: convoy-lifecycle-mcp
|
slug: convoy-lifecycle-mcp
|
||||||
title: Convoy lifecycle MCP — state machine and convoy_events
|
title: Convoy lifecycle MCP — state machine and convoy_events
|
||||||
plan_slug: automation-foundation
|
plan_slug: automation-foundation
|
||||||
status: draft
|
status: done
|
||||||
priority: P1
|
priority: P1
|
||||||
tenant_id: global
|
tenant_id: global
|
||||||
cursor_epic_id: null
|
cursor_epic_id: null
|
||||||
|
|
@ -43,10 +43,12 @@ Reference: agent-pipeline v0.4 plan §7; local-first `.convoys/` with `.pending-
|
||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Invalid transition `draft → released` rejected with structured error.
|
- [x] Invalid transition `draft → released` rejected with structured error.
|
||||||
- [ ] `role-architect` cannot call `human-gate-1` transition.
|
- [x] `role-architect` cannot call `human-gate-1` transition.
|
||||||
- [ ] Offline convoy edit reconciles via `reconcile_from_files`.
|
- [x] Offline convoy edit reconciles via `reconcile_from_files`.
|
||||||
|
|
||||||
## Tasks
|
## 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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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`)
|
||||||
|
|
@ -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 | — |
|
| 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) |
|
| 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 | — |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@ importers:
|
||||||
'@tasks/shared':
|
'@tasks/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
yaml:
|
||||||
|
specifier: ^2.8.3
|
||||||
|
version: 2.8.3
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.0
|
specifier: ^3.24.0
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue