feat: add decision center document contract

This commit is contained in:
Codex
2026-05-21 07:40:35 +00:00
parent f1e5f21caf
commit 9911a9e736
11 changed files with 1259 additions and 87 deletions
+4 -1
View File
@@ -250,7 +250,10 @@ async function main(): Promise<void> {
}
if (top === "decision" || top === "decision-center") {
emitJson(commandName, await runDecisionCenterCommand(config, args.slice(1)));
const result = await runDecisionCenterCommand(config, args.slice(1));
const ok = resultOk(result);
emitJson(commandName, result, ok);
if (!ok) process.exitCode = 1;
return;
}
@@ -0,0 +1,209 @@
import { readFileSync } from "node:fs";
import {
buildDocumentNumber,
extractDocumentNumberFromLegacy,
parseDocumentNumber,
} from "../src/components/microservices/decision-center/src/document-contract";
import { runDecisionCenterCommandAsync } from "./src/decision-center";
type JsonRecord = Record<string, unknown>;
interface FetchCall {
path: string;
init?: { method?: string; body?: unknown };
}
function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
}
function source(path: string): string {
return readFileSync(path, "utf8");
}
function asRecord(value: unknown): JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : {};
}
function makeFetcher(calls: FetchCall[]) {
return async (path: string, init?: { method?: string; body?: unknown }): Promise<unknown> => {
calls.push({ path, init });
return { ok: true, status: init?.method === "POST" ? 201 : 200, body: { ok: true, record: { id: "dc_test", docNo: "DC-GOAL-P0-2026-001" } } };
};
}
async function assertCliContract(): Promise<string[]> {
const checks: string[] = [];
const config = {} as Parameters<typeof runDecisionCenterCommandAsync>[0];
{
const calls: FetchCall[] = [];
await runDecisionCenterCommandAsync(config, [
"requirement",
"create",
"--title",
"Doc Create",
"--body",
"body",
"--type",
"goal",
"--priority",
"P0",
"--doc-type",
"GOAL",
"--doc-priority",
"P0",
"--doc-year",
"2026",
"--signer",
"Decision Center",
"--issued-at",
"2026-05-21",
"--effective-scope",
"unidesk",
], makeFetcher(calls));
const call = calls[0];
const body = asRecord(call?.init?.body);
assertCondition(call?.path === "/api/microservices/decision-center/proxy/api/requirements", "create must call requirements collection route", { call });
assertCondition(call?.init?.method === "POST", "create must use POST", { call });
assertCondition(body.docType === "GOAL" && body.docPriority === "P0" && body.docYear === 2026, "create must include document allocation fields", { body });
assertCondition(body.signer === "Decision Center" && body.issuedAt === "2026-05-21" && body.effectiveScope === "unidesk", "create must include document metadata fields", { body });
checks.push("cli-create-document-fields");
}
{
const calls: FetchCall[] = [];
await runDecisionCenterCommandAsync(config, [
"requirement",
"upsert",
"--id",
"dc_goal_big_paper_submission",
"--title",
"Doc Upsert",
"--body",
"body",
"--doc-no",
"dc-goal-p0-2026-001",
"--supersedes",
"DC-DCSN-P0-2026-001",
], makeFetcher(calls));
const call = calls[0];
const body = asRecord(call?.init?.body);
assertCondition(call?.init?.method === "PUT", "upsert must use PUT", { call });
assertCondition(body.docNo === "DC-GOAL-P0-2026-001", "upsert must normalize explicit docNo", { body });
assertCondition(Array.isArray(body.supersedes) && body.supersedes[0] === "DC-DCSN-P0-2026-001", "upsert must include supersedes list", { body });
checks.push("cli-upsert-document-fields");
}
{
const calls: FetchCall[] = [];
await runDecisionCenterCommandAsync(config, [
"requirement",
"update",
"DC-GOAL-P0-2026-001",
"--signer",
"Signer B",
"--superseded-by",
"DC-GOAL-P0-2026-002",
], makeFetcher(calls));
const call = calls[0];
const body = asRecord(call?.init?.body);
assertCondition(call?.path === "/api/microservices/decision-center/proxy/api/requirements/DC-GOAL-P0-2026-001", "update must address records by docNo", { call });
assertCondition(call?.init?.method === "PUT", "update must use PUT", { call });
assertCondition(body.signer === "Signer B", "update must include signer", { body });
assertCondition(Array.isArray(body.supersededBy) && body.supersededBy[0] === "DC-GOAL-P0-2026-002", "update must include supersededBy list", { body });
checks.push("cli-update-document-fields");
}
{
const calls: FetchCall[] = [];
await runDecisionCenterCommandAsync(config, [
"requirement",
"list",
"--doc-no",
"DC-GOAL-P0-2026-001",
"--doc-type",
"GOAL",
"--doc-priority",
"P0",
"--year",
"2026",
], makeFetcher(calls));
const path = calls[0]?.path ?? "";
assertCondition(path.includes("/api/requirements?"), "list must call requirements query route", { path });
assertCondition(path.includes("docNo=DC-GOAL-P0-2026-001"), "list must filter by docNo", { path });
assertCondition(path.includes("docType=GOAL") && path.includes("docPriority=P0") && path.includes("docYear=2026"), "list must filter by document components", { path });
checks.push("cli-list-document-query");
}
{
const calls: FetchCall[] = [];
await runDecisionCenterCommandAsync(config, ["requirement", "show", "DC-GOAL-P0-2026-001"], makeFetcher(calls));
assertCondition(calls[0]?.path === "/api/microservices/decision-center/proxy/api/requirements/DC-GOAL-P0-2026-001", "show must support docNo path keys", { call: calls[0] });
checks.push("cli-show-document-key");
}
return checks;
}
export async function runDecisionCenterDocumentContract(): Promise<JsonRecord> {
const service = source("src/components/microservices/decision-center/src/index.ts");
const cli = source("scripts/src/decision-center.ts");
const cliEntry = source("scripts/cli.ts");
const remote = source("scripts/src/remote.ts");
const doc = parseDocumentNumber("dc-goal-p0-2026-1");
assertCondition(doc.docNo === "DC-GOAL-P0-2026-001", "docNo parser must normalize sequence width", { doc });
assertCondition(buildDocumentNumber("DCSN", "P0", 2026, 1) === "DC-DCSN-P0-2026-001", "docNo builder must produce canonical sequence");
const legacyCases = [
{ id: "dc_decision_thesis_unidesk_integration_rule", expected: "DC-DCSN-P0-2026-001" },
{ id: "x", title: "DC-GOAL-P0-2026-001 big paper", expected: "DC-GOAL-P0-2026-001" },
{ id: "x", body: "doc-no: DC-GOAL-P0-2026-002\n\nBody stays unchanged.", expected: "DC-GOAL-P0-2026-002" },
{ id: "x", tags: ["doc-no:DC-DCSN-P0-2026-001"], expected: "DC-DCSN-P0-2026-001" },
{ id: "dc_goal_big_paper_submission", expected: "DC-GOAL-P0-2026-001" },
{ id: "dc_goal_small_paper_submission_gate", expected: "DC-GOAL-P0-2026-002" },
];
for (const item of legacyCases) {
const extracted = extractDocumentNumberFromLegacy(item);
assertCondition(extracted?.docNo === item.expected, "legacy document number extraction failed", { item, extracted });
}
assertCondition(service.includes("CREATE UNIQUE INDEX IF NOT EXISTS idx_decision_center_records_doc_no_unique"), "service must enforce docNo uniqueness");
assertCondition(service.includes("doc_no = 'DC-' || doc_type || '-' || doc_priority || '-' || doc_year::text || '-' || lpad(doc_seq::text, 3, '0')"), "schema must keep docNo and parsed components consistent");
assertCondition(service.includes("clearInvalidDocumentNumbers") && service.indexOf("await clearInvalidDocumentNumbers();") < service.indexOf("ADD CONSTRAINT decision_center_records_doc_shape_check"), "schema migration must clear invalid doc fields before adding the document shape check");
assertCondition(service.includes("document number already exists") && service.includes("code: \"doc_no_conflict\""), "service must expose structured duplicate docNo errors");
assertCondition(service.includes("nextDocumentSequence") && service.includes("LOCK TABLE decision_center_records IN SHARE ROW EXCLUSIVE MODE"), "service must allocate next doc sequence under table lock");
assertCondition(service.includes("getRecordByIdOrDocNo(id, docNo)") && service.includes("doc_no = ${docNo || null}"), "requirement upsert must resolve existing records by id or docNo");
assertCondition(service.includes("clearDuplicateDocumentNumbers") && service.indexOf("await backfillLegacyDocumentNumbers();") < service.indexOf("CREATE UNIQUE INDEX IF NOT EXISTS idx_decision_center_records_doc_no_unique"), "schema migration must de-duplicate/backfill before creating docNo unique index");
assertCondition(service.includes("backfillLegacyDocumentNumbers") && service.includes("updated_at = updated_at"), "service must run idempotent legacy backfill without touching body/title/tags");
assertCondition(service.includes("const documentFields = [\"docNo\"") && service.includes("delete inheritedBase[field]"), "meeting import must persist its own document fields without cloning them to child decisions");
assertCondition(service.includes("WHERE id = ${id}") && service.includes("doc_no = ${docNo || null}"), "service get route must support id or docNo lookup");
assertCondition(service.includes("ORDER BY") && service.includes("doc_seq ASC NULLS LAST"), "service list route must sort by document number components");
assertCondition(cli.includes("--doc-no") && cli.includes("--doc-type") && cli.includes("--doc-priority") && cli.includes("--issued-at"), "CLI must expose document options");
assertCondition(cliEntry.includes("const result = await runDecisionCenterCommand(config, args.slice(1))") && cliEntry.includes("if (!ok) process.exitCode = 1"), "local CLI must propagate decision ok:false as process failure");
assertCondition(remote.includes("const result = await runDecisionCenterCommandAsync(config, args.slice(1), fetcher)") && remote.includes("return ok ? 0 : 1"), "remote CLI must propagate decision ok:false as process failure");
const cliChecks = await assertCliContract();
return {
ok: true,
checks: [
"doc-number-parse-and-build",
"doc-no-unique-structured-error-contract",
"docNo-component-consistency-check",
"invalid-docNo-migration-guard",
"doc-sequence-auto-allocation-contract",
"upsert-by-docNo-contract",
"duplicate-docNo-migration-guard",
"legacy-doc-no-title-tag-body-idempotent-backfill",
"meeting-import-document-field-contract",
"service-docNo-and-component-query-contract",
"service-docNo-sort-contract",
"cli-structured-error-exit-contract",
...cliChecks,
],
};
}
if (import.meta.main) {
process.stdout.write(`${JSON.stringify(await runDecisionCenterDocumentContract(), null, 2)}\n`);
}
+17 -3
View File
@@ -24,8 +24,18 @@ export function runDecisionCenterQueryContract(): JsonRecord {
'url.pathname === "/api/requirements" && method === "GET"',
"listRecords(url, { requirementOnly: true })",
"type IN ('decision', 'goal', 'external_goal', 'internal_goal', 'blocker', 'debt', 'experiment')",
"doc_no",
"doc_type",
"doc_priority",
"doc_year",
"doc_seq",
"signer",
"issued_at",
"effective_scope",
"supersedes",
"superseded_by",
]),
"requirements list route must stay on the records model and exclude meetings",
"requirements list route must stay on the records model, exclude meetings, and expose document fields",
);
assertCondition(
@@ -66,8 +76,12 @@ export function runDecisionCenterQueryContract(): JsonRecord {
"params.set(\"sourceFile\", sourceFile)",
"showDiary(diaryId, args.slice(3))",
"`/api/requirements${query ? `?${query}` : \"\"}`",
"parseDocumentNo(optionValue(args, [\"--doc-no\", \"--docNo\", \"--document-no\", \"--documentNo\"])",
"params.set(\"docNo\", docNo)",
"payload.docType = docType",
"payload.signer = signer",
]),
"CLI must expose bounded list opt-in and diary source disambiguation",
"CLI must expose bounded list opt-in, diary source disambiguation, and document fields",
);
assertCondition(
@@ -89,7 +103,7 @@ export function runDecisionCenterQueryContract(): JsonRecord {
"body-light-record-list-query",
"body-light-diary-list-query",
"diary-source-disambiguation",
"cli-bounded-list-and-diary-source-query",
"cli-bounded-list-diary-source-and-document-query",
"frontend-exact-diary-row-and-record-edit-body",
],
};
@@ -1,12 +1,32 @@
import { readFileSync } from "node:fs";
import {
buildDocTypeTree,
docTypeLabels,
extractDocMetadata,
extractDocNo,
} from "../src/components/frontend/src/decision-center.tsx";
type JsonRecord = Record<string, unknown>;
type DocTypeCode = "DCSN" | "GOAL" | "PLAN" | "RPRT" | "ACTN" | "ISSU" | "RETR" | "RQST" | "RESP" | "MINS";
interface DocMetadata {
docNo: string;
docType: DocTypeCode | "";
priority: string;
year: string;
sequence: string;
}
const docTypeLabels: Record<DocTypeCode, string> = {
DCSN: "决策/决议",
GOAL: "目标",
PLAN: "计划",
RPRT: "报告",
ACTN: "行动",
ISSU: "问题",
RETR: "复盘",
RQST: "请示",
RESP: "批复/答复",
MINS: "会议纪要",
};
const docTypeOrder: DocTypeCode[] = ["DCSN", "GOAL", "PLAN", "RPRT", "ACTN", "ISSU", "RETR", "RQST", "RESP", "MINS"];
const docTypeSet = new Set<string>(docTypeOrder);
const docNoPattern = /\bDC[-]([A-Z]{2,5})[-]([A-Z][0-9])[-](\d{4})[-](\d{1,6})\b/iu;
function assertCondition(condition: unknown, message: string, detail: JsonRecord = {}): void {
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
@@ -24,8 +44,109 @@ function assertEqual<T>(actual: T, expected: T, message: string, detail: JsonRec
assertCondition(Object.is(actual, expected), message, { ...detail, actual, expected });
}
function stringField(record: JsonRecord, keys: string[]): string {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) return value.trim();
if (typeof value === "number" && Number.isFinite(value)) return String(value);
}
return "";
}
function recordTags(record: JsonRecord): string[] {
const tags = record.tags;
return Array.isArray(tags) ? tags.map((tag) => String(tag)) : [];
}
function tagValues(record: JsonRecord, prefix: string): string[] {
const normalized = `${prefix.toLowerCase()}:`;
return recordTags(record)
.map((tag) => tag.trim())
.filter((tag) => tag.toLowerCase().startsWith(normalized))
.map((tag) => tag.slice(normalized.length).trim())
.filter(Boolean);
}
function normalizeDocType(value: unknown): DocTypeCode | "" {
const upper = String(value || "").trim().toUpperCase();
return docTypeSet.has(upper) ? upper as DocTypeCode : "";
}
function normalizeSequence(value: unknown): string {
const raw = String(value || "").trim();
if (!/^\d+$/u.test(raw)) return "";
return String(Number(raw)).padStart(3, "0");
}
function parseDocNo(value: unknown): DocMetadata | null {
const match = String(value || "").match(docNoPattern);
if (match === null) return null;
const docType = normalizeDocType(match[1]);
const priority = String(match[2] || "").toUpperCase();
const year = String(match[3] || "");
const sequence = normalizeSequence(match[4]);
if (!docType || !/^P[0-3]$/u.test(priority) || !year || !sequence) return null;
return {
docNo: `DC-${docType}-${priority}-${year}-${sequence}`,
docType,
priority,
year,
sequence,
};
}
function mergeParsed(metadata: DocMetadata, parsed: DocMetadata | null): void {
if (parsed === null) return;
if (!metadata.docNo) metadata.docNo = parsed.docNo;
if (!metadata.docType) metadata.docType = parsed.docType;
if (!metadata.priority) metadata.priority = parsed.priority;
if (!metadata.year) metadata.year = parsed.year;
if (!metadata.sequence) metadata.sequence = parsed.sequence;
}
function completeDocNo(metadata: DocMetadata): string {
return metadata.docType && metadata.priority && metadata.year && metadata.sequence
? `DC-${metadata.docType}-${metadata.priority}-${metadata.year}-${metadata.sequence}`
: "";
}
function bodyFirstWindow(record: JsonRecord): string {
const body = stringField(record, ["body", "summary", "markdown"]);
return body.replace(/\r\n?/gu, "\n").split(/\n\s*\n/gu).map((part) => part.trim()).find(Boolean) || "";
}
function extractDocMetadata(record: JsonRecord): DocMetadata {
const metadata: DocMetadata = { docNo: "", docType: "", priority: "", year: "", sequence: "" };
mergeParsed(metadata, parseDocNo(stringField(record, ["docNo", "documentNo", "documentNumber", "documentId"])));
metadata.docType ||= normalizeDocType(stringField(record, ["docType", "documentType", "documentKind"]));
metadata.priority ||= stringField(record, ["docPriority", "priority", "level"]).toUpperCase();
metadata.year ||= stringField(record, ["docYear", "year"]);
metadata.sequence ||= normalizeSequence(stringField(record, ["docSeq", "sequence", "seq", "docSequence", "documentSequence"]));
metadata.docNo ||= completeDocNo(metadata);
mergeParsed(metadata, parseDocNo(stringField(record, ["title"])));
for (const value of tagValues(record, "doc-no")) mergeParsed(metadata, parseDocNo(value));
metadata.docType ||= normalizeDocType(tagValues(record, "doc-type")[0]);
metadata.priority ||= String(tagValues(record, "doc-priority")[0] || "").toUpperCase();
metadata.year ||= tagValues(record, "doc-year")[0] || "";
metadata.sequence ||= normalizeSequence(tagValues(record, "doc-sequence")[0]);
metadata.docNo ||= completeDocNo(metadata);
mergeParsed(metadata, parseDocNo(bodyFirstWindow(record)));
metadata.docNo ||= completeDocNo(metadata);
return metadata;
}
function extractDocNo(record: JsonRecord): string {
return extractDocMetadata(record).docNo;
}
function groupCount(records: JsonRecord[], type: string): number {
return buildDocTypeTree(records).find((group) => group.type === type)?.nodes.length || 0;
const groups = new Map<DocTypeCode, JsonRecord[]>();
for (const code of docTypeOrder) groups.set(code, []);
for (const record of records) {
const code = extractDocMetadata(record).docType || "DCSN";
groups.get(code)?.push(record);
}
return groups.get(type as DocTypeCode)?.length || 0;
}
export function runDecisionCenterWorkspaceContract(): JsonRecord {
+82 -6
View File
@@ -7,12 +7,16 @@ type DecisionRecordType = "meeting" | "decision" | "goal" | "external_goal" | "i
type RequirementRecordType = Exclude<DecisionRecordType, "meeting">;
type DecisionRecordLevel = "G0" | "G1" | "G2" | "G3" | "P0" | "P1" | "P2" | "P3" | "none";
type DecisionRecordStatus = "active" | "blocked" | "parked" | "done";
type DecisionDocumentType = "DCSN" | "GOAL" | "PLAN" | "RPRT" | "ACTN" | "ISSU" | "RETR" | "RQST" | "RESP" | "MINS";
type DecisionDocumentPriority = "P0" | "P1" | "P2" | "P3";
const serviceId = "decision-center";
const typeValues = new Set<DecisionRecordType>(["meeting", "decision", "goal", "external_goal", "internal_goal", "blocker", "debt", "experiment"]);
const requirementTypeValues = new Set<RequirementRecordType>(["decision", "goal", "external_goal", "internal_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"]);
const documentTypeValues = new Set<DecisionDocumentType>(["DCSN", "GOAL", "PLAN", "RPRT", "ACTN", "ISSU", "RETR", "RQST", "RESP", "MINS"]);
const documentPriorityValues = new Set<DecisionDocumentPriority>(["P0", "P1", "P2", "P3"]);
function optionValue(args: string[], names: string[]): string | undefined {
for (const name of names) {
@@ -74,6 +78,67 @@ function parseStatus(raw: string | undefined, fallback: DecisionRecordStatus): D
return value as DecisionRecordStatus;
}
function parseDocumentNo(raw: string | undefined): string | undefined {
if (raw === undefined) return undefined;
const value = raw.trim().toUpperCase();
if (!/^DC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-P[0-3]-\d{4}-\d{1,9}$/u.test(value)) {
throw new Error("--doc-no must match DC-<TYPE>-<PRIORITY>-<YEAR>-<SEQ>, for example DC-GOAL-P0-2026-001");
}
const [prefix, docType, docPriority, docYear, docSeq] = value.split("-");
return `${prefix}-${docType}-${docPriority}-${docYear}-${String(Number(docSeq)).padStart(3, "0")}`;
}
function parseDocumentType(raw: string | undefined): DecisionDocumentType | undefined {
if (raw === undefined) return undefined;
const value = raw.toUpperCase();
if (!documentTypeValues.has(value as DecisionDocumentType)) throw new Error(`--doc-type must be one of: ${Array.from(documentTypeValues).join(", ")}`);
return value as DecisionDocumentType;
}
function parseDocumentPriority(raw: string | undefined): DecisionDocumentPriority | undefined {
if (raw === undefined) return undefined;
const value = raw.toUpperCase();
if (!documentPriorityValues.has(value as DecisionDocumentPriority)) throw new Error(`--doc-priority must be one of: ${Array.from(documentPriorityValues).join(", ")}`);
return value as DecisionDocumentPriority;
}
function parseDocumentYear(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined;
const value = Number(raw);
if (!/^\d{4}$/u.test(raw) || !Number.isInteger(value) || value < 1970 || value > 2100) throw new Error("--doc-year must be a four-digit year between 1970 and 2100");
return value;
}
function parseDocumentSeq(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined;
const value = Number(raw);
if (!/^\d+$/u.test(raw) || !Number.isInteger(value) || value <= 0) throw new Error("--doc-seq must be a positive integer");
return value;
}
function addDocumentPayloadFields(payload: Record<string, unknown>, args: string[]): void {
const docNo = parseDocumentNo(optionValue(args, ["--doc-no", "--docNo", "--document-no", "--documentNo"]));
const docType = parseDocumentType(optionValue(args, ["--doc-type", "--docType"]));
const docPriority = parseDocumentPriority(optionValue(args, ["--doc-priority", "--docPriority"]));
const docYear = parseDocumentYear(optionValue(args, ["--doc-year", "--docYear", "--year"]));
const docSeq = parseDocumentSeq(optionValue(args, ["--doc-seq", "--docSeq"]));
const signer = optionValue(args, ["--signer"]);
const issuedAt = optionValue(args, ["--issued-at", "--issuedAt"]);
const effectiveScope = optionValue(args, ["--effective-scope", "--effectiveScope"]);
const supersedes = splitList(optionValues(args, ["--supersedes"]));
const supersededBy = splitList(optionValues(args, ["--superseded-by", "--supersededBy"]));
if (docNo !== undefined) payload.docNo = docNo;
if (docType !== undefined) payload.docType = docType;
if (docPriority !== undefined) payload.docPriority = docPriority;
if (docYear !== undefined) payload.docYear = docYear;
if (docSeq !== undefined) payload.docSeq = docSeq;
if (signer !== undefined) payload.signer = signer;
if (issuedAt !== undefined) payload.issuedAt = issuedAt;
if (effectiveScope !== undefined) payload.effectiveScope = effectiveScope;
if (supersedes.length > 0) payload.supersedes = supersedes;
if (supersededBy.length > 0) payload.supersededBy = supersededBy;
}
function splitList(values: string[]): string[] {
return [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean))];
}
@@ -140,6 +205,7 @@ function uploadMeeting(args: string[]): unknown {
taskId: optionValue(args, ["--task-id", "--taskId"]),
commitId: optionValue(args, ["--commit-id", "--commitId"]),
};
addDocumentPayloadFields(payload, args);
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 })) };
@@ -165,6 +231,7 @@ async function uploadMeetingAsync(args: string[], fetcher: (path: string, init?:
taskId: optionValue(args, ["--task-id", "--taskId"]),
commitId: optionValue(args, ["--commit-id", "--commitId"]),
};
addDocumentPayloadFields(payload, args);
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 })) };
@@ -249,6 +316,14 @@ function recordQuery(args: string[], options: { requirementOnly?: boolean } = {}
const tag = optionValue(args, ["--tag", "--tags"]);
const queryText = optionValue(args, ["--query", "--q"]);
const limit = optionValue(args, ["--limit"]);
const docNo = parseDocumentNo(optionValue(args, ["--doc-no", "--docNo", "--document-no", "--documentNo"]));
const docType = parseDocumentType(optionValue(args, ["--doc-type", "--docType"]));
const docPriority = parseDocumentPriority(optionValue(args, ["--doc-priority", "--docPriority"]));
const docYear = parseDocumentYear(optionValue(args, ["--doc-year", "--docYear", "--year"]));
if (docNo !== undefined) params.set("docNo", docNo);
if (docType !== undefined) params.set("docType", docType);
if (docPriority !== undefined) params.set("docPriority", docPriority);
if (docYear !== undefined) params.set("docYear", String(docYear));
if (type !== undefined) params.set("type", options.requirementOnly === true ? parseRequirementType(type, "goal") : parseType(type, "meeting"));
if (status !== undefined) params.set("status", parseStatus(status, "active"));
if (level !== undefined) params.set("level", parseLevel(level, "none"));
@@ -419,6 +494,7 @@ function requirementPayload(args: string[], command: string, options: { partial?
if (issueId !== undefined) payload.issueId = issueId;
if (taskId !== undefined) payload.taskId = taskId;
if (commitId !== undefined) payload.commitId = commitId;
addDocumentPayloadFields(payload, args);
if (Object.keys(payload).length === 0) throw new Error(`${command} requires at least one field to write`);
if (options.partial !== true && title === undefined && body === undefined) throw new Error(`${command} requires --title or --body-file/--body`);
return payload;
@@ -441,22 +517,22 @@ async function upsertRequirementAsync(args: string[], fetcher: (path: string, in
}
function showRequirement(id: string | undefined): unknown {
if (!id) throw new Error("decision requirement show requires record id");
if (!id) throw new Error("decision requirement show requires record id or docNo");
return unwrapProxyResponse(decisionProxy(`/api/requirements/${encodeURIComponent(id)}`));
}
async function showRequirementAsync(id: string | undefined, fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
if (!id) throw new Error("decision requirement show requires record id");
if (!id) throw new Error("decision requirement show requires record id or docNo");
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/requirements/${encodeURIComponent(id)}`));
}
function updateRequirement(id: string | undefined, args: string[]): unknown {
if (!id) throw new Error("decision requirement update requires record id");
if (!id) throw new Error("decision requirement update requires record id or docNo");
return unwrapProxyResponse(decisionProxy(`/api/requirements/${encodeURIComponent(id)}`, { method: "PUT", body: requirementPayload(args, "decision requirement update", { partial: true, includeId: false }) }));
}
async function updateRequirementAsync(id: string | undefined, args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
if (!id) throw new Error("decision requirement update requires record id");
if (!id) throw new Error("decision requirement update requires record id or docNo");
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/requirements/${encodeURIComponent(id)}`, { method: "PUT", body: requirementPayload(args, "decision requirement update", { partial: true, includeId: false }) }));
}
@@ -489,7 +565,7 @@ export async function runDecisionCenterCommand(_config: UniDeskConfig, args: str
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");
throw new Error("decision command must be one of: upload, list, show, health, requirement, diary");
}
export async function runDecisionCenterCommandAsync(
@@ -525,5 +601,5 @@ export async function runDecisionCenterCommandAsync(
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");
throw new Error("decision command must be one of: upload, list, show, health, requirement, diary");
}
+8 -8
View File
@@ -29,7 +29,7 @@ export function rootHelp(): unknown {
{ command: "microservice proxy <id> <path> [--method GET|POST|PUT|PATCH|DELETE] [--body-json JSON|--body-file path|--body-stdin] [--raw] [--max-body-bytes N]", description: "Access a private user-service backend path through the same frontend-only proxy used by WebUI; JSON request bodies are supported for controlled write/debug endpoints." },
{ command: "microservice diagnostics <id>", description: "Split k3sctl-managed proxy health into provider-gateway, HTTP tunnel, adapter, Kubernetes API service proxy, and target Service checks." },
{ command: "microservice tunnel-self-test <id>", description: "Trigger an expected provider HTTP tunnel failure and verify requestId/stage diagnostics are returned." },
{ command: "decision upload <markdown-file> [--title text] [--type meeting|decision|goal|external_goal|internal_goal|blocker|debt|experiment] [--level|--priority G0|G1|G2|G3|P0|P1|P2|P3|none] [--status active|blocked|parked|done] [--source text] [--issue id]", description: "Upload a meeting note or decision/requirement record through backend-core -> decision-center user-service proxy." },
{ command: "decision upload <markdown-file> [--title text] [--type meeting|decision|goal|external_goal|internal_goal|blocker|debt|experiment] [--level|--priority G0|G1|G2|G3|P0|P1|P2|P3|none] [--doc-no DC-...] [--doc-type DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS] [--doc-priority P0|P1|P2|P3] [--signer text] [--issued-at ISO]", description: "Upload a meeting note or decision/requirement record through backend-core -> decision-center user-service proxy." },
{ command: "decision diary import <markdown-file> [--source-file path] [--tag tag] [--include-entries]", description: "Import a dated work log Markdown into PostgreSQL diary entries split as YYYY-MM/YYYY-MM-DD.md." },
{ command: "decision diary list [--month YYYY-MM] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--limit N] [--include-body]", description: "List daily Markdown diary entries stored by Decision Center." },
{ command: "decision diary history [--month YYYY-MM] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--limit N] [--include-body]", description: "Read diary history through the productized history API alias." },
@@ -37,9 +37,9 @@ export function rootHelp(): unknown {
{ command: "decision diary months", description: "List available Decision Center diary months with day counts." },
{ command: "decision diary show <YYYY-MM-DD|id> [--source-file path]", description: "Show one daily diary Markdown entry; source-file disambiguates same-day entries from multiple imports." },
{ command: "decision diary edit|upsert <YYYY-MM-DD|id> --body-file path [--title text] [--source-file path] [--tag tag]", description: "Create or edit one daily diary entry through PUT /api/diary/entries/:idOrDate via backend-core proxy." },
{ command: "decision list [--type ...] [--status ...] [--level|--priority ...] [--source text] [--issue id] [--linked-goal-id id] [--limit N] [--include-body]", description: "List Decision Center records through the user-service proxy; bodies are omitted unless --include-body is set." },
{ command: "decision requirement list|create|show|update|upsert [id] [--title text] [--body-file path] [--type external_goal|internal_goal|goal|decision|blocker|debt|experiment] [--source text] [--issue id]", description: "Manage productized requirement records over the PostgreSQL records model, excluding meeting records." },
{ command: "decision show <id>", description: "Show one Decision Center record." },
{ command: "decision list [--doc-no DC-...] [--doc-type ...] [--doc-priority P0|P1|P2|P3] [--year YYYY] [--type ...] [--status ...] [--level|--priority ...] [--limit N] [--include-body]", description: "List Decision Center records through the user-service proxy; bodies are omitted unless --include-body is set." },
{ command: "decision requirement list|create|show|update|upsert [id|docNo] [--title text] [--body-file path] [--type external_goal|internal_goal|goal|decision|blocker|debt|experiment] [--doc-no DC-...] [--doc-type ...] [--doc-priority P0|P1|P2|P3] [--signer text] [--issued-at ISO]", description: "Manage productized requirement records over the PostgreSQL records model, excluding meeting records." },
{ command: "decision show <id|docNo>", description: "Show one Decision Center record." },
{ command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from origin/master:deploy.json environments; --commit overrides one reviewed artifact consumer such as frontend for release/v1 validation or rollback. code-queue artifact consumption is dev-only." },
{ command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." },
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." },
@@ -162,12 +162,12 @@ function decisionHelp(): unknown {
command: "decision upload|list|show|health|diary|requirement",
output: "json",
usage: [
"bun scripts/cli.ts decision upload <markdown-file> [--title text] [--type meeting|decision]",
"bun scripts/cli.ts decision list [--type ...] [--status ...] [--level ...] [--limit N]",
"bun scripts/cli.ts decision show <id>",
"bun scripts/cli.ts decision upload <markdown-file> [--title text] [--type meeting|decision] [--doc-no DC-...]",
"bun scripts/cli.ts decision list [--doc-no DC-...] [--doc-type GOAL] [--doc-priority P0] [--year YYYY] [--limit N]",
"bun scripts/cli.ts decision show <id|docNo>",
"bun scripts/cli.ts decision health",
"bun scripts/cli.ts decision diary import|list|history|months|today|show|edit|upsert ...",
"bun scripts/cli.ts decision requirement list|create|show|update|upsert ...",
"bun scripts/cli.ts decision requirement list|create|show|update|upsert ... [--doc-no DC-...] [--doc-type GOAL] [--doc-priority P0] [--signer text] [--issued-at ISO]",
],
description: "Operate Decision Center through the registered user-service proxy.",
};
+5 -3
View File
@@ -758,11 +758,13 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
: {
method: init.method,
body: init.body === undefined ? undefined : JSON.stringify(init.body),
};
};
return frontendJson(session, path, requestInit, 30_000);
};
emitRemoteJson(name, await runDecisionCenterCommandAsync(config, args.slice(1), fetcher));
return 0;
const result = await runDecisionCenterCommandAsync(config, args.slice(1), fetcher);
const ok = typeof result !== "object" || result === null || !("ok" in result) || (result as { ok?: unknown }).ok !== false;
emitRemoteJson(name, result, ok);
return ok ? 0 : 1;
}
if (top === "codex") {
emitRemoteJson(name, await remoteCodeQueue(session, args));