|
|
|
@@ -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" });
|