feat: add decision requirement and diary upsert APIs
This commit is contained in:
@@ -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 rollout,dev desired-state smoke 使用 `ci run-dev-e2e`;规则见 `docs/reference/deploy.md`、`docs/reference/dev-environment.md` 和 `docs/reference/dev-ci-runner.md`。
|
||||
- `dev-env validate [--manifest path] [--kubectl-dry-run]` 离线校验 D601 `unidesk-dev` namespace、dev PostgreSQL 底座和 dev workload manifest。默认检查 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-foundation.k8s.yaml`;也可显式校验 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-core.k8s.yaml` 或 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-code-queue.k8s.yaml`。所有 namespaced 对象必须只落到 `unidesk-dev`,foundation manifest 必须包含 `postgres-dev` StatefulSet/Service、dev secret/config、迁移 Job 和 DB URL guard,core manifest 必须包含 `backend-core-dev`/`frontend-dev` Deployment/Service,Code Queue dev manifest 必须包含 `code-queue-scheduler-dev`、`code-queue-read-dev`、`code-queue-write-dev` 和 dev provider egress proxy。加 `--kubectl-dry-run` 时额外执行 `kubectl apply --dry-run=client --validate=false -f <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 自测,不能视为交付完成。
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user