Submatter/apps/mcp-server/src/lib/convoy-files.ts
Randall Stillwell 9405da8a45 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>
2026-07-07 14:51:19 -05:00

147 lines
4.2 KiB
TypeScript

// @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");
}
}