import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { type UniDeskConfig, repoRoot } from "./config"; import { coreInternalFetch } from "./microservices"; type DecisionRecordType = "meeting" | "decision" | "goal" | "blocker" | "debt" | "experiment"; type DecisionRecordLevel = "G0" | "G1" | "G2" | "G3" | "P0" | "P1" | "P2" | "P3" | "none"; type DecisionRecordStatus = "active" | "blocked" | "parked" | "done"; const serviceId = "decision-center"; const typeValues = new Set(["meeting", "decision", "goal", "blocker", "debt", "experiment"]); const levelValues = new Set(["G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"]); const statusValues = new Set(["active", "blocked", "parked", "done"]); function optionValue(args: string[], names: string[]): string | undefined { for (const name of names) { const index = args.indexOf(name); if (index === -1) continue; const raw = args[index + 1]; if (raw === undefined || raw.length === 0 || raw.startsWith("--")) throw new Error(`${name} requires a non-empty value`); return raw; } return undefined; } function optionValues(args: string[], names: string[]): string[] { const values: string[] = []; for (let index = 0; index < args.length; index += 1) { if (!names.includes(args[index] ?? "")) continue; const raw = args[index + 1]; if (raw === undefined || raw.length === 0 || raw.startsWith("--")) throw new Error(`${args[index]} requires a non-empty value`); values.push(raw); index += 1; } return values; } function positionalArgs(args: string[]): string[] { const positions: string[] = []; for (let index = 0; index < args.length; index += 1) { const value = args[index] ?? ""; if (value.startsWith("--")) { index += 1; continue; } positions.push(value); } return positions; } function parseType(raw: string | undefined, fallback: DecisionRecordType): DecisionRecordType { const value = raw || fallback; if (!typeValues.has(value as DecisionRecordType)) throw new Error(`--type must be one of: ${Array.from(typeValues).join(", ")}`); return value as DecisionRecordType; } function parseLevel(raw: string | undefined, fallback: DecisionRecordLevel): DecisionRecordLevel { const value = raw || fallback; if (!levelValues.has(value as DecisionRecordLevel)) throw new Error(`--level must be one of: ${Array.from(levelValues).join(", ")}`); return value as DecisionRecordLevel; } function parseStatus(raw: string | undefined, fallback: DecisionRecordStatus): DecisionRecordStatus { const value = raw || fallback; if (!statusValues.has(value as DecisionRecordStatus)) throw new Error(`--status must be one of: ${Array.from(statusValues).join(", ")}`); return value as DecisionRecordStatus; } function splitList(values: string[]): string[] { return [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean))]; } function readMarkdownFile(path: string): { absolutePath: string; markdown: string } { const absolutePath = resolve(repoRoot, path); const markdown = readFileSync(absolutePath, "utf8"); if (markdown.trim().length === 0) throw new Error(`markdown file is empty: ${absolutePath}`); if (markdown.length > 1_000_000) throw new Error(`markdown file is too large: ${absolutePath}`); return { absolutePath, markdown }; } function decisionProxy(path: string, init?: { method?: string; body?: unknown }): unknown { return coreInternalFetch(`/api/microservices/${encodeURIComponent(serviceId)}/proxy${path}`, init); } async function decisionProxyAsync( fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise, path: string, init?: { method?: string; body?: unknown }, ): Promise { return await fetcher(`/api/microservices/${encodeURIComponent(serviceId)}/proxy${path}`, init); } function unwrapProxyResponse(response: unknown): unknown { const record = typeof response === "object" && response !== null && !Array.isArray(response) ? response as Record : {}; if (record.ok !== true) return response; const body = record.body; return { upstream: { ok: record.ok, status: record.status }, body }; } function uploadMeeting(args: string[]): unknown { const file = positionalArgs(args)[0]; if (!file) throw new Error("decision upload requires markdown file"); const { absolutePath, markdown } = readMarkdownFile(file); const type = parseType(optionValue(args, ["--type"]), "meeting"); const payload = { markdown, title: optionValue(args, ["--title"]), type, level: parseLevel(optionValue(args, ["--level"]), "none"), status: parseStatus(optionValue(args, ["--status"]), "active"), linkedGoalId: optionValue(args, ["--linked-goal-id", "--linkedGoalId"]), tags: splitList(optionValues(args, ["--tag", "--tags"])), evidenceLinks: splitList(optionValues(args, ["--evidence", "--evidence-link", "--evidenceLinks"])), sourceSession: optionValue(args, ["--source-session", "--sourceSession"]), taskId: optionValue(args, ["--task-id", "--taskId"]), commitId: optionValue(args, ["--commit-id", "--commitId"]), }; const endpoint = type === "meeting" ? "/api/meetings/import" : "/api/records"; const body = type === "meeting" ? payload : { ...payload, body: markdown }; return { file: absolutePath, result: unwrapProxyResponse(decisionProxy(endpoint, { method: "POST", body })) }; } async function uploadMeetingAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise): Promise { const file = positionalArgs(args)[0]; if (!file) throw new Error("decision upload requires markdown file"); const { absolutePath, markdown } = readMarkdownFile(file); const type = parseType(optionValue(args, ["--type"]), "meeting"); const payload = { markdown, title: optionValue(args, ["--title"]), type, level: parseLevel(optionValue(args, ["--level"]), "none"), status: parseStatus(optionValue(args, ["--status"]), "active"), linkedGoalId: optionValue(args, ["--linked-goal-id", "--linkedGoalId"]), tags: splitList(optionValues(args, ["--tag", "--tags"])), evidenceLinks: splitList(optionValues(args, ["--evidence", "--evidence-link", "--evidenceLinks"])), sourceSession: optionValue(args, ["--source-session", "--sourceSession"]), taskId: optionValue(args, ["--task-id", "--taskId"]), commitId: optionValue(args, ["--commit-id", "--commitId"]), }; const endpoint = type === "meeting" ? "/api/meetings/import" : "/api/records"; const body = type === "meeting" ? payload : { ...payload, body: markdown }; return { file: absolutePath, result: unwrapProxyResponse(await decisionProxyAsync(fetcher, endpoint, { method: "POST", body })) }; } function listRecords(args: string[]): unknown { const params = new URLSearchParams(); const type = optionValue(args, ["--type"]); const status = optionValue(args, ["--status"]); const level = optionValue(args, ["--level"]); const linkedGoalId = optionValue(args, ["--linked-goal-id", "--linkedGoalId"]); const limit = optionValue(args, ["--limit"]); if (type !== undefined) params.set("type", parseType(type, "meeting")); if (status !== undefined) params.set("status", parseStatus(status, "active")); if (level !== undefined) params.set("level", parseLevel(level, "none")); if (linkedGoalId !== undefined) params.set("linkedGoalId", linkedGoalId); if (limit !== undefined) params.set("limit", limit); const query = params.toString(); return unwrapProxyResponse(decisionProxy(`/api/records${query ? `?${query}` : ""}`)); } async function listRecordsAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise): Promise { const params = new URLSearchParams(); const type = optionValue(args, ["--type"]); const status = optionValue(args, ["--status"]); const level = optionValue(args, ["--level"]); const linkedGoalId = optionValue(args, ["--linked-goal-id", "--linkedGoalId"]); const limit = optionValue(args, ["--limit"]); if (type !== undefined) params.set("type", parseType(type, "meeting")); if (status !== undefined) params.set("status", parseStatus(status, "active")); if (level !== undefined) params.set("level", parseLevel(level, "none")); if (linkedGoalId !== undefined) params.set("linkedGoalId", linkedGoalId); if (limit !== undefined) params.set("limit", limit); const query = params.toString(); return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/records${query ? `?${query}` : ""}`)); } function showRecord(id: string | undefined): unknown { if (!id) throw new Error("decision show requires record id"); return unwrapProxyResponse(decisionProxy(`/api/records/${encodeURIComponent(id)}`)); } async function showRecordAsync(id: string | undefined, fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise): Promise { if (!id) throw new Error("decision show requires record id"); return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/records/${encodeURIComponent(id)}`)); } export async function runDecisionCenterCommand(_config: UniDeskConfig, args: string[]): Promise { const [action = "list", id] = args; if (action === "upload") return uploadMeeting(args.slice(1)); if (action === "list") return listRecords(args.slice(1)); if (action === "show") return showRecord(id); if (action === "health") return unwrapProxyResponse(coreInternalFetch(`/api/microservices/${encodeURIComponent(serviceId)}/health`)); throw new Error("decision command must be one of: upload, list, show, health"); } export async function runDecisionCenterCommandAsync( _config: UniDeskConfig, args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise, ): Promise { const [action = "list", id] = args; if (action === "upload") return uploadMeetingAsync(args.slice(1), fetcher); if (action === "list") return listRecordsAsync(args.slice(1), fetcher); if (action === "show") return showRecordAsync(id, fetcher); if (action === "health") return unwrapProxyResponse(await fetcher(`/api/microservices/${encodeURIComponent(serviceId)}/health`)); throw new Error("decision command must be one of: upload, list, show, health"); }