feat: add decision center diary imports

This commit is contained in:
Codex
2026-05-18 02:36:40 +00:00
parent feb2550fac
commit 54c1f8e165
10 changed files with 736 additions and 95 deletions
+1
View File
@@ -36,6 +36,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文
- `bun scripts/cli.ts ssh <providerId> [ssh-like args...]`:通过 provider-gateway 的 Host SSH / WSL SSH 维护桥打开近似原生 ssh 的交互会话或远端命令,并在远端 PATH 注入 `apply_patch``glob``skill-discover``apply-patch``py``skills`、结构化 `find``glob``argv` 子命令用于避免远端补丁、Python stdin、skill 发现与常用只读命令的嵌套转义问题,使用规则见 `docs/reference/cli.md``docs/reference/provider-gateway.md`
- `bun scripts/cli.ts microservice list/status/health/diagnostics/tunnel-self-test/proxy`:管理和验证挂载在主 server、计算节点 Docker 或 k3s 控制面上的用户服务,`proxy` 支持受控 JSON bodyOA Event Flow/Todo Note/Baidu Netdisk/Code Queue Manager on main-server、k3s Control/Code Queue 执行面/MDTODO/Decision Center/FindJob/Pipeline/MET Nonlinear on D601 的规则见 `docs/reference/microservices.md`
- `bun scripts/cli.ts decision upload/list/show/health`:通过 backend-core 用户服务代理上传会议记录/决议 Markdown、列出记录和查看详情;Decision Center 运行在 D601 k3s,规则见 `docs/reference/microservices.md`
- `bun scripts/cli.ts decision diary import/list/months/show`:把带日期标题的工作日志 Markdown 拆成 `YYYY-MM/YYYY-MM-DD.md` 日记条目并写入 PostgreSQL,规则见 `docs/reference/microservices.md`
- `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json|--env dev|prod] [--service <id>]`:按根目录或固定环境 ref 的服务 repo 和 commit 期望状态校验或更新用户服务;`--env dev` 当前开放 backend-core/frontend/code-queue 开发环境部署,目标侧自行 fetch、构建、部署和 live commit 验证;规则见 `docs/reference/deploy.md`
- `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]` / `dev-env prewarm-images`:离线校验 D601 `unidesk-dev` 生产隔离护栏,或把开发底座基础镜像预热到 D601 原生 k3s containerd,规则见 `docs/reference/deploy.md``docs/reference/microservices.md`
- `bun scripts/cli.ts ci install/status/run/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,只做每 commit 检查和 Code Queue 只读性能门禁,不部署 CD;规则见 `docs/reference/ci.md`
+1 -1
View File
@@ -113,7 +113,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 deploy apply --service decision-center``deploy.json` 期望状态部署,确认 job 在 D601 target-side build、导入原生 k3s/containerd、apply `src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml`、stamp deployment commit、rollout 并通过 UniDesk microservice proxy 验证 live commit。运行 `bun scripts/cli.ts microservice health decision-center`,确认 `service=decision-center``storage=postgres``schemaReady=true`;准备一份临时 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 且能看到刚上传的记录。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Decision Center`,确认页面显示 G0/G1 目标、P0/P1 Blocker、停放事项、最近会议/决议、筛选全部记录表,刚上传的会议记录可见;页面不得提供聊天/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 deploy apply --service decision-center``deploy.json` 期望状态部署,确认 job 在 D601 target-side build、导入原生 k3s/containerd、apply `src/components/microservices/k3sctl-adapter/k3s/decision-center.k8s.yaml`、stamp deployment commit、rollout 并通过 UniDesk microservice proxy 验证 live commit。运行 `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 且能看到刚上传的记录。再准备一份包含 `# 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 show 2026-05-01`,确认日记按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径拆分、写入 PostgreSQL 且重复导入幂等。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Decision Center`,确认页面显示 G0/G1 目标、P0/P1 Blocker、停放事项、最近会议/决议、筛选全部记录表和工作日记标签;日记标签可按月筛选并查看单日 Markdown 正文;页面不得提供聊天/LLM 会话窗口,默认不得裸 JSON,完整 JSON 只能通过 `查看原始JSON` 打开。
## T24 MET Nonlinear D601 GPU User Service
+2 -1
View File
@@ -21,6 +21,7 @@ UniDesk 的统一 CLI 入口是根目录 `scripts/cli.ts`,运行方式固定
- `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、列出权威记录、查看详情和健康检查;它不得直连 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` 分别用于按月/日期查询、列出月份和查看单日正文。
- `deploy check/plan/apply` 默认从根目录 `deploy.json` 读取服务 repo 与 commit 期望状态,join `config.json` 和现有 manifest 后使用 target-side build 单一路径校验或更新直管服务与 k3s 代管服务;`deploy plan --env dev|prod` 只从固定 Git ref 读取 manifest 并输出 dry-run 环境计划,不使用本地 dirty worktree`deploy apply --env dev --service backend-core|frontend|code-queue` 可按 `origin/deploy/dev:deploy.json` 部署当前 D601 dev slice`--env prod` apply 仍禁用;规则见 `docs/reference/deploy.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 guardcore manifest 必须包含 `backend-core-dev`/`frontend-dev` Deployment/ServiceCode 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 <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。
@@ -121,7 +122,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``microservice list/status/health/diagnostics/tunnel-self-test/proxy``decision upload/list/show/health``codex task <taskId>``codex output <taskId>``codex judge <taskId> --attempt N``ssh <PROVIDER_ID> <remote-command>`。其中 `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 <key>``--main-server-transport ssh`,这时 CLI 会通过 SSH 登录主 server 的 `--main-server-root` 目录执行同一个 `bun scripts/cli.ts <command>`
默认 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 <taskId>``codex output <taskId>``codex judge <taskId> --attempt N``ssh <PROVIDER_ID> <remote-command>`。其中 `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 <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 自测,不能视为交付完成。
+8 -7
View File
@@ -227,15 +227,16 @@ D601 上必须显式使用原生 k3s kubeconfig`KUBECONFIG=/etc/rancher/k3s/k
### Decision Center k3s-Managed
当前 Decision Center 作为 `id=decision-center``k3sctl-managed` 用户服务登记在 `config.json`,用于沉淀 Codex/人工会议后的会议记录、决议、目标、问题分级、停放事项证据。它只负责权威记录和展示,不承载通用聊天、LLM 会话窗口或自动参谋对话。
当前 Decision Center 作为 `id=decision-center``k3sctl-managed` 用户服务登记在 `config.json`,用于沉淀 Codex/人工会议后的会议记录、决议、目标、问题分级、停放事项证据和按天工作日记。它只负责权威记录、Markdown 日记和展示,不承载通用聊天、LLM 会话窗口或自动参谋对话。
- Orchestrator`deployment.mode=k3sctl-managed``deployment.adapterServiceId=k3sctl-adapter``deployment.k3sServiceId=decision-center``backend.proxyMode=k3sctl-adapter-http``backend.nodeBaseUrl=k3s://decision-center`;正式链路只能是 `frontend/CLI -> backend-core -> k3sctl-adapter -> Kubernetes API service proxy -> Kubernetes Service decision-center:4277`
- 部署引用:后端源码位于 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`;不得使用浏览器 `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`
- 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`,错误必须返回结构化 JSON,便于 CLI 与 frontend 诊断
- CLI`bun scripts/cli.ts decision upload <markdown-file>``decision list``decision show <id>``decision health` 只能通过 backend-core 用户服务代理访问 Decision Center,不得直连 D601 Service、NodePort 或 provider-gateway `microservice.http`
- UniDesk 前端:`用户服务 / Decision Center` React 页面只展示高密度记录、筛选、当前 G0/G1 目标、P0/P1 blocker、停放事项和最近会议/决议;默认不得展示裸 JSON,完整原始数据只能通过 `查看原始JSON` 打开
- 状态权威: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`
- 日记数据模型:基于 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 <markdown-file>``decision list``decision show <id>``decision diary import/list/months/show``decision health` 只能通过 backend-core 用户服务代理访问 Decision Center,不得直连 D601 Service、NodePort 或 provider-gateway `microservice.http`
- UniDesk 前端:`用户服务 / Decision Center` React 页面展示权威记录筛选、当前 G0/G1 目标、P0/P1 blocker、停放事项、最近会议/决议和工作日记;日记视图按月份筛选并展示每天 Markdown 正文。默认不得展示裸 JSON,完整原始数据只能通过 `查看原始JSON` 打开。
### MDTODO k3s-Managed
@@ -256,7 +257,7 @@ D601 上必须显式使用原生 k3s kubeconfig`KUBECONFIG=/etc/rancher/k3s/k
- `pipeline`Pipeline v2 控制与观测服务,UniDesk frontend 渲染组件矩阵、React Flow 控制图、epoch 甘特图、运行材料索引和 node 精细控制面板。
- `met-nonlinear`MET Nonlinear 训练编排服务,UniDesk frontend 渲染 GPU/镜像、训练队列、Project config 预览、训练进度、ETA 和历史记录。
- `claudeqq`ClaudeQQ 纯后端 QQ 消息网关,UniDesk frontend 渲染 NapCat 连接、事件订阅、消息推送、最近 QQ 事件和发送记录。
- `decision-center`Decision Center 决策权威记录服务,D601 k3s 代管,状态写入主 PostgreSQLUniDesk frontend 渲染记录筛选、目标、blocker、停放事项会议/决议。
- `decision-center`Decision Center 决策权威记录和 Markdown 日记服务,D601 k3s 代管,状态写入主 PostgreSQLUniDesk frontend 渲染记录筛选、目标、blocker、停放事项会议/决议和按月工作日记
### D601 Docker/k3s Restart Recovery
+4
View File
@@ -52,6 +52,10 @@ function help(): unknown {
{ 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] [--level G0|G1|G2|G3|P0|P1|P2|P3|none] [--status active|blocked|parked|done] [--linked-goal-id id] [--evidence url]", description: "Upload a meeting note or decision 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 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 list [--type ...] [--status ...] [--level ...] [--linked-goal-id id] [--limit N]", description: "List Decision Center records through the user-service proxy." },
{ command: "decision show <id>", description: "Show one Decision Center record." },
{ command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest; --env reads fixed environment refs and can apply supported dev services." },
+118 -1
View File
@@ -74,7 +74,7 @@ function readMarkdownFile(path: string): { absolutePath: string; markdown: strin
const absolutePath = resolve(repoRoot, path);
const markdown = readFileSync(absolutePath, "utf8");
if (markdown.trim().length === 0) throw new Error(`markdown file is empty: ${absolutePath}`);
if (markdown.length > 1_000_000) throw new Error(`markdown file is too large: ${absolutePath}`);
if (markdown.length > 4_500_000) throw new Error(`markdown file is too large: ${absolutePath}`);
return { absolutePath, markdown };
}
@@ -143,6 +143,64 @@ async function uploadMeetingAsync(args: string[], fetcher: (path: string, init?:
return { file: absolutePath, result: unwrapProxyResponse(await decisionProxyAsync(fetcher, endpoint, { method: "POST", body })) };
}
function summarizeDiaryImportResult(result: unknown, includeEntries: boolean): unknown {
const body = typeof result === "object" && result !== null && !Array.isArray(result) ? result as Record<string, unknown> : {};
const entries = Array.isArray(body.entries) ? body.entries : [];
const summaryEntries = entries.map((item) => {
const record = typeof item === "object" && item !== null && !Array.isArray(item) ? item as Record<string, unknown> : {};
return {
id: record.id,
date: record.date,
month: record.month,
title: record.title,
markdownPath: record.markdownPath,
summary: record.summary,
sourceFile: record.sourceFile,
updatedAt: record.updatedAt,
};
});
return {
...body,
entries: includeEntries ? summaryEntries : summaryEntries.slice(0, 20),
entriesOmitted: includeEntries ? 0 : Math.max(0, summaryEntries.length - 20),
outputPolicy: includeEntries
? { bounded: false, entries: summaryEntries.length }
: { bounded: true, entriesShown: Math.min(summaryEntries.length, 20), fullCommand: "Re-run with --include-entries to show every imported day summary." },
};
}
function diaryImportPayload(args: string[]): { absolutePath: string; payload: Record<string, unknown> } {
const file = positionalArgs(args)[0];
if (!file) throw new Error("decision diary import requires markdown file");
const { absolutePath, markdown } = readMarkdownFile(file);
return {
absolutePath,
payload: {
markdown,
sourceFile: optionValue(args, ["--source-file", "--source-path", "--source"]) ?? absolutePath,
tags: splitList(optionValues(args, ["--tag", "--tags"])),
},
};
}
function importDiary(args: string[]): unknown {
const { absolutePath, payload } = diaryImportPayload(args);
const result = unwrapProxyResponse(decisionProxy("/api/diary/import", { method: "POST", body: payload }));
const body = typeof result === "object" && result !== null && !Array.isArray(result) && "body" in result
? (result as { body?: unknown }).body
: result;
return { file: absolutePath, result: { ...(typeof result === "object" && result !== null && !Array.isArray(result) && "upstream" in result ? { upstream: (result as { upstream?: unknown }).upstream } : {}), body: summarizeDiaryImportResult(body, args.includes("--include-entries")) } };
}
async function importDiaryAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
const { absolutePath, payload } = diaryImportPayload(args);
const result = unwrapProxyResponse(await decisionProxyAsync(fetcher, "/api/diary/import", { method: "POST", body: payload }));
const body = typeof result === "object" && result !== null && !Array.isArray(result) && "body" in result
? (result as { body?: unknown }).body
: result;
return { file: absolutePath, result: { ...(typeof result === "object" && result !== null && !Array.isArray(result) && "upstream" in result ? { upstream: (result as { upstream?: unknown }).upstream } : {}), body: summarizeDiaryImportResult(body, args.includes("--include-entries")) } };
}
function listRecords(args: string[]): unknown {
const params = new URLSearchParams();
const type = optionValue(args, ["--type"]);
@@ -175,6 +233,49 @@ async function listRecordsAsync(args: string[], fetcher: (path: string, init?: {
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/records${query ? `?${query}` : ""}`));
}
function diaryQuery(args: string[]): string {
const params = new URLSearchParams();
const month = optionValue(args, ["--month"]);
const from = optionValue(args, ["--from"]);
const to = optionValue(args, ["--to"]);
const sourceFile = optionValue(args, ["--source-file", "--source-path", "--source"]);
const limit = optionValue(args, ["--limit"]);
if (month !== undefined) params.set("month", month);
if (from !== undefined) params.set("from", from);
if (to !== undefined) params.set("to", to);
if (sourceFile !== undefined) params.set("sourceFile", sourceFile);
if (limit !== undefined) params.set("limit", limit);
if (args.includes("--include-body")) params.set("includeBody", "true");
const query = params.toString();
return query ? `?${query}` : "";
}
function listDiary(args: string[]): unknown {
return unwrapProxyResponse(decisionProxy(`/api/diary/entries${diaryQuery(args)}`));
}
async function listDiaryAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/diary/entries${diaryQuery(args)}`));
}
function listDiaryMonths(): unknown {
return unwrapProxyResponse(decisionProxy("/api/diary/months"));
}
async function listDiaryMonthsAsync(fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
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)}`));
}
async function showDiaryAsync(key: string | undefined, fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): 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)}`));
}
function showRecord(id: string | undefined): unknown {
if (!id) throw new Error("decision show requires record id");
return unwrapProxyResponse(decisionProxy(`/api/records/${encodeURIComponent(id)}`));
@@ -187,6 +288,14 @@ async function showRecordAsync(id: string | undefined, fetcher: (path: string, i
export async function runDecisionCenterCommand(_config: UniDeskConfig, args: string[]): Promise<unknown> {
const [action = "list", id] = args;
if (action === "diary") {
const [diaryAction = "list", diaryId] = args.slice(1);
if (diaryAction === "import") return importDiary(args.slice(2));
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 (action === "upload") return uploadMeeting(args.slice(1));
if (action === "list") return listRecords(args.slice(1));
if (action === "show") return showRecord(id);
@@ -200,6 +309,14 @@ export async function runDecisionCenterCommandAsync(
fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>,
): Promise<unknown> {
const [action = "list", id] = args;
if (action === "diary") {
const [diaryAction = "list", diaryId] = args.slice(1);
if (diaryAction === "import") return importDiaryAsync(args.slice(2), fetcher);
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 (action === "upload") return uploadMeetingAsync(args.slice(1), fetcher);
if (action === "list") return listRecordsAsync(args.slice(1), fetcher);
if (action === "show") return showRecordAsync(id, fetcher);
File diff suppressed because one or more lines are too long
+80 -1
View File
@@ -6672,6 +6672,80 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
gap: 10px;
align-items: start;
}
.decision-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.decision-tabs button {
min-height: 34px;
padding: 0 12px;
border: 1px solid var(--line-soft);
color: var(--muted);
background: var(--panel-3);
cursor: pointer;
}
.decision-tabs button.active,
.decision-tabs button:hover {
border-color: rgba(78, 183, 168, 0.5);
color: var(--text);
background: rgba(78, 183, 168, 0.12);
}
.diary-layout {
display: grid;
grid-template-columns: minmax(320px, 0.42fr) minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.diary-entry-list {
display: grid;
gap: 8px;
max-height: calc(100vh - 250px);
overflow: auto;
padding-right: 2px;
}
.diary-entry-card {
display: grid;
gap: 8px;
min-width: 0;
padding: 9px;
border: 1px solid var(--line-soft);
background: rgba(0,0,0,0.14);
}
.diary-entry-card.selected {
border-color: rgba(78, 183, 168, 0.55);
background: rgba(78, 183, 168, 0.09);
}
.diary-entry-main {
display: grid;
gap: 4px;
width: 100%;
padding: 0;
border: 0;
color: inherit;
background: transparent;
text-align: left;
cursor: pointer;
}
.diary-date {
color: var(--accent-2);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.diary-entry-main strong {
min-width: 0;
overflow-wrap: anywhere;
font-size: 14px;
}
.diary-entry-main span:last-child {
color: var(--muted);
line-height: 1.45;
}
.diary-markdown {
max-height: calc(100vh - 250px);
}
.decision-card-list {
display: grid;
gap: 8px;
@@ -6761,7 +6835,12 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.mdtodo-layout,
.decision-hero,
.decision-filter-bar,
.decision-default-grid {
.decision-default-grid,
.diary-layout {
grid-template-columns: 1fr;
}
.diary-entry-list,
.diary-markdown {
max-height: none;
}
}
+104 -1
View File
@@ -164,10 +164,37 @@ function filteredQuery(filters: AnyRecord): string {
return params.toString();
}
function diaryQuery(filters: AnyRecord): string {
const params = new URLSearchParams();
if (filters.month !== "all") params.set("month", filters.month);
if (filters.from.trim()) params.set("from", filters.from.trim());
if (filters.to.trim()) params.set("to", filters.to.trim());
params.set("limit", "180");
return params.toString();
}
function DiaryEntryCard({ entry, selected, onSelect, onRaw }: AnyRecord) {
return h("article", { className: `diary-entry-card ${selected ? "selected" : ""}`, "data-testid": `diary-entry-${String(entry.date || entry.id || "").replace(/[^A-Za-z0-9_-]+/g, "-")}` },
h("button", { type: "button", className: "diary-entry-main", onClick: () => onSelect(entry) },
h("span", { className: "diary-date" }, entry.date || "--"),
h("strong", null, entry.title || entry.markdownPath || "--"),
h("span", null, shortText(entry.summary || entry.body, 180)),
),
h("div", { className: "decision-record-foot" },
h("code", null, entry.markdownPath || "--"),
h("span", null, fmtRecordTime(entry.updatedAt || entry.importedAt)),
h(RawButton, { title: `Diary ${entry.date || entry.id}`, data: entry, onOpen: onRaw }),
),
);
}
export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
const service = microservices.find((item: any) => item.id === "decision-center") || null;
const [state, setState] = useState({ loading: false, error: "", health: null, records: [], refreshedAt: null });
const [filters, setFilters] = useState({ type: "all", status: "all", level: "all", linkedGoalId: "" });
const [activeView, setActiveView] = useState("records");
const [diaryState, setDiaryState] = useState({ loading: false, error: "", entries: [], months: [], selected: null, refreshedAt: null });
const [diaryFilters, setDiaryFilters] = useState({ month: "all", from: "", to: "" });
async function load(): Promise<void> {
if (!service) return;
@@ -175,6 +202,7 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
try {
const query = filteredQuery(filters);
const records = await requestJson(decisionApi(apiBaseUrl, `/api/records?${query}`));
const months = await requestJson(decisionApi(apiBaseUrl, "/api/diary/months"));
let health: any = state.health;
let healthError = "";
try {
@@ -183,11 +211,42 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
healthError = errorMessage(err, "Decision Center health 检查失败");
}
setState({ loading: false, error: healthError, health, records: Array.isArray(records.records) ? records.records : [], refreshedAt: new Date() });
setDiaryState((prev: any) => ({ ...prev, months: Array.isArray(months.months) ? months.months : [] }));
} catch (err) {
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Decision Center 加载失败") }));
}
}
async function loadDiary(): Promise<void> {
if (!service) return;
setDiaryState((prev: any) => ({ ...prev, loading: true, error: "" }));
try {
const entriesResponse = await requestJson(decisionApi(apiBaseUrl, `/api/diary/entries?${diaryQuery(diaryFilters)}`));
const monthsResponse = await requestJson(decisionApi(apiBaseUrl, "/api/diary/months"));
const entries = Array.isArray(entriesResponse.entries) ? entriesResponse.entries : [];
setDiaryState((prev: any) => ({
loading: false,
error: "",
entries,
months: Array.isArray(monthsResponse.months) ? monthsResponse.months : prev.months,
selected: prev.selected && entries.some((entry: any) => entry.id === prev.selected?.id) ? prev.selected : entries[0] || null,
refreshedAt: new Date(),
}));
} catch (err) {
setDiaryState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "日记加载失败") }));
}
}
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)}`));
setDiaryState((prev: any) => ({ ...prev, selected: response.entry || entry }));
} catch (err) {
setDiaryState((prev: any) => ({ ...prev, error: errorMessage(err, "日记正文加载失败") }));
}
}
useEffect(() => {
load();
}, [service?.id, service?.runtime?.providerStatus]);
@@ -197,6 +256,12 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
return () => clearTimeout(timer);
}, [filters.type, filters.status, filters.level, filters.linkedGoalId]);
useEffect(() => {
if (activeView !== "diary") return;
const timer = setTimeout(() => void loadDiary(), 120);
return () => clearTimeout(timer);
}, [activeView, diaryFilters.month, diaryFilters.from, diaryFilters.to, service?.id, service?.runtime?.providerStatus]);
if (!service) return h(EmptyState, { title: "Decision Center 未登记", text: "请在 config.json 的 microservices 中登记用户服务 id=decision-center" });
const runtime = microserviceRuntime(service);
@@ -207,6 +272,10 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
const blockers = records.filter((record: any) => record.type === "blocker" && ["P0", "P1"].includes(record.level) && record.status !== "done").slice(0, 8);
const parked = records.filter((record: any) => record.status === "parked").slice(0, 8);
const recentMeetings = records.filter((record: any) => record.type === "meeting" || record.type === "decision").slice(0, 12);
const diaryEntries = Array.isArray(diaryState.entries) ? diaryState.entries : [];
const diaryMonths = Array.isArray(diaryState.months) ? diaryState.months : [];
const selectedDiary = diaryState.selected;
const diaryCount = Number(state.health?.diaryEntryCount ?? 0);
return h("div", { className: "decision-center-page", "data-testid": "decision-center-page" },
h(Panel, { title: "Decision Center", eyebrow: "Authority Records", loading: state.loading, actions: h("div", { className: "inline-actions" },
@@ -216,9 +285,9 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
h("div", { className: "decision-hero" },
h("div", { className: "metric-grid" },
h(MetricCard, { label: "记录数", value: records.length, hint: `PostgreSQL / ${state.health?.storage || "postgres"}`, tone: "ok" }),
h(MetricCard, { label: "日记", value: diaryCount, hint: "按月 Markdown", tone: diaryCount > 0 ? "ok" : "warn" }),
h(MetricCard, { label: "G0/G1 目标", value: goals.length, hint: "active authority goals", tone: "ok" }),
h(MetricCard, { label: "P0/P1 Blocker", value: blockers.length, hint: "requires decision", tone: blockers.length > 0 ? "warn" : "ok" }),
h(MetricCard, { label: "Parked", value: parked.length, hint: "停放事项", tone: parked.length > 0 ? "warn" : "ok" }),
),
h("div", { className: "microservice-ref-card" },
h("span", null, "Runtime"),
@@ -229,6 +298,39 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
),
h(UniDeskErrorBanner, { error: state.error, title: "Decision Center 请求失败" }),
),
h("div", { className: "decision-tabs", role: "tablist" },
h("button", { type: "button", className: activeView === "records" ? "active" : "", onClick: () => setActiveView("records") }, "权威记录"),
h("button", { type: "button", className: activeView === "diary" ? "active" : "", onClick: () => setActiveView("diary") }, "工作日记"),
),
activeView === "diary" ? h(React.Fragment, null,
h(Panel, { title: "日记筛选", eyebrow: "Markdown by Month", loading: diaryState.loading, actions: h("div", { className: "inline-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: () => void loadDiary(), disabled: diaryState.loading }, diaryState.loading ? "刷新中" : "刷新"),
h(RawButton, { title: "Diary Months", data: diaryMonths, onOpen: onRaw, testId: "raw-decision-center-diary-months" }),
) },
h("div", { className: "decision-filter-bar" },
h("label", null, "月份", h("select", { value: diaryFilters.month, onChange: (event: any) => setDiaryFilters((prev: any) => ({ ...prev, month: event.target.value })) },
h("option", { value: "all" }, "all"),
diaryMonths.map((month: any) => h("option", { key: month.month, value: month.month }, `${month.month} (${month.count})`)),
)),
h("label", null, "开始日期", h("input", { type: "date", value: diaryFilters.from, onChange: (event: any) => setDiaryFilters((prev: any) => ({ ...prev, from: event.target.value })) })),
h("label", null, "结束日期", h("input", { type: "date", value: diaryFilters.to, onChange: (event: any) => setDiaryFilters((prev: any) => ({ ...prev, to: event.target.value })) })),
h("label", null, "存储", h("input", { value: "PostgreSQL / YYYY-MM/YYYY-MM-DD.md", readOnly: true })),
),
h(UniDeskErrorBanner, { error: diaryState.error, title: "日记请求失败" }),
),
h("div", { className: "diary-layout" },
h(Panel, { title: "按天条目", eyebrow: `${diaryEntries.length} Entries`, loading: diaryState.loading },
diaryEntries.length === 0
? h(EmptyState, { title: "暂无日记", text: "使用 CLI 导入按日期标题拆分的工作日志 Markdown。" })
: h("div", { className: "diary-entry-list" }, diaryEntries.map((entry: any) => h(DiaryEntryCard, { key: entry.id, entry, selected: selectedDiary?.id === entry.id, onSelect: selectDiaryEntry, onRaw }))),
),
h(Panel, { title: selectedDiary?.title || "日记正文", eyebrow: selectedDiary?.markdownPath || "Daily Markdown", actions: selectedDiary ? h(RawButton, { title: `Diary ${selectedDiary.date}`, data: selectedDiary, onOpen: onRaw, testId: "raw-decision-center-diary-selected" }) : null },
selectedDiary
? h(MarkdownBody, { markdown: selectedDiary.body || selectedDiary.summary || "", className: "decision-markdown diary-markdown" })
: h(EmptyState, { title: "未选择日记", text: "从左侧选择一天查看 Markdown 正文。" }),
),
),
) : h(React.Fragment, null,
h(Panel, { title: "筛选", eyebrow: "Type / Status / Level" },
h("div", { className: "decision-filter-bar", "data-testid": "decision-center-filters" },
h("label", null, "类型", h("select", { value: filters.type, onChange: (event: any) => setFilters((prev: any) => ({ ...prev, type: event.target.value })) }, selectOptions(recordTypes))),
@@ -258,5 +360,6 @@ export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }
h(Panel, { title: "全部记录", eyebrow: `${records.length} Records`, actions: state.refreshedAt ? h("span", { className: "muted" }, `刷新 ${fmtClock(state.refreshedAt)}`) : null },
h(RecordTable, { records, onRaw }),
),
),
);
}
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import postgres from "postgres";
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../shared/src/rotating-jsonl";
@@ -52,6 +52,50 @@ interface DecisionRecord extends JsonRecord {
updatedAt: string;
}
interface DiaryEntryRow {
id: string;
entry_date: Date | string;
month: string;
title: string;
body: string;
source_file: string;
markdown_path: string;
tags: JsonValue;
content_hash: string;
created_at: Date | string;
updated_at: Date | string;
imported_at: Date | string;
}
interface DiaryEntry extends JsonRecord {
id: string;
date: string;
month: string;
title: string;
summary: string;
body: string;
sourceFile: string;
markdownPath: string;
tags: string[];
contentHash: string;
createdAt: string;
updatedAt: string;
importedAt: string;
}
interface DiaryDraft {
date: string;
month: string;
title: string;
body: string;
markdownPath: string;
}
interface DiaryUpsertRow extends DiaryEntryRow {
inserted: boolean;
changed: boolean;
}
class HttpError extends Error {
readonly status: number;
readonly detail: JsonRecord;
@@ -271,7 +315,7 @@ function titleFromMarkdown(markdown: string, fallback: string): string {
async function readJsonBody(req: Request): Promise<Record<string, unknown>> {
const text = await req.text();
if (text.length > 1_000_000) throw new HttpError(413, "request body is too large", { maxBytes: 1_000_000 });
if (text.length > 5_000_000) throw new HttpError(413, "request body is too large", { maxBytes: 5_000_000 });
if (!text.trim()) return {};
try {
const parsed = JSON.parse(text) as unknown;
@@ -310,6 +354,27 @@ async function ensureSchema(): Promise<void> {
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 TABLE IF NOT EXISTS decision_center_diary_entries (
id TEXT PRIMARY KEY,
entry_date DATE NOT NULL,
month TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
source_file TEXT NOT NULL DEFAULT '',
markdown_path TEXT NOT NULL,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
content_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
imported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT decision_center_diary_entries_month_check CHECK (month ~ '^[0-9]{4}-[0-9]{2}$'),
CONSTRAINT decision_center_diary_entries_path_check CHECK (markdown_path ~ '^[0-9]{4}-[0-9]{2}/[0-9]{4}-[0-9]{2}-[0-9]{2}[.]md$'),
CONSTRAINT decision_center_diary_entries_source_date_unique UNIQUE (source_file, entry_date)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_diary_entries_month_date ON decision_center_diary_entries(month DESC, entry_date DESC)`;
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_diary_entries_updated ON decision_center_diary_entries(updated_at DESC)`;
}, { retryRead: true });
}
@@ -335,11 +400,16 @@ async function refreshDatabaseHealth(): Promise<void> {
if (databaseHealthRefreshInFlight) return;
databaseHealthRefreshInFlight = true;
try {
const rows = await withDatabaseRecovery("refresh_database_health", () => sql<{ count: string | number }[]>`SELECT count(*) AS count FROM decision_center_records`, { retryRead: true });
const rows = await withDatabaseRecovery("refresh_database_health", () => sql<{ record_count: string | number; diary_entry_count: string | number }[]>`
SELECT
(SELECT count(*) FROM decision_center_records) AS record_count,
(SELECT count(*) FROM decision_center_diary_entries) AS diary_entry_count
`, { retryRead: true });
const checkedAt = new Date().toISOString();
databaseHealth = {
ok: true,
recordCount: Number(rows[0]?.count ?? 0),
recordCount: Number(rows[0]?.record_count ?? 0),
diaryEntryCount: Number(rows[0]?.diary_entry_count ?? 0),
checkedAt,
lastOkAt: checkedAt,
degraded: false,
@@ -353,6 +423,7 @@ async function refreshDatabaseHealth(): Promise<void> {
databaseHealth = {
ok: withinGrace,
recordCount: Number(databaseHealth.recordCount ?? 0),
diaryEntryCount: Number(databaseHealth.diaryEntryCount ?? 0),
checkedAt,
lastOkAt,
degraded: withinGrace,
@@ -382,6 +453,7 @@ function health(): JsonRecord {
storage: "postgres",
schemaReady,
recordCount: Number(databaseHealth.recordCount ?? 0),
diaryEntryCount: Number(databaseHealth.diaryEntryCount ?? 0),
database: databaseHealth,
deploy: deployInfo(),
};
@@ -528,6 +600,258 @@ async function deleteRecord(id: string): Promise<JsonRecord> {
return { ok: true, deleted: recordFromRow(rows[0]!) };
}
function dateOnly(value: Date | string | null | undefined): string {
if (value === null || value === undefined) return "";
if (typeof value === "string") {
const match = value.match(/^(\d{4}-\d{2}-\d{2})/u);
if (match !== null) return match[1] ?? "";
}
return iso(value).slice(0, 10);
}
function pad2(value: number): string {
return String(value).padStart(2, "0");
}
function normalizedDate(year: number, month: number, day: number): string | null {
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
if (year < 1970 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return null;
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null;
return `${year}-${pad2(month)}-${pad2(day)}`;
}
function parseDiaryHeadingDate(line: string): string | null {
const trimmed = line.trim();
if (!trimmed) return null;
const heading = trimmed.match(/^#{1,6}\s+(.+)$/u);
const candidate = heading === null ? trimmed : heading[1]?.trim() ?? "";
if (heading === null && !/^\d{4}/u.test(candidate)) return null;
const match = candidate.match(/^(\d{4})\s*(?:|[-/.])\s*(\d{1,2})\s*(?:|[-/.])\s*(\d{1,2})\s*(?:)?(?:\s*(?:[(].*[)]|[]|[]|[-:].*))?$/u);
if (match === null) return null;
return normalizedDate(Number(match[1]), Number(match[2]), Number(match[3]));
}
function contentHash(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function diaryEntryFromRow(row: DiaryEntryRow, options: { includeBody?: boolean } = {}): DiaryEntry {
const includeBody = options.includeBody !== false;
const body = row.body || "";
return {
id: row.id,
date: dateOnly(row.entry_date),
month: row.month,
title: row.title,
summary: summaryFromBody(body),
body: includeBody ? body : "",
sourceFile: row.source_file,
markdownPath: row.markdown_path,
tags: Array.isArray(row.tags) ? row.tags.map(String) : [],
contentHash: row.content_hash,
createdAt: iso(row.created_at),
updatedAt: iso(row.updated_at),
importedAt: iso(row.imported_at),
};
}
function splitDiaryMarkdown(markdown: string): DiaryDraft[] {
const normalized = markdown.replace(/\r\n?/gu, "\n");
const lines = normalized.split("\n");
const entries = new Map<string, DiaryDraft>();
let currentDate = "";
let currentLines: string[] = [];
const flush = (): void => {
if (!currentDate) return;
const body = currentLines.join("\n").trim();
if (!body) return;
const month = currentDate.slice(0, 7);
const existing = entries.get(currentDate);
const title = titleFromMarkdown(body, `${currentDate} 工作日志`);
const draft: DiaryDraft = {
date: currentDate,
month,
title,
body: existing === undefined ? body : `${existing.body}\n\n---\n\n${body}`,
markdownPath: `${month}/${currentDate}.md`,
};
entries.set(currentDate, draft);
};
for (const line of lines) {
const date = parseDiaryHeadingDate(line);
if (date !== null) {
flush();
currentDate = date;
currentLines = [line];
continue;
}
if (currentDate) currentLines.push(line);
}
flush();
return [...entries.values()].sort((left, right) => left.date.localeCompare(right.date));
}
function validDateFilter(value: string, field: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/u.test(value)) throw new HttpError(400, `${field} must use YYYY-MM-DD`, { value });
const [year, month, day] = value.split("-").map(Number);
if (normalizedDate(year ?? 0, month ?? 0, day ?? 0) !== value) throw new HttpError(400, `${field} is not a valid date`, { value });
return value;
}
function validMonthFilter(value: string): string {
if (!/^\d{4}-\d{2}$/u.test(value)) throw new HttpError(400, "month must use YYYY-MM", { value });
const [year, month] = value.split("-").map(Number);
if (normalizedDate(year ?? 0, month ?? 0, 1)?.slice(0, 7) !== value) throw new HttpError(400, "month is not valid", { value });
return value;
}
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);
const rows = await withDatabaseRecovery("upsert_diary_entry", () => sql<DiaryUpsertRow[]>`
WITH existing AS (
SELECT content_hash
FROM decision_center_diary_entries
WHERE source_file = ${sourceFile} AND entry_date = ${draft.date}::date
),
upsert AS (
INSERT INTO decision_center_diary_entries (
id, entry_date, month, title, body, source_file, markdown_path, tags, content_hash
) VALUES (
${`diary_${randomUUID()}`},
${draft.date}::date,
${draft.month},
${draft.title},
${draft.body},
${sourceFile},
${draft.markdownPath},
${sql.json(tags)},
${hash}
)
ON CONFLICT (source_file, entry_date) DO UPDATE
SET
month = EXCLUDED.month,
title = EXCLUDED.title,
body = EXCLUDED.body,
markdown_path = EXCLUDED.markdown_path,
tags = EXCLUDED.tags,
content_hash = EXCLUDED.content_hash,
imported_at = now(),
updated_at = CASE
WHEN decision_center_diary_entries.content_hash IS DISTINCT FROM EXCLUDED.content_hash THEN now()
ELSE decision_center_diary_entries.updated_at
END
RETURNING *
)
SELECT
upsert.*,
NOT EXISTS(SELECT 1 FROM existing) AS inserted,
EXISTS(SELECT 1 FROM existing WHERE existing.content_hash IS DISTINCT FROM upsert.content_hash) AS changed
FROM upsert
`);
const row = rows[0];
if (row === undefined) throw new HttpError(500, "diary upsert returned no row", { date: draft.date });
return {
entry: diaryEntryFromRow(row, { includeBody: false }),
action: row.inserted ? "created" : row.changed ? "updated" : "unchanged",
};
}
async function importDiary(input: Record<string, unknown>): Promise<JsonRecord> {
const markdown = asString(input.markdown ?? input.body);
if (!markdown) throw new HttpError(400, "markdown is required");
if (markdown.length > 4_500_000) throw new HttpError(400, "markdown must be at most 4500000 characters");
const sourceFile = asString(input.sourceFile ?? input.sourcePath ?? input.source) || "inline";
const tags = asStringArray(input.tags, "tags");
const drafts = splitDiaryMarkdown(markdown);
if (drafts.length === 0) {
throw new HttpError(400, "no dated diary sections found", {
expectedHeading: "# 2025年2月12日 or # 2025-02-12",
});
}
const entries: DiaryEntry[] = [];
let createdCount = 0;
let updatedCount = 0;
let unchangedCount = 0;
for (const draft of drafts) {
const result = await upsertDiaryEntry(draft, sourceFile, tags);
entries.push(result.entry);
if (result.action === "created") createdCount += 1;
else if (result.action === "updated") updatedCount += 1;
else unchangedCount += 1;
}
log("info", "diary_imported", { sourceFile, days: drafts.length, createdCount, updatedCount, unchangedCount });
return {
ok: true,
sourceFile,
totalDays: drafts.length,
createdCount,
updatedCount,
unchangedCount,
firstDate: entries[0]?.date ?? "",
lastDate: entries[entries.length - 1]?.date ?? "",
pathPattern: "YYYY-MM/YYYY-MM-DD.md",
entries,
};
}
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 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 *
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)
AND (${to || null}::date IS NULL OR entry_date <= ${to || null}::date)
AND (${sourceFile || null}::text IS NULL OR source_file = ${sourceFile || null})
ORDER BY entry_date DESC, updated_at DESC
LIMIT ${limit}
`, { retryRead: true });
return rows.map((row) => diaryEntryFromRow(row, { includeBody }));
}
async function listDiaryMonths(): Promise<JsonRecord[]> {
const rows = await withDatabaseRecovery("list_diary_months", () => sql<Array<{ month: string; count: string | number; first_date: Date | string; last_date: Date | string; updated_at: Date | string }>>`
SELECT month, count(*) AS count, min(entry_date) AS first_date, max(entry_date) AS last_date, max(updated_at) AS updated_at
FROM decision_center_diary_entries
GROUP BY month
ORDER BY month DESC
`, { retryRead: true });
return rows.map((row) => ({
month: row.month,
count: Number(row.count),
firstDate: dateOnly(row.first_date),
lastDate: dateOnly(row.last_date),
updatedAt: iso(row.updated_at),
}));
}
async function getDiaryEntry(key: string): Promise<DiaryEntry> {
const date = /^\d{4}-\d{2}-\d{2}$/u.test(key) ? validDateFilter(key, "date") : "";
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})
ORDER BY imported_at DESC
LIMIT 1
`, { retryRead: true });
if (rows.length === 0) throw new HttpError(404, "diary entry not found", { key });
return diaryEntryFromRow(rows[0]!);
}
async function route(req: Request): Promise<Response> {
const url = new URL(req.url);
const method = req.method.toUpperCase();
@@ -542,6 +866,16 @@ async function route(req: Request): Promise<Response> {
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/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) });
if (url.pathname === "/api/diary/import" && method === "POST") return jsonResponse(await importDiary(await readJsonBody(req)), 201);
const diaryMatch = url.pathname.match(/^\/api\/diary\/entries\/([^/]+)$/u);
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) });
throw new HttpError(405, "diary entry route supports GET", { method });
}
const recordMatch = url.pathname.match(/^\/api\/records\/([^/]+)$/u);
if (recordMatch !== null) {
const id = decodeURIComponent(recordMatch[1] ?? "");