feat: productize decision center backend APIs
This commit is contained in:
@@ -37,8 +37,9 @@ 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 body,OA 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 microservice health/diagnostics/proxy code-agent-sandbox`:验证独立 Code Agent Sandbox 的 health、只读 diagnostics、trace 和 adapter/mode/credential boundary 契约,规则见 `docs/reference/code-agent-sandbox.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 decision upload/list/show/health`:通过 backend-core 用户服务代理上传会议记录/需求/决议 Markdown、列出记录和查看详情;Decision Center 运行在 D601 k3s,规则见 `docs/reference/microservices.md`。
|
||||
- `bun scripts/cli.ts decision requirement list/create/show/update/upsert`:管理 Decision Center 产品化需求记录,类型覆盖外部目标、内部目标、决议、阻塞、债务和实验,规则见 `docs/reference/microservices.md`。
|
||||
- `bun scripts/cli.ts decision diary import/list/history/months/today/show/edit/upsert`:把带日期标题的工作日志 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>]`:按根目录 `deploy.json` 或 `origin/master:deploy.json#environments.<env>` 的服务 repo 和 commit 期望状态校验或更新用户服务;`--env dev` 开放 D601 `backend-core` rollout、reviewed registry artifact consumers 和 D601 direct consumer validation,`findjob`/`pipeline` 是 D601 direct pull-only 样板,`met-nonlinear` dry-run blocked,`k3sctl-adapter` supervisor-only,`code-queue` prod unsupported,规则见 `docs/reference/deploy.md` 与 `docs/reference/dev-environment.md`。
|
||||
- `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]` / `dev-env prewarm-images`:离线校验 D601 `unidesk-dev` 生产隔离护栏和 dev workload manifests,或把开发底座基础镜像预热到 D601 原生 k3s containerd,规则见 `docs/reference/deploy.md` 与 `docs/reference/microservices.md`。
|
||||
- `bun scripts/cli.ts artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service`:管理 D601 host-managed CNCF Distribution registry,并通过短生命周期 relay 或 D601 pull/import 做 commit-pinned pull-only artifact CD;`deploy-backend-core` 是 deprecated 兼容名,`findjob`/`pipeline` 支持 D601 direct dev/prod,`met-nonlinear` 和 `k3sctl-adapter` 只给受限计划路径,`code-queue` 只支持 dev,规则见 `docs/reference/artifact-registry.md`。
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
|
||||
## T23B D601 Decision Center User Service
|
||||
|
||||
阅读 `AGENTS.md` 和 `docs/reference/microservices.md`,运行 `bun scripts/cli.ts microservice list`,确认 `decision-center` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/unidesk`、k3s/k8s `k3s://unidesk/decision-center:4277` 逻辑服务映射、`deployment.mode=k3sctl-managed`、`runtime.orchestrator=k3sctl` 且无业务直连容器摘要。随后运行 `bun scripts/cli.ts microservice health decision-center`,确认 `service=decision-center`、`storage=postgres`、`schemaReady=true` 且 health 中包含 `diaryEntryCount`;准备一份临时 Markdown 会议记录,运行 `bun scripts/cli.ts decision upload <markdown-file> --title <title> --type meeting --level G1 --status active --evidence <url>`,再运行 `bun scripts/cli.ts decision list` 和 `bun scripts/cli.ts decision show <id>`,确认 CLI 只通过 backend-core 用户服务代理访问,返回结构化 JSON 且能看到刚上传的记录。再准备一份包含 `# 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`、`bun scripts/cli.ts decision diary upsert 2026-05-03 --body-file <markdown-file>` 和 `bun scripts/cli.ts decision diary edit 2026-05-03 --body-file <markdown-file> --tag e2e`,确认日记按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径拆分、写入 PostgreSQL、重复导入幂等且历史日记可按日期编辑。用户服务上线前的 dev 自动门禁使用 focused 集合:`bun scripts/cli.ts e2e run --only microservice:decision-center-record-crud,microservice:decision-center-diary-lifecycle,frontend:decision-center-visible,frontend:decision-center-demand-management-visible,frontend:decision-center-diary-visible`,该门禁只验证用户服务行为,不测试 CI/CD 自举,也不部署 prod。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Decision Center`,确认页面显示 G0/G1 目标、P0/P1 Blocker、停放事项、最近会议/决议、需求管理工作区、需求录入编辑器、全部记录表和工作日记编辑台;日记页支持“今天”自动填入真实日期、保存当天 Markdown、编辑历史 Markdown 并再次读取一致。prod 人工验收必须在 CD 拉取已发布 artifact 后执行,逐项确认 health、records、diary editor、frontend 页面、无公网业务端口、live commit / artifact 信息;页面不得提供聊天/LLM 会话窗口,默认不得裸 JSON,完整 JSON 只能通过 `查看原始JSON` 打开。
|
||||
阅读 `AGENTS.md` 和 `docs/reference/microservices.md`,运行 `bun scripts/cli.ts microservice list`,确认 `decision-center` 显示为 `providerId=D601`、`public=false`、`frontendOnly=true`、仓库 URL `https://github.com/pikasTech/unidesk`、k3s/k8s `k3s://unidesk/decision-center:4277` 逻辑服务映射、`deployment.mode=k3sctl-managed`、`runtime.orchestrator=k3sctl` 且无业务直连容器摘要。随后运行 `bun scripts/cli.ts microservice health decision-center`,确认 `service=decision-center`、`storage=postgres`、`schemaReady=true` 且 health 中包含 `diaryEntryCount`;准备一份临时 Markdown 会议记录,运行 `bun scripts/cli.ts decision upload <markdown-file> --title <title> --type meeting --level G1 --status active --evidence <url>`,再运行 `bun scripts/cli.ts decision list` 和 `bun scripts/cli.ts decision show <id>`,确认 CLI 只通过 backend-core 用户服务代理访问,返回结构化 JSON 且能看到刚上传的记录。准备一份临时需求 Markdown,运行 `bun scripts/cli.ts decision requirement create --title <title> --body-file <markdown-file> --type external_goal --priority G0 --status active --source external --issue '#22'`,再运行 `bun scripts/cli.ts decision requirement list --type external_goal --issue '#22'`、`bun scripts/cli.ts decision requirement show <id>` 和 `bun scripts/cli.ts decision requirement update <id> --title <updated> --body-file <markdown-file> --type internal_goal --linked-goal-id <externalId>`,确认需求记录支持 create/list/show/update,字段包含类型、标题、内容、状态、优先级、来源、关联 issue/task、创建/更新时间,且外部目标可用 `linkedGoalId` 拆解到内部目标或阻塞项。再准备一份包含 `# 2026年5月1日` 和 `# 2026年5月2日` 的临时工作日志 Markdown,运行 `bun scripts/cli.ts decision diary import <markdown-file> --source-file test-work-log.md --tag e2e`、`bun scripts/cli.ts decision diary months`、`bun scripts/cli.ts decision diary list --month 2026-05`、`bun scripts/cli.ts decision diary history --month 2026-05`、`bun scripts/cli.ts decision diary show 2026-05-01`、`bun scripts/cli.ts decision diary today`、`bun scripts/cli.ts decision diary today --edit --body-file <today-file>`、`bun scripts/cli.ts decision diary upsert 2026-05-03 --body-file <markdown-file>` 和 `bun scripts/cli.ts decision diary edit 2026-05-03 --body-file <markdown-file> --tag e2e`,确认日记按 `YYYY-MM/YYYY-MM-DD.md` 虚拟路径拆分、写入 PostgreSQL、当天日记按真实日期自动创建、重复导入幂等且历史日记可按日期编辑。用户服务上线前的 dev 自动门禁使用 focused 集合:`bun scripts/cli.ts e2e run --only microservice:decision-center-record-crud,microservice:decision-center-diary-lifecycle,frontend:decision-center-visible,frontend:decision-center-demand-management-visible,frontend:decision-center-diary-visible`,该门禁只验证用户服务行为,不测试 CI/CD 自举,也不部署 prod。最后登录公网 frontend `http://74.48.78.17:18081/`,进入 `用户服务 / Decision Center`,确认页面显示 G0/G1 目标、P0/P1 Blocker、停放事项、最近会议/决议、需求管理工作区、需求录入编辑器、全部记录表和工作日记编辑台;日记页支持“今天”自动填入真实日期、保存当天 Markdown、编辑历史 Markdown 并再次读取一致。prod 人工验收必须在 CD 拉取已发布 artifact 后执行,逐项确认 health、records、diary editor、frontend 页面、无公网业务端口、live commit / artifact 信息;页面不得提供聊天/LLM 会话窗口,默认不得裸 JSON,完整 JSON 只能通过 `查看原始JSON` 打开。
|
||||
|
||||
## T24 MET Nonlinear D601 GPU User Service
|
||||
|
||||
|
||||
@@ -252,12 +252,12 @@ D601 上必须显式使用原生 k3s kubeconfig:`KUBECONFIG=/etc/rancher/k3s/k
|
||||
- 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`。dev 环境使用 `src/components/microservices/k3sctl-adapter/k3s/dev/unidesk-dev-decision-center.k8s.yaml` 和 `unidesk-dev-decision-center.k3s.json`,服务名为 `decision-center-dev`。主 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` 记录应承接必须偿还的技术/流程债。任何新需求都应先写成可验证的外部收益,再分解为这些内部记录,而不是先发散成内部审美或架构偏好。需求管理 API 复用 `decision_center_records`,`/api/requirements` 只是在同一模型上排除 `meeting` 并提供需求语义的 list/upsert 别名,不引入第二套需求表。
|
||||
- 记录数据模型:记录类型为 `meeting|decision|goal|external_goal|internal_goal|blocker|debt|experiment`,等级/优先级为 `G0|G1|G2|G3|P0|P1|P2|P3|none`,状态为 `active|blocked|parked|done`,字段包含 `title`、Markdown `summary/body`、`priority`/`level`、`source`、`issueId`、`linkedGoalId`、`tags`、`evidenceLinks`、`sourceSession`、`taskId`、`commitId`、`createdAt` 和 `updatedAt`。
|
||||
- 需求管理:Decision Center 里的 `external_goal` 记录应承接外部需求或外部目标,`internal_goal`/`goal` 记录应承接拆解后的内部目标,`decision` 记录应承接需求分解后的取舍,`blocker` 记录应承接当前阻塞,`experiment` 记录应承接验证性工作,`debt` 记录应承接必须偿还的技术/流程债。任何新需求都应先写成可验证的外部收益,再分解为这些内部记录,而不是先发散成内部审美或架构偏好。需求管理 API 复用 `decision_center_records`,`/api/requirements` 在同一模型上排除 `meeting`,并提供 list/create/show/update/upsert 的需求语义入口,不引入第二套需求表。
|
||||
- 日记数据模型:基于 Markdown 的日记系统以“每天一篇”为最小单元,导入器识别 `# YYYY年M月D日`、`# YYYY-MM-DD` 或 `# YYYY/M/D` 标题并拆分为 `entry_date`、`month`、Markdown `body`、`source_file`、`content_hash` 与虚拟 `markdown_path=YYYY-MM/YYYY-MM-DD.md`;同一 `source_file + entry_date` 使用 upsert,内容未变时保持幂等。
|
||||
- 日记编辑:工作日记必须支持按真实日期创建当天条目,并支持按日期回看和编辑历史条目;`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>` 读取确认。
|
||||
- 日记编辑:工作日记必须支持按真实日期创建当天条目,并支持按日期回看和编辑历史条目;`GET /api/diary/today` 按服务当前真实日期自动创建或返回当天条目,`PUT /api/diary/today` 保存当天 Markdown,`PUT /api/diary/entries/:idOrDate` 允许安全更新 `body`/`markdown`、`title`、`tags` 和 `sourceFile`,按 `YYYY-MM-DD` key 且不存在时创建当天或历史条目,按非日期 id 时只编辑既有条目。数据库仍是唯一权威,前端只是编辑入口和展示入口。
|
||||
- API:只允许 `/health`、`/live`、`/logs` 和 `/api/` 前缀;允许 `GET`、`HEAD`、`POST`、`PUT` 和 `DELETE`。业务 API 包含 `GET /api/records`、`POST /api/records`、`GET|PUT|DELETE /api/records/:id`、`GET|POST|PUT /api/requirements`、`GET|PUT /api/requirements/:id`、`POST /api/meetings/import`、`POST /api/diary/import`、`GET /api/diary/entries`、`GET /api/diary/history`、`GET|POST|PUT /api/diary/today`、`GET|PUT /api/diary/entries/:idOrDate` 和 `GET /api/diary/months`,错误必须返回结构化 JSON,便于 CLI 与 frontend 诊断。
|
||||
- CLI:`bun scripts/cli.ts decision upload <markdown-file>`、`decision list`、`decision show <id>`、`decision requirement list/create/show/update/upsert`、`decision diary import/list/history/months/today/show/edit/upsert` 和 `decision health` 只能通过 backend-core 用户服务代理访问 Decision Center,不得直连 D601 Service、NodePort 或 provider-gateway `microservice.http`。日记编辑验收应使用 `decision diary today` 确认真实日期自动创建当天条目,使用 `decision diary today --edit --body-file <file>` 保存当天 Markdown,使用 `decision diary upsert <YYYY-MM-DD> --body-file <file>` 创建或更新历史日记,再用 `decision diary show <YYYY-MM-DD>` 读取确认。
|
||||
- Dev/prod CD:Decision Center 的 dev/prod rollout 都必须走 D601 registry artifact consumer,验证同一个 commit-pinned artifact contract,证明 live `deploy.commit` 与 `deploy.requestedCommit` 一致,再通过 Kubernetes API service proxy 验证健康;不得回退到维护通道直连或 NodePort/hostPort。
|
||||
- UniDesk 前端:`用户服务 / Decision Center` React 页面展示权威记录筛选、当前 G0/G1 目标、P0/P1 blocker、停放事项、最近会议/决议和工作日记;它还应成为需求管理入口,让外部目标、内部拆解和每日工作记录在同一页面中可追溯。日记视图按月份筛选并展示每天 Markdown 正文,未来应支持当天自动创建与历史编辑。默认不得展示裸 JSON,完整原始数据只能通过 `查看原始JSON` 打开。
|
||||
|
||||
|
||||
+129
-28
@@ -3,14 +3,14 @@ import { resolve } from "node:path";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
import { coreInternalFetch } from "./microservices";
|
||||
|
||||
type DecisionRecordType = "meeting" | "decision" | "goal" | "blocker" | "debt" | "experiment";
|
||||
type DecisionRecordType = "meeting" | "decision" | "goal" | "external_goal" | "internal_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 typeValues = new Set<DecisionRecordType>(["meeting", "decision", "goal", "external_goal", "internal_goal", "blocker", "debt", "experiment"]);
|
||||
const requirementTypeValues = new Set<RequirementRecordType>(["decision", "goal", "external_goal", "internal_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"]);
|
||||
|
||||
@@ -129,12 +129,14 @@ function uploadMeeting(args: string[]): unknown {
|
||||
markdown,
|
||||
title: optionValue(args, ["--title"]),
|
||||
type,
|
||||
level: parseLevel(optionValue(args, ["--level"]), "none"),
|
||||
level: parseLevel(optionValue(args, ["--level", "--priority"]), "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"])),
|
||||
source: optionValue(args, ["--source"]),
|
||||
sourceSession: optionValue(args, ["--source-session", "--sourceSession"]),
|
||||
issueId: optionValue(args, ["--issue", "--issue-id", "--issueId", "--linked-issue", "--linked-task"]),
|
||||
taskId: optionValue(args, ["--task-id", "--taskId"]),
|
||||
commitId: optionValue(args, ["--commit-id", "--commitId"]),
|
||||
};
|
||||
@@ -152,12 +154,14 @@ async function uploadMeetingAsync(args: string[], fetcher: (path: string, init?:
|
||||
markdown,
|
||||
title: optionValue(args, ["--title"]),
|
||||
type,
|
||||
level: parseLevel(optionValue(args, ["--level"]), "none"),
|
||||
level: parseLevel(optionValue(args, ["--level", "--priority"]), "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"])),
|
||||
source: optionValue(args, ["--source"]),
|
||||
sourceSession: optionValue(args, ["--source-session", "--sourceSession"]),
|
||||
issueId: optionValue(args, ["--issue", "--issue-id", "--issueId", "--linked-issue", "--linked-task"]),
|
||||
taskId: optionValue(args, ["--task-id", "--taskId"]),
|
||||
commitId: optionValue(args, ["--commit-id", "--commitId"]),
|
||||
};
|
||||
@@ -238,8 +242,10 @@ function recordQuery(args: string[], options: { requirementOnly?: boolean } = {}
|
||||
const params = new URLSearchParams();
|
||||
const type = optionValue(args, ["--type"]);
|
||||
const status = optionValue(args, ["--status"]);
|
||||
const level = optionValue(args, ["--level"]);
|
||||
const level = optionValue(args, ["--level", "--priority"]);
|
||||
const linkedGoalId = optionValue(args, ["--linked-goal-id", "--linkedGoalId"]);
|
||||
const source = optionValue(args, ["--source"]);
|
||||
const issueId = optionValue(args, ["--issue", "--issue-id", "--issueId", "--linked-issue", "--linked-task"]);
|
||||
const tag = optionValue(args, ["--tag", "--tags"]);
|
||||
const queryText = optionValue(args, ["--query", "--q"]);
|
||||
const limit = optionValue(args, ["--limit"]);
|
||||
@@ -247,6 +253,8 @@ function recordQuery(args: string[], options: { requirementOnly?: boolean } = {}
|
||||
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 (source !== undefined) params.set("source", source);
|
||||
if (issueId !== undefined) params.set("issueId", issueId);
|
||||
if (tag !== undefined) params.set("tag", tag);
|
||||
if (queryText !== undefined) params.set("q", queryText);
|
||||
if (limit !== undefined) params.set("limit", limit);
|
||||
@@ -278,6 +286,14 @@ async function listDiaryAsync(args: string[], fetcher: (path: string, init?: { m
|
||||
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/diary/entries${diaryQuery(args)}`));
|
||||
}
|
||||
|
||||
function diaryHistory(args: string[]): unknown {
|
||||
return unwrapProxyResponse(decisionProxy(`/api/diary/history${diaryQuery(args)}`));
|
||||
}
|
||||
|
||||
async function diaryHistoryAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
||||
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/diary/history${diaryQuery(args)}`));
|
||||
}
|
||||
|
||||
function listDiaryMonths(): unknown {
|
||||
return unwrapProxyResponse(decisionProxy("/api/diary/months"));
|
||||
}
|
||||
@@ -296,6 +312,14 @@ async function showDiaryAsync(key: string | undefined, fetcher: (path: string, i
|
||||
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/diary/entries/${encodeURIComponent(key)}`));
|
||||
}
|
||||
|
||||
function todayDiary(): unknown {
|
||||
return unwrapProxyResponse(decisionProxy("/api/diary/today"));
|
||||
}
|
||||
|
||||
async function todayDiaryAsync(fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
||||
return unwrapProxyResponse(await decisionProxyAsync(fetcher, "/api/diary/today"));
|
||||
}
|
||||
|
||||
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`);
|
||||
@@ -320,6 +344,30 @@ async function editDiaryAsync(args: string[], fetcher: (path: string, init?: { m
|
||||
return { key, bodySource, result: unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/diary/entries/${encodeURIComponent(key)}`, { method: "PUT", body: payload })) };
|
||||
}
|
||||
|
||||
function editTodayDiary(args: string[]): unknown {
|
||||
const { body, bodySource } = bodyFromArgs(args, "decision diary today --edit");
|
||||
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 { bodySource, result: unwrapProxyResponse(decisionProxy("/api/diary/today", { method: "PUT", body: payload })) };
|
||||
}
|
||||
|
||||
async function editTodayDiaryAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
||||
const { body, bodySource } = bodyFromArgs(args, "decision diary today --edit");
|
||||
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 { bodySource, result: unwrapProxyResponse(await decisionProxyAsync(fetcher, "/api/diary/today", { 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)}`));
|
||||
@@ -330,26 +378,49 @@ 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> {
|
||||
function requirementPayload(args: string[], command: string, options: { partial?: boolean; includeId?: boolean } = {}): 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"]),
|
||||
};
|
||||
const payload: Record<string, unknown> = {};
|
||||
const id = optionValue(args, ["--id"]);
|
||||
const type = optionValue(args, ["--type"]);
|
||||
const level = optionValue(args, ["--level", "--priority"]);
|
||||
const status = optionValue(args, ["--status"]);
|
||||
const linkedGoalId = optionValue(args, ["--linked-goal-id", "--linkedGoalId"]);
|
||||
const source = optionValue(args, ["--source"]);
|
||||
const sourceSession = optionValue(args, ["--source-session", "--sourceSession"]);
|
||||
const issueId = optionValue(args, ["--issue", "--issue-id", "--issueId", "--linked-issue", "--linked-task"]);
|
||||
const taskId = optionValue(args, ["--task-id", "--taskId"]);
|
||||
const commitId = optionValue(args, ["--commit-id", "--commitId"]);
|
||||
const tags = splitList(optionValues(args, ["--tag", "--tags"]));
|
||||
const evidenceLinks = splitList(optionValues(args, ["--evidence", "--evidence-link", "--evidenceLinks"]));
|
||||
if (options.includeId !== false && id !== undefined) payload.id = id;
|
||||
if (title !== undefined) payload.title = title;
|
||||
if (body !== undefined) payload.body = body;
|
||||
if (type !== undefined || options.partial !== true) payload.type = parseRequirementType(type, "external_goal");
|
||||
if (level !== undefined || options.partial !== true) payload.level = parseLevel(level, "none");
|
||||
if (status !== undefined || options.partial !== true) payload.status = parseStatus(status, "active");
|
||||
if (linkedGoalId !== undefined) payload.linkedGoalId = linkedGoalId;
|
||||
if (tags.length > 0 || options.partial !== true) payload.tags = tags;
|
||||
if (evidenceLinks.length > 0 || options.partial !== true) payload.evidenceLinks = evidenceLinks;
|
||||
if (source !== undefined) payload.source = source;
|
||||
if (sourceSession !== undefined) payload.sourceSession = sourceSession;
|
||||
if (issueId !== undefined) payload.issueId = issueId;
|
||||
if (taskId !== undefined) payload.taskId = taskId;
|
||||
if (commitId !== undefined) payload.commitId = commitId;
|
||||
if (Object.keys(payload).length === 0) throw new Error(`${command} requires at least one field to write`);
|
||||
if (options.partial !== true && title === undefined && body === undefined) throw new Error(`${command} requires --title or --body-file/--body`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function createRequirement(args: string[]): unknown {
|
||||
return unwrapProxyResponse(decisionProxy("/api/requirements", { method: "POST", body: requirementPayload(args, "decision requirement create") }));
|
||||
}
|
||||
|
||||
async function createRequirementAsync(args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
||||
return unwrapProxyResponse(await decisionProxyAsync(fetcher, "/api/requirements", { method: "POST", body: requirementPayload(args, "decision requirement create") }));
|
||||
}
|
||||
|
||||
function upsertRequirement(args: string[]): unknown {
|
||||
@@ -360,25 +431,50 @@ async function upsertRequirementAsync(args: string[], fetcher: (path: string, in
|
||||
return unwrapProxyResponse(await decisionProxyAsync(fetcher, "/api/requirements", { method: "PUT", body: requirementPayload(args, "decision requirement upsert") }));
|
||||
}
|
||||
|
||||
function showRequirement(id: string | undefined): unknown {
|
||||
if (!id) throw new Error("decision requirement show requires record id");
|
||||
return unwrapProxyResponse(decisionProxy(`/api/requirements/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
async function showRequirementAsync(id: string | undefined, fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
||||
if (!id) throw new Error("decision requirement show requires record id");
|
||||
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/requirements/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
function updateRequirement(id: string | undefined, args: string[]): unknown {
|
||||
if (!id) throw new Error("decision requirement update requires record id");
|
||||
return unwrapProxyResponse(decisionProxy(`/api/requirements/${encodeURIComponent(id)}`, { method: "PUT", body: requirementPayload(args, "decision requirement update", { partial: true, includeId: false }) }));
|
||||
}
|
||||
|
||||
async function updateRequirementAsync(id: string | undefined, args: string[], fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise<unknown>): Promise<unknown> {
|
||||
if (!id) throw new Error("decision requirement update requires record id");
|
||||
return unwrapProxyResponse(await decisionProxyAsync(fetcher, `/api/requirements/${encodeURIComponent(id)}`, { method: "PUT", body: requirementPayload(args, "decision requirement update", { partial: true, includeId: false }) }));
|
||||
}
|
||||
|
||||
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 === "history") return diaryHistory(args.slice(2));
|
||||
if (diaryAction === "months") return listDiaryMonths();
|
||||
if (diaryAction === "today") return args.includes("--edit") ? editTodayDiary(args.slice(2).filter((arg) => arg !== "--edit")) : todayDiary();
|
||||
if (diaryAction === "show") return showDiary(diaryId);
|
||||
if (diaryAction === "edit" || diaryAction === "upsert") return editDiary(args.slice(2));
|
||||
throw new Error("decision diary command must be one of: import, list, months, show, edit, upsert");
|
||||
throw new Error("decision diary command must be one of: import, list, history, months, today, show, edit, upsert");
|
||||
}
|
||||
if (action === "requirement" || action === "requirements") {
|
||||
const [requirementAction = "list"] = args.slice(1);
|
||||
const [requirementAction = "list", requirementId] = args.slice(1);
|
||||
if (requirementAction === "list") {
|
||||
const query = recordQuery(args.slice(2), { requirementOnly: true });
|
||||
return unwrapProxyResponse(decisionProxy(`/api/requirements${query ? `?${query}` : ""}`));
|
||||
}
|
||||
if (requirementAction === "create") return createRequirement(args.slice(2));
|
||||
if (requirementAction === "upsert") return upsertRequirement(args.slice(2));
|
||||
throw new Error("decision requirement command must be one of: list, upsert");
|
||||
if (requirementAction === "show") return showRequirement(requirementId);
|
||||
if (requirementAction === "update") return updateRequirement(requirementId, args.slice(3));
|
||||
throw new Error("decision requirement command must be one of: list, create, show, update, upsert");
|
||||
}
|
||||
if (action === "upload") return uploadMeeting(args.slice(1));
|
||||
if (action === "list") return listRecords(args.slice(1));
|
||||
@@ -397,19 +493,24 @@ export async function runDecisionCenterCommandAsync(
|
||||
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 === "history") return diaryHistoryAsync(args.slice(2), fetcher);
|
||||
if (diaryAction === "months") return listDiaryMonthsAsync(fetcher);
|
||||
if (diaryAction === "today") return args.includes("--edit") ? editTodayDiaryAsync(args.slice(2).filter((arg) => arg !== "--edit"), fetcher) : todayDiaryAsync(fetcher);
|
||||
if (diaryAction === "show") return showDiaryAsync(diaryId, fetcher);
|
||||
if (diaryAction === "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");
|
||||
throw new Error("decision diary command must be one of: import, list, history, months, today, show, edit, upsert");
|
||||
}
|
||||
if (action === "requirement" || action === "requirements") {
|
||||
const [requirementAction = "list"] = args.slice(1);
|
||||
const [requirementAction = "list", requirementId] = 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 === "create") return createRequirementAsync(args.slice(2), fetcher);
|
||||
if (requirementAction === "upsert") return upsertRequirementAsync(args.slice(2), fetcher);
|
||||
throw new Error("decision requirement command must be one of: list, upsert");
|
||||
if (requirementAction === "show") return showRequirementAsync(requirementId, fetcher);
|
||||
if (requirementAction === "update") return updateRequirementAsync(requirementId, args.slice(3), fetcher);
|
||||
throw new Error("decision requirement command must be one of: list, create, show, update, upsert");
|
||||
}
|
||||
if (action === "upload") return uploadMeetingAsync(args.slice(1), fetcher);
|
||||
if (action === "list") return listRecordsAsync(args.slice(1), fetcher);
|
||||
|
||||
+7
-5
@@ -27,14 +27,16 @@ export function rootHelp(): unknown {
|
||||
{ command: "microservice proxy <id> <path> [--method GET|POST|PUT|PATCH|DELETE] [--body-json JSON|--body-file path|--body-stdin] [--raw] [--max-body-bytes N]", description: "Access a private user-service backend path through the same frontend-only proxy used by WebUI; JSON request bodies are supported for controlled write/debug endpoints." },
|
||||
{ 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 upload <markdown-file> [--title text] [--type meeting|decision|goal|external_goal|internal_goal|blocker|debt|experiment] [--level|--priority G0|G1|G2|G3|P0|P1|P2|P3|none] [--status active|blocked|parked|done] [--source text] [--issue id]", description: "Upload a meeting note or decision/requirement 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 history [--month YYYY-MM] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--limit N] [--include-body]", description: "Read diary history through the productized history API alias." },
|
||||
{ command: "decision diary today [--edit --body-file path] [--title text] [--tag tag]", description: "Get or create today's diary entry using the service's real current date; --edit saves today's Markdown." },
|
||||
{ command: "decision diary months", description: "List available Decision Center diary months with day counts." },
|
||||
{ command: "decision diary show <YYYY-MM-DD|id>", description: "Show one daily diary Markdown entry." },
|
||||
{ command: "decision diary edit|upsert <YYYY-MM-DD|id> --body-file path [--title text] [--source-file path] [--tag tag]", description: "Create or edit one daily diary entry through PUT /api/diary/entries/:idOrDate via backend-core proxy." },
|
||||
{ command: "decision list [--type ...] [--status ...] [--level ...] [--linked-goal-id id] [--limit N]", description: "List Decision Center records through the user-service proxy." },
|
||||
{ command: "decision requirement list|upsert [--id id] [--title text] [--body-file path] [--type goal|decision|blocker|debt|experiment]", description: "Manage requirement records over the existing records model, excluding meeting records." },
|
||||
{ command: "decision list [--type ...] [--status ...] [--level|--priority ...] [--source text] [--issue id] [--linked-goal-id id] [--limit N]", description: "List Decision Center records through the user-service proxy." },
|
||||
{ command: "decision requirement list|create|show|update|upsert [id] [--title text] [--body-file path] [--type external_goal|internal_goal|goal|decision|blocker|debt|experiment] [--source text] [--issue id]", description: "Manage productized requirement records over the PostgreSQL records model, excluding meeting records." },
|
||||
{ command: "decision show <id>", description: "Show one Decision Center record." },
|
||||
{ command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from a repo+commit manifest; --env reads origin/master:deploy.json environments and applies supported dev target-side rollouts or reviewed D601 registry artifact consumers. code-queue artifact consumption is dev-only." },
|
||||
{ command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." },
|
||||
@@ -155,8 +157,8 @@ function decisionHelp(): unknown {
|
||||
"bun scripts/cli.ts decision list [--type ...] [--status ...] [--level ...] [--limit N]",
|
||||
"bun scripts/cli.ts decision show <id>",
|
||||
"bun scripts/cli.ts decision health",
|
||||
"bun scripts/cli.ts decision diary import|list|months|show|edit|upsert ...",
|
||||
"bun scripts/cli.ts decision requirement list|upsert ...",
|
||||
"bun scripts/cli.ts decision diary import|list|history|months|today|show|edit|upsert ...",
|
||||
"bun scripts/cli.ts decision requirement list|create|show|update|upsert ...",
|
||||
],
|
||||
description: "Operate Decision Center through the registered user-service proxy.",
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../s
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
type JsonRecord = Record<string, JsonValue>;
|
||||
|
||||
type DecisionRecordType = "meeting" | "decision" | "goal" | "blocker" | "debt" | "experiment";
|
||||
type DecisionRecordType = "meeting" | "decision" | "goal" | "external_goal" | "internal_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";
|
||||
@@ -28,7 +28,9 @@ interface DecisionRecordRow {
|
||||
linked_goal_id: string | null;
|
||||
tags: JsonValue;
|
||||
evidence_links: JsonValue;
|
||||
source: string;
|
||||
source_session: string;
|
||||
issue_id: string;
|
||||
task_id: string;
|
||||
commit_id: string;
|
||||
created_at: Date | string;
|
||||
@@ -43,10 +45,13 @@ interface DecisionRecord extends JsonRecord {
|
||||
title: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
priority: DecisionRecordLevel;
|
||||
linkedGoalId: string | null;
|
||||
tags: string[];
|
||||
evidenceLinks: string[];
|
||||
source: string;
|
||||
sourceSession: string;
|
||||
issueId: string;
|
||||
taskId: string;
|
||||
commitId: string;
|
||||
createdAt: string;
|
||||
@@ -109,8 +114,8 @@ 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 recordTypes = new Set<DecisionRecordType>(["meeting", "decision", "goal", "external_goal", "internal_goal", "blocker", "debt", "experiment"]);
|
||||
const requirementRecordTypes = new Set<RequirementRecordType>(["decision", "goal", "external_goal", "internal_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();
|
||||
@@ -286,10 +291,13 @@ function recordFromRow(row: DecisionRecordRow): DecisionRecord {
|
||||
title: row.title,
|
||||
summary: summaryFromBody(body),
|
||||
body,
|
||||
priority: row.level,
|
||||
linkedGoalId: row.linked_goal_id,
|
||||
tags: Array.isArray(row.tags) ? row.tags.map(String) : [],
|
||||
evidenceLinks: Array.isArray(row.evidence_links) ? row.evidence_links.map(String) : [],
|
||||
source: row.source,
|
||||
sourceSession: row.source_session,
|
||||
issueId: row.issue_id,
|
||||
taskId: row.task_id,
|
||||
commitId: row.commit_id,
|
||||
createdAt: iso(row.created_at),
|
||||
@@ -317,6 +325,10 @@ function parseLevel(value: unknown, fallback: DecisionRecordLevel): DecisionReco
|
||||
return raw as DecisionRecordLevel;
|
||||
}
|
||||
|
||||
function parsePriority(input: Record<string, unknown>, fallback: DecisionRecordLevel): DecisionRecordLevel {
|
||||
return parseLevel(input.priority ?? input.level, fallback);
|
||||
}
|
||||
|
||||
function parseStatus(value: unknown, fallback: DecisionRecordStatus): DecisionRecordStatus {
|
||||
const raw = asString(value) || fallback;
|
||||
if (!recordStatuses.has(raw as DecisionRecordStatus)) throw new HttpError(400, "unsupported record status", { value: raw, allowed: [...recordStatuses] });
|
||||
@@ -364,19 +376,30 @@ async function ensureSchema(): Promise<void> {
|
||||
linked_goal_id TEXT,
|
||||
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
evidence_links JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
source_session TEXT NOT NULL DEFAULT '',
|
||||
issue_id TEXT NOT NULL DEFAULT '',
|
||||
task_id TEXT NOT NULL DEFAULT '',
|
||||
commit_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT decision_center_records_type_check CHECK (type IN ('meeting', 'decision', 'goal', 'blocker', 'debt', 'experiment')),
|
||||
CONSTRAINT decision_center_records_type_check CHECK (type IN ('meeting', 'decision', 'goal', 'external_goal', 'internal_goal', 'blocker', 'debt', 'experiment')),
|
||||
CONSTRAINT decision_center_records_level_check CHECK (level IN ('G0', 'G1', 'G2', 'G3', 'P0', 'P1', 'P2', 'P3', 'none')),
|
||||
CONSTRAINT decision_center_records_status_check CHECK (status IN ('active', 'blocked', 'parked', 'done'))
|
||||
)
|
||||
`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS issue_id TEXT NOT NULL DEFAULT ''`;
|
||||
await sql`ALTER TABLE decision_center_records DROP CONSTRAINT IF EXISTS decision_center_records_type_check`;
|
||||
await sql`
|
||||
ALTER TABLE decision_center_records
|
||||
ADD CONSTRAINT decision_center_records_type_check
|
||||
CHECK (type IN ('meeting', 'decision', 'goal', 'external_goal', 'internal_goal', 'blocker', 'debt', 'experiment'))
|
||||
`;
|
||||
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 INDEX IF NOT EXISTS idx_decision_center_records_issue_id ON decision_center_records(issue_id)`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS decision_center_diary_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -489,18 +512,20 @@ async function createRecord(input: Record<string, unknown>): Promise<DecisionRec
|
||||
const id = asString(input.id) || `dc_${randomUUID()}`;
|
||||
const rows = await withDatabaseRecovery("create_record", () => sql<DecisionRecordRow[]>`
|
||||
INSERT INTO decision_center_records (
|
||||
id, type, level, status, title, body, linked_goal_id, tags, evidence_links, source_session, task_id, commit_id
|
||||
id, type, level, status, title, body, linked_goal_id, tags, evidence_links, source, source_session, issue_id, task_id, commit_id
|
||||
) VALUES (
|
||||
${id},
|
||||
${parseRecordType(input.type, "meeting")},
|
||||
${parseLevel(input.level, "none")},
|
||||
${parsePriority(input, "none")},
|
||||
${parseStatus(input.status, "active")},
|
||||
${title},
|
||||
${body},
|
||||
${asString(input.linkedGoalId) || null},
|
||||
${sql.json(asStringArray(input.tags, "tags"))},
|
||||
${sql.json(asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks"))},
|
||||
${asString(input.source)},
|
||||
${asString(input.sourceSession)},
|
||||
${asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask ?? input.taskId)},
|
||||
${asString(input.taskId)},
|
||||
${asString(input.commitId)}
|
||||
)
|
||||
@@ -519,14 +544,16 @@ async function updateRecord(id: string, input: Record<string, unknown>): Promise
|
||||
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},
|
||||
level = ${"level" in input || "priority" in input ? parsePriority(input, existing.level) : existing.level},
|
||||
status = ${"status" in input ? parseStatus(input.status, existing.status) : existing.status},
|
||||
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)},
|
||||
source = ${"source" in input ? asString(input.source) : existing.source},
|
||||
source_session = ${"sourceSession" in input ? asString(input.sourceSession) : existing.sourceSession},
|
||||
issue_id = ${"issueId" in input || "issue" in input || "linkedIssue" in input || "linkedTask" in input ? asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask) : existing.issueId},
|
||||
task_id = ${"taskId" in input ? asString(input.taskId) : existing.taskId},
|
||||
commit_id = ${"commitId" in input ? asString(input.commitId) : existing.commitId},
|
||||
updated_at = now()
|
||||
@@ -557,7 +584,7 @@ async function upsertRequirementRecord(input: Record<string, unknown>): Promise<
|
||||
const record = await createRecord({
|
||||
...input,
|
||||
id: id || undefined,
|
||||
type: parseRequirementRecordType(input.type, "goal"),
|
||||
type: parseRequirementRecordType(input.type, "external_goal"),
|
||||
});
|
||||
log("info", "requirement_created", { id: record.id, type: record.type, level: record.level, status: record.status });
|
||||
return { ok: true, action: "created", record };
|
||||
@@ -574,6 +601,8 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
const status = asString(url.searchParams.get("status"));
|
||||
const level = asString(url.searchParams.get("level"));
|
||||
const linkedGoalId = asString(url.searchParams.get("linkedGoalId"));
|
||||
const source = asString(url.searchParams.get("source"));
|
||||
const issueId = asString(url.searchParams.get("issueId") ?? url.searchParams.get("issue"));
|
||||
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";
|
||||
@@ -582,16 +611,20 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
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 (source.length > 240) throw new HttpError(400, "source filter must be at most 240 characters");
|
||||
if (issueId.length > 240) throw new HttpError(400, "issueId filter must be at most 240 characters");
|
||||
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 (${requirementOnly}::boolean IS FALSE OR type IN ('decision', 'goal', 'external_goal', 'internal_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 (${source || null}::text IS NULL OR source = ${source || null})
|
||||
AND (${issueId || null}::text IS NULL OR issue_id = ${issueId || 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
|
||||
@@ -612,6 +645,33 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
|
||||
return rows.map(recordFromRow);
|
||||
}
|
||||
|
||||
async function getRequirementRecord(id: string): Promise<DecisionRecord> {
|
||||
const record = await getRecord(id);
|
||||
if (!requirementRecordTypes.has(record.type as RequirementRecordType)) {
|
||||
throw new HttpError(404, "requirement record not found", { id, type: record.type });
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async function createRequirementRecord(input: Record<string, unknown>): Promise<DecisionRecord> {
|
||||
const record = await createRecord({
|
||||
...input,
|
||||
type: parseRequirementRecordType(input.type, "external_goal"),
|
||||
});
|
||||
log("info", "requirement_created", { id: record.id, type: record.type, level: record.level, status: record.status });
|
||||
return record;
|
||||
}
|
||||
|
||||
async function updateRequirementRecord(id: string, input: Record<string, unknown>): Promise<DecisionRecord> {
|
||||
const existing = await getRequirementRecord(id);
|
||||
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 record;
|
||||
}
|
||||
|
||||
function normalizeDecisionDrafts(value: unknown): Array<Record<string, unknown>> {
|
||||
if (value === undefined || value === null) return [];
|
||||
if (!Array.isArray(value)) throw new HttpError(400, "decisions must be an array");
|
||||
@@ -627,14 +687,16 @@ async function importMeeting(input: Record<string, unknown>): Promise<JsonRecord
|
||||
if (!markdown) throw new HttpError(400, "markdown is required");
|
||||
const base: Record<string, unknown> = {
|
||||
type: "meeting",
|
||||
level: parseLevel(input.level, "none"),
|
||||
level: parsePriority(input, "none"),
|
||||
status: parseStatus(input.status, "active"),
|
||||
title: asString(input.title) || titleFromMarkdown(markdown, "Imported meeting"),
|
||||
body: markdown,
|
||||
linkedGoalId: asString(input.linkedGoalId) || null,
|
||||
tags: asStringArray(input.tags, "tags"),
|
||||
evidenceLinks: asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks"),
|
||||
source: asString(input.source),
|
||||
sourceSession: asString(input.sourceSession),
|
||||
issueId: asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask ?? input.taskId),
|
||||
taskId: asString(input.taskId),
|
||||
commitId: asString(input.commitId),
|
||||
};
|
||||
@@ -769,6 +831,15 @@ function validMonthFilter(value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function realTodayUtc(): string {
|
||||
const now = new Date();
|
||||
return `${now.getUTCFullYear()}-${pad2(now.getUTCMonth() + 1)}-${pad2(now.getUTCDate())}`;
|
||||
}
|
||||
|
||||
function defaultDiaryBody(date: string): string {
|
||||
return `# ${date}\n\n`;
|
||||
}
|
||||
|
||||
function markdownPathForDate(date: string): string {
|
||||
const month = date.slice(0, 7);
|
||||
return `${month}/${date}.md`;
|
||||
@@ -951,7 +1022,7 @@ async function upsertDiaryEntryByKey(key: string, input: Record<string, unknown>
|
||||
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 ?? "";
|
||||
const body = bodyProvided ? asText(input.body ?? input.markdown) : existing?.body ?? defaultDiaryBody(date);
|
||||
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) : [];
|
||||
@@ -1007,6 +1078,24 @@ async function upsertDiaryEntryByKey(key: string, input: Record<string, unknown>
|
||||
return { ok: true, action, entry: diaryEntryFromRow(row) };
|
||||
}
|
||||
|
||||
async function getOrCreateTodayDiaryEntry(input: Record<string, unknown> = {}): Promise<JsonRecord> {
|
||||
const today = realTodayUtc();
|
||||
try {
|
||||
const entry = await getDiaryEntry(today);
|
||||
return { ok: true, action: "existing", today, entry };
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError) || error.status !== 404) throw error;
|
||||
}
|
||||
const body = "body" in input || "markdown" in input ? asText(input.body ?? input.markdown) : defaultDiaryBody(today);
|
||||
const result = await upsertDiaryEntryByKey(today, {
|
||||
...input,
|
||||
body,
|
||||
sourceFile: input.sourceFile ?? input.sourcePath ?? input.source ?? "today",
|
||||
});
|
||||
const entry = asRecord(result.entry) as DiaryEntry;
|
||||
return { ok: true, action: "created", today, entry };
|
||||
}
|
||||
|
||||
async function route(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const method = req.method.toUpperCase();
|
||||
@@ -1021,14 +1110,39 @@ 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/requirements" && method === "GET") return jsonResponse({ ok: true, requirements: await listRecords(url, { requirementOnly: true }) });
|
||||
if (url.pathname === "/api/requirements" && (method === "POST" || method === "PUT")) {
|
||||
if (url.pathname === "/api/requirements" && method === "POST") {
|
||||
const input = await readJsonBody(req);
|
||||
if (asString(input.id)) {
|
||||
const result = await upsertRequirementRecord(input);
|
||||
return jsonResponse(result, result.action === "created" ? 201 : 200);
|
||||
}
|
||||
return jsonResponse({ ok: true, record: await createRequirementRecord(input) }, 201);
|
||||
}
|
||||
if (url.pathname === "/api/requirements" && 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/history" && method === "GET") return jsonResponse({ ok: true, entries: await listDiaryEntries(url) });
|
||||
if (url.pathname === "/api/diary/today") {
|
||||
if (method === "GET") return jsonResponse(await getOrCreateTodayDiaryEntry());
|
||||
if (method === "PUT" || method === "POST") {
|
||||
const result = await upsertDiaryEntryByKey(realTodayUtc(), await readJsonBody(req));
|
||||
return jsonResponse(result, result.action === "created" ? 201 : 200);
|
||||
}
|
||||
throw new HttpError(405, "diary today route supports GET, POST, and PUT", { method });
|
||||
}
|
||||
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 requirementMatch = url.pathname.match(/^\/api\/requirements\/([^/]+)$/u);
|
||||
if (requirementMatch !== null) {
|
||||
const id = decodeURIComponent(requirementMatch[1] ?? "");
|
||||
if (!id) throw new HttpError(400, "requirement id is required");
|
||||
if (method === "GET") return jsonResponse({ ok: true, record: await getRequirementRecord(id) });
|
||||
if (method === "PUT") return jsonResponse({ ok: true, record: await updateRequirementRecord(id, await readJsonBody(req)) });
|
||||
throw new HttpError(405, "requirement route supports GET and PUT", { method });
|
||||
}
|
||||
const diaryMatch = url.pathname.match(/^\/api\/diary\/entries\/([^/]+)$/u);
|
||||
if (diaryMatch !== null) {
|
||||
const key = decodeURIComponent(diaryMatch[1] ?? "");
|
||||
|
||||
Reference in New Issue
Block a user