fix: tighten decision center query paths
This commit is contained in:
@@ -125,7 +125,7 @@
|
||||
|
||||
## T23B D601 Decision Center User Service
|
||||
|
||||
阅读 `AGENTS.md` 和 `docs/reference/microservices.md`,运行 `bun scripts/cli.ts microservice list`,确认 `decision-center` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/unidesk`、k3s/k8s `k3s://unidesk/decision-center:4277` 逻辑服务映射、`deployment.mode=k3sctl-managed`、`runtime.orchestrator=k3sctl` 且无业务直连容器摘要。随后运行 `bun scripts/cli.ts microservice health decision-center`,确认 `service=decision-center`、`storage=postgres`、`schemaReady=true` 且 health 中包含 `diaryEntryCount`;准备一份临时 Markdown 会议记录,运行 `bun scripts/cli.ts decision upload <markdown-file> --title <title> --type meeting --level G1 --status active --evidence <url>`,再运行 `bun scripts/cli.ts decision list` 和 `bun scripts/cli.ts decision show <id>`,确认 CLI 只通过 backend-core 用户服务代理访问,返回结构化 JSON 且能看到刚上传的记录。准备一份临时需求 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日` 和 `# 2026年5月2日` 的临时工作日志 Markdown,运行 `bun scripts/cli.ts decision diary import <markdown-file> --source-file test-work-log.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`、`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、当天日记按真实日期自动创建、重复导入幂等且历史日记可按日期编辑。用户服务上线前的 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 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` 打开。
|
||||
|
||||
## T24 MET Nonlinear D601 GPU User Service
|
||||
|
||||
|
||||
@@ -23,8 +23,8 @@ 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 requirement list/upsert` 在同一 records 模型上管理 `goal|decision|blocker|debt|experiment` 需求记录。它们不得直连 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/months/show` 分别用于按月/日期查询、列出月份和查看单日正文;`decision diary edit|upsert <YYYY-MM-DD|id> --body-file <path> [--title text] [--source-file path] [--tag tag]` 通过 `PUT /api/diary/entries/:idOrDate` 创建当天或历史条目并编辑既有条目。
|
||||
- `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 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 资源。
|
||||
- `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。
|
||||
@@ -146,7 +146,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://<ip>:<frontendPort>/` 获取 HttpOnly session cookie,然后通过 frontend 的 `/api/*` 同源代理访问 backend-core 内网 API;因此计算节点只需要能访问公网 frontend,不需要主 server SSH key,也不需要打开 backend-core REST API 或 PostgreSQL 端口。
|
||||
|
||||
默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`artifact-registry status|health`、`ci publish-user-service --dry-run`、`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 <taskId>`、`codex tasks`、`codex output <taskId>`、`codex judge <taskId> --attempt N` 和 `ssh <PROVIDER_ID> <remote-command>`。运行中纠偏 `codex steer` 属于 active run write control,应在主 server 本机 CLI 或显式 SSH 传输上执行,避免公网 frontend 透传限制 stdin/body 审计语义。其中 `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`。当 backend-core、database、provider-dispatch 或 provider-host-ssh 缺失时,这些 read-only 预检必须返回结构化 `runnerDisposition=infra-blocked` 和缺失通道列表,而不是裸 `No such container`。若确实需要旧行为,可使用 `--main-server-key <key>` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`。
|
||||
默认 frontend 传输支持 `debug health`、`debug dispatch`、`debug task`、`artifact-registry status|health`、`ci publish-user-service --dry-run`、`microservice list/status/health/diagnostics/tunnel-self-test/proxy`、`decision upload/list/show/health`、`decision requirement list/upsert`、`decision diary import/list/history/months/show/edit/upsert`、`codex task <taskId>`、`codex tasks`、`codex output <taskId>`、`codex judge <taskId> --attempt N` 和 `ssh <PROVIDER_ID> <remote-command>`。运行中纠偏 `codex steer` 属于 active run write control,应在主 server 本机 CLI 或显式 SSH 传输上执行,避免公网 frontend 透传限制 stdin/body 审计语义。其中 `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`。当 backend-core、database、provider-dispatch 或 provider-host-ssh 缺失时,这些 read-only 预检必须返回结构化 `runnerDisposition=infra-blocked` 和缺失通道列表,而不是裸 `No such container`。若确实需要旧行为,可使用 `--main-server-key <key>` 或 `--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`。
|
||||
|
||||
计算节点可以用该入口测试自身的远程升级闭环,而不需要在计算节点公开 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_ID> provider.upgrade --mode schedule --wait-ms 15000` 触发真实 `provider.upgrade`;随后再次运行 `debug health` 确认节点重新上线;最后运行 `bun scripts/cli.ts --main-server-ip 74.48.78.17 debug dispatch <PROVIDER_ID> host.ssh --wait-ms 15000` 和 `bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh <PROVIDER_ID> hostname` 验证 SSH 透传能力。provider-gateway 新部署或升级后没有完成这组 remote CLI 自测,不能视为交付完成。
|
||||
|
||||
|
||||
@@ -261,10 +261,10 @@ D601 上必须显式使用原生 k3s kubeconfig:`KUBECONFIG=/etc/rancher/k3s/k
|
||||
- 文书元数据:正式产品化后,记录模型必须显式保存 `docNo`、`docType`、`docPriority`、`docSeq`、`signer`、`issuedAt`、`effectiveScope`、`supersedes` 和 `supersededBy` 等字段,并为 `docNo` 建立唯一约束;在 schema 完成前,临时过渡可以把文号写入 `title` 前缀、`tags` 和 Markdown `body` 首段,但不得把临时正文约定当作长期数据模型。
|
||||
- 文书流转:正式文书应支持 `draft` 草拟、`review` 核稿、`issued` 已签发、`active` 执行中、`done` 办结、`void` 作废等状态或等价状态映射;Agent 可以起草、核稿和生成报告,但涉及战略优先级、外部目标和长期约束的 `DCSN`/`GOAL` 文书必须由用户签发后才进入已签发序列。编号一经签发不得复用,作废也必须保留编号和替代关系。
|
||||
- 需求管理:Decision Center 里的 `external_goal` 记录应承接外部需求或外部目标,`internal_goal`/`goal` 记录应承接拆解后的内部目标,`decision` 记录应承接需求分解后的取舍,`blocker` 记录应承接当前阻塞,`experiment` 记录应承接验证性工作,`debt` 记录应承接必须偿还的技术/流程债。任何新需求都应先写成可验证的外部收益,再分解为这些内部记录,而不是先发散成内部审美或架构偏好。需求管理 API 复用 `decision_center_records`,`/api/requirements` 在同一模型上排除 `meeting`,并提供 list/create/show/update/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,内容未变时保持幂等。
|
||||
- 日记数据模型:基于 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,内容未变时保持幂等;同一天存在多个 `source_file` 时,列表项应保留 `id` 与 `sourceFile`,按日期读取可用 `sourceFile` 查询参数消歧。
|
||||
- 日记编辑:工作日记必须支持按真实日期创建当天条目,并支持按日期回看和编辑历史条目;`GET /api/diary/today` 按服务当前真实日期自动创建或返回当天条目,`PUT /api/diary/today` 保存当天 Markdown,`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`、`GET|PUT /api/requirements/:id`、`POST /api/meetings/import`、`POST /api/diary/import`、`GET /api/diary/entries`、`GET /api/diary/history`、`GET|POST|PUT /api/diary/today`、`GET|PUT /api/diary/entries/:idOrDate` 和 `GET /api/diary/months`,错误必须返回结构化 JSON,便于 CLI 与 frontend 诊断。
|
||||
- CLI:`bun scripts/cli.ts decision upload <markdown-file>`、`decision list`、`decision show <id>`、`decision requirement list/create/show/update/upsert`、`decision diary import/list/history/months/today/show/edit/upsert` 和 `decision health` 只能通过 backend-core 用户服务代理访问 Decision Center,不得直连 D601 Service、NodePort 或 provider-gateway `microservice.http`。日记编辑验收应使用 `decision diary today` 确认真实日期自动创建当天条目,使用 `decision diary today --edit --body-file <file>` 保存当天 Markdown,使用 `decision diary upsert <YYYY-MM-DD> --body-file <file>` 创建或更新历史日记,再用 `decision diary show <YYYY-MM-DD>` 读取确认。
|
||||
- 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`、`GET|PUT /api/requirements/:id`、`POST /api/meetings/import`、`POST /api/diary/import`、`GET /api/diary/entries`、`GET /api/diary/history`、`GET|POST|PUT /api/diary/today`、`GET|PUT /api/diary/entries/:idOrDate` 和 `GET /api/diary/months`,错误必须返回结构化 JSON,便于 CLI 与 frontend 诊断。记录与日记列表默认只返回摘要级正文,完整 Markdown body 只能由详情接口或显式 `includeBody=true` 获取,避免大 body 列表穿过 frontend/proxy 链路导致超时。
|
||||
- CLI:`bun scripts/cli.ts decision upload <markdown-file>`、`decision list`、`decision show <id>`、`decision requirement list/create/show/update/upsert`、`decision diary import/list/history/months/today/show/edit/upsert` 和 `decision health` 只能通过 backend-core 用户服务代理访问 Decision Center,不得直连 D601 Service、NodePort 或 provider-gateway `microservice.http`。列表命令默认省略完整正文,需要完整 body 时显式加 `--include-body`;日记编辑验收应使用 `decision diary today` 确认真实日期自动创建当天条目,使用 `decision diary today --edit --body-file <file>` 保存当天 Markdown,使用 `decision diary upsert <YYYY-MM-DD> --body-file <file>` 创建或更新历史日记,再用 `decision diary show <YYYY-MM-DD> --source-file <source>` 或 `decision diary show <id>` 读取确认。
|
||||
- Dev/prod CD:Decision Center 的 dev/prod rollout 都必须走 D601 registry artifact consumer,验证同一个 commit-pinned artifact contract,证明 live `deploy.commit` 与 `deploy.requestedCommit` 一致,再通过 Kubernetes API service proxy 验证健康;不得回退到维护通道直连或 NodePort/hostPort。
|
||||
- UniDesk 前端:`用户服务 / Decision Center` React 页面展示权威记录筛选、当前 G0/G1 目标、P0/P1 blocker、停放事项、最近会议/决议和工作日记;它还应成为需求管理入口,让外部目标、内部拆解和每日工作记录在同一页面中可追溯。日记视图按月份筛选并展示每天 Markdown 正文,未来应支持当天自动创建与历史编辑。默认不得展示裸 JSON,完整原始数据只能通过 `查看原始JSON` 打开。
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
type JsonRecord = Record<string, 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 includesAll(text: string, snippets: string[]): boolean {
|
||||
return snippets.every((snippet) => text.includes(snippet));
|
||||
}
|
||||
|
||||
export function runDecisionCenterQueryContract(): JsonRecord {
|
||||
const service = source("src/components/microservices/decision-center/src/index.ts");
|
||||
const cli = source("scripts/src/decision-center.ts");
|
||||
const frontend = source("src/components/frontend/src/decision-center.tsx");
|
||||
|
||||
assertCondition(
|
||||
includesAll(service, [
|
||||
'url.pathname === "/api/requirements" && method === "GET"',
|
||||
"listRecords(url, { requirementOnly: true })",
|
||||
"type IN ('decision', 'goal', 'external_goal', 'internal_goal', 'blocker', 'debt', 'experiment')",
|
||||
]),
|
||||
"requirements list route must stay on the records model and exclude meetings",
|
||||
);
|
||||
|
||||
assertCondition(
|
||||
includesAll(service, [
|
||||
"CASE WHEN ${includeBody}::boolean THEN body ELSE left(body, 4000) END AS body",
|
||||
"return rows.map((row) => recordFromRow(row, { includeBody }));",
|
||||
"body: includeBody ? body : \"\"",
|
||||
]),
|
||||
"record list must be body-light by default while preserving summaries",
|
||||
);
|
||||
|
||||
assertCondition(
|
||||
includesAll(service, [
|
||||
"sourceFileFilterFromUrl(url)",
|
||||
"url.searchParams.get(\"sourceFile\") ?? url.searchParams.get(\"sourcePath\") ?? url.searchParams.get(\"source\")",
|
||||
"AND (${sourceFile || null}::text IS NULL OR source_file = ${sourceFile || null})",
|
||||
"getDiaryEntry(key, { sourceFile: sourceFileFilterFromUrl(url) })",
|
||||
]),
|
||||
"diary date lookup must support sourceFile disambiguation for same-day entries",
|
||||
);
|
||||
assertCondition(
|
||||
service.split("AND (${sourceFile || null}::text IS NULL OR source_file = ${sourceFile || null})").length >= 3,
|
||||
"diary sourceFile filter must cover both read and date-key upsert lookup paths",
|
||||
);
|
||||
|
||||
assertCondition(
|
||||
includesAll(service, [
|
||||
"CASE WHEN ${includeBody}::boolean THEN body ELSE left(body, 4000) END AS body",
|
||||
"return rows.map((row) => diaryEntryFromRow(row, { includeBody }));",
|
||||
]),
|
||||
"diary list must be body-light by default while preserving summaries",
|
||||
);
|
||||
|
||||
assertCondition(
|
||||
includesAll(cli, [
|
||||
"if (args.includes(\"--include-body\")) params.set(\"includeBody\", \"true\")",
|
||||
"function diaryShowQuery(args: string[]): string",
|
||||
"params.set(\"sourceFile\", sourceFile)",
|
||||
"showDiary(diaryId, args.slice(3))",
|
||||
"`/api/requirements${query ? `?${query}` : \"\"}`",
|
||||
]),
|
||||
"CLI must expose bounded list opt-in and diary source disambiguation",
|
||||
);
|
||||
|
||||
assertCondition(
|
||||
includesAll(frontend, [
|
||||
"function diaryEntryLookupPath(entry: any): string",
|
||||
"const key = entry?.id || entry?.date",
|
||||
"if (entry?.sourceFile) params.set(\"sourceFile\", String(entry.sourceFile))",
|
||||
"decisionApi(apiBaseUrl, diaryEntryLookupPath(entry))",
|
||||
"if (!record?.id || record?.body) return",
|
||||
"`/api/records/${encodeURIComponent(record.id)}`",
|
||||
]),
|
||||
"frontend must select exact diary rows and fetch full record bodies before editing list results",
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
checks: [
|
||||
"requirements-route",
|
||||
"body-light-record-list-query",
|
||||
"body-light-diary-list-query",
|
||||
"diary-source-disambiguation",
|
||||
"cli-bounded-list-and-diary-source-query",
|
||||
"frontend-exact-diary-row-and-record-edit-body",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
process.stdout.write(`${JSON.stringify(runDecisionCenterQueryContract(), null, 2)}\n`);
|
||||
}
|
||||
@@ -258,6 +258,7 @@ function recordQuery(args: string[], options: { requirementOnly?: boolean } = {}
|
||||
if (tag !== undefined) params.set("tag", tag);
|
||||
if (queryText !== undefined) params.set("q", queryText);
|
||||
if (limit !== undefined) params.set("limit", limit);
|
||||
if (args.includes("--include-body")) params.set("includeBody", "true");
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
@@ -302,14 +303,22 @@ async function listDiaryMonthsAsync(fetcher: (path: string, init?: { method?: st
|
||||
return unwrapProxyResponse(await decisionProxyAsync(fetcher, "/api/diary/months"));
|
||||
}
|
||||
|
||||
function showDiary(key: string | undefined): unknown {
|
||||
if (!key) throw new Error("decision diary show requires entry id or YYYY-MM-DD date");
|
||||
return unwrapProxyResponse(decisionProxy(`/api/diary/entries/${encodeURIComponent(key)}`));
|
||||
function diaryShowQuery(args: string[]): string {
|
||||
const params = new URLSearchParams();
|
||||
const sourceFile = optionValue(args, ["--source-file", "--source-path", "--source"]);
|
||||
if (sourceFile !== undefined) params.set("sourceFile", sourceFile);
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
|
||||
async function showDiaryAsync(key: string | undefined, fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
||||
function showDiary(key: string | undefined, args: string[] = []): unknown {
|
||||
if (!key) throw new Error("decision diary show requires entry id or YYYY-MM-DD date");
|
||||
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/diary/entries/${encodeURIComponent(key)}`));
|
||||
return unwrapProxyResponse(decisionProxy(`/api/diary/entries/${encodeURIComponent(key)}${diaryShowQuery(args)}`));
|
||||
}
|
||||
|
||||
async function showDiaryAsync(key: string | undefined, fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>, args: string[] = []): Promise<unknown> {
|
||||
if (!key) throw new Error("decision diary show requires entry id or YYYY-MM-DD date");
|
||||
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/diary/entries/${encodeURIComponent(key)}${diaryShowQuery(args)}`));
|
||||
}
|
||||
|
||||
function todayDiary(): unknown {
|
||||
@@ -460,7 +469,7 @@ export async function runDecisionCenterCommand(_config: UniDeskConfig, args: str
|
||||
if (diaryAction === "history") return diaryHistory(args.slice(2));
|
||||
if (diaryAction === "months") return listDiaryMonths();
|
||||
if (diaryAction === "today") return args.includes("--edit") ? editTodayDiary(args.slice(2).filter((arg) => arg !== "--edit")) : todayDiary();
|
||||
if (diaryAction === "show") return showDiary(diaryId);
|
||||
if (diaryAction === "show") return showDiary(diaryId, args.slice(3));
|
||||
if (diaryAction === "edit" || diaryAction === "upsert") return editDiary(args.slice(2));
|
||||
throw new Error("decision diary command must be one of: import, list, history, months, today, show, edit, upsert");
|
||||
}
|
||||
@@ -496,7 +505,7 @@ export async function runDecisionCenterCommandAsync(
|
||||
if (diaryAction === "history") return diaryHistoryAsync(args.slice(2), fetcher);
|
||||
if (diaryAction === "months") return listDiaryMonthsAsync(fetcher);
|
||||
if (diaryAction === "today") return args.includes("--edit") ? editTodayDiaryAsync(args.slice(2).filter((arg) => arg !== "--edit"), fetcher) : todayDiaryAsync(fetcher);
|
||||
if (diaryAction === "show") return showDiaryAsync(diaryId, fetcher);
|
||||
if (diaryAction === "show") return showDiaryAsync(diaryId, fetcher, args.slice(3));
|
||||
if (diaryAction === "edit" || diaryAction === "upsert") return editDiaryAsync(args.slice(2), fetcher);
|
||||
throw new Error("decision diary command must be one of: import, list, history, months, today, show, edit, upsert");
|
||||
}
|
||||
|
||||
+2
-2
@@ -35,9 +35,9 @@ export function rootHelp(): unknown {
|
||||
{ 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." },
|
||||
{ command: "decision diary today [--edit --body-file path] [--title text] [--tag tag]", description: "Get or create today's diary entry using the service's real current date; --edit saves today's Markdown." },
|
||||
{ command: "decision diary months", description: "List available Decision Center diary months with day counts." },
|
||||
{ command: "decision diary show <YYYY-MM-DD|id>", description: "Show one daily diary Markdown entry." },
|
||||
{ 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]", description: "List Decision Center records through the user-service 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: "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." },
|
||||
|
||||
@@ -438,6 +438,14 @@ function diaryQuery(filters: AnyRecord): string {
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function diaryEntryLookupPath(entry: any): string {
|
||||
const key = entry?.id || entry?.date;
|
||||
const params = new URLSearchParams();
|
||||
if (entry?.sourceFile) params.set("sourceFile", String(entry.sourceFile));
|
||||
const query = params.toString();
|
||||
return `/api/diary/entries/${encodeURIComponent(key)}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
function DiaryEntryCard({ entry, selected, onSelect, onRaw }: AnyRecord) {
|
||||
return h("article", { className: `diary-entry-card ${selected ? "selected" : ""}`, "data-testid": `diary-entry-${stableTestId(entry.date || entry.id)}` },
|
||||
h("button", { type: "button", className: "diary-entry-main", onClick: () => onSelect(entry) },
|
||||
@@ -702,7 +710,7 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
async function selectDiaryEntry(entry: any): Promise<void> {
|
||||
setDiaryState((prev: any) => ({ ...prev, selected: entry }));
|
||||
try {
|
||||
const response = await requestJson(decisionApi(apiBaseUrl, `/api/diary/entries/${encodeURIComponent(entry.date || entry.id)}`));
|
||||
const response = await requestJson(decisionApi(apiBaseUrl, diaryEntryLookupPath(entry)));
|
||||
const selected = response.entry || entry;
|
||||
setDiaryState((prev: any) => ({ ...prev, selected }));
|
||||
setDiaryForm(diaryFormFromEntry(selected));
|
||||
@@ -740,10 +748,18 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
|
||||
}));
|
||||
}
|
||||
|
||||
function editRecord(record: any): void {
|
||||
async function editRecord(record: any): Promise<void> {
|
||||
setRecordForm(recordFormFromRecord(record));
|
||||
setRecordSaveState({ saving: false, error: "", message: `正在编辑 ${record?.id || ""}` });
|
||||
setActiveView("records");
|
||||
if (!record?.id || record?.body) return;
|
||||
try {
|
||||
const response = await requestJson(decisionApi(apiBaseUrl, `/api/records/${encodeURIComponent(record.id)}`));
|
||||
const fullRecord = response.record || record;
|
||||
setRecordForm(recordFormFromRecord(fullRecord));
|
||||
} catch (err) {
|
||||
setRecordSaveState({ saving: false, error: errorMessage(err, "记录正文加载失败"), message: "" });
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRecord(event: any): Promise<void> {
|
||||
|
||||
@@ -281,7 +281,8 @@ function summaryFromBody(body: string): string {
|
||||
.slice(0, 280);
|
||||
}
|
||||
|
||||
function recordFromRow(row: DecisionRecordRow): DecisionRecord {
|
||||
function recordFromRow(row: DecisionRecordRow, options: { includeBody?: boolean } = {}): DecisionRecord {
|
||||
const includeBody = options.includeBody !== false;
|
||||
const body = row.body || "";
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -290,7 +291,7 @@ function recordFromRow(row: DecisionRecordRow): DecisionRecord {
|
||||
status: row.status,
|
||||
title: row.title,
|
||||
summary: summaryFromBody(body),
|
||||
body,
|
||||
body: includeBody ? body : "",
|
||||
priority: row.level,
|
||||
linkedGoalId: row.linked_goal_id,
|
||||
tags: Array.isArray(row.tags) ? row.tags.map(String) : [],
|
||||
@@ -606,6 +607,7 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
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 includeBody = url.searchParams.get("includeBody") === "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 });
|
||||
@@ -616,7 +618,23 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
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<DecisionRecordRow[]>`
|
||||
SELECT *
|
||||
SELECT
|
||||
id,
|
||||
type,
|
||||
level,
|
||||
status,
|
||||
title,
|
||||
CASE WHEN ${includeBody}::boolean THEN body ELSE left(body, 4000) END AS body,
|
||||
linked_goal_id,
|
||||
tags,
|
||||
evidence_links,
|
||||
source,
|
||||
source_session,
|
||||
issue_id,
|
||||
task_id,
|
||||
commit_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM decision_center_records
|
||||
WHERE (${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'))
|
||||
@@ -642,7 +660,7 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
updated_at DESC
|
||||
LIMIT ${limit}
|
||||
`, { retryRead: true });
|
||||
return rows.map(recordFromRow);
|
||||
return rows.map((row) => recordFromRow(row, { includeBody }));
|
||||
}
|
||||
|
||||
async function getRequirementRecord(id: string): Promise<DecisionRecord> {
|
||||
@@ -851,6 +869,11 @@ function validateDiarySourceFile(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function sourceFileFilterFromUrl(url: URL): string {
|
||||
const sourceFile = asString(url.searchParams.get("sourceFile") ?? url.searchParams.get("sourcePath") ?? url.searchParams.get("source"));
|
||||
return sourceFile ? validateDiarySourceFile(sourceFile) : "";
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -952,14 +975,26 @@ async function listDiaryEntries(url: URL): Promise<DiaryEntry[]> {
|
||||
const month = asString(url.searchParams.get("month"));
|
||||
const from = asString(url.searchParams.get("from"));
|
||||
const to = asString(url.searchParams.get("to"));
|
||||
const sourceFile = asString(url.searchParams.get("sourceFile"));
|
||||
const sourceFile = sourceFileFilterFromUrl(url);
|
||||
const includeBody = url.searchParams.get("includeBody") === "true";
|
||||
const limit = Math.max(1, Math.min(1000, Number(url.searchParams.get("limit") || 240) || 240));
|
||||
if (month) validMonthFilter(month);
|
||||
if (from) validDateFilter(from, "from");
|
||||
if (to) validDateFilter(to, "to");
|
||||
const rows = await withDatabaseRecovery("list_diary_entries", () => sql<DiaryEntryRow[]>`
|
||||
SELECT *
|
||||
SELECT
|
||||
id,
|
||||
entry_date,
|
||||
month,
|
||||
title,
|
||||
CASE WHEN ${includeBody}::boolean THEN body ELSE left(body, 4000) END AS body,
|
||||
source_file,
|
||||
markdown_path,
|
||||
tags,
|
||||
content_hash,
|
||||
created_at,
|
||||
updated_at,
|
||||
imported_at
|
||||
FROM decision_center_diary_entries
|
||||
WHERE (${month || null}::text IS NULL OR month = ${month || null})
|
||||
AND (${from || null}::date IS NULL OR entry_date >= ${from || null}::date)
|
||||
@@ -987,17 +1022,21 @@ async function listDiaryMonths(): Promise<JsonRecord[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
async function getDiaryEntry(key: string): Promise<DiaryEntry> {
|
||||
async function getDiaryEntry(key: string, options: { sourceFile?: string } = {}): Promise<DiaryEntry> {
|
||||
const date = /^\d{4}-\d{2}-\d{2}$/u.test(key) ? validDateFilter(key, "date") : "";
|
||||
const sourceFile = options.sourceFile ? validateDiarySourceFile(options.sourceFile) : "";
|
||||
const rows = await withDatabaseRecovery("get_diary_entry", () => sql<DiaryEntryRow[]>`
|
||||
SELECT *
|
||||
FROM decision_center_diary_entries
|
||||
WHERE (${date || null}::date IS NOT NULL AND entry_date = ${date || null}::date)
|
||||
OR (${date || null}::date IS NULL AND id = ${key})
|
||||
WHERE (
|
||||
(${date || null}::date IS NOT NULL AND entry_date = ${date || null}::date)
|
||||
OR (${date || null}::date IS NULL AND id = ${key})
|
||||
)
|
||||
AND (${sourceFile || null}::text IS NULL OR source_file = ${sourceFile || null})
|
||||
ORDER BY imported_at DESC
|
||||
LIMIT 1
|
||||
`, { retryRead: true });
|
||||
if (rows.length === 0) throw new HttpError(404, "diary entry not found", { key });
|
||||
if (rows.length === 0) throw new HttpError(404, "diary entry not found", { key, ...(sourceFile ? { sourceFile } : {}) });
|
||||
return diaryEntryFromRow(rows[0]!);
|
||||
}
|
||||
|
||||
@@ -1012,8 +1051,11 @@ async function upsertDiaryEntryByKey(key: string, input: Record<string, unknown>
|
||||
const existingRows = await withDatabaseRecovery("get_diary_entry_for_upsert", () => sql<DiaryEntryRow[]>`
|
||||
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})
|
||||
WHERE (
|
||||
(${keyDate || null}::date IS NOT NULL AND entry_date = ${keyDate || null}::date)
|
||||
OR (${keyDate || null}::date IS NULL AND id = ${key})
|
||||
)
|
||||
AND (${sourceFile || null}::text IS NULL OR source_file = ${sourceFile || null})
|
||||
ORDER BY imported_at DESC
|
||||
LIMIT 1
|
||||
`, { retryRead: true });
|
||||
@@ -1147,7 +1189,7 @@ async function route(req: Request): Promise<Response> {
|
||||
if (diaryMatch !== null) {
|
||||
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) });
|
||||
if (method === "GET") return jsonResponse({ ok: true, entry: await getDiaryEntry(key, { sourceFile: sourceFileFilterFromUrl(url) }) });
|
||||
if (method === "PUT") {
|
||||
const result = await upsertDiaryEntryByKey(key, await readJsonBody(req));
|
||||
return jsonResponse(result, result.action === "created" ? 201 : 200);
|
||||
|
||||
Reference in New Issue
Block a user