feat: add decision center service

This commit is contained in:
Codex
2026-05-17 06:17:17 +00:00
parent 1cafe6da6a
commit d74439ecba
26 changed files with 1517 additions and 14 deletions
+115 -2
View File
@@ -1422,7 +1422,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.result-card dd { margin: 0; }
.result-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.microservice-page, .findjob-page, .pipeline-page, .met-page, .code-queue-page, .baidu-netdisk-page, .filebrowser-page, .oa-event-flow-page {
.microservice-page, .findjob-page, .pipeline-page, .met-page, .code-queue-page, .baidu-netdisk-page, .filebrowser-page, .oa-event-flow-page, .decision-center-page {
display: grid;
gap: 10px;
}
@@ -6617,8 +6617,121 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
overflow-wrap: anywhere;
}
.decision-hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 0.36fr);
gap: 10px;
align-items: stretch;
}
.decision-filter-bar {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 10px;
}
.decision-filter-bar label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.decision-default-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
align-items: start;
}
.decision-card-list {
display: grid;
gap: 8px;
}
.decision-record-card {
display: grid;
gap: 8px;
min-width: 0;
padding: 10px;
border: 1px solid var(--line-soft);
background: rgba(0,0,0,0.16);
}
.decision-record-card.compact {
padding: 9px;
}
.decision-record-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
}
.decision-record-head strong {
display: block;
min-width: 0;
margin-top: 4px;
overflow-wrap: anywhere;
font-size: 14px;
}
.decision-record-meta,
.decision-record-foot,
.decision-tags,
.decision-evidence {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.decision-record-meta span:first-child {
color: var(--accent);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.decision-summary {
margin: 0;
color: var(--muted);
line-height: 1.45;
overflow-wrap: anywhere;
}
.decision-markdown {
max-height: 280px;
overflow: auto;
padding-right: 4px;
}
.decision-record-foot {
color: var(--muted);
font-size: 11px;
}
.decision-record-foot code {
white-space: normal;
overflow-wrap: anywhere;
}
.decision-tags span {
padding: 2px 6px;
border: 1px solid rgba(78, 183, 168, 0.34);
color: var(--accent-2);
background: rgba(78, 183, 168, 0.08);
font-size: 11px;
}
.decision-evidence a {
color: var(--text);
text-decoration: none;
overflow-wrap: anywhere;
}
.decision-table th,
.decision-table td {
vertical-align: top;
}
.decision-table td strong,
.decision-table td code {
display: block;
min-width: 0;
overflow-wrap: anywhere;
}
@media (max-width: 1100px) {
.mdtodo-layout {
.mdtodo-layout,
.decision-hero,
.decision-filter-bar,
.decision-default-grid {
grid-template-columns: 1fr;
}
}
+3
View File
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
import { BaiduNetdiskPage } from "./baidu-netdisk";
import { ClaudeQqPage } from "./claudeqq";
import { CodeQueuePage } from "./code-queue";
import { DecisionCenterPage } from "./decision-center";
import { FileBrowserPage } from "./filebrowser";
import { FindJobPage } from "./findjob";
import { MetNonlinearPage } from "./met-nonlinear";
@@ -1652,6 +1653,7 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
service.id === "k3sctl-adapter" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "k3sctl"), "data-testid": "open-k3sctl-button" }, "打开") : null,
service.id === "code-queue" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "code-queue"), "data-testid": "open-code-queue-button" }, "打开") : null,
service.id === "mdtodo" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "mdtodo"), "data-testid": "open-mdtodo-button" }, "打开") : null,
service.id === "decision-center" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "decision-center"), "data-testid": "open-decision-center-button" }, "打开") : null,
service.id === "project-manager" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "project-manager"), "data-testid": "open-project-manager-button" }, "打开") : null,
h(RawButton, { title: `用户服务 ${service.id}`, data: service, onOpen: onRaw }),
),
@@ -2160,6 +2162,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
if (activeModule === "apps" && activeTab === "k3sctl") return h(K3sCtlPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl, onNavigate });
if (activeModule === "apps" && activeTab === "code-queue") return h(CodeQueuePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl, initialTasksData: initialCodeQueueOverview });
if (activeModule === "apps" && activeTab === "mdtodo") return h(MdtodoPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "decision-center") return h(DecisionCenterPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "project-manager") return h(ProjectManagerPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data });
if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session });
@@ -0,0 +1,258 @@
import React from "react";
import { fmtClock, fmtDate } from "./time";
import { LoadingTitle } from "./loading-indicator";
import { MarkdownBody } from "./markdown";
import { errorMessage, requestJson } from "./unidesk-error";
import { UniDeskErrorBanner } from "./unidesk-error-banner";
type AnyRecord = Record<string, any>;
const h = React.createElement;
const { useEffect } = React;
const useState: any = React.useState;
const recordTypes = ["all", "meeting", "decision", "goal", "blocker", "debt", "experiment"];
const recordLevels = ["all", "G0", "G1", "G2", "G3", "P0", "P1", "P2", "P3", "none"];
const recordStatuses = ["all", "active", "blocked", "parked", "done"];
function StatusBadge({ status, children }: AnyRecord) {
const normalized = String(status || "unknown").toLowerCase();
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
}
function MetricCard({ label, value, hint, tone }: AnyRecord) {
return h("article", { className: `metric-card ${tone || ""}` },
h("div", { className: "metric-label" }, label),
h("div", { className: "metric-value" }, value),
h("div", { className: "metric-hint" }, hint),
);
}
function Panel({ title, eyebrow, actions, children, className, loading }: AnyRecord) {
return h("section", { className: `panel ${className || ""}` },
h("div", { className: "panel-head" },
h("div", null,
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
h(LoadingTitle, { title, loading }),
),
actions ? h("div", { className: "panel-actions" }, actions) : null,
),
h("div", { className: "panel-body" }, children),
);
}
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
return h("button", {
type: "button",
className: "ghost-btn",
"data-testid": testId,
onClick: () => onOpen(title, data),
}, "查看原始JSON");
}
function EmptyState({ title, text }: AnyRecord) {
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
}
function microserviceRuntime(service: any): AnyRecord {
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
}
function microserviceBackend(service: any): AnyRecord {
return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {};
}
function microserviceRepository(service: any): AnyRecord {
return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {};
}
function decisionApi(apiBaseUrl: string, path: string): string {
return `${apiBaseUrl}/microservices/decision-center/proxy${path}`;
}
function levelTone(level: string): string {
if (level === "G0" || level === "G1") return "online";
if (level === "P0" || level === "P1") return "failed";
if (level === "none") return "unknown";
return "warn";
}
function statusTone(status: string): string {
if (status === "done") return "online";
if (status === "blocked") return "failed";
if (status === "parked") return "warn";
return "unknown";
}
function fmtRecordTime(value: any): string {
return fmtDate(value) || "--";
}
function shortText(value: any, max = 220): string {
const text = String(value || "").replace(/\s+/gu, " ").trim();
return text.length > max ? `${text.slice(0, max - 1)}...` : text;
}
function RecordCard({ record, onRaw, compact }: AnyRecord) {
const tags = Array.isArray(record.tags) ? record.tags : [];
const evidence = Array.isArray(record.evidenceLinks) ? record.evidenceLinks : [];
return h("article", { className: `decision-record-card ${compact ? "compact" : ""}`, "data-testid": `decision-record-${String(record.id || "").replace(/[^A-Za-z0-9_-]+/g, "-")}` },
h("div", { className: "decision-record-head" },
h("div", null,
h("div", { className: "decision-record-meta" },
h("span", null, record.type || "--"),
h(StatusBadge, { status: levelTone(record.level) }, record.level || "none"),
h(StatusBadge, { status: statusTone(record.status) }, record.status || "--"),
),
h("strong", null, record.title || "--"),
),
h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw }),
),
compact
? h("p", { className: "decision-summary" }, shortText(record.summary || record.body))
: h(MarkdownBody, { markdown: record.body || record.summary || "", className: "decision-markdown" }),
h("div", { className: "decision-record-foot" },
record.linkedGoalId ? h("code", null, `goal:${record.linkedGoalId}`) : null,
record.taskId ? h("code", null, `task:${record.taskId}`) : null,
record.commitId ? h("code", null, record.commitId.slice(0, 12)) : null,
h("span", null, fmtRecordTime(record.updatedAt)),
),
tags.length > 0 ? h("div", { className: "decision-tags" }, tags.slice(0, 8).map((tag: string) => h("span", { key: tag }, tag))) : null,
evidence.length > 0 ? h("div", { className: "decision-evidence" }, evidence.slice(0, 4).map((link: string) => h("a", { key: link, href: link, target: "_blank", rel: "noreferrer" }, shortText(link, 58)))) : null,
);
}
function RecordTable({ records, onRaw }: AnyRecord) {
if (!records.length) return h(EmptyState, { title: "暂无记录", text: "通过 CLI 上传会议记录或决议后会显示在这里。" });
return h("div", { className: "table-wrap" },
h("table", { className: "decision-table", "data-testid": "decision-center-record-table" },
h("thead", null, h("tr", null,
h("th", null, "等级"),
h("th", null, "状态"),
h("th", null, "类型"),
h("th", null, "标题"),
h("th", null, "摘要"),
h("th", null, "证据"),
h("th", null, "更新"),
h("th", null, "操作"),
)),
h("tbody", null, records.map((record: any) => h("tr", { key: record.id },
h("td", null, h(StatusBadge, { status: levelTone(record.level) }, record.level || "none")),
h("td", null, h(StatusBadge, { status: statusTone(record.status) }, record.status || "--")),
h("td", null, record.type || "--"),
h("td", null, h("strong", null, record.title || "--"), record.linkedGoalId ? h("code", null, record.linkedGoalId) : null),
h("td", null, shortText(record.summary || record.body, 180)),
h("td", null, Array.isArray(record.evidenceLinks) ? record.evidenceLinks.length : 0),
h("td", null, fmtRecordTime(record.updatedAt)),
h("td", null, h(RawButton, { title: `Decision ${record.id}`, data: record, onOpen: onRaw })),
))),
),
);
}
function selectOptions(values: string[]): any[] {
return values.map((value) => h("option", { key: value, value }, value));
}
function filteredQuery(filters: AnyRecord): string {
const params = new URLSearchParams();
if (filters.type !== "all") params.set("type", filters.type);
if (filters.status !== "all") params.set("status", filters.status);
if (filters.level !== "all") params.set("level", filters.level);
if (filters.linkedGoalId.trim()) params.set("linkedGoalId", filters.linkedGoalId.trim());
params.set("limit", "240");
return params.toString();
}
export function DecisionCenterPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
const service = microservices.find((item: any) => item.id === "decision-center") || null;
const [state, setState] = useState({ loading: false, error: "", health: null, records: [], refreshedAt: null });
const [filters, setFilters] = useState({ type: "all", status: "all", level: "all", linkedGoalId: "" });
async function load(): Promise<void> {
if (!service) return;
setState((prev: any) => ({ ...prev, loading: true, error: "" }));
try {
const query = filteredQuery(filters);
const [health, records] = await Promise.all([
requestJson(`${apiBaseUrl}/microservices/decision-center/health`),
requestJson(decisionApi(apiBaseUrl, `/api/records?${query}`)),
]);
setState({ loading: false, error: "", health, records: Array.isArray(records.records) ? records.records : [], refreshedAt: new Date() });
} catch (err) {
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Decision Center 加载失败") }));
}
}
useEffect(() => {
load();
}, [service?.id, service?.runtime?.providerStatus]);
useEffect(() => {
const timer = setTimeout(() => void load(), 120);
return () => clearTimeout(timer);
}, [filters.type, filters.status, filters.level, filters.linkedGoalId]);
if (!service) return h(EmptyState, { title: "Decision Center 未登记", text: "请在 config.json 的 microservices 中登记用户服务 id=decision-center" });
const runtime = microserviceRuntime(service);
const repository = microserviceRepository(service);
const backend = microserviceBackend(service);
const records = Array.isArray(state.records) ? state.records : [];
const goals = records.filter((record: any) => record.type === "goal" && ["G0", "G1"].includes(record.level) && record.status !== "done").slice(0, 8);
const blockers = records.filter((record: any) => record.type === "blocker" && ["P0", "P1"].includes(record.level) && record.status !== "done").slice(0, 8);
const parked = records.filter((record: any) => record.status === "parked").slice(0, 8);
const recentMeetings = records.filter((record: any) => record.type === "meeting" || record.type === "decision").slice(0, 12);
return h("div", { className: "decision-center-page", "data-testid": "decision-center-page" },
h(Panel, { title: "Decision Center", eyebrow: "Authority Records", loading: state.loading, actions: h("div", { className: "inline-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: () => void load(), disabled: state.loading }, state.loading ? "刷新中" : "刷新"),
h(RawButton, { title: "Decision Center Health", data: state.health, onOpen: onRaw, testId: "raw-decision-center-health" }),
) },
h("div", { className: "decision-hero" },
h("div", { className: "metric-grid" },
h(MetricCard, { label: "记录数", value: records.length, hint: `PostgreSQL / ${state.health?.storage || "postgres"}`, tone: "ok" }),
h(MetricCard, { label: "G0/G1 目标", value: goals.length, hint: "active authority goals", tone: "ok" }),
h(MetricCard, { label: "P0/P1 Blocker", value: blockers.length, hint: "requires decision", tone: blockers.length > 0 ? "warn" : "ok" }),
h(MetricCard, { label: "Parked", value: parked.length, hint: "停放事项", tone: parked.length > 0 ? "warn" : "ok" }),
),
h("div", { className: "microservice-ref-card" },
h("span", null, "Runtime"),
h("strong", null, runtime.orchestrator || service.deployment?.mode || "k3sctl"),
h("code", null, `${service.providerId} / ${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
h("code", null, repository.commitId || "--"),
),
),
h(UniDeskErrorBanner, { error: state.error, title: "Decision Center 请求失败" }),
),
h(Panel, { title: "筛选", eyebrow: "Type / Status / Level" },
h("div", { className: "decision-filter-bar", "data-testid": "decision-center-filters" },
h("label", null, "类型", h("select", { value: filters.type, onChange: (event: any) => setFilters((prev: any) => ({ ...prev, type: event.target.value })) }, selectOptions(recordTypes))),
h("label", null, "状态", h("select", { value: filters.status, onChange: (event: any) => setFilters((prev: any) => ({ ...prev, status: event.target.value })) }, selectOptions(recordStatuses))),
h("label", null, "等级", h("select", { value: filters.level, onChange: (event: any) => setFilters((prev: any) => ({ ...prev, level: event.target.value })) }, selectOptions(recordLevels))),
h("label", null, "Linked Goal", h("input", { value: filters.linkedGoalId, onChange: (event: any) => setFilters((prev: any) => ({ ...prev, linkedGoalId: event.target.value })), placeholder: "goal id" })),
),
),
h("div", { className: "decision-default-grid" },
h(Panel, { title: "G0/G1 目标", eyebrow: `${goals.length} Goals` },
goals.length === 0 ? h(EmptyState, { title: "暂无当前目标", text: "目标记录使用 type=goal 且 level=G0/G1。" }) :
h("div", { className: "decision-card-list" }, goals.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
),
h(Panel, { title: "P0/P1 Blocker", eyebrow: `${blockers.length} Blockers` },
blockers.length === 0 ? h(EmptyState, { title: "暂无高优先级阻塞", text: "阻塞记录使用 type=blocker 且 level=P0/P1。" }) :
h("div", { className: "decision-card-list" }, blockers.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
),
h(Panel, { title: "停放事项", eyebrow: `${parked.length} Parked` },
parked.length === 0 ? h(EmptyState, { title: "暂无停放事项", text: "status=parked 的记录会集中展示。" }) :
h("div", { className: "decision-card-list" }, parked.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
),
h(Panel, { title: "最近会议/决议", eyebrow: `${recentMeetings.length} Recent` },
recentMeetings.length === 0 ? h(EmptyState, { title: "暂无会议或决议", text: "使用 CLI 上传 Markdown 会议记录后会显示。" }) :
h("div", { className: "decision-card-list" }, recentMeetings.map((record: any) => h(RecordCard, { key: record.id, record, onRaw, compact: true }))),
),
),
h(Panel, { title: "全部记录", eyebrow: `${records.length} Records`, actions: state.refreshedAt ? h("span", { className: "muted" }, `刷新 ${fmtClock(state.refreshedAt)}`) : null },
h(RecordTable, { records, onRaw }),
),
);
}
@@ -73,6 +73,7 @@ export const MODULES: UniDeskModuleDefinition[] = [
{ id: "k3sctl", label: "k3s Control" },
{ id: "code-queue", label: "Code Queue" },
{ id: "mdtodo", label: "MDTODO" },
{ id: "decision-center", label: "Decision Center" },
{ id: "project-manager", label: "Project Manager" },
] },
{ id: "config", label: "系统配置", code: "CFG", tabs: [
@@ -0,0 +1,11 @@
FROM oven/bun:1-alpine
WORKDIR /app/src/components/microservices/decision-center
COPY src/components/microservices/decision-center/package.json ./package.json
RUN bun install --production
COPY src/components/microservices/decision-center/tsconfig.json ./tsconfig.json
COPY src/components/shared /app/src/components/shared
COPY src/components/microservices/decision-center/src ./src
EXPOSE 4277
CMD ["bun", "run", "src/index.ts"]
@@ -0,0 +1,12 @@
{
"name": "@unidesk/decision-center",
"private": true,
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"postgres": "latest"
}
}
@@ -0,0 +1,510 @@
import { randomUUID } from "node:crypto";
import postgres from "postgres";
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../shared/src/rotating-jsonl";
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
type JsonRecord = Record<string, JsonValue>;
type DecisionRecordType = "meeting" | "decision" | "goal" | "blocker" | "debt" | "experiment";
type DecisionRecordLevel = "G0" | "G1" | "G2" | "G3" | "P0" | "P1" | "P2" | "P3" | "none";
type DecisionRecordStatus = "active" | "blocked" | "parked" | "done";
interface RuntimeConfig {
host: string;
port: number;
databaseUrl: string;
logFile: string;
databasePoolMax: number;
}
interface DecisionRecordRow {
id: string;
type: DecisionRecordType;
level: DecisionRecordLevel;
status: DecisionRecordStatus;
title: string;
body: string;
linked_goal_id: string | null;
tags: JsonValue;
evidence_links: JsonValue;
source_session: string;
task_id: string;
commit_id: string;
created_at: Date | string;
updated_at: Date | string;
}
interface DecisionRecord extends JsonRecord {
id: string;
type: DecisionRecordType;
level: DecisionRecordLevel;
status: DecisionRecordStatus;
title: string;
summary: string;
body: string;
linkedGoalId: string | null;
tags: string[];
evidenceLinks: string[];
sourceSession: string;
taskId: string;
commitId: string;
createdAt: string;
updatedAt: string;
}
class HttpError extends Error {
readonly status: number;
readonly detail: JsonRecord;
constructor(status: number, message: string, detail: JsonRecord = {}) {
super(message);
this.name = "HttpError";
this.status = status;
this.detail = detail;
}
}
const recordTypes = new Set<DecisionRecordType>(["meeting", "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();
const recentLogs: JsonRecord[] = [];
let schemaReady = false;
let schemaLastError: JsonRecord | null = null;
function envString(name: string, fallback: string): string {
const value = process.env[name];
return value === undefined || value.length === 0 ? fallback : value;
}
function envNumber(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw.length === 0) return fallback;
const value = Number(raw);
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
}
function configFromEnv(): RuntimeConfig {
const databaseUrl = process.env.DATABASE_URL || "";
if (!databaseUrl) throw new Error("DATABASE_URL is required");
return {
host: envString("HOST", "0.0.0.0"),
port: envNumber("PORT", 4277),
databaseUrl,
logFile: envString("LOG_FILE", "/var/log/unidesk/decision-center.jsonl"),
databasePoolMax: Math.max(1, Math.min(8, envNumber("DATABASE_POOL_MAX", 2))),
};
}
const config = configFromEnv();
const sql = postgres(config.databaseUrl, {
max: config.databasePoolMax,
idle_timeout: 20,
connect_timeout: 10,
connection: { application_name: "unidesk-decision-center" },
});
const logWriter = config.logFile
? createHourlyJsonlWriter({
baseLogFile: config.logFile,
service: "decision-center",
maxBytes: logRetentionBytesForService("decision-center"),
})
: null;
logWriter?.prune();
function log(level: "info" | "warn" | "error", event: string, detail: JsonRecord = {}): void {
const record: JsonRecord = { at: new Date().toISOString(), service: "decision-center", level, event, ...detail };
recentLogs.push(record);
while (recentLogs.length > 300) recentLogs.shift();
try {
logWriter?.appendJson(record, new Date(String(record.at)));
} catch {
// Logging must not break decision writes.
}
const line = JSON.stringify(record);
const writer = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
writer(line);
}
function jsonResponse(body: JsonValue, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
function errorToJson(error: unknown): JsonRecord {
if (error instanceof HttpError) return { name: error.name, message: error.message, status: error.status, detail: error.detail };
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack || "" };
return { message: String(error) };
}
function errorResponse(error: unknown): Response {
const status = error instanceof HttpError ? error.status : 500;
const body = error instanceof HttpError
? { ok: false, error: error.message, ...error.detail }
: { ok: false, error: error instanceof Error ? error.message : String(error) };
log(status >= 500 ? "error" : "warn", "request_failed", { status, error: errorToJson(error) });
return jsonResponse(body, status);
}
function iso(value: Date | string | null | undefined): string {
if (value === null || value === undefined) return "";
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toISOString();
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function asString(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value.trim();
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value).trim();
return "";
}
function asStringArray(value: unknown, field: string): string[] {
if (value === null || value === undefined || value === "") return [];
if (typeof value === "string") {
return value.split(",").map((item) => item.trim()).filter(Boolean).slice(0, 50);
}
if (!Array.isArray(value)) throw new HttpError(400, `${field} must be an array of strings`);
const items = value.map((item) => asString(item)).filter(Boolean);
if (items.length > 50) throw new HttpError(400, `${field} must contain at most 50 items`);
return [...new Set(items)];
}
function summaryFromBody(body: string): string {
return body
.split("\n")
.map((line) => line.replace(/^#{1,6}\s+/u, "").trim())
.filter(Boolean)
.join(" ")
.replace(/\s+/gu, " ")
.slice(0, 280);
}
function recordFromRow(row: DecisionRecordRow): DecisionRecord {
const body = row.body || "";
return {
id: row.id,
type: row.type,
level: row.level,
status: row.status,
title: row.title,
summary: summaryFromBody(body),
body,
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) : [],
sourceSession: row.source_session,
taskId: row.task_id,
commitId: row.commit_id,
createdAt: iso(row.created_at),
updatedAt: iso(row.updated_at),
};
}
function parseRecordType(value: unknown, fallback: DecisionRecordType): DecisionRecordType {
const raw = asString(value) || fallback;
if (!recordTypes.has(raw as DecisionRecordType)) throw new HttpError(400, "unsupported record type", { value: raw, allowed: [...recordTypes] });
return raw as DecisionRecordType;
}
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] });
return raw as DecisionRecordLevel;
}
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] });
return raw as DecisionRecordStatus;
}
function titleFromMarkdown(markdown: string, fallback: string): string {
const heading = markdown.split("\n").map((line) => line.trim()).find((line) => /^#{1,3}\s+\S/u.test(line));
if (heading !== undefined) return heading.replace(/^#{1,3}\s+/u, "").trim().slice(0, 220);
const firstLine = markdown.split("\n").map((line) => line.trim()).find(Boolean);
return (firstLine || fallback).slice(0, 220);
}
async function readJsonBody(req: Request): Promise<Record<string, unknown>> {
const text = await req.text();
if (text.length > 1_000_000) throw new HttpError(413, "request body is too large", { maxBytes: 1_000_000 });
if (!text.trim()) return {};
try {
const parsed = JSON.parse(text) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error("JSON body must be an object");
}
return parsed as Record<string, unknown>;
} catch (error) {
throw new HttpError(400, "invalid JSON body", { detail: error instanceof Error ? error.message : String(error) });
}
}
async function ensureSchema(): Promise<void> {
await sql`
CREATE TABLE IF NOT EXISTS decision_center_records (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'none',
status TEXT NOT NULL DEFAULT 'active',
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
linked_goal_id TEXT,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
evidence_links JSONB NOT NULL DEFAULT '[]'::jsonb,
source_session 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_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`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)`;
}
async function waitForSchema(): Promise<void> {
for (let attempt = 1; attempt <= 30; attempt += 1) {
try {
await ensureSchema();
schemaReady = true;
schemaLastError = null;
log("info", "schema_ready", { attempt });
return;
} catch (error) {
schemaReady = false;
schemaLastError = errorToJson(error);
log("warn", "schema_wait", { attempt, error: schemaLastError });
await Bun.sleep(Math.min(1000 + attempt * 250, 5000));
}
}
throw new Error(`Decision Center schema initialization failed: ${JSON.stringify(schemaLastError)}`);
}
function deployInfo(): JsonRecord {
return {
serviceId: envString("UNIDESK_DEPLOY_SERVICE_ID", "decision-center"),
repo: envString("UNIDESK_DEPLOY_REPO", ""),
commit: envString("UNIDESK_DEPLOY_COMMIT", ""),
requestedCommit: envString("UNIDESK_DEPLOY_REQUESTED_COMMIT", ""),
};
}
async function health(): Promise<JsonRecord> {
let dbOk = false;
let recordCount = 0;
let dbError: JsonValue = null;
try {
const rows = await sql<{ count: string | number }[]>`SELECT count(*) AS count FROM decision_center_records`;
dbOk = true;
recordCount = Number(rows[0]?.count ?? 0);
} catch (error) {
dbError = errorToJson(error);
}
return {
ok: schemaReady && dbOk,
service: "decision-center",
status: schemaReady && dbOk ? "ready" : "not-ready",
startedAt: serviceStartedAt,
storage: "postgres",
schemaReady,
recordCount,
database: { ok: dbOk, error: dbError },
deploy: deployInfo(),
};
}
async function createRecord(input: Record<string, unknown>): Promise<DecisionRecord> {
const body = asString(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");
const id = asString(input.id) || `dc_${randomUUID()}`;
const rows = await 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
) VALUES (
${id},
${parseRecordType(input.type, "meeting")},
${parseLevel(input.level, "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.sourceSession)},
${asString(input.taskId)},
${asString(input.commitId)}
)
RETURNING *
`;
log("info", "record_created", { id: rows[0]?.id ?? id, type: rows[0]?.type ?? "", level: rows[0]?.level ?? "" });
return recordFromRow(rows[0]!);
}
async function updateRecord(id: string, input: Record<string, unknown>): Promise<DecisionRecord> {
const existing = await getRecord(id);
const rows = await 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},
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_session = ${"sourceSession" in input ? asString(input.sourceSession) : existing.sourceSession},
task_id = ${"taskId" in input ? asString(input.taskId) : existing.taskId},
commit_id = ${"commitId" in input ? asString(input.commitId) : existing.commitId},
updated_at = now()
WHERE id = ${id}
RETURNING *
`;
return recordFromRow(rows[0]!);
}
async function getRecord(id: string): Promise<DecisionRecord> {
const rows = await sql<DecisionRecordRow[]>`SELECT * FROM decision_center_records WHERE id = ${id}`;
if (rows.length === 0) throw new HttpError(404, "decision record not found", { id });
return recordFromRow(rows[0]!);
}
async function listRecords(url: URL): 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 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 (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 });
const rows = await sql<DecisionRecordRow[]>`
SELECT *
FROM decision_center_records
WHERE (${type || null}::text IS NULL OR type = ${type || null})
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})
ORDER BY
CASE level
WHEN 'G0' THEN 0
WHEN 'P0' THEN 1
WHEN 'G1' THEN 2
WHEN 'P1' THEN 3
WHEN 'G2' THEN 4
WHEN 'P2' THEN 5
WHEN 'G3' THEN 6
WHEN 'P3' THEN 7
ELSE 8
END ASC,
updated_at DESC
LIMIT ${limit}
`;
return rows.map(recordFromRow);
}
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");
return value.map((item, index) => {
const record = asRecord(item);
if (Object.keys(record).length === 0) throw new HttpError(400, "decision item must be an object", { index });
return record;
});
}
async function importMeeting(input: Record<string, unknown>): Promise<JsonRecord> {
const markdown = asString(input.markdown ?? input.body ?? input.summary);
if (!markdown) throw new HttpError(400, "markdown is required");
const base: Record<string, unknown> = {
type: "meeting",
level: parseLevel(input.level, "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"),
sourceSession: asString(input.sourceSession),
taskId: asString(input.taskId),
commitId: asString(input.commitId),
};
const meeting = await createRecord(base);
const decisionInputs = normalizeDecisionDrafts(input.decisions);
const decisions: DecisionRecord[] = [];
for (const decision of decisionInputs) {
decisions.push(await createRecord({
...base,
...decision,
type: "decision",
linkedGoalId: asString(decision.linkedGoalId) || meeting.linkedGoalId,
body: asString(decision.body ?? decision.summary ?? decision.markdown) || asString(decision.title),
}));
}
return { ok: true, meeting, decisions, createdCount: 1 + decisions.length };
}
async function deleteRecord(id: string): Promise<JsonRecord> {
const rows = await sql<DecisionRecordRow[]>`DELETE FROM decision_center_records WHERE id = ${id} RETURNING *`;
if (rows.length === 0) throw new HttpError(404, "decision record not found", { id });
log("info", "record_deleted", { id });
return { ok: true, deleted: recordFromRow(rows[0]!) };
}
async function route(req: Request): Promise<Response> {
const url = new URL(req.url);
const method = req.method.toUpperCase();
if (url.pathname === "/live") {
return jsonResponse({ ok: true, service: "decision-center", status: "alive", startedAt: serviceStartedAt, deploy: deployInfo() });
}
if (url.pathname === "/health") {
const body = await health();
return jsonResponse(body, body.ok === true ? 200 : 503);
}
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/meetings/import" && method === "POST") return jsonResponse(await importMeeting(await readJsonBody(req)), 201);
const recordMatch = url.pathname.match(/^\/api\/records\/([^/]+)$/u);
if (recordMatch !== null) {
const id = decodeURIComponent(recordMatch[1] ?? "");
if (!id) throw new HttpError(400, "record id is required");
if (method === "GET") return jsonResponse({ ok: true, record: await getRecord(id) });
if (method === "PUT") return jsonResponse({ ok: true, record: await updateRecord(id, await readJsonBody(req)) });
if (method === "DELETE") return jsonResponse(await deleteRecord(id));
throw new HttpError(405, "record route supports GET, PUT, DELETE", { method });
}
throw new HttpError(404, "route not found", { path: url.pathname });
}
Bun.serve({
hostname: config.host,
port: config.port,
async fetch(req) {
try {
return await route(req);
} catch (error) {
return errorResponse(error);
}
},
});
void waitForSchema().catch((error) => {
log("error", "schema_init_failed", { error: errorToJson(error) });
});
log("info", "service_started", { host: config.host, port: config.port, storage: "postgres" });
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../../shared" }]
}
@@ -39,7 +39,8 @@ services:
K3SCTL_NATIVE_SERVICE_TUNNEL_CONNECT_TIMEOUT_MS: "${K3SCTL_NATIVE_SERVICE_TUNNEL_CONNECT_TIMEOUT_MS:-3000}"
K3SCTL_NATIVE_SERVICE_URL_CODE_QUEUE: "${K3SCTL_NATIVE_SERVICE_URL_CODE_QUEUE:-}"
K3SCTL_NATIVE_SERVICE_URL_MDTODO: "${K3SCTL_NATIVE_SERVICE_URL_MDTODO:-}"
K3SCTL_MANIFEST_PATHS: "${K3SCTL_MANIFEST_PATHS:-k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json}"
K3SCTL_NATIVE_SERVICE_URL_DECISION_CENTER: "${K3SCTL_NATIVE_SERVICE_URL_DECISION_CENTER:-}"
K3SCTL_MANIFEST_PATHS: "${K3SCTL_MANIFEST_PATHS:-k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json,k3s/decision-center.k3s.json}"
K3SCTL_SERVICES_JSON: "${K3SCTL_SERVICES_JSON:-[]}"
UNIDESK_LOG_RETENTION_BYTES: "${UNIDESK_LOG_RETENTION_BYTES:-512MiB}"
volumes:
@@ -0,0 +1,37 @@
{
"apiVersion": "unidesk.ai/k3s/v1",
"kind": "ManagedKubernetesService",
"metadata": {
"name": "decision-center",
"namespace": "unidesk"
},
"spec": {
"adapterServiceId": "k3sctl-adapter",
"controlPlane": {
"type": "kubernetes",
"cluster": "unidesk-k3s",
"context": "unidesk-k3s"
},
"route": {
"kind": "kubernetes-service",
"serviceName": "decision-center",
"servicePort": 4277
},
"activeInstanceId": "D601",
"singleWriter": true,
"expectedNodeIds": [
"D601"
],
"instances": [
{
"id": "D601",
"nodeId": "D601",
"role": "primary",
"baseUrl": "kubernetes://unidesk/services/decision-center:4277",
"healthPath": "/health",
"healthMode": "service-proxy"
}
],
"requireAllInstancesHealthy": true
}
}
@@ -0,0 +1,102 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: decision-center
namespace: unidesk
labels:
app.kubernetes.io/name: decision-center
app.kubernetes.io/part-of: unidesk
unidesk.ai/deployment-mode: k3sctl-managed
unidesk.ai/instance-id: D601
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: decision-center
unidesk.ai/instance-id: D601
template:
metadata:
labels:
app.kubernetes.io/name: decision-center
app.kubernetes.io/part-of: unidesk
unidesk.ai/deployment-mode: k3sctl-managed
unidesk.ai/instance-id: D601
unidesk.ai/node-id: D601
spec:
nodeSelector:
unidesk.ai/node-id: D601
terminationGracePeriodSeconds: 15
containers:
- name: decision-center
image: unidesk-decision-center:d601
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 4277
env:
- name: HOST
value: "0.0.0.0"
- name: PORT
value: "4277"
- name: DATABASE_URL
value: "postgres://unidesk:unidesk_dev_password@d601-tcp-egress-gateway.unidesk.svc.cluster.local:15432/unidesk"
- name: DATABASE_POOL_MAX
value: "2"
- name: LOG_FILE
value: "/var/log/unidesk/decision-center.jsonl"
- name: UNIDESK_LOG_RETENTION_BYTES
value: "512MiB"
volumeMounts:
- name: logs
mountPath: /var/log/unidesk
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 18
livenessProbe:
httpGet:
path: /live
port: http
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 6
startupProbe:
httpGet:
path: /live
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 30
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
memory: 512Mi
volumes:
- name: logs
hostPath:
path: /home/ubuntu/cq-deploy/.state/decision-center/logs
type: DirectoryOrCreate
---
apiVersion: v1
kind: Service
metadata:
name: decision-center
namespace: unidesk
labels:
app.kubernetes.io/name: decision-center
app.kubernetes.io/part-of: unidesk
unidesk.ai/deployment-mode: k3sctl-managed
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: decision-center
unidesk.ai/instance-id: D601
ports:
- name: http
port: 4277
targetPort: http
@@ -274,7 +274,7 @@ function mergeServices(services: ManagedService[]): ManagedService[] {
}
function readConfig(): RuntimeConfig {
const paths = manifestPaths(envString("K3SCTL_MANIFEST_PATHS", "k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json"));
const paths = manifestPaths(envString("K3SCTL_MANIFEST_PATHS", "k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json,k3s/decision-center.k3s.json"));
const inlineServices = parseServices(envString("K3SCTL_SERVICES_JSON", "[]"));
const manifestServices = readManifestServices(paths);
return {