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
@@ -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 {