diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 29c503ae..92de6bde 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -23,8 +23,8 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `ssh py [script-args...] < script.py` 把本地 stdin 落到远端临时 `.py` 文件后再以 `python3 -u` 执行并自动清理,避免再手写 `'python3 -'`、heredoc 或多层引号;`script-args` 会按 argv 安全透传给远端脚本。 - `ssh 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、列出权威记录、查看详情和健康检查;它不得直连 D601 Service、NodePort 或 provider-gateway 业务 HTTP。 -- `decision diary import ` 将带 `# YYYY年M月D日`、`# YYYY-MM-DD` 或 `# YYYY/M/D` 标题的工作日志拆成每天一篇 Markdown 日记,按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径写入 Decision Center PostgreSQL;`decision diary list/months/show` 分别用于按月/日期查询、列出月份和查看单日正文。 +- `decision upload/list/show/health` 通过 backend-core 用户服务代理访问 D601 k3s Decision Center,用于上传会议记录/决议 Markdown、列出权威记录、查看详情和健康检查;`decision requirement list/upsert` 在同一 records 模型上管理 `goal|decision|blocker|debt|experiment` 需求记录。它们不得直连 D601 Service、NodePort 或 provider-gateway 业务 HTTP。 +- `decision diary import ` 将带 `# YYYY年M月D日`、`# YYYY-MM-DD` 或 `# YYYY/M/D` 标题的工作日志拆成每天一篇 Markdown 日记,按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径写入 Decision Center PostgreSQL;`decision diary list/months/show` 分别用于按月/日期查询、列出月份和查看单日正文;`decision diary edit|upsert --body-file [--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.` 读取 manifest 并输出 dry-run 环境计划,不使用本地 dirty worktree;当前 `deploy apply --env dev` 只支持 D601 `backend-core` 与 `frontend` persistent dev rollout,dev desired-state smoke 使用 `ci run-dev-e2e`;规则见 `docs/reference/deploy.md`、`docs/reference/dev-environment.md` 和 `docs/reference/dev-ci-runner.md`。 - `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。加 `--kubectl-dry-run` 时额外执行 `kubectl apply --dry-run=client --validate=false -f `,仍不 apply 资源。 - `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine` 和 `rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。 @@ -129,7 +129,7 @@ bun scripts/cli.ts ssh D601 glob --root /home/ubuntu/pikapython --pattern '**/*- `--main-server-ip` 是一个全局前缀,必须放在需要透传的命令同一次调用中,例如 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health`。默认传输是公网 frontend:本地 CLI 读取本仓库 `config.json` 中的 frontend 登录账号密码,登录 `http://:/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。 -默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`microservice list/status/health/diagnostics/tunnel-self-test/proxy`、`decision upload/list/show/health`、`decision diary import/list/months/show`、`codex task `、`codex output `、`codex judge --attempt N` 和 `ssh `。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname` 和 `ssh D601 skills` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。frontend 远程透传不会流式转发本地 stdin,因此 `ssh py < script.py`、`ssh apply-patch < patch.diff` 这类 stdin-backed helper 必须在主 server 本机运行,或显式切换到 `--main-server-transport ssh`。若确实需要旧行为,可使用 `--main-server-key ` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts `。 +默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`microservice list/status/health/diagnostics/tunnel-self-test/proxy`、`decision upload/list/show/health`、`decision requirement list/upsert`、`decision diary import/list/months/show/edit/upsert`、`codex task `、`codex output `、`codex judge --attempt N` 和 `ssh `。其中 `ssh` 的 remote frontend 传输使用 `host.ssh` dispatch 执行有界远端命令,适合 `ssh D601 hostname` 和 `ssh D601 skills` 这类自测;交互式登录 shell 仍应在主 server 本机 CLI 使用,或显式切换到旧 SSH 传输后在主 server 上执行。frontend 远程透传不会流式转发本地 stdin,因此 `ssh py < script.py`、`ssh apply-patch < patch.diff` 这类 stdin-backed helper 必须在主 server 本机运行,或显式切换到 `--main-server-transport ssh`。若确实需要旧行为,可使用 `--main-server-key ` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts `。 计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 core REST API 或 database。标准顺序是:先运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug health` 确认主 server 看到当前 Provider 在线,且该 Provider labels 中 `unideskCapabilities` 包含 `host.ssh`、`hostSshConfigured=true`、`hostSshKeyPresent=true`;再运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch provider.upgrade --mode schedule --wait-ms 15000` 触发真实 `provider.upgrade`;随后再次运行 `debug health` 确认节点重新上线;最后运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh hostname` 验证 SSH 透传能力。provider-gateway 新部署或升级后没有完成这组 remote CLI 自测,不能视为交付完成。 diff --git a/docs/reference/microservices.md b/docs/reference/microservices.md index 642ed05b..6f4ddf1b 100644 --- a/docs/reference/microservices.md +++ b/docs/reference/microservices.md @@ -235,11 +235,11 @@ D601 上必须显式使用原生 k3s kubeconfig:`KUBECONFIG=/etc/rancher/k3s/k - 部署引用:后端源码位于 UniDesk 仓库 `src/components/microservices/decision-center`,Dockerfile 为 `src/components/microservices/decision-center/Dockerfile`;k3s manifest 为 `src/components/microservices/k3sctl-adapter/k3s/decision-center.k3s.json`,Kubernetes 运行清单为 `src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml`,镜像名固定为 `unidesk-decision-center:d601`。主 server `docker-compose.yml` 不得加入该服务,也不得公开 `4277`。 - 状态权威:Decision Center 必须写入主 PostgreSQL,权威记录表为 `decision_center_records`,日记表为 `decision_center_diary_entries`;不得使用浏览器 `localStorage`、IndexedDB、容器 writable layer 或本地 JSON 文件作为会议、决议、目标、问题或日记状态权威。D601 Pod 通过集群内 `d601-tcp-egress-gateway.unidesk.svc.cluster.local:15432` 访问主 PostgreSQL。 - 记录数据模型:记录类型为 `meeting|decision|goal|blocker|debt|experiment`,等级为 `G0|G1|G2|G3|P0|P1|P2|P3|none`,状态为 `active|blocked|parked|done`,字段包含 `title`、Markdown `summary/body`、`linkedGoalId`、`tags`、`evidenceLinks`、`sourceSession`、`taskId`、`commitId`、`createdAt` 和 `updatedAt`。 -- 需求管理:Decision Center 里的 `goal` 记录应承接外部需求或长期目标,`decision` 记录应承接需求分解后的取舍,`blocker` 记录应承接当前阻塞,`experiment` 记录应承接验证性工作,`debt` 记录应承接必须偿还的技术/流程债。任何新需求都应先写成可验证的外部收益,再分解为这些内部记录,而不是先发散成内部审美或架构偏好。 +- 需求管理:Decision Center 里的 `goal` 记录应承接外部需求或长期目标,`decision` 记录应承接需求分解后的取舍,`blocker` 记录应承接当前阻塞,`experiment` 记录应承接验证性工作,`debt` 记录应承接必须偿还的技术/流程债。任何新需求都应先写成可验证的外部收益,再分解为这些内部记录,而不是先发散成内部审美或架构偏好。需求管理 API 复用 `decision_center_records`,`/api/requirements` 只是在同一模型上排除 `meeting` 并提供需求语义的 list/upsert 别名,不引入第二套需求表。 - 日记数据模型:基于 Markdown 的日记系统以“每天一篇”为最小单元,导入器识别 `# YYYY年M月D日`、`# YYYY-MM-DD` 或 `# YYYY/M/D` 标题并拆分为 `entry_date`、`month`、Markdown `body`、`source_file`、`content_hash` 与虚拟 `markdown_path=YYYY-MM/YYYY-MM-DD.md`;同一 `source_file + entry_date` 使用 upsert,内容未变时保持幂等。 -- 日记编辑:工作日记必须支持按真实日期创建当天条目,并支持按日期回看和编辑历史条目;数据库仍是唯一权威,前端只是编辑入口和展示入口。 -- API:只允许 `/health`、`/live`、`/logs` 和 `/api/` 前缀;允许 `GET`、`HEAD`、`POST`、`PUT` 和 `DELETE`。业务 API 包含 `GET /api/records`、`POST /api/records`、`GET|PUT|DELETE /api/records/:id`、`POST /api/meetings/import`、`POST /api/diary/import`、`GET /api/diary/entries`、`GET /api/diary/entries/:idOrDate` 和 `GET /api/diary/months`,错误必须返回结构化 JSON,便于 CLI 与 frontend 诊断。 -- CLI:`bun scripts/cli.ts decision upload `、`decision list`、`decision show `、`decision diary import/list/months/show` 和 `decision health` 只能通过 backend-core 用户服务代理访问 Decision Center,不得直连 D601 Service、NodePort 或 provider-gateway `microservice.http`。后续若增加日记编辑或需求管理 CLI,应保持同一代理边界。 +- 日记编辑:工作日记必须支持按真实日期创建当天条目,并支持按日期回看和编辑历史条目;`PUT /api/diary/entries/:idOrDate` 允许安全更新 `body`/`markdown`、`title`、`tags` 和 `sourceFile`,按 `YYYY-MM-DD` key 且不存在时创建当天或历史条目,按非日期 id 时只编辑既有条目。数据库仍是唯一权威,前端只是编辑入口和展示入口。 +- API:只允许 `/health`、`/live`、`/logs` 和 `/api/` 前缀;允许 `GET`、`HEAD`、`POST`、`PUT` 和 `DELETE`。业务 API 包含 `GET /api/records`、`POST /api/records`、`GET|PUT|DELETE /api/records/:id`、`GET|POST|PUT /api/requirements`、`POST /api/meetings/import`、`POST /api/diary/import`、`GET /api/diary/entries`、`GET|PUT /api/diary/entries/:idOrDate` 和 `GET /api/diary/months`,错误必须返回结构化 JSON,便于 CLI 与 frontend 诊断。 +- CLI:`bun scripts/cli.ts decision upload `、`decision list`、`decision show `、`decision requirement list/upsert`、`decision diary import/list/months/show/edit/upsert` 和 `decision health` 只能通过 backend-core 用户服务代理访问 Decision Center,不得直连 D601 Service、NodePort 或 provider-gateway `microservice.http`。日记编辑验收应使用 `decision diary upsert --body-file ` 创建或更新,再用 `decision diary show ` 读取确认。 - UniDesk 前端:`用户服务 / Decision Center` React 页面展示权威记录筛选、当前 G0/G1 目标、P0/P1 blocker、停放事项、最近会议/决议和工作日记;它还应成为需求管理入口,让外部目标、内部拆解和每日工作记录在同一页面中可追溯。日记视图按月份筛选并展示每天 Markdown 正文,未来应支持当天自动创建与历史编辑。默认不得展示裸 JSON,完整原始数据只能通过 `查看原始JSON` 打开。 ### MDTODO k3s-Managed diff --git a/scripts/src/decision-center.ts b/scripts/src/decision-center.ts index 49065bbc..cf6d92f3 100644 --- a/scripts/src/decision-center.ts +++ b/scripts/src/decision-center.ts @@ -4,11 +4,13 @@ import { type UniDeskConfig, repoRoot } from "./config"; import { coreInternalFetch } from "./microservices"; type DecisionRecordType = "meeting" | "decision" | "goal" | "blocker" | "debt" | "experiment"; +type RequirementRecordType = Exclude; type DecisionRecordLevel = "G0" | "G1" | "G2" | "G3" | "P0" | "P1" | "P2" | "P3" | "none"; type DecisionRecordStatus = "active" | "blocked" | "parked" | "done"; const serviceId = "decision-center"; const typeValues = new Set(["meeting", "decision", "goal", "blocker", "debt", "experiment"]); +const requirementTypeValues = new Set(["decision", "goal", "blocker", "debt", "experiment"]); const levelValues = new Set(["G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"]); const statusValues = new Set(["active", "blocked", "parked", "done"]); @@ -54,6 +56,12 @@ function parseType(raw: string | undefined, fallback: DecisionRecordType): Decis return value as DecisionRecordType; } +function parseRequirementType(raw: string | undefined, fallback: RequirementRecordType): RequirementRecordType { + const value = raw || fallback; + if (!requirementTypeValues.has(value as RequirementRecordType)) throw new Error(`--type must be one of: ${Array.from(requirementTypeValues).join(", ")}`); + return value as RequirementRecordType; +} + function parseLevel(raw: string | undefined, fallback: DecisionRecordLevel): DecisionRecordLevel { const value = raw || fallback; if (!levelValues.has(value as DecisionRecordLevel)) throw new Error(`--level must be one of: ${Array.from(levelValues).join(", ")}`); @@ -78,6 +86,21 @@ function readMarkdownFile(path: string): { absolutePath: string; markdown: strin return { absolutePath, markdown }; } +function bodyFromArgs(args: string[], command: string): { body: string; bodySource: Record } { + const body = optionValue(args, ["--body"]); + const bodyFile = optionValue(args, ["--body-file", "--markdown-file"]); + const markdownFile = optionValue(args, ["--file"]); + const sources = [body !== undefined, bodyFile !== undefined, markdownFile !== undefined].filter(Boolean).length; + if (sources > 1) throw new Error(`${command} accepts only one of --body, --body-file, or --file`); + if (body !== undefined) return { body, bodySource: { kind: "inline" } }; + const file = bodyFile ?? markdownFile; + if (file !== undefined) { + const { absolutePath, markdown } = readMarkdownFile(file); + return { body: markdown, bodySource: { kind: "file", path: absolutePath } }; + } + throw new Error(`${command} requires --body text or --body-file path`); +} + function decisionProxy(path: string, init?: { method?: string; body?: unknown }): unknown { return coreInternalFetch(`/api/microservices/${encodeURIComponent(serviceId)}/proxy${path}`, init); } @@ -202,35 +225,32 @@ async function importDiaryAsync(args: string[], fetcher: (path: string, init?: { } function listRecords(args: string[]): unknown { - const params = new URLSearchParams(); - const type = optionValue(args, ["--type"]); - const status = optionValue(args, ["--status"]); - const level = optionValue(args, ["--level"]); - const linkedGoalId = optionValue(args, ["--linked-goal-id", "--linkedGoalId"]); - const limit = optionValue(args, ["--limit"]); - if (type !== undefined) params.set("type", parseType(type, "meeting")); - if (status !== undefined) params.set("status", parseStatus(status, "active")); - if (level !== undefined) params.set("level", parseLevel(level, "none")); - if (linkedGoalId !== undefined) params.set("linkedGoalId", linkedGoalId); - if (limit !== undefined) params.set("limit", limit); - const query = params.toString(); + const query = recordQuery(args); return unwrapProxyResponse(decisionProxy(`/api/records${query ? `?${query}` : ""}`)); } async function listRecordsAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise): Promise { + const query = recordQuery(args); + return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/records${query ? `?${query}` : ""}`)); +} + +function recordQuery(args: string[], options: { requirementOnly?: boolean } = {}): string { const params = new URLSearchParams(); const type = optionValue(args, ["--type"]); const status = optionValue(args, ["--status"]); const level = optionValue(args, ["--level"]); const linkedGoalId = optionValue(args, ["--linked-goal-id", "--linkedGoalId"]); + const tag = optionValue(args, ["--tag", "--tags"]); + const queryText = optionValue(args, ["--query", "--q"]); const limit = optionValue(args, ["--limit"]); - if (type !== undefined) params.set("type", parseType(type, "meeting")); + 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")); if (linkedGoalId !== undefined) params.set("linkedGoalId", linkedGoalId); + if (tag !== undefined) params.set("tag", tag); + if (queryText !== undefined) params.set("q", queryText); if (limit !== undefined) params.set("limit", limit); - const query = params.toString(); - return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/records${query ? `?${query}` : ""}`)); + return params.toString(); } function diaryQuery(args: string[]): string { @@ -276,6 +296,30 @@ async function showDiaryAsync(key: string | undefined, fetcher: (path: string, i return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/diary/entries/${encodeURIComponent(key)}`)); } +function diaryEditPayload(args: string[], command: string): { key: string; payload: Record; bodySource: Record } { + const key = positionalArgs(args)[0]; + if (!key) throw new Error(`${command} requires entry id or YYYY-MM-DD date`); + const { body, bodySource } = bodyFromArgs(args, command); + const payload: Record = { body }; + const title = optionValue(args, ["--title"]); + const sourceFile = optionValue(args, ["--source-file", "--source-path", "--source"]); + if (title !== undefined) payload.title = title; + if (sourceFile !== undefined) payload.sourceFile = sourceFile; + const tags = splitList(optionValues(args, ["--tag", "--tags"])); + if (tags.length > 0) payload.tags = tags; + return { key, payload, bodySource }; +} + +function editDiary(args: string[]): unknown { + const { key, payload, bodySource } = diaryEditPayload(args, "decision diary edit"); + return { key, bodySource, result: unwrapProxyResponse(decisionProxy(`/api/diary/entries/${encodeURIComponent(key)}`, { method: "PUT", body: payload })) }; +} + +async function editDiaryAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise): Promise { + const { key, payload, bodySource } = diaryEditPayload(args, "decision diary edit"); + return { key, bodySource, result: unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/diary/entries/${encodeURIComponent(key)}`, { method: "PUT", body: payload })) }; +} + function showRecord(id: string | undefined): unknown { if (!id) throw new Error("decision show requires record id"); return unwrapProxyResponse(decisionProxy(`/api/records/${encodeURIComponent(id)}`)); @@ -286,6 +330,36 @@ async function showRecordAsync(id: string | undefined, fetcher: (path: string, i return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/records/${encodeURIComponent(id)}`)); } +function requirementPayload(args: string[], command: string): Record { + const title = optionValue(args, ["--title"]); + const bodyArg = optionValue(args, ["--body"]); + const bodyFile = optionValue(args, ["--body-file", "--markdown-file", "--file"]); + const body = bodyArg !== undefined ? bodyArg : bodyFile !== undefined ? readMarkdownFile(bodyFile).markdown : undefined; + if (!title && body === undefined) throw new Error(`${command} requires --title or --body-file/--body`); + return { + id: optionValue(args, ["--id"]), + title, + body, + type: parseRequirementType(optionValue(args, ["--type"]), "goal"), + level: parseLevel(optionValue(args, ["--level"]), "none"), + status: parseStatus(optionValue(args, ["--status"]), "active"), + linkedGoalId: optionValue(args, ["--linked-goal-id", "--linkedGoalId"]), + tags: splitList(optionValues(args, ["--tag", "--tags"])), + evidenceLinks: splitList(optionValues(args, ["--evidence", "--evidence-link", "--evidenceLinks"])), + sourceSession: optionValue(args, ["--source-session", "--sourceSession"]), + taskId: optionValue(args, ["--task-id", "--taskId"]), + commitId: optionValue(args, ["--commit-id", "--commitId"]), + }; +} + +function upsertRequirement(args: string[]): unknown { + return unwrapProxyResponse(decisionProxy("/api/requirements", { method: "PUT", body: requirementPayload(args, "decision requirement upsert") })); +} + +async function upsertRequirementAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise): Promise { + return unwrapProxyResponse(await decisionProxyAsync(fetcher, "/api/requirements", { method: "PUT", body: requirementPayload(args, "decision requirement upsert") })); +} + export async function runDecisionCenterCommand(_config: UniDeskConfig, args: string[]): Promise { const [action = "list", id] = args; if (action === "diary") { @@ -294,7 +368,17 @@ export async function runDecisionCenterCommand(_config: UniDeskConfig, args: str if (diaryAction === "list") return listDiary(args.slice(2)); if (diaryAction === "months") return listDiaryMonths(); if (diaryAction === "show") return showDiary(diaryId); - throw new Error("decision diary command must be one of: import, list, months, show"); + if (diaryAction === "edit" || diaryAction === "upsert") return editDiary(args.slice(2)); + throw new Error("decision diary command must be one of: import, list, months, show, edit, upsert"); + } + if (action === "requirement" || action === "requirements") { + const [requirementAction = "list"] = args.slice(1); + if (requirementAction === "list") { + const query = recordQuery(args.slice(2), { requirementOnly: true }); + return unwrapProxyResponse(decisionProxy(`/api/requirements${query ? `?${query}` : ""}`)); + } + if (requirementAction === "upsert") return upsertRequirement(args.slice(2)); + throw new Error("decision requirement command must be one of: list, upsert"); } if (action === "upload") return uploadMeeting(args.slice(1)); if (action === "list") return listRecords(args.slice(1)); @@ -315,7 +399,17 @@ export async function runDecisionCenterCommandAsync( if (diaryAction === "list") return listDiaryAsync(args.slice(2), fetcher); if (diaryAction === "months") return listDiaryMonthsAsync(fetcher); if (diaryAction === "show") return showDiaryAsync(diaryId, fetcher); - throw new Error("decision diary command must be one of: import, list, months, show"); + if (diaryAction === "edit" || diaryAction === "upsert") return editDiaryAsync(args.slice(2), fetcher); + throw new Error("decision diary command must be one of: import, list, months, show, edit, upsert"); + } + if (action === "requirement" || action === "requirements") { + const [requirementAction = "list"] = args.slice(1); + if (requirementAction === "list") { + const query = recordQuery(args.slice(2), { requirementOnly: true }); + return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/requirements${query ? `?${query}` : ""}`)); + } + if (requirementAction === "upsert") return upsertRequirementAsync(args.slice(2), fetcher); + throw new Error("decision requirement command must be one of: list, upsert"); } if (action === "upload") return uploadMeetingAsync(args.slice(1), fetcher); if (action === "list") return listRecordsAsync(args.slice(1), fetcher); diff --git a/src/components/microservices/decision-center/src/index.ts b/src/components/microservices/decision-center/src/index.ts index 3b42aca4..8ef53f4a 100644 --- a/src/components/microservices/decision-center/src/index.ts +++ b/src/components/microservices/decision-center/src/index.ts @@ -6,6 +6,7 @@ type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string type JsonRecord = Record; type DecisionRecordType = "meeting" | "decision" | "goal" | "blocker" | "debt" | "experiment"; +type RequirementRecordType = Exclude; type DecisionRecordLevel = "G0" | "G1" | "G2" | "G3" | "P0" | "P1" | "P2" | "P3" | "none"; type DecisionRecordStatus = "active" | "blocked" | "parked" | "done"; @@ -109,6 +110,7 @@ class HttpError extends Error { } const recordTypes = new Set(["meeting", "decision", "goal", "blocker", "debt", "experiment"]); +const requirementRecordTypes = new Set(["decision", "goal", "blocker", "debt", "experiment"]); const recordLevels = new Set(["G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"]); const recordStatuses = new Set(["active", "blocked", "parked", "done"]); const serviceStartedAt = new Date().toISOString(); @@ -246,6 +248,13 @@ function asString(value: unknown): string { return ""; } +function asText(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value); + return ""; +} + function asStringArray(value: unknown, field: string): string[] { if (value === null || value === undefined || value === "") return []; if (typeof value === "string") { @@ -294,6 +303,14 @@ function parseRecordType(value: unknown, fallback: DecisionRecordType): Decision return raw as DecisionRecordType; } +function parseRequirementRecordType(value: unknown, fallback: RequirementRecordType): RequirementRecordType { + const raw = asString(value) || fallback; + if (!requirementRecordTypes.has(raw as RequirementRecordType)) { + throw new HttpError(400, "unsupported requirement record type", { value: raw, allowed: [...requirementRecordTypes] }); + } + return raw as RequirementRecordType; +} + function parseLevel(value: unknown, fallback: DecisionRecordLevel): DecisionRecordLevel { const raw = asString(value) || fallback; if (!recordLevels.has(raw as DecisionRecordLevel)) throw new HttpError(400, "unsupported record level", { value: raw, allowed: [...recordLevels] }); @@ -313,6 +330,12 @@ function titleFromMarkdown(markdown: string, fallback: string): string { return (firstLine || fallback).slice(0, 220); } +function validateRecordDraft(title: string, body: string): void { + if (!title) throw new HttpError(400, "title is required"); + if (title.length > 240) throw new HttpError(400, "title must be at most 240 characters"); + if (body.length > 300_000) throw new HttpError(400, "body must be at most 300000 characters"); +} + async function readJsonBody(req: Request): Promise> { const text = await req.text(); if (text.length > 5_000_000) throw new HttpError(413, "request body is too large", { maxBytes: 5_000_000 }); @@ -460,11 +483,9 @@ function health(): JsonRecord { } async function createRecord(input: Record): Promise { - const body = asString(input.body ?? input.summary ?? input.markdown); + const body = asText(input.body ?? input.summary ?? input.markdown); const title = asString(input.title) || titleFromMarkdown(body, "Untitled decision record"); - if (!title) throw new HttpError(400, "title is required"); - if (title.length > 240) throw new HttpError(400, "title must be at most 240 characters"); - if (body.length > 300_000) throw new HttpError(400, "body must be at most 300000 characters"); + validateRecordDraft(title, body); const id = asString(input.id) || `dc_${randomUUID()}`; const rows = await withDatabaseRecovery("create_record", () => sql` INSERT INTO decision_center_records ( @@ -491,14 +512,17 @@ async function createRecord(input: Record): Promise): 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; + validateRecordDraft(title, body); const rows = await withDatabaseRecovery("update_record", () => sql` UPDATE decision_center_records SET type = ${"type" in input ? parseRecordType(input.type, existing.type) : existing.type}, level = ${"level" in input ? parseLevel(input.level, existing.level) : existing.level}, status = ${"status" in input ? parseStatus(input.status, existing.status) : existing.status}, - title = ${"title" in input ? asString(input.title) : existing.title}, - body = ${"body" in input || "summary" in input || "markdown" in input ? asString(input.body ?? input.summary ?? input.markdown) : existing.body}, + 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)}, @@ -512,28 +536,64 @@ async function updateRecord(id: string, input: Record): Promise return recordFromRow(rows[0]!); } +async function upsertRequirementRecord(input: Record): Promise { + const id = asString(input.id); + if (id) { + try { + const existing = await getRecord(id); + if (!requirementRecordTypes.has(existing.type as RequirementRecordType)) { + throw new HttpError(409, "existing record is not a requirement-management record", { id, type: existing.type }); + } + const record = await updateRecord(id, { + ...input, + type: "type" in input ? parseRequirementRecordType(input.type, existing.type as RequirementRecordType) : existing.type, + }); + log("info", "requirement_updated", { id: record.id, type: record.type, level: record.level, status: record.status }); + return { ok: true, action: "updated", record }; + } catch (error) { + if (!(error instanceof HttpError) || error.status !== 404) throw error; + } + } + const record = await createRecord({ + ...input, + id: id || undefined, + type: parseRequirementRecordType(input.type, "goal"), + }); + log("info", "requirement_created", { id: record.id, type: record.type, level: record.level, status: record.status }); + return { ok: true, action: "created", record }; +} + async function getRecord(id: string): Promise { const rows = await withDatabaseRecovery("get_record", () => sql`SELECT * FROM decision_center_records WHERE id = ${id}`, { retryRead: true }); if (rows.length === 0) throw new HttpError(404, "decision record not found", { id }); return recordFromRow(rows[0]!); } -async function listRecords(url: URL): Promise { +async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}): Promise { const type = asString(url.searchParams.get("type")); const status = asString(url.searchParams.get("status")); const level = asString(url.searchParams.get("level")); const linkedGoalId = asString(url.searchParams.get("linkedGoalId")); + const tag = asString(url.searchParams.get("tag")); + const query = asString(url.searchParams.get("q") ?? url.searchParams.get("query")); + const requirementOnly = options.requirementOnly === true || url.searchParams.get("requirementOnly") === "true"; const limit = Math.max(1, Math.min(500, Number(url.searchParams.get("limit") || 200) || 200)); 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 }); if (level && !recordLevels.has(level as DecisionRecordLevel)) throw new HttpError(400, "unsupported level filter", { level }); + if (tag.length > 120) throw new HttpError(400, "tag filter must be at most 120 characters"); + if (query.length > 240) throw new HttpError(400, "query filter must be at most 240 characters"); const rows = await withDatabaseRecovery("list_records", () => sql` SELECT * FROM decision_center_records WHERE (${type || null}::text IS NULL OR type = ${type || null}) + AND (${requirementOnly}::boolean IS FALSE OR type IN ('decision', 'goal', 'blocker', 'debt', 'experiment')) AND (${status || null}::text IS NULL OR status = ${status || null}) AND (${level || null}::text IS NULL OR level = ${level || null}) AND (${linkedGoalId || null}::text IS NULL OR linked_goal_id = ${linkedGoalId || null}) + 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 level WHEN 'G0' THEN 0 @@ -709,6 +769,24 @@ function validMonthFilter(value: string): string { return value; } +function markdownPathForDate(date: string): string { + const month = date.slice(0, 7); + return `${month}/${date}.md`; +} + +function validateDiarySourceFile(value: string): string { + if (value.length > 1000) throw new HttpError(400, "sourceFile must be at most 1000 characters"); + if (/[\u0000-\u001f\u007f]/u.test(value)) throw new HttpError(400, "sourceFile contains control characters"); + return value; +} + +function diaryTitleFor(date: string, body: string, rawTitle: unknown): string { + const title = asString(rawTitle) || titleFromMarkdown(body, `${date} 工作日志`); + if (!title) throw new HttpError(400, "title is required"); + if (title.length > 240) throw new HttpError(400, "title must be at most 240 characters"); + return title; +} + async function upsertDiaryEntry(draft: DiaryDraft, sourceFile: string, tags: string[]): Promise<{ entry: DiaryEntry; action: "created" | "updated" | "unchanged" }> { if (draft.body.length > 300_000) throw new HttpError(400, "diary day body must be at most 300000 characters", { date: draft.date, length: draft.body.length }); const hash = contentHash(draft.body); @@ -852,6 +930,83 @@ async function getDiaryEntry(key: string): Promise { return diaryEntryFromRow(rows[0]!); } +async function upsertDiaryEntryByKey(key: string, input: Record): Promise { + const keyDate = /^\d{4}-\d{2}-\d{2}$/u.test(key) ? validDateFilter(key, "date") : ""; + const bodyProvided = "body" in input || "markdown" in input; + const titleProvided = "title" in input; + const tagsProvided = "tags" in input; + const sourceFileProvided = "sourceFile" in input || "sourcePath" in input || "source" in input; + const sourceFile = sourceFileProvided ? validateDiarySourceFile(asString(input.sourceFile ?? input.sourcePath ?? input.source)) : ""; + + const existingRows = await withDatabaseRecovery("get_diary_entry_for_upsert", () => sql` + SELECT * + FROM decision_center_diary_entries + WHERE (${keyDate || null}::date IS NOT NULL AND entry_date = ${keyDate || null}::date) + OR (${keyDate || null}::date IS NULL AND id = ${key}) + ORDER BY imported_at DESC + LIMIT 1 + `, { retryRead: true }); + + const existing = existingRows[0]; + if (existing === undefined && !keyDate) throw new HttpError(404, "diary entry not found", { key }); + + const date = existing === undefined ? keyDate : dateOnly(existing.entry_date); + const body = bodyProvided ? asText(input.body ?? input.markdown) : existing?.body ?? ""; + if (body.length > 300_000) throw new HttpError(400, "diary day body must be at most 300000 characters", { date, length: body.length }); + const title = titleProvided || existing === undefined ? diaryTitleFor(date, body, input.title) : existing.title; + const tags = tagsProvided ? asStringArray(input.tags, "tags") : Array.isArray(existing?.tags) ? existing.tags.map(String) : []; + const finalSourceFile = sourceFileProvided ? sourceFile : validateDiarySourceFile(existing?.source_file ?? "manual"); + const month = date.slice(0, 7); + const markdownPath = markdownPathForDate(date); + const hash = contentHash(body); + + const rows = existing === undefined + ? await withDatabaseRecovery("create_diary_entry_by_date", () => sql` + INSERT INTO decision_center_diary_entries ( + id, entry_date, month, title, body, source_file, markdown_path, tags, content_hash, imported_at, updated_at + ) VALUES ( + ${`diary_${randomUUID()}`}, + ${date}::date, + ${month}, + ${title}, + ${body}, + ${finalSourceFile}, + ${markdownPath}, + ${sql.json(tags)}, + ${hash}, + now(), + now() + ) + RETURNING * + `) + : await withDatabaseRecovery("update_diary_entry", () => sql` + UPDATE decision_center_diary_entries + SET + title = ${title}, + body = ${body}, + source_file = ${finalSourceFile}, + markdown_path = ${markdownPath}, + month = ${month}, + tags = ${sql.json(tags)}, + content_hash = ${hash}, + updated_at = CASE + WHEN content_hash IS DISTINCT FROM ${hash} + OR title IS DISTINCT FROM ${title} + OR source_file IS DISTINCT FROM ${finalSourceFile} + OR tags IS DISTINCT FROM ${sql.json(tags)}::jsonb + THEN now() + ELSE updated_at + END + WHERE id = ${existing.id} + RETURNING * + `); + const row = rows[0]; + if (row === undefined) throw new HttpError(500, "diary edit returned no row", { key }); + const action = existing === undefined ? "created" : "updated"; + log("info", "diary_entry_upserted", { id: row.id, date, action, sourceFile: row.source_file }); + return { ok: true, action, entry: diaryEntryFromRow(row) }; +} + async function route(req: Request): Promise { const url = new URL(req.url); const method = req.method.toUpperCase(); @@ -865,6 +1020,11 @@ async function route(req: Request): Promise { if (url.pathname === "/logs" && method === "GET") return jsonResponse({ ok: true, logs: recentLogs.slice(-200) }); if (url.pathname === "/api/records" && method === "GET") return jsonResponse({ ok: true, records: await listRecords(url) }); if (url.pathname === "/api/records" && method === "POST") return jsonResponse({ ok: true, record: await createRecord(await readJsonBody(req)) }, 201); + if (url.pathname === "/api/requirements" && method === "GET") return jsonResponse({ ok: true, requirements: await listRecords(url, { requirementOnly: true }) }); + if (url.pathname === "/api/requirements" && (method === "POST" || method === "PUT")) { + const result = await upsertRequirementRecord(await readJsonBody(req)); + return jsonResponse(result, result.action === "created" ? 201 : 200); + } if (url.pathname === "/api/meetings/import" && method === "POST") return jsonResponse(await importMeeting(await readJsonBody(req)), 201); if (url.pathname === "/api/diary/months" && method === "GET") return jsonResponse({ ok: true, months: await listDiaryMonths() }); if (url.pathname === "/api/diary/entries" && method === "GET") return jsonResponse({ ok: true, entries: await listDiaryEntries(url) }); @@ -874,7 +1034,11 @@ async function route(req: Request): Promise { const key = decodeURIComponent(diaryMatch[1] ?? ""); if (!key) throw new HttpError(400, "diary entry id or date is required"); if (method === "GET") return jsonResponse({ ok: true, entry: await getDiaryEntry(key) }); - throw new HttpError(405, "diary entry route supports GET", { method }); + if (method === "PUT") { + const result = await upsertDiaryEntryByKey(key, await readJsonBody(req)); + return jsonResponse(result, result.action === "created" ? 201 : 200); + } + throw new HttpError(405, "diary entry route supports GET and PUT", { method }); } const recordMatch = url.pathname.match(/^\/api\/records\/([^/]+)$/u); if (recordMatch !== null) {