feat: add decision requirement and diary upsert APIs

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