feat: add decision center service
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user