feat: add decision center document contract
This commit is contained in:
@@ -125,7 +125,7 @@
|
||||
|
||||
## T23B D601 Decision Center User Service
|
||||
|
||||
阅读 `AGENTS.md` 和 `docs/reference/microservices.md`,运行 `bun scripts/cli.ts microservice list`,确认 `decision-center` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/unidesk`、k3s/k8s `k3s://unidesk/decision-center:4277` 逻辑服务映射、`deployment.mode=k3sctl-managed`、`runtime.orchestrator=k3sctl` 且无业务直连容器摘要。随后运行 `bun scripts/cli.ts microservice health decision-center`,确认 `service=decision-center`、`storage=postgres`、`schemaReady=true` 且 health 中包含 `diaryEntryCount`;准备一份临时 Markdown 会议记录,运行 `bun scripts/cli.ts decision upload <markdown-file> --title <title> --type meeting --level G1 --status active --evidence <url>`,再运行 `bun scripts/cli.ts decision list` 和 `bun scripts/cli.ts decision show <id>`,确认 CLI 只通过 backend-core 用户服务代理访问,返回结构化 JSON,列表默认不携带完整 `body`,详情能看到刚上传记录的完整 Markdown。准备一份临时需求 Markdown,运行 `bun scripts/cli.ts decision requirement create --title <title> --body-file <markdown-file> --type external_goal --priority G0 --status active --source external --issue '#22'`,再运行 `bun scripts/cli.ts decision requirement list --type external_goal --issue '#22'`、`bun scripts/cli.ts decision requirement show <id>` 和 `bun scripts/cli.ts decision requirement update <id> --title <updated> --body-file <markdown-file> --type internal_goal --linked-goal-id <externalId>`,确认需求记录支持 create/list/show/update,字段包含类型、标题、内容、状态、优先级、来源、关联 issue/task、创建/更新时间,且外部目标可用 `linkedGoalId` 拆解到内部目标或阻塞项。再准备两份都包含 `# 2026年5月1日` 的临时工作日志 Markdown,分别运行 `bun scripts/cli.ts decision diary import <file-a> --source-file source-a.md --tag e2e` 和 `bun scripts/cli.ts decision diary import <file-b> --source-file source-b.md --tag e2e`,随后运行 `bun scripts/cli.ts decision diary months`、`bun scripts/cli.ts decision diary list --month 2026-05`、`bun scripts/cli.ts decision diary history --month 2026-05`、`bun scripts/cli.ts decision diary show 2026-05-01 --source-file source-a.md`、`bun scripts/cli.ts decision diary show 2026-05-01 --source-file source-b.md`、`bun scripts/cli.ts decision diary today`、`bun scripts/cli.ts decision diary today --edit --body-file <today-file>`、`bun scripts/cli.ts decision diary upsert 2026-05-03 --body-file <markdown-file>` 和 `bun scripts/cli.ts decision diary edit 2026-05-03 --body-file <markdown-file> --tag e2e`,确认日记按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径拆分、写入 PostgreSQL、同日多 source 可精确读取、当天日记按真实日期自动创建、重复导入幂等且历史日记可按日期编辑。用户服务上线前的 dev 自动门禁使用 focused 集合:`bun scripts/cli.ts e2e run --only microservice:decision-center-record-crud,microservice:decision-center-diary-lifecycle,frontend:decision-center-visible,frontend:decision-center-demand-management-visible,frontend:decision-center-diary-visible`,该门禁只验证用户服务行为,不测试 CI/CD 自举,也不部署 prod。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Decision Center`,确认页面显示 G0/G1 目标、P0/P1 Blocker、停放事项、最近会议/决议、需求管理工作区、需求录入编辑器、全部记录表和工作日记编辑台;日记页支持“今天”自动填入真实日期、保存当天 Markdown、编辑历史 Markdown 并再次读取一致。prod 人工验收必须在 CD 拉取已发布 artifact 后执行,逐项确认 health、records、diary editor、frontend 页面、无公网业务端口、live commit / artifact 信息;页面不得提供聊天/LLM 会话窗口,默认不得裸 JSON,完整 JSON 只能通过 `查看原始JSON` 打开。
|
||||
阅读 `AGENTS.md` 和 `docs/reference/microservices.md`,运行 `bun scripts/cli.ts microservice list`,确认 `decision-center` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/unidesk`、k3s/k8s `k3s://unidesk/decision-center:4277` 逻辑服务映射、`deployment.mode=k3sctl-managed`、`runtime.orchestrator=k3sctl` 且无业务直连容器摘要。随后运行 `bun scripts/cli.ts microservice health decision-center`,确认 `service=decision-center`、`storage=postgres`、`schemaReady=true` 且 health 中包含 `diaryEntryCount`;准备一份临时 Markdown 会议记录,运行 `bun scripts/cli.ts decision upload <markdown-file> --title <title> --type meeting --level G1 --status active --evidence <url>`,再运行 `bun scripts/cli.ts decision list` 和 `bun scripts/cli.ts decision show <id>`,确认 CLI 只通过 backend-core 用户服务代理访问,返回结构化 JSON,列表默认不携带完整 `body`,详情能看到刚上传记录的完整 Markdown。准备一份临时需求 Markdown,运行 `bun scripts/cli.ts decision requirement create --title <title> --body-file <markdown-file> --type external_goal --priority P0 --status active --source external --issue '#22' --doc-type GOAL --doc-priority P0 --doc-year 2026 --signer <signer> --issued-at 2026-05-21`,再运行 `bun scripts/cli.ts decision requirement list --doc-type GOAL --doc-priority P0 --year 2026`、`bun scripts/cli.ts decision requirement list --doc-no <docNo>`、`bun scripts/cli.ts decision requirement show <docNo>` 和 `bun scripts/cli.ts decision requirement update <docNo> --title <updated> --body-file <markdown-file> --type internal_goal --linked-goal-id <externalId> --supersedes DC-DCSN-P0-2026-001`,确认需求记录支持 create/list/show/update/upsert,字段包含类型、标题、内容、状态、优先级、来源、关联 issue/task、创建/更新时间、`docNo/docType/docPriority/docYear/docSeq/signer/issuedAt/effectiveScope/supersedes/supersededBy`,且 list/show 输出来自结构化字段而不是 Markdown body 解析;重复运行相同 `--doc-no` 的 create 必须返回结构化 JSON 错误 `doc_no_conflict` 或等价 409,upsert 同一 `docNo` 必须更新既有记录而不是创建重复记录。再准备两份都包含 `# 2026年5月1日` 的临时工作日志 Markdown,分别运行 `bun scripts/cli.ts decision diary import <file-a> --source-file source-a.md --tag e2e` 和 `bun scripts/cli.ts decision diary import <file-b> --source-file source-b.md --tag e2e`,随后运行 `bun scripts/cli.ts decision diary months`、`bun scripts/cli.ts decision diary list --month 2026-05`、`bun scripts/cli.ts decision diary history --month 2026-05`、`bun scripts/cli.ts decision diary show 2026-05-01 --source-file source-a.md`、`bun scripts/cli.ts decision diary show 2026-05-01 --source-file source-b.md`、`bun scripts/cli.ts decision diary today`、`bun scripts/cli.ts decision diary today --edit --body-file <today-file>`、`bun scripts/cli.ts decision diary upsert 2026-05-03 --body-file <markdown-file>` 和 `bun scripts/cli.ts decision diary edit 2026-05-03 --body-file <markdown-file> --tag e2e`,确认日记按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径拆分、写入 PostgreSQL、同日多 source 可精确读取、当天日记按真实日期自动创建、重复导入幂等且历史日记可按日期编辑。用户服务上线前的 dev 自动门禁使用 focused 集合:`bun scripts/cli.ts e2e run --only microservice:decision-center-record-crud,microservice:decision-center-diary-lifecycle,frontend:decision-center-visible,frontend:decision-center-demand-management-visible,frontend:decision-center-diary-visible`,该门禁只验证用户服务行为,不测试 CI/CD 自举,也不部署 prod。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Decision Center`,确认页面显示 G0/G1 目标、P0/P1 Blocker、停放事项、最近会议/决议、需求管理工作区、需求录入编辑器、全部记录表和工作日记编辑台;日记页支持“今天”自动填入真实日期、保存当天 Markdown、编辑历史 Markdown 并再次读取一致。prod 人工验收必须在 CD 拉取已发布 artifact 后执行,逐项确认 health、records、diary editor、frontend 页面、无公网业务端口、live commit / artifact 信息;页面不得提供聊天/LLM 会话窗口,默认不得裸 JSON,完整 JSON 只能通过 `查看原始JSON` 打开。
|
||||
|
||||
## T24 MET Nonlinear D601 GPU User Service
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI
|
||||
- `ssh <providerId> py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。
|
||||
- `ssh <providerId> skills [--scope all|wsl|windows] [--limit N]` 发现目标节点上的 WSL/Linux skill 根目录;当 provider 是 WSL 时同一次调用还会扫描 Windows 用户目录下的 `.agents/skills` 与 `.codex/skills`。
|
||||
- `microservice list/status/health/diagnostics/tunnel-self-test/proxy` 通过 backend-core 内网 API 管理挂载在计算节点 Docker 或 k3s 控制面中的用户服务(底层命令名仍为 microservice);`health`、`diagnostics`、`tunnel-self-test` 和 `proxy` 会走真实 backend-core -> provider-gateway 或 k3sctl-adapter -> 节点服务链路,`proxy` 支持受控 JSON 请求体并对超大响应 body 默认输出有界预览,规则见 `docs/reference/microservices.md`。
|
||||
- `decision upload/list/show/health` 通过 backend-core 用户服务代理访问 D601 k3s Decision Center,用于上传会议记录/决议 Markdown、列出权威记录、查看详情和健康检查;`decision list` 默认只返回摘要并省略完整 Markdown body,需要排查大正文时显式加 `--include-body`;`decision requirement list/upsert` 在同一 records 模型上管理 `goal|decision|blocker|debt|experiment` 需求记录。它们不得直连 D601 Service、NodePort 或 provider-gateway 业务 HTTP。
|
||||
- `decision upload/list/show/health` 通过 backend-core 用户服务代理访问 D601 k3s Decision Center,用于上传会议记录/决议 Markdown、列出权威记录、查看详情和健康检查;`decision list` 默认只返回摘要并省略完整 Markdown body,需要排查大正文时显式加 `--include-body`。正式文书字段通过 records 模型一等字段返回和查询:`--doc-no DC-...`、`--doc-type DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS`、`--doc-priority P0|P1|P2|P3`、`--year YYYY`、`--signer`、`--issued-at`、`--effective-scope`、`--supersedes`、`--superseded-by`;`show` 和 `requirement update` 可使用 `id` 或 `docNo`。`decision requirement list/create/upsert/update/show` 在同一 records 模型上管理 `goal|decision|blocker|debt|experiment` 需求记录,`docNo` 唯一,未传 `--doc-no` 但提供 `--doc-type/--doc-priority/--year` 时由服务分配下一个序号。它们不得直连 D601 Service、NodePort 或 provider-gateway 业务 HTTP。
|
||||
- `decision diary import <markdown-file>` 将带 `# YYYY年M月D日`、`# YYYY-MM-DD` 或 `# YYYY/M/D` 标题的工作日志拆成每天一篇 Markdown 日记,按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径写入 Decision Center PostgreSQL;`decision diary list/history` 默认只返回摘要,需要完整 Markdown 时显式加 `--include-body`;`decision diary show <YYYY-MM-DD|id> [--source-file path]` 查看单日正文,`--source-file` 用于同一天存在多个导入来源时精确选择;`decision diary edit|upsert <YYYY-MM-DD|id> --body-file <path> [--title text] [--source-file path] [--tag tag]` 通过 `PUT /api/diary/entries/:idOrDate` 创建当天或历史条目并编辑既有条目。
|
||||
- `deploy check/plan/apply` 默认从根目录 `deploy.json` 读取服务 repo 与 commit 期望状态,join `config.json` 和现有 manifest 后使用 target-side build 单一路径校验或更新已支持目标;`deploy plan --env dev|prod` 只从 `origin/master:deploy.json#environments.<env>` 读取 manifest 并输出 dry-run 环境计划,不使用本地 dirty worktree;当前 `deploy apply --env dev` 支持 D601 `backend-core` target-side rollout,以及 `frontend`/`baidu-netdisk`/`decision-center` artifact consumers,`findjob`/`pipeline`/`met-nonlinear` 为 D601 direct Compose artifact consumers,`k3sctl-adapter` 只提供 plan/dry-run;dev desired-state smoke 使用 `ci run-dev-e2e`;规则见 `docs/reference/deploy.md`、`docs/reference/dev-environment.md` 和 `docs/reference/dev-ci-runner.md`。`deploy apply --env prod` 同时覆盖 `findjob` 和 `pipeline` 的 pull-only Compose CD,但 `met-nonlinear` 仍然只允许 dry-run/plan,`k3sctl-adapter` 只允许 plan/dry-run。
|
||||
- `dev-env validate [--manifest path] [--kubectl-dry-run]` 离线校验 D601 `unidesk-dev` namespace、dev PostgreSQL 底座和 dev workload manifest。默认检查 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml`;也可显式校验 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml` 或 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml`。所有 namespaced 对象必须只落到 `unidesk-dev`,foundation manifest 必须包含 `postgres-dev` StatefulSet/Service、dev secret/config、迁移 Job 和 DB URL guard,core manifest 必须包含 `backend-core-dev`/`frontend-dev` Deployment/Service,Code Queue dev manifest 必须包含 `code-queue-scheduler-dev`、`code-queue-read-dev`、`code-queue-write-dev`、dev provider egress proxy,以及只读挂载宿主 `/home/ubuntu/.agents/skills` 到容器 `/root/.agents/skills` 的 `skills-dir` volume。加 `--kubectl-dry-run` 时额外执行 `kubectl apply --dry-run=client --validate=false -f <manifest>`,仍不 apply 资源。
|
||||
|
||||
+4
-1
@@ -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`);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
export type DecisionDocumentType = "DCSN" | "GOAL" | "PLAN" | "RPRT" | "ACTN" | "ISSU" | "RETR" | "RQST" | "RESP" | "MINS";
|
||||
export type DecisionDocumentPriority = "P0" | "P1" | "P2" | "P3";
|
||||
|
||||
export interface DecisionDocumentNumber {
|
||||
docNo: string;
|
||||
docType: DecisionDocumentType;
|
||||
docPriority: DecisionDocumentPriority;
|
||||
docYear: number;
|
||||
docSeq: number;
|
||||
}
|
||||
|
||||
export interface LegacyDocumentSource {
|
||||
id?: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
tags?: unknown;
|
||||
}
|
||||
|
||||
export class DocumentContractError extends Error {
|
||||
readonly detail: Record<string, string | number | boolean | null | string[]>;
|
||||
|
||||
constructor(message: string, detail: Record<string, string | number | boolean | null | string[]> = {}) {
|
||||
super(message);
|
||||
this.name = "DocumentContractError";
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
export const decisionDocumentTypes: readonly DecisionDocumentType[] = ["DCSN", "GOAL", "PLAN", "RPRT", "ACTN", "ISSU", "RETR", "RQST", "RESP", "MINS"];
|
||||
export const decisionDocumentPriorities: readonly DecisionDocumentPriority[] = ["P0", "P1", "P2", "P3"];
|
||||
|
||||
export const legacyDocumentNumberMap: Readonly<Record<string, string>> = {
|
||||
dc_decision_thesis_unidesk_integration_rule: "DC-DCSN-P0-2026-001",
|
||||
dc_goal_big_paper_submission: "DC-GOAL-P0-2026-001",
|
||||
dc_goal_small_paper_submission_gate: "DC-GOAL-P0-2026-002",
|
||||
};
|
||||
|
||||
const documentTypeValues = new Set<string>(decisionDocumentTypes);
|
||||
const documentPriorityValues = new Set<string>(decisionDocumentPriorities);
|
||||
const docNoSearchPattern = /\bDC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-(P[0-3])-(\d{4})-(\d{1,9})\b/iu;
|
||||
|
||||
function textValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value).trim();
|
||||
return "";
|
||||
}
|
||||
|
||||
function legacyKey(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function buildDocumentNumber(docType: DecisionDocumentType, docPriority: DecisionDocumentPriority, docYear: number, docSeq: number): string {
|
||||
return `DC-${docType}-${docPriority}-${docYear}-${String(docSeq).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
export function parseDocumentNumber(value: unknown, field = "docNo"): DecisionDocumentNumber {
|
||||
const raw = textValue(value).toUpperCase();
|
||||
const match = raw.match(/^DC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-(P[0-3])-(\d{4})-(\d{1,9})$/u);
|
||||
if (match === null) {
|
||||
throw new DocumentContractError(`${field} must match DC-<TYPE>-<PRIORITY>-<YEAR>-<SEQ>`, {
|
||||
field,
|
||||
value: textValue(value),
|
||||
allowedTypes: [...decisionDocumentTypes],
|
||||
allowedPriorities: [...decisionDocumentPriorities],
|
||||
example: "DC-GOAL-P0-2026-001",
|
||||
});
|
||||
}
|
||||
const docSeq = Number(match[4]);
|
||||
const docYear = Number(match[3]);
|
||||
if (!Number.isInteger(docSeq) || docSeq <= 0) {
|
||||
throw new DocumentContractError(`${field} sequence must be a positive integer`, { field, value: textValue(value) });
|
||||
}
|
||||
return {
|
||||
docNo: buildDocumentNumber(match[1] as DecisionDocumentType, match[2] as DecisionDocumentPriority, docYear, docSeq),
|
||||
docType: match[1] as DecisionDocumentType,
|
||||
docPriority: match[2] as DecisionDocumentPriority,
|
||||
docYear,
|
||||
docSeq,
|
||||
};
|
||||
}
|
||||
|
||||
export function tryParseDocumentNumber(value: unknown): DecisionDocumentNumber | null {
|
||||
try {
|
||||
return parseDocumentNumber(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDocumentType(value: unknown, field = "docType"): DecisionDocumentType {
|
||||
const raw = textValue(value).toUpperCase();
|
||||
if (!documentTypeValues.has(raw)) {
|
||||
throw new DocumentContractError(`${field} must be one of: ${decisionDocumentTypes.join(", ")}`, {
|
||||
field,
|
||||
value: textValue(value),
|
||||
allowed: [...decisionDocumentTypes],
|
||||
});
|
||||
}
|
||||
return raw as DecisionDocumentType;
|
||||
}
|
||||
|
||||
export function parseOptionalDocumentType(value: unknown, field = "docType"): DecisionDocumentType | "" {
|
||||
const raw = textValue(value);
|
||||
return raw ? parseDocumentType(raw, field) : "";
|
||||
}
|
||||
|
||||
export function parseDocumentPriority(value: unknown, field = "docPriority"): DecisionDocumentPriority {
|
||||
const raw = textValue(value).toUpperCase();
|
||||
if (!documentPriorityValues.has(raw)) {
|
||||
throw new DocumentContractError(`${field} must be one of: ${decisionDocumentPriorities.join(", ")}`, {
|
||||
field,
|
||||
value: textValue(value),
|
||||
allowed: [...decisionDocumentPriorities],
|
||||
});
|
||||
}
|
||||
return raw as DecisionDocumentPriority;
|
||||
}
|
||||
|
||||
export function parseOptionalDocumentPriority(value: unknown, field = "docPriority"): DecisionDocumentPriority | "" {
|
||||
const raw = textValue(value);
|
||||
return raw ? parseDocumentPriority(raw, field) : "";
|
||||
}
|
||||
|
||||
export function parseDocumentYear(value: unknown, field = "docYear"): number {
|
||||
const raw = textValue(value);
|
||||
const year = Number(raw);
|
||||
if (!/^\d{4}$/u.test(raw) || !Number.isInteger(year) || year < 1970 || year > 2100) {
|
||||
throw new DocumentContractError(`${field} must be a four-digit year between 1970 and 2100`, { field, value: raw });
|
||||
}
|
||||
return year;
|
||||
}
|
||||
|
||||
export function parseDocumentSequence(value: unknown, field = "docSeq"): number {
|
||||
const raw = textValue(value);
|
||||
const seq = Number(raw);
|
||||
if (!/^\d+$/u.test(raw) || !Number.isInteger(seq) || seq <= 0 || seq > 999_999_999) {
|
||||
throw new DocumentContractError(`${field} must be a positive integer`, { field, value: raw });
|
||||
}
|
||||
return seq;
|
||||
}
|
||||
|
||||
export function parseIssuedAt(value: unknown, field = "issuedAt"): string {
|
||||
const raw = textValue(value);
|
||||
if (!raw) return "";
|
||||
const date = /^\d{4}-\d{2}-\d{2}$/u.test(raw) ? new Date(`${raw}T00:00:00.000Z`) : new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new DocumentContractError(`${field} must be an ISO timestamp or YYYY-MM-DD date`, { field, value: raw });
|
||||
}
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export function normalizeDocumentNumberList(value: unknown, field: string): string[] {
|
||||
if (value === null || value === undefined || value === "") return [];
|
||||
const rawItems = typeof value === "string"
|
||||
? value.split(",")
|
||||
: Array.isArray(value)
|
||||
? value
|
||||
: null;
|
||||
if (rawItems === null) throw new DocumentContractError(`${field} must be a document number or array of document numbers`, { field });
|
||||
const normalized = rawItems
|
||||
.map((item) => textValue(item))
|
||||
.filter(Boolean)
|
||||
.map((item) => parseDocumentNumber(item, field).docNo);
|
||||
return [...new Set(normalized)].slice(0, 100);
|
||||
}
|
||||
|
||||
function docNoFromText(text: string, options: { prefixOnly?: boolean } = {}): DecisionDocumentNumber | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
const docNoTagged = trimmed.match(/^doc-no\s*:\s*(DC-[A-Z0-9-]+)/iu);
|
||||
if (docNoTagged !== null) return tryParseDocumentNumber(docNoTagged[1] ?? "");
|
||||
const prefix = trimmed.match(/^\[?\s*(DC-[A-Z0-9-]+)\s*\]?/iu);
|
||||
if (prefix !== null) return tryParseDocumentNumber(prefix[1] ?? "");
|
||||
if (options.prefixOnly === true) return null;
|
||||
const match = trimmed.match(docNoSearchPattern);
|
||||
return match === null ? null : tryParseDocumentNumber(match[0]);
|
||||
}
|
||||
|
||||
function mappedLegacyDocNo(value: string): DecisionDocumentNumber | null {
|
||||
const mapped = legacyDocumentNumberMap[legacyKey(value)];
|
||||
return mapped === undefined ? null : parseDocumentNumber(mapped);
|
||||
}
|
||||
|
||||
function firstParagraph(body: string): string {
|
||||
return body
|
||||
.replace(/\r\n?/gu, "\n")
|
||||
.split(/\n\s*\n/gu)
|
||||
.map((part) => part.trim())
|
||||
.find(Boolean) ?? "";
|
||||
}
|
||||
|
||||
export function extractDocumentNumberFromLegacy(source: LegacyDocumentSource): DecisionDocumentNumber | null {
|
||||
const id = textValue(source.id);
|
||||
const idMapped = mappedLegacyDocNo(id);
|
||||
if (idMapped !== null) return idMapped;
|
||||
|
||||
const tags = Array.isArray(source.tags) ? source.tags.map((tag) => textValue(tag)).filter(Boolean) : [];
|
||||
for (const tag of tags) {
|
||||
const mapped = mappedLegacyDocNo(tag);
|
||||
if (mapped !== null) return mapped;
|
||||
const tagged = tag.match(/^doc-no\s*:\s*(.+)$/iu);
|
||||
if (tagged !== null) {
|
||||
const parsed = tryParseDocumentNumber(tagged[1] ?? "");
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
const parsed = docNoFromText(tag);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
|
||||
const title = textValue(source.title);
|
||||
const titleMapped = mappedLegacyDocNo(title.split(/\s+/u)[0] ?? "");
|
||||
if (titleMapped !== null) return titleMapped;
|
||||
const titleDocNo = docNoFromText(title, { prefixOnly: true });
|
||||
if (titleDocNo !== null) return titleDocNo;
|
||||
|
||||
const paragraph = firstParagraph(textValue(source.body));
|
||||
const paragraphMapped = Object.entries(legacyDocumentNumberMap).find(([legacy]) => paragraph.toLowerCase().includes(legacy));
|
||||
if (paragraphMapped !== undefined) return parseDocumentNumber(paragraphMapped[1]);
|
||||
return docNoFromText(paragraph);
|
||||
}
|
||||
@@ -1,6 +1,24 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import postgres from "postgres";
|
||||
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../shared/src/rotating-jsonl";
|
||||
import {
|
||||
DocumentContractError,
|
||||
buildDocumentNumber,
|
||||
decisionDocumentPriorities,
|
||||
decisionDocumentTypes,
|
||||
extractDocumentNumberFromLegacy,
|
||||
normalizeDocumentNumberList,
|
||||
parseDocumentNumber,
|
||||
parseDocumentPriority,
|
||||
parseDocumentSequence,
|
||||
parseDocumentType,
|
||||
parseDocumentYear,
|
||||
parseIssuedAt,
|
||||
tryParseDocumentNumber,
|
||||
type DecisionDocumentNumber,
|
||||
type DecisionDocumentPriority,
|
||||
type DecisionDocumentType,
|
||||
} from "./document-contract";
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
type JsonRecord = Record<string, JsonValue>;
|
||||
@@ -9,6 +27,7 @@ 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 SqlExecutor = postgres.Sql | postgres.TransactionSql;
|
||||
|
||||
interface RuntimeConfig {
|
||||
host: string;
|
||||
@@ -28,6 +47,16 @@ interface DecisionRecordRow {
|
||||
linked_goal_id: string | null;
|
||||
tags: JsonValue;
|
||||
evidence_links: JsonValue;
|
||||
doc_no: string;
|
||||
doc_type: string;
|
||||
doc_priority: string;
|
||||
doc_year: number | null;
|
||||
doc_seq: number | null;
|
||||
signer: string;
|
||||
issued_at: Date | string | null;
|
||||
effective_scope: string;
|
||||
supersedes: JsonValue;
|
||||
superseded_by: JsonValue;
|
||||
source: string;
|
||||
source_session: string;
|
||||
issue_id: string;
|
||||
@@ -49,6 +78,16 @@ interface DecisionRecord extends JsonRecord {
|
||||
linkedGoalId: string | null;
|
||||
tags: string[];
|
||||
evidenceLinks: string[];
|
||||
docNo: string;
|
||||
docType: string;
|
||||
docPriority: string;
|
||||
docYear: number | null;
|
||||
docSeq: number | null;
|
||||
signer: string;
|
||||
issuedAt: string;
|
||||
effectiveScope: string;
|
||||
supersedes: string[];
|
||||
supersededBy: string[];
|
||||
source: string;
|
||||
sourceSession: string;
|
||||
issueId: string;
|
||||
@@ -194,16 +233,50 @@ function jsonResponse(body: JsonValue, status = 200): Response {
|
||||
|
||||
function errorToJson(error: unknown): JsonRecord {
|
||||
if (error instanceof HttpError) return { name: error.name, message: error.message, status: error.status, detail: error.detail };
|
||||
if (error instanceof DocumentContractError) return { name: error.name, message: error.message, status: 400, detail: error.detail };
|
||||
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack || "" };
|
||||
return { message: String(error) };
|
||||
}
|
||||
|
||||
function databaseErrorToHttp(error: unknown): HttpError | null {
|
||||
const record = typeof error === "object" && error !== null ? error as Record<string, unknown> : {};
|
||||
const code = typeof record.code === "string" ? record.code : "";
|
||||
const constraint = typeof record.constraint_name === "string"
|
||||
? record.constraint_name
|
||||
: typeof record.constraint === "string"
|
||||
? record.constraint
|
||||
: "";
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (code === "23505" && /doc_no|document/iu.test(`${constraint} ${message}`)) {
|
||||
return new HttpError(409, "document number already exists", {
|
||||
code: "doc_no_conflict",
|
||||
constraint,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function responseError(error: unknown): HttpError | DocumentContractError | unknown {
|
||||
return databaseErrorToHttp(error) ?? error;
|
||||
}
|
||||
|
||||
function errorResponse(error: unknown): Response {
|
||||
const status = error instanceof HttpError ? error.status : 500;
|
||||
const body = error instanceof HttpError
|
||||
? { ok: false, error: error.message, ...error.detail }
|
||||
: { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
log(status >= 500 ? "error" : "warn", "request_failed", { status, error: errorToJson(error) });
|
||||
const normalized = responseError(error);
|
||||
const status = normalized instanceof HttpError ? normalized.status : normalized instanceof DocumentContractError ? 400 : 500;
|
||||
const detail = normalized instanceof HttpError || normalized instanceof DocumentContractError ? normalized.detail : {};
|
||||
const message = normalized instanceof Error ? normalized.message : String(normalized);
|
||||
const body = {
|
||||
ok: false,
|
||||
error: message,
|
||||
...detail,
|
||||
errorInfo: {
|
||||
name: normalized instanceof Error ? normalized.name : "Error",
|
||||
message,
|
||||
status,
|
||||
detail,
|
||||
},
|
||||
};
|
||||
log(status >= 500 ? "error" : "warn", "request_failed", { status, error: errorToJson(normalized) });
|
||||
return jsonResponse(body, status);
|
||||
}
|
||||
|
||||
@@ -271,6 +344,182 @@ function asStringArray(value: unknown, field: string): string[] {
|
||||
return [...new Set(items)];
|
||||
}
|
||||
|
||||
function jsonStringArray(value: JsonValue): string[] {
|
||||
return Array.isArray(value) ? value.map(String).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function hasAny(input: Record<string, unknown>, fields: string[]): boolean {
|
||||
return fields.some((field) => field in input);
|
||||
}
|
||||
|
||||
function currentUtcYear(): number {
|
||||
return new Date().getUTCFullYear();
|
||||
}
|
||||
|
||||
function priorityFromLevel(level: DecisionRecordLevel): DecisionDocumentPriority | "" {
|
||||
return level === "P0" || level === "P1" || level === "P2" || level === "P3" ? level : "";
|
||||
}
|
||||
|
||||
function documentTypeFromRecordType(type: DecisionRecordType): DecisionDocumentType {
|
||||
if (type === "decision") return "DCSN";
|
||||
if (type === "goal" || type === "external_goal" || type === "internal_goal") return "GOAL";
|
||||
if (type === "meeting") return "MINS";
|
||||
if (type === "blocker" || type === "debt") return "ISSU";
|
||||
return "RPRT";
|
||||
}
|
||||
|
||||
function existingDocumentNumber(record: DecisionRecord): DecisionDocumentNumber | null {
|
||||
return record.docNo ? tryParseDocumentNumber(record.docNo) : null;
|
||||
}
|
||||
|
||||
function inputDocumentNumber(input: Record<string, unknown>): DecisionDocumentNumber | null {
|
||||
const raw = asString(input.docNo ?? input.docNumber ?? input.documentNo ?? input.documentNumber);
|
||||
return raw ? parseDocumentNumber(raw, "docNo") : null;
|
||||
}
|
||||
|
||||
function assertDocumentComponentsMatch(input: Record<string, unknown>, doc: DecisionDocumentNumber): void {
|
||||
if ("docType" in input && parseDocumentType(input.docType) !== doc.docType) throw new HttpError(400, "docType does not match docNo", { docNo: doc.docNo, docType: asString(input.docType), expected: doc.docType });
|
||||
if ("docPriority" in input && parseDocumentPriority(input.docPriority) !== doc.docPriority) throw new HttpError(400, "docPriority does not match docNo", { docNo: doc.docNo, docPriority: asString(input.docPriority), expected: doc.docPriority });
|
||||
if ("docYear" in input && parseDocumentYear(input.docYear) !== doc.docYear) throw new HttpError(400, "docYear does not match docNo", { docNo: doc.docNo, docYear: asString(input.docYear), expected: doc.docYear });
|
||||
if ("docSeq" in input && parseDocumentSequence(input.docSeq) !== doc.docSeq) throw new HttpError(400, "docSeq does not match docNo", { docNo: doc.docNo, docSeq: asString(input.docSeq), expected: doc.docSeq });
|
||||
}
|
||||
|
||||
async function nextDocumentSequence(client: SqlExecutor, docType: DecisionDocumentType, docPriority: DecisionDocumentPriority, docYear: number): Promise<number> {
|
||||
await client`LOCK TABLE decision_center_records IN SHARE ROW EXCLUSIVE MODE`;
|
||||
const rows = await client<{ next_seq: number }[]>`
|
||||
SELECT COALESCE(max(doc_seq), 0)::int + 1 AS next_seq
|
||||
FROM decision_center_records
|
||||
WHERE doc_type = ${docType}
|
||||
AND doc_priority = ${docPriority}
|
||||
AND doc_year = ${docYear}
|
||||
`;
|
||||
return Number(rows[0]?.next_seq ?? 1);
|
||||
}
|
||||
|
||||
function documentInputPresent(input: Record<string, unknown>): boolean {
|
||||
return hasAny(input, ["docNo", "docNumber", "documentNo", "documentNumber", "docType", "docPriority", "docYear", "docSeq"]);
|
||||
}
|
||||
|
||||
async function documentNumberForCreate(
|
||||
client: SqlExecutor,
|
||||
input: Record<string, unknown>,
|
||||
draft: { id: string; type: DecisionRecordType; level: DecisionRecordLevel; title: string; body: string; tags: string[] },
|
||||
): Promise<DecisionDocumentNumber | null> {
|
||||
const explicitDocNo = inputDocumentNumber(input);
|
||||
if (explicitDocNo !== null) {
|
||||
assertDocumentComponentsMatch(input, explicitDocNo);
|
||||
return explicitDocNo;
|
||||
}
|
||||
|
||||
const legacyDocNo = extractDocumentNumberFromLegacy(draft);
|
||||
if (legacyDocNo !== null && !documentInputPresent(input)) return legacyDocNo;
|
||||
|
||||
if (!documentInputPresent(input)) return null;
|
||||
|
||||
const docType = "docType" in input ? parseDocumentType(input.docType) : documentTypeFromRecordType(draft.type);
|
||||
const fallbackPriority = priorityFromLevel(draft.level);
|
||||
if (!("docPriority" in input) && !fallbackPriority) {
|
||||
throw new HttpError(400, "docPriority is required when assigning a document number without docNo", { allowed: [...decisionDocumentPriorities] });
|
||||
}
|
||||
const docPriority = "docPriority" in input ? parseDocumentPriority(input.docPriority) : fallbackPriority as DecisionDocumentPriority;
|
||||
const docYear = "docYear" in input ? parseDocumentYear(input.docYear) : currentUtcYear();
|
||||
const docSeq = "docSeq" in input ? parseDocumentSequence(input.docSeq) : await nextDocumentSequence(client, docType, docPriority, docYear);
|
||||
return parseDocumentNumber(buildDocumentNumber(docType, docPriority, docYear, docSeq));
|
||||
}
|
||||
|
||||
async function documentNumberForUpdate(
|
||||
client: SqlExecutor,
|
||||
input: Record<string, unknown>,
|
||||
existing: DecisionRecord,
|
||||
nextType: DecisionRecordType,
|
||||
nextLevel: DecisionRecordLevel,
|
||||
): Promise<DecisionDocumentNumber | null> {
|
||||
const explicitDocNo = inputDocumentNumber(input);
|
||||
if (explicitDocNo !== null) {
|
||||
assertDocumentComponentsMatch(input, explicitDocNo);
|
||||
return explicitDocNo;
|
||||
}
|
||||
|
||||
const existingDoc = existingDocumentNumber(existing);
|
||||
if (!documentInputPresent(input)) return existingDoc;
|
||||
|
||||
const docType = "docType" in input
|
||||
? parseDocumentType(input.docType)
|
||||
: existingDoc?.docType ?? documentTypeFromRecordType(nextType);
|
||||
const fallbackPriority = existingDoc?.docPriority ?? priorityFromLevel(nextLevel);
|
||||
if (!("docPriority" in input) && !fallbackPriority) {
|
||||
throw new HttpError(400, "docPriority is required when assigning a document number without docNo", { allowed: [...decisionDocumentPriorities] });
|
||||
}
|
||||
const docPriority = "docPriority" in input ? parseDocumentPriority(input.docPriority) : fallbackPriority as DecisionDocumentPriority;
|
||||
const docYear = "docYear" in input ? parseDocumentYear(input.docYear) : existingDoc?.docYear ?? currentUtcYear();
|
||||
const keepsExistingSeq = existingDoc !== null
|
||||
&& existingDoc.docType === docType
|
||||
&& existingDoc.docPriority === docPriority
|
||||
&& existingDoc.docYear === docYear;
|
||||
const docSeq = "docSeq" in input
|
||||
? parseDocumentSequence(input.docSeq)
|
||||
: keepsExistingSeq
|
||||
? existingDoc.docSeq
|
||||
: await nextDocumentSequence(client, docType, docPriority, docYear);
|
||||
return parseDocumentNumber(buildDocumentNumber(docType, docPriority, docYear, docSeq));
|
||||
}
|
||||
|
||||
function parseDocumentDateColumn(value: string): string | null {
|
||||
return value ? parseIssuedAt(value) : null;
|
||||
}
|
||||
|
||||
function documentColumnsForInsert(doc: DecisionDocumentNumber | null, input: Record<string, unknown>): {
|
||||
docNo: string;
|
||||
docType: string;
|
||||
docPriority: string;
|
||||
docYear: number | null;
|
||||
docSeq: number | null;
|
||||
signer: string;
|
||||
issuedAt: string | null;
|
||||
effectiveScope: string;
|
||||
supersedes: string[];
|
||||
supersededBy: string[];
|
||||
} {
|
||||
return {
|
||||
docNo: doc?.docNo ?? "",
|
||||
docType: doc?.docType ?? "",
|
||||
docPriority: doc?.docPriority ?? "",
|
||||
docYear: doc?.docYear ?? null,
|
||||
docSeq: doc?.docSeq ?? null,
|
||||
signer: asString(input.signer),
|
||||
issuedAt: parseDocumentDateColumn(asString(input.issuedAt)),
|
||||
effectiveScope: asString(input.effectiveScope),
|
||||
supersedes: normalizeDocumentNumberList(input.supersedes, "supersedes"),
|
||||
supersededBy: normalizeDocumentNumberList(input.supersededBy, "supersededBy"),
|
||||
};
|
||||
}
|
||||
|
||||
function documentColumnsForUpdate(doc: DecisionDocumentNumber | null, input: Record<string, unknown>, existing: DecisionRecord): {
|
||||
docNo: string;
|
||||
docType: string;
|
||||
docPriority: string;
|
||||
docYear: number | null;
|
||||
docSeq: number | null;
|
||||
signer: string;
|
||||
issuedAt: string | null;
|
||||
effectiveScope: string;
|
||||
supersedes: string[];
|
||||
supersededBy: string[];
|
||||
} {
|
||||
return {
|
||||
docNo: doc?.docNo ?? "",
|
||||
docType: doc?.docType ?? "",
|
||||
docPriority: doc?.docPriority ?? "",
|
||||
docYear: doc?.docYear ?? null,
|
||||
docSeq: doc?.docSeq ?? null,
|
||||
signer: "signer" in input ? asString(input.signer) : existing.signer,
|
||||
issuedAt: "issuedAt" in input ? parseDocumentDateColumn(asString(input.issuedAt)) : existing.issuedAt || null,
|
||||
effectiveScope: "effectiveScope" in input ? asString(input.effectiveScope) : existing.effectiveScope,
|
||||
supersedes: "supersedes" in input ? normalizeDocumentNumberList(input.supersedes, "supersedes") : existing.supersedes,
|
||||
supersededBy: "supersededBy" in input ? normalizeDocumentNumberList(input.supersededBy, "supersededBy") : existing.supersededBy,
|
||||
};
|
||||
}
|
||||
|
||||
function summaryFromBody(body: string): string {
|
||||
return body
|
||||
.split("\n")
|
||||
@@ -294,8 +543,18 @@ function recordFromRow(row: DecisionRecordRow, options: { includeBody?: boolean
|
||||
body: includeBody ? body : "",
|
||||
priority: row.level,
|
||||
linkedGoalId: row.linked_goal_id,
|
||||
tags: Array.isArray(row.tags) ? row.tags.map(String) : [],
|
||||
evidenceLinks: Array.isArray(row.evidence_links) ? row.evidence_links.map(String) : [],
|
||||
tags: jsonStringArray(row.tags),
|
||||
evidenceLinks: jsonStringArray(row.evidence_links),
|
||||
docNo: row.doc_no || "",
|
||||
docType: row.doc_type || "",
|
||||
docPriority: row.doc_priority || "",
|
||||
docYear: row.doc_year === null ? null : Number(row.doc_year),
|
||||
docSeq: row.doc_seq === null ? null : Number(row.doc_seq),
|
||||
signer: row.signer || "",
|
||||
issuedAt: iso(row.issued_at),
|
||||
effectiveScope: row.effective_scope || "",
|
||||
supersedes: jsonStringArray(row.supersedes),
|
||||
supersededBy: jsonStringArray(row.superseded_by),
|
||||
source: row.source,
|
||||
sourceSession: row.source_session,
|
||||
issueId: row.issue_id,
|
||||
@@ -377,6 +636,16 @@ async function ensureSchema(): Promise<void> {
|
||||
linked_goal_id TEXT,
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
evidence_links JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
doc_no TEXT NOT NULL DEFAULT '',
|
||||
doc_type TEXT NOT NULL DEFAULT '',
|
||||
doc_priority TEXT NOT NULL DEFAULT '',
|
||||
doc_year INTEGER,
|
||||
doc_seq INTEGER,
|
||||
signer TEXT NOT NULL DEFAULT '',
|
||||
issued_at TIMESTAMPTZ,
|
||||
effective_scope TEXT NOT NULL DEFAULT '',
|
||||
supersedes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
superseded_by JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
source_session TEXT NOT NULL DEFAULT '',
|
||||
issue_id TEXT NOT NULL DEFAULT '',
|
||||
@@ -391,16 +660,54 @@ async function ensureSchema(): Promise<void> {
|
||||
`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS issue_id TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS doc_no TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS doc_type TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS doc_priority TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS doc_year INTEGER`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS doc_seq INTEGER`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS signer TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS issued_at TIMESTAMPTZ`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS effective_scope TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS supersedes JSONB NOT NULL DEFAULT '[]'::jsonb`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS superseded_by JSONB NOT NULL DEFAULT '[]'::jsonb`;
|
||||
await sql`ALTER TABLE decision_center_records DROP CONSTRAINT IF EXISTS decision_center_records_type_check`;
|
||||
await sql`ALTER TABLE decision_center_records DROP CONSTRAINT IF EXISTS decision_center_records_doc_shape_check`;
|
||||
await clearInvalidDocumentNumbers();
|
||||
await sql`
|
||||
ALTER TABLE decision_center_records
|
||||
ADD CONSTRAINT decision_center_records_type_check
|
||||
CHECK (type IN ('meeting', 'decision', 'goal', 'external_goal', 'internal_goal', 'blocker', 'debt', 'experiment'))
|
||||
`;
|
||||
await sql`
|
||||
ALTER TABLE decision_center_records
|
||||
ADD CONSTRAINT decision_center_records_doc_shape_check
|
||||
CHECK (
|
||||
(
|
||||
doc_no = ''
|
||||
AND doc_type = ''
|
||||
AND doc_priority = ''
|
||||
AND doc_year IS NULL
|
||||
AND doc_seq IS NULL
|
||||
)
|
||||
OR (
|
||||
doc_no ~ '^DC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-P[0-3]-[0-9]{4}-[0-9]{3,9}$'
|
||||
AND doc_type IN ('DCSN', 'GOAL', 'PLAN', 'RPRT', 'ACTN', 'ISSU', 'RETR', 'RQST', 'RESP', 'MINS')
|
||||
AND doc_priority IN ('P0', 'P1', 'P2', 'P3')
|
||||
AND doc_year BETWEEN 1970 AND 2100
|
||||
AND doc_seq > 0
|
||||
AND doc_no = 'DC-' || doc_type || '-' || doc_priority || '-' || doc_year::text || '-' || lpad(doc_seq::text, 3, '0')
|
||||
)
|
||||
)
|
||||
`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_type_status_level ON decision_center_records(type, status, level)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_linked_goal ON decision_center_records(linked_goal_id)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_updated ON decision_center_records(updated_at DESC)`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_issue_id ON decision_center_records(issue_id)`;
|
||||
await clearDuplicateDocumentNumbers();
|
||||
await backfillLegacyDocumentNumbers();
|
||||
await clearDuplicateDocumentNumbers();
|
||||
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_decision_center_records_doc_no_unique ON decision_center_records(doc_no) WHERE doc_no <> ''`;
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_doc_filter ON decision_center_records(doc_type, doc_priority, doc_year, doc_seq) WHERE doc_no <> ''`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS decision_center_diary_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -506,33 +813,167 @@ function health(): JsonRecord {
|
||||
};
|
||||
}
|
||||
|
||||
async function clearInvalidDocumentNumbers(): Promise<void> {
|
||||
const rows = await sql<{ id: string; doc_no: string }[]>`
|
||||
UPDATE decision_center_records
|
||||
SET
|
||||
doc_no = '',
|
||||
doc_type = '',
|
||||
doc_priority = '',
|
||||
doc_year = NULL,
|
||||
doc_seq = NULL,
|
||||
updated_at = updated_at
|
||||
WHERE NOT (
|
||||
(
|
||||
doc_no = ''
|
||||
AND doc_type = ''
|
||||
AND doc_priority = ''
|
||||
AND doc_year IS NULL
|
||||
AND doc_seq IS NULL
|
||||
)
|
||||
OR (
|
||||
doc_no ~ '^DC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-P[0-3]-[0-9]{4}-[0-9]{3,9}$'
|
||||
AND doc_type IN ('DCSN', 'GOAL', 'PLAN', 'RPRT', 'ACTN', 'ISSU', 'RETR', 'RQST', 'RESP', 'MINS')
|
||||
AND doc_priority IN ('P0', 'P1', 'P2', 'P3')
|
||||
AND doc_year BETWEEN 1970 AND 2100
|
||||
AND doc_seq > 0
|
||||
AND doc_no = 'DC-' || doc_type || '-' || doc_priority || '-' || doc_year::text || '-' || lpad(doc_seq::text, 3, '0')
|
||||
)
|
||||
)
|
||||
RETURNING id, doc_no
|
||||
`;
|
||||
if (rows.length > 0) {
|
||||
log("warn", "invalid_document_numbers_cleared_for_schema_check", {
|
||||
clearedCount: rows.length,
|
||||
examples: rows.slice(0, 20).map((row) => ({ id: row.id, docNo: row.doc_no })),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDuplicateDocumentNumbers(): Promise<void> {
|
||||
const rows = await sql<{ id: string; doc_no: string }[]>`
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
doc_no,
|
||||
row_number() OVER (PARTITION BY doc_no ORDER BY updated_at DESC, created_at DESC, id ASC) AS rank
|
||||
FROM decision_center_records
|
||||
WHERE doc_no <> ''
|
||||
)
|
||||
UPDATE decision_center_records record
|
||||
SET
|
||||
doc_no = '',
|
||||
doc_type = '',
|
||||
doc_priority = '',
|
||||
doc_year = NULL,
|
||||
doc_seq = NULL,
|
||||
updated_at = updated_at
|
||||
FROM ranked
|
||||
WHERE record.id = ranked.id
|
||||
AND ranked.rank > 1
|
||||
RETURNING record.id, ranked.doc_no
|
||||
`;
|
||||
if (rows.length > 0) {
|
||||
log("warn", "duplicate_document_numbers_cleared_for_unique_index", {
|
||||
clearedCount: rows.length,
|
||||
docNos: [...new Set(rows.map((row) => row.doc_no))].slice(0, 20),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function backfillLegacyDocumentNumbers(): Promise<void> {
|
||||
const rows = await sql<DecisionRecordRow[]>`
|
||||
SELECT *
|
||||
FROM decision_center_records
|
||||
WHERE doc_no = ''
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 5000
|
||||
`;
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
for (const row of rows) {
|
||||
const doc = extractDocumentNumberFromLegacy({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
tags: row.tags,
|
||||
});
|
||||
if (doc === null) continue;
|
||||
const updated = await sql<{ id: string }[]>`
|
||||
UPDATE decision_center_records
|
||||
SET
|
||||
doc_no = ${doc.docNo},
|
||||
doc_type = ${doc.docType},
|
||||
doc_priority = ${doc.docPriority},
|
||||
doc_year = ${doc.docYear},
|
||||
doc_seq = ${doc.docSeq},
|
||||
updated_at = updated_at
|
||||
WHERE id = ${row.id}
|
||||
AND doc_no = ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM decision_center_records existing
|
||||
WHERE existing.doc_no = ${doc.docNo}
|
||||
AND existing.id <> ${row.id}
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
if (updated.length > 0) updatedCount += 1;
|
||||
else skippedCount += 1;
|
||||
}
|
||||
if (updatedCount > 0 || skippedCount > 0) {
|
||||
log("info", "legacy_document_numbers_backfilled", { updatedCount, skippedCount });
|
||||
}
|
||||
}
|
||||
|
||||
async function createRecord(input: Record<string, unknown>): Promise<DecisionRecord> {
|
||||
const body = asText(input.body ?? input.summary ?? input.markdown);
|
||||
const title = asString(input.title) || titleFromMarkdown(body, "Untitled decision record");
|
||||
validateRecordDraft(title, body);
|
||||
const id = asString(input.id) || `dc_${randomUUID()}`;
|
||||
const rows = await withDatabaseRecovery("create_record", () => sql<DecisionRecordRow[]>`
|
||||
INSERT INTO decision_center_records (
|
||||
id, type, level, status, title, body, linked_goal_id, tags, evidence_links, source, source_session, issue_id, task_id, commit_id
|
||||
) VALUES (
|
||||
${id},
|
||||
${parseRecordType(input.type, "meeting")},
|
||||
${parsePriority(input, "none")},
|
||||
${parseStatus(input.status, "active")},
|
||||
${title},
|
||||
${body},
|
||||
${asString(input.linkedGoalId) || null},
|
||||
${sql.json(asStringArray(input.tags, "tags"))},
|
||||
${sql.json(asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks"))},
|
||||
${asString(input.source)},
|
||||
${asString(input.sourceSession)},
|
||||
${asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask ?? input.taskId)},
|
||||
${asString(input.taskId)},
|
||||
${asString(input.commitId)}
|
||||
)
|
||||
RETURNING *
|
||||
`);
|
||||
log("info", "record_created", { id: rows[0]?.id ?? id, type: rows[0]?.type ?? "", level: rows[0]?.level ?? "" });
|
||||
const type = parseRecordType(input.type, "meeting");
|
||||
const level = parsePriority(input, "none");
|
||||
const status = parseStatus(input.status, "active");
|
||||
const tags = asStringArray(input.tags, "tags");
|
||||
const evidenceLinks = asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks");
|
||||
const rows = await withDatabaseRecovery("create_record", () => sql.begin(async (client) => {
|
||||
const doc = await documentNumberForCreate(client, input, { id, type, level, title, body, tags });
|
||||
const documentColumns = documentColumnsForInsert(doc, input);
|
||||
return await client<DecisionRecordRow[]>`
|
||||
INSERT INTO decision_center_records (
|
||||
id, type, level, status, title, body, linked_goal_id, tags, evidence_links,
|
||||
doc_no, doc_type, doc_priority, doc_year, doc_seq, signer, issued_at, effective_scope, supersedes, superseded_by,
|
||||
source, source_session, issue_id, task_id, commit_id
|
||||
) VALUES (
|
||||
${id},
|
||||
${type},
|
||||
${level},
|
||||
${status},
|
||||
${title},
|
||||
${body},
|
||||
${asString(input.linkedGoalId) || null},
|
||||
${client.json(tags)},
|
||||
${client.json(evidenceLinks)},
|
||||
${documentColumns.docNo},
|
||||
${documentColumns.docType},
|
||||
${documentColumns.docPriority},
|
||||
${documentColumns.docYear},
|
||||
${documentColumns.docSeq},
|
||||
${documentColumns.signer},
|
||||
${documentColumns.issuedAt},
|
||||
${documentColumns.effectiveScope},
|
||||
${client.json(documentColumns.supersedes)},
|
||||
${client.json(documentColumns.supersededBy)},
|
||||
${asString(input.source)},
|
||||
${asString(input.sourceSession)},
|
||||
${asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask ?? input.taskId)},
|
||||
${asString(input.taskId)},
|
||||
${asString(input.commitId)}
|
||||
)
|
||||
RETURNING *
|
||||
`;
|
||||
}));
|
||||
log("info", "record_created", { id: rows[0]?.id ?? id, type: rows[0]?.type ?? "", level: rows[0]?.level ?? "", docNo: rows[0]?.doc_no ?? "" });
|
||||
return recordFromRow(rows[0]!);
|
||||
}
|
||||
|
||||
@@ -540,39 +981,57 @@ async function updateRecord(id: string, input: Record<string, unknown>): Promise
|
||||
const existing = await getRecord(id);
|
||||
const title = "title" in input ? asString(input.title) : existing.title;
|
||||
const body = "body" in input || "summary" in input || "markdown" in input ? asText(input.body ?? input.summary ?? input.markdown) : existing.body;
|
||||
const nextType = "type" in input ? parseRecordType(input.type, existing.type) : existing.type;
|
||||
const nextLevel = "level" in input || "priority" in input ? parsePriority(input, existing.level) : existing.level;
|
||||
const nextStatus = "status" in input ? parseStatus(input.status, existing.status) : existing.status;
|
||||
validateRecordDraft(title, body);
|
||||
const rows = await withDatabaseRecovery("update_record", () => sql<DecisionRecordRow[]>`
|
||||
UPDATE decision_center_records
|
||||
SET
|
||||
type = ${"type" in input ? parseRecordType(input.type, existing.type) : existing.type},
|
||||
level = ${"level" in input || "priority" in input ? parsePriority(input, existing.level) : existing.level},
|
||||
status = ${"status" in input ? parseStatus(input.status, existing.status) : existing.status},
|
||||
title = ${title},
|
||||
body = ${body},
|
||||
linked_goal_id = ${"linkedGoalId" in input ? asString(input.linkedGoalId) || null : existing.linkedGoalId},
|
||||
tags = ${"tags" in input ? sql.json(asStringArray(input.tags, "tags")) : sql.json(existing.tags)},
|
||||
evidence_links = ${"evidenceLinks" in input || "evidence" in input ? sql.json(asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks")) : sql.json(existing.evidenceLinks)},
|
||||
source = ${"source" in input ? asString(input.source) : existing.source},
|
||||
source_session = ${"sourceSession" in input ? asString(input.sourceSession) : existing.sourceSession},
|
||||
issue_id = ${"issueId" in input || "issue" in input || "linkedIssue" in input || "linkedTask" in input ? asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask) : existing.issueId},
|
||||
task_id = ${"taskId" in input ? asString(input.taskId) : existing.taskId},
|
||||
commit_id = ${"commitId" in input ? asString(input.commitId) : existing.commitId},
|
||||
updated_at = now()
|
||||
WHERE id = ${id}
|
||||
RETURNING *
|
||||
`);
|
||||
const rows = await withDatabaseRecovery("update_record", () => sql.begin(async (client) => {
|
||||
const doc = await documentNumberForUpdate(client, input, existing, nextType, nextLevel);
|
||||
const documentColumns = documentColumnsForUpdate(doc, input, existing);
|
||||
return await client<DecisionRecordRow[]>`
|
||||
UPDATE decision_center_records
|
||||
SET
|
||||
type = ${nextType},
|
||||
level = ${nextLevel},
|
||||
status = ${nextStatus},
|
||||
title = ${title},
|
||||
body = ${body},
|
||||
linked_goal_id = ${"linkedGoalId" in input ? asString(input.linkedGoalId) || null : existing.linkedGoalId},
|
||||
tags = ${"tags" in input ? client.json(asStringArray(input.tags, "tags")) : client.json(existing.tags)},
|
||||
evidence_links = ${"evidenceLinks" in input || "evidence" in input ? client.json(asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks")) : client.json(existing.evidenceLinks)},
|
||||
doc_no = ${documentColumns.docNo},
|
||||
doc_type = ${documentColumns.docType},
|
||||
doc_priority = ${documentColumns.docPriority},
|
||||
doc_year = ${documentColumns.docYear},
|
||||
doc_seq = ${documentColumns.docSeq},
|
||||
signer = ${documentColumns.signer},
|
||||
issued_at = ${documentColumns.issuedAt},
|
||||
effective_scope = ${documentColumns.effectiveScope},
|
||||
supersedes = ${client.json(documentColumns.supersedes)},
|
||||
superseded_by = ${client.json(documentColumns.supersededBy)},
|
||||
source = ${"source" in input ? asString(input.source) : existing.source},
|
||||
source_session = ${"sourceSession" in input ? asString(input.sourceSession) : existing.sourceSession},
|
||||
issue_id = ${"issueId" in input || "issue" in input || "linkedIssue" in input || "linkedTask" in input ? asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask) : existing.issueId},
|
||||
task_id = ${"taskId" in input ? asString(input.taskId) : existing.taskId},
|
||||
commit_id = ${"commitId" in input ? asString(input.commitId) : existing.commitId},
|
||||
updated_at = now()
|
||||
WHERE id = ${existing.id}
|
||||
RETURNING *
|
||||
`;
|
||||
}));
|
||||
return recordFromRow(rows[0]!);
|
||||
}
|
||||
|
||||
async function upsertRequirementRecord(input: Record<string, unknown>): Promise<JsonRecord> {
|
||||
const id = asString(input.id);
|
||||
if (id) {
|
||||
const docNo = inputDocumentNumber(input)?.docNo ?? "";
|
||||
if (id || docNo) {
|
||||
try {
|
||||
const existing = await getRecord(id);
|
||||
const existing = await getRecordByIdOrDocNo(id, docNo);
|
||||
if (!requirementRecordTypes.has(existing.type as RequirementRecordType)) {
|
||||
throw new HttpError(409, "existing record is not a requirement-management record", { id, type: existing.type });
|
||||
throw new HttpError(409, "existing record is not a requirement-management record", { id: existing.id, docNo: existing.docNo, type: existing.type });
|
||||
}
|
||||
const record = await updateRecord(id, {
|
||||
const record = await updateRecord(existing.id, {
|
||||
...input,
|
||||
type: "type" in input ? parseRequirementRecordType(input.type, existing.type as RequirementRecordType) : existing.type,
|
||||
});
|
||||
@@ -591,13 +1050,41 @@ async function upsertRequirementRecord(input: Record<string, unknown>): Promise<
|
||||
return { ok: true, action: "created", record };
|
||||
}
|
||||
|
||||
async function getRecordByIdOrDocNo(id: string, docNo: string): Promise<DecisionRecord> {
|
||||
const rows = await withDatabaseRecovery("get_record_by_id_or_doc_no", () => sql<DecisionRecordRow[]>`
|
||||
SELECT *
|
||||
FROM decision_center_records
|
||||
WHERE (${id || null}::text IS NOT NULL AND id = ${id || null})
|
||||
OR (${docNo || null}::text IS NOT NULL AND doc_no = ${docNo || null})
|
||||
ORDER BY
|
||||
CASE WHEN id = ${id || ""} THEN 0 ELSE 1 END,
|
||||
updated_at DESC
|
||||
LIMIT 1
|
||||
`, { retryRead: true });
|
||||
if (rows.length === 0) throw new HttpError(404, "decision record not found", { id, docNo });
|
||||
return recordFromRow(rows[0]!);
|
||||
}
|
||||
|
||||
async function getRecord(id: string): Promise<DecisionRecord> {
|
||||
const rows = await withDatabaseRecovery("get_record", () => sql<DecisionRecordRow[]>`SELECT * FROM decision_center_records WHERE id = ${id}`, { retryRead: true });
|
||||
const docNo = tryParseDocumentNumber(id)?.docNo ?? "";
|
||||
const rows = await withDatabaseRecovery("get_record", () => sql<DecisionRecordRow[]>`
|
||||
SELECT *
|
||||
FROM decision_center_records
|
||||
WHERE id = ${id}
|
||||
OR (${docNo || null}::text IS NOT NULL AND doc_no = ${docNo || null})
|
||||
ORDER BY CASE WHEN id = ${id} THEN 0 ELSE 1 END
|
||||
LIMIT 1
|
||||
`, { retryRead: true });
|
||||
if (rows.length === 0) throw new HttpError(404, "decision record not found", { id });
|
||||
return recordFromRow(rows[0]!);
|
||||
}
|
||||
|
||||
async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}): Promise<DecisionRecord[]> {
|
||||
const docNoRaw = asString(url.searchParams.get("docNo") ?? url.searchParams.get("doc-no") ?? url.searchParams.get("documentNo") ?? url.searchParams.get("documentNumber"));
|
||||
const docNo = docNoRaw ? parseDocumentNumber(docNoRaw, "docNo").docNo : "";
|
||||
const docType = asString(url.searchParams.get("docType") ?? url.searchParams.get("doc-type"));
|
||||
const docPriority = asString(url.searchParams.get("docPriority") ?? url.searchParams.get("doc-priority"));
|
||||
const docYearRaw = asString(url.searchParams.get("docYear") ?? url.searchParams.get("doc-year") ?? url.searchParams.get("year"));
|
||||
const type = asString(url.searchParams.get("type"));
|
||||
const status = asString(url.searchParams.get("status"));
|
||||
const level = asString(url.searchParams.get("level"));
|
||||
@@ -609,6 +1096,11 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
const requirementOnly = options.requirementOnly === true || url.searchParams.get("requirementOnly") === "true";
|
||||
const includeBody = url.searchParams.get("includeBody") === "true";
|
||||
const limit = Math.max(1, Math.min(500, Number(url.searchParams.get("limit") || 200) || 200));
|
||||
if (docType && !decisionDocumentTypes.includes(docType.toUpperCase() as DecisionDocumentType)) throw new HttpError(400, "unsupported docType filter", { docType, allowed: [...decisionDocumentTypes] });
|
||||
if (docPriority && !decisionDocumentPriorities.includes(docPriority.toUpperCase() as DecisionDocumentPriority)) throw new HttpError(400, "unsupported docPriority filter", { docPriority, allowed: [...decisionDocumentPriorities] });
|
||||
const normalizedDocType = docType ? parseDocumentType(docType, "docType") : "";
|
||||
const normalizedDocPriority = docPriority ? parseDocumentPriority(docPriority, "docPriority") : "";
|
||||
const docYear = docYearRaw ? parseDocumentYear(docYearRaw, "docYear") : null;
|
||||
if (type && !recordTypes.has(type as DecisionRecordType)) throw new HttpError(400, "unsupported type filter", { type });
|
||||
if (requirementOnly && type && !requirementRecordTypes.has(type as RequirementRecordType)) throw new HttpError(400, "unsupported requirement type filter", { type });
|
||||
if (status && !recordStatuses.has(status as DecisionRecordStatus)) throw new HttpError(400, "unsupported status filter", { status });
|
||||
@@ -628,6 +1120,16 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
linked_goal_id,
|
||||
tags,
|
||||
evidence_links,
|
||||
doc_no,
|
||||
doc_type,
|
||||
doc_priority,
|
||||
doc_year,
|
||||
doc_seq,
|
||||
signer,
|
||||
issued_at,
|
||||
effective_scope,
|
||||
supersedes,
|
||||
superseded_by,
|
||||
source,
|
||||
source_session,
|
||||
issue_id,
|
||||
@@ -636,7 +1138,11 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
created_at,
|
||||
updated_at
|
||||
FROM decision_center_records
|
||||
WHERE (${type || null}::text IS NULL OR type = ${type || null})
|
||||
WHERE (${docNo || null}::text IS NULL OR doc_no = ${docNo || null})
|
||||
AND (${normalizedDocType || null}::text IS NULL OR doc_type = ${normalizedDocType || null})
|
||||
AND (${normalizedDocPriority || null}::text IS NULL OR doc_priority = ${normalizedDocPriority || null})
|
||||
AND (${docYear}::int IS NULL OR doc_year = ${docYear})
|
||||
AND (${type || null}::text IS NULL OR type = ${type || null})
|
||||
AND (${requirementOnly}::boolean IS FALSE OR type IN ('decision', 'goal', 'external_goal', 'internal_goal', 'blocker', 'debt', 'experiment'))
|
||||
AND (${status || null}::text IS NULL OR status = ${status || null})
|
||||
AND (${level || null}::text IS NULL OR level = ${level || null})
|
||||
@@ -646,6 +1152,17 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
AND (${tag || null}::text IS NULL OR tags ? ${tag || ""})
|
||||
AND (${query || null}::text IS NULL OR title ILIKE ${query ? `%${query}%` : ""} OR body ILIKE ${query ? `%${query}%` : ""})
|
||||
ORDER BY
|
||||
CASE WHEN doc_no = '' THEN 1 ELSE 0 END ASC,
|
||||
doc_type ASC NULLS LAST,
|
||||
CASE doc_priority
|
||||
WHEN 'P0' THEN 0
|
||||
WHEN 'P1' THEN 1
|
||||
WHEN 'P2' THEN 2
|
||||
WHEN 'P3' THEN 3
|
||||
ELSE 4
|
||||
END ASC,
|
||||
doc_year ASC NULLS LAST,
|
||||
doc_seq ASC NULLS LAST,
|
||||
CASE level
|
||||
WHEN 'G0' THEN 0
|
||||
WHEN 'P0' THEN 1
|
||||
@@ -718,12 +1235,20 @@ async function importMeeting(input: Record<string, unknown>): Promise<JsonRecord
|
||||
taskId: asString(input.taskId),
|
||||
commitId: asString(input.commitId),
|
||||
};
|
||||
const documentFields = ["docNo", "docNumber", "documentNo", "documentNumber", "docType", "docPriority", "docYear", "docSeq", "signer", "issuedAt", "effectiveScope", "supersedes", "supersededBy"];
|
||||
for (const field of documentFields) {
|
||||
if (field in input) base[field] = input[field];
|
||||
}
|
||||
const meeting = await createRecord(base);
|
||||
const decisionInputs = normalizeDecisionDrafts(input.decisions);
|
||||
const decisions: DecisionRecord[] = [];
|
||||
for (const decision of decisionInputs) {
|
||||
const inheritedBase = { ...base };
|
||||
for (const field of documentFields) {
|
||||
if (!(field in decision)) delete inheritedBase[field];
|
||||
}
|
||||
decisions.push(await createRecord({
|
||||
...base,
|
||||
...inheritedBase,
|
||||
...decision,
|
||||
type: "decision",
|
||||
linkedGoalId: asString(decision.linkedGoalId) || meeting.linkedGoalId,
|
||||
@@ -734,7 +1259,8 @@ async function importMeeting(input: Record<string, unknown>): Promise<JsonRecord
|
||||
}
|
||||
|
||||
async function deleteRecord(id: string): Promise<JsonRecord> {
|
||||
const rows = await withDatabaseRecovery("delete_record", () => sql<DecisionRecordRow[]>`DELETE FROM decision_center_records WHERE id = ${id} RETURNING *`);
|
||||
const existing = await getRecord(id);
|
||||
const rows = await withDatabaseRecovery("delete_record", () => sql<DecisionRecordRow[]>`DELETE FROM decision_center_records WHERE id = ${existing.id} RETURNING *`);
|
||||
if (rows.length === 0) throw new HttpError(404, "decision record not found", { id });
|
||||
log("info", "record_deleted", { id });
|
||||
return { ok: true, deleted: recordFromRow(rows[0]!) };
|
||||
|
||||
Reference in New Issue
Block a user