209 lines
10 KiB
TypeScript
209 lines
10 KiB
TypeScript
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<DecisionRecordType>(["meeting", "decision", "goal", "blocker", "debt", "experiment"]);
|
|
const levelValues = new Set<DecisionRecordLevel>(["G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"]);
|
|
const statusValues = new Set<DecisionRecordStatus>(["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<unknown>,
|
|
path: string,
|
|
init?: { method?: string; body?: unknown },
|
|
): Promise<unknown> {
|
|
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<string, unknown> : {};
|
|
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<unknown>): Promise<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(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<unknown>): Promise<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(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<unknown>): Promise<unknown> {
|
|
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<unknown> {
|
|
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<unknown>,
|
|
): Promise<unknown> {
|
|
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");
|
|
}
|