feat(convoys): convoy lifecycle MCP (Epic 4)
feat(convoys): convoy lifecycle MCP (Epic 4)
This commit is contained in:
commit
4bb553c15f
31 changed files with 5218 additions and 7 deletions
|
|
@ -14,6 +14,7 @@
|
|||
"@modelcontextprotocol/sdk": "^1.4.0",
|
||||
"@tasks/database": "workspace:*",
|
||||
"@tasks/shared": "workspace:*",
|
||||
"yaml": "^2.8.3",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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).",
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
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 { 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";
|
||||
|
|
|
|||
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,
|
||||
"tag": "0012_uneven_captain_stacy",
|
||||
"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 "./role_model_routes";
|
||||
export * from "./connected_repos";
|
||||
export * from "./convoy_events";
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
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 "./convoy-lifecycle";
|
||||
export * from "./model-routing";
|
||||
export * from "./types";
|
||||
export * from "./utils";
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 | — |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue