From c4ee6bcc90c8ab82485bddcfab465cc047243437 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Jul 2026 20:13:40 +0200 Subject: [PATCH] fix: route decision CLI to NC01 k8s --- .agents/skills/unidesk-decision/SKILL.md | 6 +- docs/reference/nc01.md | 14 +++ scripts/src/decision-center.ts | 131 ++++++++++++++++++----- 3 files changed, 121 insertions(+), 30 deletions(-) diff --git a/.agents/skills/unidesk-decision/SKILL.md b/.agents/skills/unidesk-decision/SKILL.md index e604b82e..b743ea20 100644 --- a/.agents/skills/unidesk-decision/SKILL.md +++ b/.agents/skills/unidesk-decision/SKILL.md @@ -5,7 +5,7 @@ description: UniDesk Decision Center CLI — 会议记录/决议管理、工作 # UniDesk Decision Center CLI -通过 `bun scripts/cli.ts decision ...` 管理 D601 k3s Decision Center 的会议记录、决议和工作日记。 +通过 `bun scripts/cli.ts decision ...` 管理 NC01 k8s Decision Center 的会议记录、决议和工作日记。当前生产运行面是 NC01 `unidesk/decision-center`,持久存储为 GitHub repo `pikasTech/decision-center-data`,PostgreSQL 只作为运行索引/cache。 **固定入口前缀**: `cd /root/unidesk && bun scripts/cli.ts decision ...` @@ -24,6 +24,8 @@ bun scripts/cli.ts decision health `list` 默认只返回摘要,不包含完整 Markdown body。需要正文时加 `--include-body`。 +大列表会返回 bounded 摘要和 `totalReturned`,避免跨 NC01 SSH/k8s 入口时被输出预算截断;需要单条完整正文时优先用 `show` 或 diary `show` 精确读取。 + ### 文书字段 ```bash @@ -66,6 +68,8 @@ bun scripts/cli.ts decision diary import 将带 `# YYYY年M月D日`、`# YYYY-MM-DD` 或 `# YYYY/M/D` 标题的工作日志拆成每天一篇写入 PostgreSQL。 +写入后服务会同步 GitHub repo 存储;迁移或修复后用 `decision health` 和服务侧 `/api/storage/verify` 同时确认 cache 与 repo index 计数一致。 + ### 列表/历史/查看 ```bash diff --git a/docs/reference/nc01.md b/docs/reference/nc01.md index b5456806..939327de 100644 --- a/docs/reference/nc01.md +++ b/docs/reference/nc01.md @@ -23,6 +23,19 @@ NC01 databases run on host-native PostgreSQL, not Docker or Kubernetes. Kubernet For HWLAB v0.3, the Kubernetes Service `nc01-host-postgres` in namespace `hwlab-v03` points to host PostgreSQL at `10.42.0.1:5432`. Runtime database Secrets are sourced from `.state/secrets/hwlab/*` via YAML sourceRef and must only be reported as present/fingerprint metadata, never as full values. +## Decision Center + +Decision Center production runs on NC01 k8s in namespace `unidesk`, not on a bare Docker/Compose runtime and not on the old D601 Decision Center path. Runtime objects are `deployment/decision-center` and `service/decision-center`; GitHub storage credentials are distributed through the `decision-center-github-ssh` Secret and must only be reported as object/key presence or fingerprint metadata. + +The durable store is the GitHub repo `pikasTech/decision-center-data` on branch `main`, with base path `data`. PostgreSQL is the service index/cache, not the durable source of truth. After data migration or repair, validate both layers: + +- service health shows `storage.primary=github-repo`, `recordCount` and `diaryEntryCount`; +- `/api/storage/verify` reports cache and repo index counts with zero deltas; +- the repo worktree HEAD and `git ls-remote origin refs/heads/main` match; +- `bun scripts/cli.ts decision health`, `decision list` and `decision diary list` succeed through the NC01 k8s entrypoint. + +When exporting legacy PostgreSQL data, do not stream Markdown bodies as plain JSONL through terminal output. Use a single-line-safe encoding such as hex-encoded JSON per row, write a manifest with row counts and SHA-256, transfer the bundle through `trans download/upload`, then compare canonical source and target hashes before claiming migration complete. + ## GitHub Token The local GitHub token source is `.env/gh_token.txt`. It must be stored as a bare token and consumed through controlled YAML/sourceRef or temporary non-printing credential helpers. Do not place the token in command argv, logs, committed files, or rendered manifests. @@ -37,5 +50,6 @@ Web-probe sentinel deployments that need public ingress must declare their own Y - Provider/trans: `scripts/trans NC01 argv true`. - NC01 k8s route: `scripts/trans NC01:k3s kubectl get nodes -o wide`. +- Decision Center: `bun scripts/cli.ts decision health`; drill-down storage verification can be run inside the NC01 `decision-center` Pod against `/api/storage/verify`. - HWLAB v0.3: `bun scripts/cli.ts hwlab nodes control-plane status --node NC01 --lane v03 --full`. - AgentRun v0.2: `bun scripts/cli.ts agentrun control-plane status --node NC01 --lane nc01-v02`. diff --git a/scripts/src/decision-center.ts b/scripts/src/decision-center.ts index dd9333bc..14e12235 100644 --- a/scripts/src/decision-center.ts +++ b/scripts/src/decision-center.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { type UniDeskConfig, repoRoot } from "./config"; import { coreInternalFetch } from "./microservices"; +import { runCommand } from "./command"; type DecisionRecordType = "meeting" | "decision" | "goal" | "external_goal" | "internal_goal" | "blocker" | "debt" | "experiment"; type RequirementRecordType = Exclude; @@ -176,6 +177,106 @@ function decisionProxy(path: string, init?: { method?: string; body?: unknown }) return coreInternalFetch(`/api/microservices/${encodeURIComponent(serviceId)}/proxy${path}`, init); } +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function decisionK8sPath(path: string): string { + const proxyPrefix = `/api/microservices/${encodeURIComponent(serviceId)}/proxy`; + if (path.startsWith(proxyPrefix)) return path.slice(proxyPrefix.length) || "/"; + if (path === `/api/microservices/${encodeURIComponent(serviceId)}/health`) return "/health"; + return path; +} + +function decisionK8sTarget(): { node: string; namespace: string } { + const raw = readFileSync(resolve(repoRoot, "config", "unidesk-host-k8s.yaml"), "utf8"); + const parsed = Bun.YAML.parse(raw) as unknown; + const record = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record : {}; + const target = typeof record.target === "object" && record.target !== null && !Array.isArray(record.target) ? record.target as Record : {}; + const node = typeof target.id === "string" && target.id.length > 0 ? target.id : "NC01"; + const namespace = typeof target.namespace === "string" && target.namespace.length > 0 ? target.namespace : "unidesk"; + return { node, namespace }; +} + +async function nc01K8sDecisionFetch(path: string, init?: { method?: string; body?: unknown }): Promise { + const target = decisionK8sTarget(); + const payload = { + path: decisionK8sPath(path), + method: init?.method ?? "GET", + body: init?.body, + namespace: target.namespace, + }; + const requestB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); + const remoteScript = ` +set -eu +POD=$(kubectl -n ${shellQuote(target.namespace)} get pod -l app.kubernetes.io/name=decision-center -o jsonpath='{.items[0].metadata.name}') +REQ_B64=${shellQuote(requestB64)} +kubectl -n ${shellQuote(target.namespace)} exec "$POD" -- env REQ_B64="$REQ_B64" bun -e ' +const req = JSON.parse(Buffer.from(process.env.REQ_B64, "base64").toString("utf8")); +const headers = {}; +const init = { method: req.method || "GET", headers }; +if (req.body !== undefined) { + headers["content-type"] = "application/json"; + init.body = JSON.stringify(req.body); +} +const res = await fetch("http://127.0.0.1:4277" + req.path, init); +const text = await res.text(); +let body; +try { body = text ? JSON.parse(text) : null; } catch { body = text; } +if (!String(req.path).includes("includeBody=true") && body && typeof body === "object") { + for (const key of ["records", "entries", "requirements"]) { + if (Array.isArray(body[key])) { + body[key] = body[key].map((item) => item && typeof item === "object" ? { ...item, body: "" } : item); + } + } +} +let output = JSON.stringify({ ok: res.ok, status: res.status, body }); +if (output.length > 8000 && body && typeof body === "object") { + for (const key of ["records", "entries", "requirements"]) { + if (Array.isArray(body[key])) { + const totalReturned = body[key].length; + body[key] = body[key].slice(0, 5).map((item) => { + if (!item || typeof item !== "object") return item; + return { + id: item.id, + docNo: item.docNo, + date: item.date, + title: item.title, + type: item.type, + status: item.status, + level: item.level, + sourceFile: item.sourceFile, + updatedAt: item.updatedAt, + }; + }); + body.totalReturned = totalReturned; + body[key + "Omitted"] = Math.max(0, totalReturned - body[key].length); + body.outputPolicy = { bounded: true, reason: "ssh-preview-budget", shown: body[key].length, totalReturned }; + } + } + output = JSON.stringify({ ok: res.ok, status: res.status, body }); +} +console.log(output); +' +`; + const result = runCommand(["bun", "scripts/cli.ts", "ssh", target.node, "sh", "--", remoteScript], repoRoot, { timeoutMs: 30_000 }); + if (result.exitCode !== 0) { + return { + ok: false, + status: 502, + error: "NC01 decision-center k8s proxy failed", + commandExitCode: result.exitCode, + stdoutTail: result.stdout.slice(-1000), + stderrTail: result.stderr.slice(-1000), + }; + } + const jsonLine = result.stdout.trim().split("\n").reverse().find((line) => line.trim().startsWith("{")); + if (jsonLine === undefined) { + return { ok: false, status: 502, error: "NC01 decision-center k8s proxy returned no JSON", stdoutTail: result.stdout.slice(-1000), stderrTail: result.stderr.slice(-1000) }; + } + return JSON.parse(jsonLine) as unknown; +} + async function decisionProxyAsync( fetcher: (path: string, init?: { method?: string; body?: unknown }) => Promise, path: string, @@ -555,35 +656,7 @@ async function updateRequirementAsync(id: string | undefined, args: string[], fe } export async function runDecisionCenterCommand(_config: UniDeskConfig, args: string[]): Promise { - 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, args.slice(3)); - if (diaryAction === "edit" || diaryAction === "upsert") return editDiary(args.slice(2)); - throw new Error("decision diary command must be one of: import, list, history, months, today, show, edit, upsert"); - } - if (action === "requirement" || action === "requirements") { - 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)); - 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)); - if (action === "show") return showRecord(id); - if (action === "health") return unwrapProxyResponse(coreInternalFetch(`/api/microservices/${encodeURIComponent(serviceId)}/health`)); - throw new Error("decision command must be one of: upload, list, show, health, requirement, diary"); + return runDecisionCenterCommandAsync(_config, args, nc01K8sDecisionFetch); } export async function runDecisionCenterCommandAsync(