diff --git a/TEST.md b/TEST.md index 6b4c7ef2..310507ff 100644 --- a/TEST.md +++ b/TEST.md @@ -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 --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 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 428a0437..d5790afc 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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 资源。 diff --git a/scripts/cli.ts b/scripts/cli.ts index def5a3f2..bd6880dd 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -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; } diff --git a/scripts/decision-center-document-contract-test.ts b/scripts/decision-center-document-contract-test.ts new file mode 100644 index 00000000..d0cf5a53 --- /dev/null +++ b/scripts/decision-center-document-contract-test.ts @@ -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`); +} diff --git a/scripts/decision-center-query-contract-test.ts b/scripts/decision-center-query-contract-test.ts index e1b0a41c..a42ed4b9 100644 --- a/scripts/decision-center-query-contract-test.ts +++ b/scripts/decision-center-query-contract-test.ts @@ -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", ], }; diff --git a/scripts/decision-center-workspace-contract-test.ts b/scripts/decision-center-workspace-contract-test.ts index 3b0e82e6..cb852e70 100644 --- a/scripts/decision-center-workspace-contract-test.ts +++ b/scripts/decision-center-workspace-contract-test.ts @@ -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 { diff --git a/scripts/src/decision-center.ts b/scripts/src/decision-center.ts index 1acf112d..e0b79001 100644 --- a/scripts/src/decision-center.ts +++ b/scripts/src/decision-center.ts @@ -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"); } diff --git a/scripts/src/help.ts b/scripts/src/help.ts index f281859a..b8cb5b7c 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -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.", }; diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index d553818d..9d9a53eb 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -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)); diff --git a/src/components/microservices/decision-center/src/document-contract.ts b/src/components/microservices/decision-center/src/document-contract.ts new file mode 100644 index 00000000..e7a68f73 --- /dev/null +++ b/src/components/microservices/decision-center/src/document-contract.ts @@ -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); +} diff --git a/src/components/microservices/decision-center/src/index.ts b/src/components/microservices/decision-center/src/index.ts index a11c4c8c..ee630f51 100644 --- a/src/components/microservices/decision-center/src/index.ts +++ b/src/components/microservices/decision-center/src/index.ts @@ -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]!) };