symphifo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,306 @@
1
+ import type {
2
+ IssueEntry,
3
+ JsonRecord,
4
+ RuntimeEvent,
5
+ RuntimeState,
6
+ WorkflowDefinition,
7
+ } from "./types.ts";
8
+ import {
9
+ FRONTEND_INDEX,
10
+ FRONTEND_APP_JS,
11
+ FRONTEND_STYLES_CSS,
12
+ S3DB_RUNTIME_RESOURCE,
13
+ S3DB_ISSUE_RESOURCE,
14
+ S3DB_EVENT_RESOURCE,
15
+ S3DB_AGENT_SESSION_RESOURCE,
16
+ S3DB_AGENT_PIPELINE_RESOURCE,
17
+ TERMINAL_STATES,
18
+ ALLOWED_STATES,
19
+ } from "./constants.ts";
20
+ import { now, toStringValue, normalizeState, readTextOrNull, clamp } from "./helpers.ts";
21
+ import { logger } from "./logger.ts";
22
+ import {
23
+ loadS3dbModule,
24
+ getStateDb,
25
+ getIssueStateResource,
26
+ getEventStateResource,
27
+ setActiveApiPlugin,
28
+ persistState,
29
+ } from "./store.ts";
30
+ import {
31
+ addEvent,
32
+ createIssueFromPayload,
33
+ computeCapabilityCounts,
34
+ handleStatePatch,
35
+ transition,
36
+ } from "./issues.ts";
37
+ import {
38
+ getEffectiveAgentProviders,
39
+ detectAvailableProviders,
40
+ } from "./providers.ts";
41
+ import {
42
+ loadAgentPipelineSnapshotForIssue,
43
+ loadAgentSessionSnapshotsForIssue,
44
+ } from "./agent.ts";
45
+ import { analyzeParallelizability } from "./scheduler.ts";
46
+
47
+ export async function startApiServer(
48
+ state: RuntimeState,
49
+ port: number,
50
+ workflowDefinition: WorkflowDefinition | null,
51
+ ): Promise<void> {
52
+ const stateDb = getStateDb();
53
+ if (!stateDb) {
54
+ throw new Error("Cannot start API plugin before the database is initialized.");
55
+ }
56
+
57
+ const { ApiPlugin } = await loadS3dbModule();
58
+ const indexHtml = readTextOrNull(FRONTEND_INDEX) ?? "";
59
+ const appJs = readTextOrNull(FRONTEND_APP_JS) ?? "";
60
+ const stylesCss = readTextOrNull(FRONTEND_STYLES_CSS) ?? "";
61
+
62
+ const fallback = `<!doctype html><html><body><pre>Unable to load Symphifo dashboard assets.</pre></body></html>`;
63
+ const findIssue = (issueId: string) =>
64
+ state.issues.find((c) => c.id === issueId || c.identifier === issueId);
65
+
66
+ const issueResource = getIssueStateResource();
67
+ const eventResource = getEventStateResource();
68
+
69
+ const listIssues = async (filters: { state?: string; capabilityCategory?: string } = {}): Promise<IssueEntry[]> => {
70
+ const { state: issueState, capabilityCategory } = filters;
71
+
72
+ if (issueResource?.list) {
73
+ const partition = issueState && capabilityCategory
74
+ ? "byStateAndCapability"
75
+ : issueState ? "byState"
76
+ : capabilityCategory ? "byCapabilityCategory"
77
+ : null;
78
+ const partitionValues = issueState && capabilityCategory
79
+ ? { state: issueState, capabilityCategory }
80
+ : issueState ? { state: issueState }
81
+ : capabilityCategory ? { capabilityCategory }
82
+ : {};
83
+ const records = await issueResource.list({ partition, partitionValues, limit: 500 });
84
+ return records.map((record) => record as IssueEntry);
85
+ }
86
+
87
+ return state.issues.filter((issue) => {
88
+ if (issueState && issue.state !== issueState) return false;
89
+ if (capabilityCategory && issue.capabilityCategory !== capabilityCategory) return false;
90
+ return true;
91
+ });
92
+ };
93
+
94
+ const listEvents = async (filters: { issueId?: string; kind?: string; since?: string } = {}): Promise<RuntimeEvent[]> => {
95
+ const { issueId, kind, since } = filters;
96
+
97
+ let events: RuntimeEvent[];
98
+ if (eventResource?.list) {
99
+ const partition = issueId && kind ? "byIssueIdAndKind"
100
+ : issueId ? "byIssueId"
101
+ : kind ? "byKind"
102
+ : null;
103
+ const partitionValues = issueId && kind ? { issueId, kind }
104
+ : issueId ? { issueId }
105
+ : kind ? { kind }
106
+ : {};
107
+ events = (await eventResource.list({ partition, partitionValues, limit: 200 }))
108
+ .map((record) => record as RuntimeEvent);
109
+ } else {
110
+ events = state.events.filter((event) => {
111
+ if (issueId && event.issueId !== issueId) return false;
112
+ if (kind && event.kind !== kind) return false;
113
+ return true;
114
+ });
115
+ }
116
+
117
+ return typeof since === "string" && since
118
+ ? events.filter((entry) => entry.at > since)
119
+ : events;
120
+ };
121
+
122
+ const apiPlugin = new ApiPlugin({
123
+ port,
124
+ host: "0.0.0.0",
125
+ versionPrefix: false,
126
+ docs: { enabled: true, title: "Symphifo API", version: "1.0.0", description: "Local orchestration API for Symphifo" },
127
+ cors: { enabled: true, origin: "*" },
128
+ logging: { enabled: true, excludePaths: ["/health", "/api/health"] },
129
+ compression: { enabled: true, threshold: 1024 },
130
+ health: { enabled: true },
131
+ resources: {
132
+ [S3DB_RUNTIME_RESOURCE]: { auth: false, methods: ["GET", "HEAD", "OPTIONS"] },
133
+ [S3DB_ISSUE_RESOURCE]: { auth: false, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] },
134
+ [S3DB_EVENT_RESOURCE]: { auth: false, methods: ["GET", "HEAD", "OPTIONS"] },
135
+ [S3DB_AGENT_SESSION_RESOURCE]: { auth: false, methods: ["GET", "HEAD", "OPTIONS"] },
136
+ [S3DB_AGENT_PIPELINE_RESOURCE]: { auth: false, methods: ["GET", "HEAD", "OPTIONS"] },
137
+ },
138
+ routes: {
139
+ "GET /api/state": async () => ({
140
+ ...state,
141
+ capabilities: computeCapabilityCounts(state.issues),
142
+ }),
143
+ "GET /api/health": async () => ({
144
+ status: "ok",
145
+ updatedAt: state.updatedAt,
146
+ config: state.config,
147
+ trackerKind: state.trackerKind,
148
+ }),
149
+ "GET /api/providers": async () => {
150
+ const providers = detectAvailableProviders();
151
+ return { providers };
152
+ },
153
+ "GET /api/parallelism": async () => {
154
+ return analyzeParallelizability(state.issues);
155
+ },
156
+ "GET /api/issues": async (c: any) => {
157
+ const issueState = c.req.query("state");
158
+ const capabilityCategory = c.req.query("capabilityCategory") ?? c.req.query("category");
159
+ const issues = await listIssues({
160
+ state: typeof issueState === "string" && issueState ? issueState : undefined,
161
+ capabilityCategory: typeof capabilityCategory === "string" && capabilityCategory ? capabilityCategory : undefined,
162
+ });
163
+ return { issues };
164
+ },
165
+ "POST /api/issues": async (c: any) => {
166
+ const payload = await c.req.json() as JsonRecord;
167
+ const issue = createIssueFromPayload(payload, state.issues, workflowDefinition);
168
+ const duplicate = state.issues.find((candidate) => candidate.id === issue.id || candidate.identifier === issue.identifier);
169
+
170
+ if (duplicate) {
171
+ return c.json({ ok: false, error: "Issue id or identifier already exists", issue: duplicate }, 409);
172
+ }
173
+
174
+ state.issues.push(issue);
175
+ state.updatedAt = now();
176
+ addEvent(state, issue.id, "manual", `Issue ${issue.identifier} created via API.`);
177
+ await persistState(state);
178
+ return c.json({ ok: true, issue }, 201);
179
+ },
180
+ "PUT /api/issues/:id": async (c: any) => {
181
+ const issue = findIssue(c.req.param("id"));
182
+ if (!issue) return c.json({ ok: false, error: "Issue not found" }, 404);
183
+
184
+ const payload = await c.req.json() as JsonRecord;
185
+ if (typeof payload.title === "string") issue.title = payload.title;
186
+ if (typeof payload.description === "string") issue.description = payload.description;
187
+ if (typeof payload.priority === "number") issue.priority = clamp(payload.priority, 1, 10);
188
+ if (Array.isArray(payload.labels)) issue.labels = payload.labels.filter((l: unknown): l is string => typeof l === "string");
189
+ if (Array.isArray(payload.paths)) issue.paths = payload.paths.filter((p: unknown): p is string => typeof p === "string");
190
+ if (Array.isArray(payload.blockedBy)) issue.blockedBy = payload.blockedBy.filter((b: unknown): b is string => typeof b === "string");
191
+
192
+ issue.updatedAt = now();
193
+ addEvent(state, issue.id, "manual", `Issue ${issue.identifier} updated via API.`);
194
+ await persistState(state);
195
+ return { ok: true, issue };
196
+ },
197
+ "DELETE /api/issues/:id": async (c: any) => {
198
+ const issueId = c.req.param("id");
199
+ const index = state.issues.findIndex((i) => i.id === issueId || i.identifier === issueId);
200
+ if (index === -1) return c.json({ ok: false, error: "Issue not found" }, 404);
201
+
202
+ const removed = state.issues.splice(index, 1)[0];
203
+ state.updatedAt = now();
204
+ addEvent(state, removed.id, "manual", `Issue ${removed.identifier} deleted via API.`);
205
+ await persistState(state);
206
+ return { ok: true, deleted: removed.identifier };
207
+ },
208
+ "POST /api/config/concurrency": async (c: any) => {
209
+ const payload = await c.req.json() as JsonRecord;
210
+ const value = typeof payload.concurrency === "number" ? payload.concurrency : undefined;
211
+ if (!value || value < 1 || value > 16) {
212
+ return c.json({ ok: false, error: "concurrency must be between 1 and 16" }, 400);
213
+ }
214
+ state.config.workerConcurrency = clamp(Math.round(value), 1, 16);
215
+ state.updatedAt = now();
216
+ addEvent(state, undefined, "manual", `Worker concurrency updated to ${state.config.workerConcurrency}.`);
217
+ await persistState(state);
218
+ return { ok: true, workerConcurrency: state.config.workerConcurrency };
219
+ },
220
+ "GET /api/events": async (c: any) => {
221
+ const since = c.req.query("since");
222
+ const issueId = c.req.query("issueId");
223
+ const kind = c.req.query("kind");
224
+ const events = await listEvents({
225
+ since: typeof since === "string" ? since : undefined,
226
+ issueId: typeof issueId === "string" && issueId ? issueId : undefined,
227
+ kind: typeof kind === "string" && kind ? kind : undefined,
228
+ });
229
+ return { events: events.slice(0, 200) };
230
+ },
231
+ "GET /api/issue/:id/pipeline": async (c: any) => {
232
+ const issue = findIssue(c.req.param("id"));
233
+ if (!issue) return c.json({ ok: false, error: "Issue not found" }, 404);
234
+
235
+ const providers = getEffectiveAgentProviders(state, issue, workflowDefinition);
236
+ const pipeline = await loadAgentPipelineSnapshotForIssue(issue, providers);
237
+ return { ok: true, issueId: issue.id, pipeline };
238
+ },
239
+ "GET /api/issue/:id/sessions": async (c: any) => {
240
+ const issue = findIssue(c.req.param("id"));
241
+ if (!issue) return c.json({ ok: false, error: "Issue not found" }, 404);
242
+
243
+ const providers = getEffectiveAgentProviders(state, issue, workflowDefinition);
244
+ const pipeline = await loadAgentPipelineSnapshotForIssue(issue, providers);
245
+ const sessions = await loadAgentSessionSnapshotsForIssue(issue, providers, pipeline, workflowDefinition);
246
+ return { ok: true, issueId: issue.id, pipeline, sessions };
247
+ },
248
+ "POST /api/issue/:id/state": async (c: any) => {
249
+ const issue = findIssue(c.req.param("id"));
250
+ if (!issue) return c.json({ ok: false, error: "Issue not found" }, 404);
251
+
252
+ const payload = await c.req.json() as JsonRecord;
253
+ try {
254
+ handleStatePatch(state, issue, payload);
255
+ await persistState(state);
256
+ return { ok: true, issue };
257
+ } catch (error) {
258
+ return c.json({ ok: false, error: String(error) }, 400);
259
+ }
260
+ },
261
+ "POST /api/issue/:id/retry": async (c: any) => {
262
+ const issue = findIssue(c.req.param("id"));
263
+ if (!issue) return c.json({ ok: false, error: "Issue not found" }, 404);
264
+
265
+ if (TERMINAL_STATES.has(issue.state)) {
266
+ issue.state = "Todo";
267
+ issue.attempts = Math.max(0, issue.attempts - 1);
268
+ issue.lastError = undefined;
269
+ issue.nextRetryAt = undefined;
270
+ transition(issue, "Todo", "Manual retry requested.");
271
+ } else {
272
+ issue.nextRetryAt = undefined;
273
+ issue.lastError = undefined;
274
+ }
275
+
276
+ addEvent(state, issue.id, "manual", `Manual retry requested for ${issue.id}.`);
277
+ await persistState(state);
278
+ return { ok: true, issue };
279
+ },
280
+ "POST /api/issue/:id/cancel": async (c: any) => {
281
+ const issue = findIssue(c.req.param("id"));
282
+ if (!issue) return c.json({ ok: false, error: "Issue not found" }, 404);
283
+
284
+ transition(issue, "Cancelled", "Manual cancel requested.");
285
+ addEvent(state, issue.id, "manual", `Manual cancel requested for ${issue.id}.`);
286
+ await persistState(state);
287
+ return { ok: true, issue };
288
+ },
289
+ "GET /state": async (c: any) => c.redirect("/api/state"),
290
+ "GET /": async (c: any) => c.html(indexHtml || fallback),
291
+ "GET /index.html": async (c: any) => c.html(indexHtml || fallback),
292
+ "GET /assets/app.js": async (c: any) => c.body(appJs || "console.log('Dashboard script not found.');", 200, {
293
+ "content-type": "application/javascript; charset=utf-8",
294
+ }),
295
+ "GET /assets/styles.css": async (c: any) => c.body(stylesCss || "", 200, {
296
+ "content-type": "text/css; charset=utf-8",
297
+ }),
298
+ },
299
+ });
300
+
301
+ const plugin = await stateDb.usePlugin(apiPlugin, "api") as { stop?: () => Promise<void> };
302
+ setActiveApiPlugin(plugin);
303
+ logger.info(`Local dashboard available at http://localhost:${port}`);
304
+ logger.info(`State API: http://localhost:${port}/api/state`);
305
+ logger.info(`OpenAPI docs available at http://localhost:${port}/docs`);
306
+ }
@@ -0,0 +1,102 @@
1
+ import { existsSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { env, argv, cwd as getCwd } from "node:process";
5
+ import { homedir } from "node:os";
6
+ import type { IssueState } from "./types.ts";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+
11
+ export const PACKAGE_ROOT = resolve(__dirname, "../..");
12
+ export const CLI_ARGS = argv.slice(2);
13
+
14
+ export function readArgValue(args: string[], flag: string): string | undefined {
15
+ const index = args.indexOf(flag);
16
+ if (index === -1) return undefined;
17
+ const value = args[index + 1];
18
+ if (!value || value.startsWith("--")) return undefined;
19
+ return value;
20
+ }
21
+
22
+ export function resolveInputPath(value: string): string {
23
+ if (value.startsWith("~/")) {
24
+ return resolve(homedir(), value.slice(2));
25
+ }
26
+ return resolve(value);
27
+ }
28
+
29
+ export function resolvePersistenceRoot(value: string): string {
30
+ const resolved = value.startsWith("file://")
31
+ ? fileURLToPath(value)
32
+ : resolveInputPath(value);
33
+
34
+ return basename(resolved) === ".symphifo"
35
+ ? resolved
36
+ : join(resolved, ".symphifo");
37
+ }
38
+
39
+ const CLI_WORKSPACE_ROOT = readArgValue(CLI_ARGS, "--workspace");
40
+ const CLI_PERSISTENCE = readArgValue(CLI_ARGS, "--persistence");
41
+
42
+ export const TARGET_ROOT = resolveInputPath(
43
+ env.SYMPHIFO_WORKSPACE_ROOT ?? CLI_WORKSPACE_ROOT ?? getCwd(),
44
+ );
45
+
46
+ export const TRACKER_KIND = env.SYMPHIFO_TRACKER_KIND ?? "filesystem";
47
+
48
+ export const STATE_ROOT = resolvePersistenceRoot(
49
+ env.SYMPHIFO_PERSISTENCE
50
+ ?? CLI_PERSISTENCE
51
+ ?? env.SYMPHIFO_BOOTSTRAP_ROOT
52
+ ?? TARGET_ROOT,
53
+ );
54
+
55
+ export const SOURCE_ROOT = `${STATE_ROOT}/source`;
56
+ export const WORKSPACE_ROOT = `${STATE_ROOT}/workspaces`;
57
+ export const SOURCE_MARKER = `${SOURCE_ROOT}/.symphifo-local-source-ready`;
58
+
59
+ export const WORKFLOW_TEMPLATE = existsSync(join(TARGET_ROOT, "WORKFLOW.md"))
60
+ ? join(TARGET_ROOT, "WORKFLOW.md")
61
+ : existsSync(join(PACKAGE_ROOT, "WORKFLOW.md"))
62
+ ? join(PACKAGE_ROOT, "WORKFLOW.md")
63
+ : "";
64
+
65
+ export const WORKFLOW_RENDERED = `${STATE_ROOT}/WORKFLOW.local.md`;
66
+
67
+ export const S3DB_DATABASE_PATH = `${STATE_ROOT}/s3db`;
68
+ export const S3DB_BUCKET = env.SYMPHIFO_STORAGE_BUCKET ?? "symphifo";
69
+ export const S3DB_KEY_PREFIX = env.SYMPHIFO_STORAGE_KEY_PREFIX ?? "state";
70
+
71
+ export const S3DB_RUNTIME_RESOURCE = "symphifo_runtime_state";
72
+ export const S3DB_ISSUE_RESOURCE = "symphifo_issues";
73
+ export const S3DB_EVENT_RESOURCE = "symphifo_events";
74
+ export const S3DB_AGENT_SESSION_RESOURCE = "symphifo_agent_sessions";
75
+ export const S3DB_AGENT_PIPELINE_RESOURCE = "symphifo_agent_pipelines";
76
+ export const S3DB_RUNTIME_RECORD_ID = "current";
77
+ export const S3DB_RUNTIME_SCHEMA_VERSION = 1;
78
+
79
+ export const DEFAULT_ISSUES_TEMPLATE = `${PACKAGE_ROOT}/src/fixtures/local-issues.json`;
80
+ export const LOCAL_ISSUES_FILE = resolveInputPath(
81
+ env.SYMPHIFO_ISSUES_FILE ?? join(STATE_ROOT, "issues.json"),
82
+ );
83
+
84
+ export const FRONTEND_DIR = `${PACKAGE_ROOT}/src/dashboard`;
85
+ export const FRONTEND_INDEX = `${FRONTEND_DIR}/index.html`;
86
+ export const FRONTEND_APP_JS = `${FRONTEND_DIR}/app.js`;
87
+ export const FRONTEND_STYLES_CSS = `${FRONTEND_DIR}/styles.css`;
88
+
89
+ export const DEBUG_BOOT = env.SYMPHIFO_DEBUG_BOOT === "1";
90
+
91
+ export const ALLOWED_STATES: IssueState[] = [
92
+ "Todo",
93
+ "In Progress",
94
+ "In Review",
95
+ "Blocked",
96
+ "Done",
97
+ "Cancelled",
98
+ ];
99
+
100
+ export const TERMINAL_STATES = new Set<IssueState>(["Done", "Cancelled"]);
101
+ export const EXECUTING_STATES = new Set<IssueState>(["In Progress", "In Review"]);
102
+ export const PERSIST_EVENTS_MAX = 500;
@@ -0,0 +1,134 @@
1
+ import { appendFileSync, readFileSync } from "node:fs";
2
+ import { env } from "node:process";
3
+ import { parse as parseYaml } from "yaml";
4
+ import type { IssueState, JsonRecord } from "./types.ts";
5
+ import { ALLOWED_STATES, DEBUG_BOOT } from "./constants.ts";
6
+
7
+ export function now(): string {
8
+ return new Date().toISOString();
9
+ }
10
+
11
+ export function sleep(ms: number): Promise<void> {
12
+ return new Promise((resolve) => setTimeout(resolve, ms));
13
+ }
14
+
15
+ export function toStringValue(value: unknown, fallback = ""): string {
16
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
17
+ }
18
+
19
+ export function toNumberValue(value: unknown, fallback = 1): number {
20
+ const parsed =
21
+ typeof value === "number"
22
+ ? value
23
+ : typeof value === "string"
24
+ ? Number.parseInt(value, 10)
25
+ : Number.NaN;
26
+
27
+ return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
28
+ }
29
+
30
+ export function toBooleanValue(value: unknown, fallback: boolean): boolean {
31
+ return typeof value === "boolean" ? value : fallback;
32
+ }
33
+
34
+ export function toStringArray(value: unknown): string[] {
35
+ if (!Array.isArray(value)) return [];
36
+ return value
37
+ .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
38
+ .map((entry) => entry.trim());
39
+ }
40
+
41
+ export function clamp(value: number, min: number, max: number): number {
42
+ return Math.min(Math.max(value, min), max);
43
+ }
44
+
45
+ export function normalizeState(value: unknown): IssueState {
46
+ const raw = typeof value === "string" ? value.trim() : "";
47
+ if ((ALLOWED_STATES as readonly string[]).includes(raw)) {
48
+ return raw as IssueState;
49
+ }
50
+ return "Todo";
51
+ }
52
+
53
+ export function parseEnvNumber(name: string, fallback: number): number {
54
+ return toNumberValue(env[name], fallback);
55
+ }
56
+
57
+ export function parseIntArg(value: string, fallback: number): number {
58
+ const parsed = Number.parseInt(value, 10);
59
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
60
+ }
61
+
62
+ export function parsePositiveIntEnv(name: string, fallback: number): number {
63
+ const source = env[name];
64
+ if (!source) return fallback;
65
+ return parseIntArg(source, fallback);
66
+ }
67
+
68
+ export function withRetryBackoff(attempt: number, baseDelayMs: number): number {
69
+ return Math.min(baseDelayMs * 2 ** attempt, 5 * 60 * 1000);
70
+ }
71
+
72
+ export function idToSafePath(value: string): string {
73
+ return value.toLowerCase().replace(/[^a-z0-9._-]/g, "-");
74
+ }
75
+
76
+ export function appendFileTail(target: string, text: string, maxLength: number): string {
77
+ const merged = `${target}\n${text}`;
78
+ if (merged.length <= maxLength) return merged;
79
+ return `…${merged.slice(-(maxLength - 1))}`;
80
+ }
81
+
82
+ export function readTextOrNull(path: string): string | null {
83
+ try {
84
+ return readFileSync(path, "utf8");
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ export function parseFrontMatter(source: string): { config: JsonRecord; body: string } {
91
+ const match = source.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
92
+ if (!match) {
93
+ return { config: {}, body: source.trim() };
94
+ }
95
+
96
+ const rawConfig = parseYaml(match[1]) as unknown;
97
+ const config = rawConfig && typeof rawConfig === "object" && !Array.isArray(rawConfig)
98
+ ? rawConfig as JsonRecord
99
+ : {};
100
+
101
+ return { config, body: match[2].trim() };
102
+ }
103
+
104
+ export function getNestedRecord(source: unknown, key: string): JsonRecord {
105
+ if (!source || typeof source !== "object" || Array.isArray(source)) return {};
106
+ const value = (source as JsonRecord)[key];
107
+ return value && typeof value === "object" && !Array.isArray(value)
108
+ ? value as JsonRecord
109
+ : {};
110
+ }
111
+
112
+ export function getNestedString(source: unknown, key: string, fallback = ""): string {
113
+ if (!source || typeof source !== "object" || Array.isArray(source)) return fallback;
114
+ return toStringValue((source as JsonRecord)[key], fallback);
115
+ }
116
+
117
+ export function getNestedNumber(source: unknown, key: string, fallback: number): number {
118
+ if (!source || typeof source !== "object" || Array.isArray(source)) return fallback;
119
+ return toNumberValue((source as JsonRecord)[key], fallback);
120
+ }
121
+
122
+ export function appendLog(logPath: string, entry: string): void {
123
+ appendFileSync(logPath, `${now()} [symphifo-local-ts] ${entry}\n`, "utf8");
124
+ }
125
+
126
+ export function debugBoot(message: string): void {
127
+ if (!DEBUG_BOOT) return;
128
+ console.error(`[SYMPHIFO_DEBUG_BOOT] ${message}`);
129
+ }
130
+
131
+ export function fail(message: string): never {
132
+ console.error(message);
133
+ process.exit(1);
134
+ }