@dotdrelle/wiki-manager 0.6.34 → 0.7.3

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.
Files changed (42) hide show
  1. package/bin/wiki-manager.js +2 -2
  2. package/docker-compose.yml +25 -1
  3. package/package.json +2 -2
  4. package/src/agent/graph.js +2 -19
  5. package/src/cli/wiki-manager.js +236 -129
  6. package/src/commands/slash.js +17 -1
  7. package/src/commands/slash.test.js +78 -0
  8. package/src/core/activity.js +14 -0
  9. package/src/core/agentEvents.js +24 -2
  10. package/src/core/agentLoop.js +164 -0
  11. package/src/core/agentLoop.test.js +85 -0
  12. package/src/core/cacert.js +4 -3
  13. package/src/core/compose.js +4 -3
  14. package/src/core/dockerCompose.test.js +14 -0
  15. package/src/core/documentIntake.test.js +7 -7
  16. package/src/core/env.js +6 -0
  17. package/src/core/jobQueue.js +18 -4
  18. package/src/core/mcp.js +1 -1
  19. package/src/core/queueStore.js +27 -0
  20. package/src/core/queueStore.test.js +31 -0
  21. package/src/core/startupCheck.js +1 -1
  22. package/src/core/startupCheck.test.js +66 -0
  23. package/src/core/wikirc.js +2 -2
  24. package/src/core/workspaces.js +8 -1
  25. package/src/runtime/auth.js +35 -0
  26. package/src/runtime/auth.test.js +21 -0
  27. package/src/runtime/client.js +108 -0
  28. package/src/runtime/lifecycle.js +60 -0
  29. package/src/runtime/queueStore.js +6 -0
  30. package/src/runtime/runner.js +73 -0
  31. package/src/runtime/server.js +160 -0
  32. package/src/runtime/server.test.js +53 -0
  33. package/src/runtime/store.js +292 -0
  34. package/src/runtime/store.test.js +103 -0
  35. package/src/runtime/supervisor.js +98 -0
  36. package/src/runtime/supervisor.test.js +99 -0
  37. package/src/shell/repl.js +132 -17
  38. package/src/shell/repl.test.js +38 -0
  39. package/src/shell/tui.tsx +4 -1
  40. package/src/shell/useAgent.ts +20 -2
  41. package/src/shell/useSession.ts +146 -11
  42. package/wiki-workspace +39 -30
@@ -0,0 +1,27 @@
1
+ export function createQueueStore(session, { persist = () => {} } = {}) {
2
+ session.jobQueue ??= [];
3
+ return {
4
+ list() {
5
+ session.jobQueue ??= [];
6
+ return session.jobQueue;
7
+ },
8
+ replace(queue) {
9
+ session.jobQueue = Array.isArray(queue) ? queue : [];
10
+ persist();
11
+ return session.jobQueue;
12
+ },
13
+ changed() {
14
+ session.jobQueue ??= [];
15
+ persist();
16
+ },
17
+ };
18
+ }
19
+
20
+ export function createMemoryQueueStore(session) {
21
+ return createQueueStore(session, { persist: () => session._onQueueUpdate?.(session.jobQueue) });
22
+ }
23
+
24
+ export function queueStoreFor(session) {
25
+ session.queueStore ??= createMemoryQueueStore(session);
26
+ return session.queueStore;
27
+ }
@@ -0,0 +1,31 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { enqueueProductionJob, ensureJobQueue } from './jobQueue.js';
4
+
5
+ test('job queue uses an injected queue store', () => {
6
+ const queue = [];
7
+ let changed = 0;
8
+ const session = {
9
+ workspace: 'docs',
10
+ queueStore: {
11
+ list() {
12
+ return queue;
13
+ },
14
+ replace(next) {
15
+ queue.splice(0, queue.length, ...next);
16
+ changed += 1;
17
+ return queue;
18
+ },
19
+ changed() {
20
+ changed += 1;
21
+ },
22
+ },
23
+ };
24
+
25
+ const item = enqueueProductionJob(session, { type: 'build' }, 'workspace_busy');
26
+
27
+ assert.equal(item.workspace, 'docs');
28
+ assert.equal(queue.length, 1);
29
+ assert.equal(ensureJobQueue(session), queue);
30
+ assert.equal(changed, 1);
31
+ });
@@ -92,7 +92,7 @@ function checkWikirc(workspace) {
92
92
  profileName: loaded.profile.name,
93
93
  };
94
94
  const gaps = [];
95
- if (!summary.hasApiKey || !summary.model) gaps.push({ kind: 'llm', context });
95
+ if (!summary.provider || !summary.baseUrl || !summary.hasApiKey || !summary.model) gaps.push({ kind: 'llm', context });
96
96
  if (!summary.vectorEnabled) gaps.push({ kind: 'vector', context });
97
97
  return gaps;
98
98
  } catch {
@@ -0,0 +1,66 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+ import { runChecks } from './startupCheck.js';
8
+
9
+ async function withWorkspace(wikircLines, fn) {
10
+ const root = await mkdtemp(join(tmpdir(), 'wiki-manager-startup-check-'));
11
+ const registryRoot = join(root, 'registry');
12
+ const registryPath = join(registryRoot, 'demo');
13
+ const workspacePath = join(root, 'workspace');
14
+ mkdirSync(registryPath, { recursive: true });
15
+ mkdirSync(workspacePath, { recursive: true });
16
+ writeFileSync(join(registryPath, '.env'), [
17
+ 'WORKSPACE_NAME=demo',
18
+ `WIKI_WORKSPACE_PATH=${workspacePath}`,
19
+ '',
20
+ ].join('\n'), 'utf8');
21
+ writeFileSync(join(workspacePath, '.wikirc.yaml'), [...wikircLines, ''].join('\n'), 'utf8');
22
+
23
+ const previousDir = process.env.WIKI_WORKSPACES_DIR;
24
+ process.env.WIKI_WORKSPACES_DIR = registryRoot;
25
+ try {
26
+ await fn({ workspacePath });
27
+ } finally {
28
+ if (previousDir === undefined) delete process.env.WIKI_WORKSPACES_DIR;
29
+ else process.env.WIKI_WORKSPACES_DIR = previousDir;
30
+ }
31
+ }
32
+
33
+ function hasGap(gaps, kind) {
34
+ return gaps.some((gap) => gap.kind === kind);
35
+ }
36
+
37
+ test('runChecks treats default LLM config without baseUrl as incomplete', async () => {
38
+ await withWorkspace([
39
+ 'llm:',
40
+ ' provider: openai-compatible',
41
+ ' apiKey: key',
42
+ ' model: chat-model',
43
+ 'retrieval:',
44
+ ' vector:',
45
+ ' enabled: true',
46
+ ], async () => {
47
+ const gaps = await runChecks();
48
+ assert.equal(hasGap(gaps, 'llm'), true);
49
+ });
50
+ });
51
+
52
+ test('runChecks treats default LLM config with provider, baseUrl, apiKey and model as complete', async () => {
53
+ await withWorkspace([
54
+ 'llm:',
55
+ ' provider: openai-compatible',
56
+ ' baseUrl: http://localhost:8000/v1',
57
+ ' apiKey: key',
58
+ ' model: chat-model',
59
+ 'retrieval:',
60
+ ' vector:',
61
+ ' enabled: true',
62
+ ], async () => {
63
+ const gaps = await runChecks();
64
+ assert.equal(hasGap(gaps, 'llm'), false);
65
+ });
66
+ });
@@ -29,8 +29,8 @@ export function resolveWikircProfile(workspacePath, profileName = 'default') {
29
29
  const normalized = profileName || 'default';
30
30
  const found = profiles.find((profile) => profile.name === normalized || profile.fileName === normalized);
31
31
  if (!found) {
32
- const available = profiles.map((profile) => profile.name).join(', ') || 'aucun';
33
- throw new Error(`profil wikirc introuvable: ${normalized} (disponibles: ${available})`);
32
+ const available = profiles.map((profile) => profile.name).join(', ') || 'none';
33
+ throw new Error(`wikirc profile not found: ${normalized} (available: ${available})`);
34
34
  }
35
35
  return found;
36
36
  }
@@ -33,12 +33,19 @@ export function listWorkspaces() {
33
33
  if (!existsSync(envFile)) return [];
34
34
  const env = readEnvFile(envFile);
35
35
  const workspacePath = env.WIKI_WORKSPACE_PATH || registryPath;
36
+ let resolvedWorkspacePath;
37
+ try {
38
+ resolvedWorkspacePath = realpathSync.native?.(workspacePath) ?? realpathSync(workspacePath);
39
+ } catch {
40
+ // Host-absolute WIKI_WORKSPACE_PATH not accessible here (e.g. inside a container); use registry dir.
41
+ resolvedWorkspacePath = registryPath;
42
+ }
36
43
  return [
37
44
  {
38
45
  name: env.WORKSPACE_NAME || entry.name,
39
46
  registryPath,
40
47
  envFile,
41
- workspacePath: realpathSync.native?.(workspacePath) ?? realpathSync(workspacePath),
48
+ workspacePath: resolvedWorkspacePath,
42
49
  env,
43
50
  },
44
51
  ];
@@ -0,0 +1,35 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { defaultRuntimeStateDir } from '../core/env.js';
5
+
6
+ export function runtimeTokenPath(stateDir = defaultRuntimeStateDir()) {
7
+ return join(resolve(stateDir), 'runtime.token');
8
+ }
9
+
10
+ export function runtimeTokenFromEnv() {
11
+ return process.env.WIKI_MANAGER_RUNTIME_TOKEN ?? process.env.RUNTIME_AUTH_TOKEN ?? null;
12
+ }
13
+
14
+ export function resolveRuntimeAuthToken({
15
+ host = '127.0.0.1',
16
+ stateDir = defaultRuntimeStateDir(),
17
+ explicitToken = runtimeTokenFromEnv(),
18
+ } = {}) {
19
+ if (explicitToken) return { token: explicitToken, source: 'env', tokenPath: null };
20
+ if (!isExposedHost(host)) return { token: null, source: 'none', tokenPath: null };
21
+
22
+ const tokenPath = runtimeTokenPath(stateDir);
23
+ if (existsSync(tokenPath)) {
24
+ const token = readFileSync(tokenPath, 'utf8').trim();
25
+ if (token) return { token, source: 'file', tokenPath };
26
+ }
27
+ const token = randomBytes(32).toString('hex');
28
+ mkdirSync(resolve(stateDir), { recursive: true });
29
+ writeFileSync(tokenPath, `${token}\n`, { mode: 0o600 });
30
+ return { token, source: 'generated', tokenPath };
31
+ }
32
+
33
+ export function isExposedHost(host) {
34
+ return ['0.0.0.0', '::', '[::]'].includes(String(host ?? '').trim());
35
+ }
@@ -0,0 +1,21 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtempSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import test from 'node:test';
6
+ import { resolveRuntimeAuthToken } from './auth.js';
7
+
8
+ test('resolveRuntimeAuthToken: loopback host does not require token', () => {
9
+ const result = resolveRuntimeAuthToken({ host: '127.0.0.1', explicitToken: null });
10
+ assert.equal(result.token, null);
11
+ assert.equal(result.source, 'none');
12
+ });
13
+
14
+ test('resolveRuntimeAuthToken: exposed host generates and reuses token', () => {
15
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-auth-'));
16
+ const first = resolveRuntimeAuthToken({ host: '0.0.0.0', stateDir, explicitToken: null });
17
+ const second = resolveRuntimeAuthToken({ host: '0.0.0.0', stateDir, explicitToken: null });
18
+ assert.equal(first.source, 'generated');
19
+ assert.equal(second.source, 'file');
20
+ assert.equal(second.token, first.token);
21
+ });
@@ -0,0 +1,108 @@
1
+ import { runtimeTokenFromEnv as runtimeToken } from './auth.js';
2
+
3
+ function base(url) {
4
+ return url.replace(/\/$/, '');
5
+ }
6
+
7
+ export function runtimeUrlFromEnv() {
8
+ return process.env.WIKI_MANAGER_RUNTIME_URL ?? 'http://127.0.0.1:7788';
9
+ }
10
+
11
+ export async function fetchRuntimeState({
12
+ url = runtimeUrlFromEnv(),
13
+ token = runtimeToken(),
14
+ } = {}) {
15
+ const response = await fetch(`${base(url)}/state`, {
16
+ headers: runtimeHeaders(token),
17
+ });
18
+ if (!response.ok) throw new Error(`Runtime state failed: HTTP ${response.status}`);
19
+ return response.json();
20
+ }
21
+
22
+ export async function checkRuntimeHealth({
23
+ url = runtimeUrlFromEnv(),
24
+ token = runtimeToken(),
25
+ } = {}) {
26
+ const response = await fetch(`${base(url)}/health`, {
27
+ headers: runtimeHeaders(token),
28
+ });
29
+ if (!response.ok) return null;
30
+ return response.json();
31
+ }
32
+
33
+ export async function postRuntimeRun(input, {
34
+ url = runtimeUrlFromEnv(),
35
+ token = runtimeToken(),
36
+ workspace = null,
37
+ } = {}) {
38
+ const response = await fetch(`${base(url)}/run`, {
39
+ method: 'POST',
40
+ headers: {
41
+ ...runtimeHeaders(token),
42
+ 'Content-Type': 'application/json',
43
+ },
44
+ body: JSON.stringify({ input, workspace }),
45
+ });
46
+ if (!response.ok) throw new Error(`Runtime run failed: HTTP ${response.status}`);
47
+ return response.json();
48
+ }
49
+
50
+ export async function postRuntimeCancel({
51
+ url = runtimeUrlFromEnv(),
52
+ token = runtimeToken(),
53
+ } = {}) {
54
+ const response = await fetch(`${base(url)}/cancel`, {
55
+ method: 'POST',
56
+ headers: runtimeHeaders(token),
57
+ });
58
+ if (!response.ok && response.status !== 501) throw new Error(`Runtime cancel failed: HTTP ${response.status}`);
59
+ return response.json();
60
+ }
61
+
62
+ function runtimeHeaders(token) {
63
+ return token ? { Authorization: `Bearer ${token}` } : {};
64
+ }
65
+
66
+ export async function* streamRuntimeEvents({
67
+ url = runtimeUrlFromEnv(),
68
+ token = runtimeToken(),
69
+ signal = null,
70
+ } = {}) {
71
+ const response = await fetch(`${base(url)}/events/stream`, {
72
+ headers: { ...runtimeHeaders(token), Accept: 'text/event-stream' },
73
+ signal,
74
+ });
75
+ if (!response.ok) throw new Error(`Runtime SSE connect failed: HTTP ${response.status}`);
76
+ const reader = response.body.getReader();
77
+ const decoder = new TextDecoder();
78
+ let buffer = '';
79
+ try {
80
+ while (true) {
81
+ const { done, value } = await reader.read();
82
+ if (done) break;
83
+ buffer += decoder.decode(value, { stream: true });
84
+ const blocks = buffer.split('\n\n');
85
+ buffer = blocks.pop() ?? '';
86
+ for (const block of blocks) {
87
+ let type = 'message';
88
+ let data = '';
89
+ for (const line of block.split('\n')) {
90
+ if (line.startsWith('event: ')) type = line.slice(7).trim();
91
+ else if (line.startsWith('data: ')) data = line.slice(6);
92
+ }
93
+ if (!data) continue;
94
+ try {
95
+ yield { type, data: JSON.parse(data) };
96
+ } catch {
97
+ // malformed frame — skip
98
+ }
99
+ }
100
+ }
101
+ } finally {
102
+ reader.releaseLock();
103
+ }
104
+ }
105
+
106
+ export function runtimeFetchOptions(token = runtimeToken()) {
107
+ return { headers: runtimeHeaders(token) };
108
+ }
@@ -0,0 +1,60 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { checkRuntimeHealth, runtimeUrlFromEnv } from './client.js';
5
+ import { defaultRuntimeStateDir } from '../core/env.js';
6
+ import { resolveRuntimeAuthToken, runtimeTokenFromEnv } from './auth.js';
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const managerRoot = resolve(__dirname, '../..');
10
+ const binPath = resolve(managerRoot, 'bin/wiki-manager.js');
11
+
12
+ export async function ensureRuntime({
13
+ host = process.env.WIKI_MANAGER_RUNTIME_HOST ?? '0.0.0.0',
14
+ port = Number(process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788),
15
+ stateDir = process.env.WIKI_MANAGER_STATE_DIR ?? defaultRuntimeStateDir(),
16
+ url = process.env.WIKI_MANAGER_RUNTIME_URL ?? `http://127.0.0.1:${port}`,
17
+ timeoutMs = 5000,
18
+ } = {}) {
19
+ const auth = resolveRuntimeAuthToken({ host, stateDir });
20
+ if (auth.token) process.env.WIKI_MANAGER_RUNTIME_TOKEN = auth.token;
21
+ const existing = await runtimeHealthOrNull(url, auth.token);
22
+ if (existing) return { url, started: false, health: existing, token: auth.token, tokenPath: auth.tokenPath };
23
+
24
+ const child = spawn(process.execPath, [
25
+ binPath,
26
+ 'runtime',
27
+ '--host',
28
+ host,
29
+ '--port',
30
+ String(port),
31
+ '--state-dir',
32
+ stateDir,
33
+ ], {
34
+ detached: true,
35
+ stdio: 'ignore',
36
+ env: {
37
+ ...process.env,
38
+ ...(auth.token ? { WIKI_MANAGER_RUNTIME_TOKEN: auth.token } : {}),
39
+ WIKI_MANAGER_RUNTIME_CHILD: '1',
40
+ },
41
+ });
42
+ child.unref();
43
+
44
+ const deadline = Date.now() + timeoutMs;
45
+ let health = null;
46
+ while (Date.now() < deadline) {
47
+ health = await runtimeHealthOrNull(url, auth.token);
48
+ if (health) return { url, started: true, health, pid: child.pid, token: auth.token, tokenPath: auth.tokenPath };
49
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 150));
50
+ }
51
+ throw new Error(`Runtime did not become healthy at ${url}`);
52
+ }
53
+
54
+ export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = runtimeTokenFromEnv()) {
55
+ try {
56
+ return await checkRuntimeHealth({ url, token });
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
@@ -0,0 +1,6 @@
1
+ import { createQueueStore } from '../core/queueStore.js';
2
+
3
+ export function createSqliteQueueStore(store, session) {
4
+ session.jobQueue = store.listQueue();
5
+ return createQueueStore(session, { persist: () => store.saveQueue(session.jobQueue) });
6
+ }
@@ -0,0 +1,73 @@
1
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
2
+ import { sessionActivities, terminalFailures } from '../core/activity.js';
3
+ import { runAgenticLoop, throwIfAborted } from '../core/agentLoop.js';
4
+ import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
5
+
6
+ async function waitForRuntimeActivities(session, startedActivities, { timeoutMs, signal, pollBusy }) {
7
+ const deadline = Date.now() + timeoutMs;
8
+ const trackedKeys = new Set(startedActivities.map((activity) => activity.key));
9
+ emitRuntimeLog(session, `activity-loop: tracking ${trackedKeys.size} activity(s)`);
10
+
11
+ let tracked = [];
12
+ while (Date.now() < deadline) {
13
+ throwIfAborted(signal, 'Runtime run cancelled.');
14
+ await pollActivitiesOnce(session, { pollBusy, signal });
15
+ tracked = sessionActivities(session).filter((activity) => trackedKeys.has(activity.key));
16
+ const active = tracked.filter((activity) => !activity.terminal);
17
+ if (active.length === 0) {
18
+ const failures = terminalFailures(tracked);
19
+ if (failures.length > 0) {
20
+ for (const failure of failures) {
21
+ emitRuntimeLog(session, `activity-loop: ${failure.label} -> ${failure.status}${failure.error ? ` (${failure.error})` : ''}`);
22
+ }
23
+ return { ok: false, completed: tracked };
24
+ }
25
+ emitRuntimeLog(session, 'activity-loop: tracked activities terminal');
26
+ return { ok: true, completed: tracked };
27
+ }
28
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 1000));
29
+ }
30
+
31
+ emitRuntimeLog(session, 'activity-loop: timeout');
32
+ return { ok: false, timedOut: true, completed: tracked };
33
+ }
34
+
35
+ export async function runRuntimeAgenticLoop(agent, session, initialInput, { signal, timeoutMs, maxTurns, runId, pollBusy }) {
36
+ return runAgenticLoop(agent, session, initialInput, {
37
+ signal,
38
+ timeoutMs,
39
+ maxTurns,
40
+ runId,
41
+ abortMessage: 'Runtime run cancelled.',
42
+ waitForActivities: (turnSession, startedActivities, waitOptions) =>
43
+ waitForRuntimeActivities(turnSession, startedActivities, { ...waitOptions, pollBusy }),
44
+ onTurnStart: ({ turn, maxTurns: totalTurns }) => {
45
+ emitRuntimeLog(session, `agentic-loop: turn ${turn}/${totalTurns}`);
46
+ },
47
+ onAssistantMessage: ({ response }) => {
48
+ const lastMessage = session.agentProjection?.conversation?.at(-1);
49
+ if (lastMessage?.role !== 'assistant' || lastMessage.content !== response) {
50
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', {
51
+ origin: 'agent',
52
+ runId,
53
+ payload: { content: response },
54
+ }));
55
+ }
56
+ },
57
+ onPlanExtracted: ({ steps }) => {
58
+ emitRuntimeLog(session, `agentic-loop: plan extracted from text (${steps.length} steps)`);
59
+ },
60
+ onComplete: () => {
61
+ emitRuntimeLog(session, 'agentic-loop: no pending activity or plan step');
62
+ },
63
+ onPendingSteps: ({ pendingSteps }) => {
64
+ emitRuntimeLog(session, `agentic-loop: ${pendingSteps.length} pending step(s), continuing`);
65
+ },
66
+ onActivitiesStarted: ({ activities }) => {
67
+ emitRuntimeLog(session, `agentic-loop: ${activities.length} new activity(s), waiting`);
68
+ },
69
+ onMaxTurns: ({ maxTurns: totalTurns }) => {
70
+ emitRuntimeLog(session, `agentic-loop: max turns (${totalTurns}) reached`);
71
+ },
72
+ });
73
+ }
@@ -0,0 +1,160 @@
1
+ import { createServer } from 'node:http';
2
+ import { runtimeTokenFromEnv } from './auth.js';
3
+
4
+ export function startRuntimeServer({
5
+ host = '0.0.0.0',
6
+ port = 7788,
7
+ token = runtimeTokenFromEnv(),
8
+ store,
9
+ session,
10
+ run,
11
+ cancel,
12
+ } = {}) {
13
+ const clients = new Set();
14
+ let running = false;
15
+ let currentAbortController = null;
16
+
17
+ function publish(event) {
18
+ const payload = `event: agent_event\ndata: ${JSON.stringify(event)}\n\n`;
19
+ for (const response of clients) {
20
+ response.write(payload);
21
+ }
22
+ }
23
+
24
+ const server = createServer(async (request, response) => {
25
+ try {
26
+ if (!isAuthorized(request, token)) {
27
+ sendJson(response, 401, { error: 'Unauthorized' });
28
+ return;
29
+ }
30
+
31
+ const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
32
+ if (request.method === 'GET' && url.pathname === '/health') {
33
+ sendJson(response, 200, { ok: true, status: running ? 'running' : 'idle', dbPath: store.dbPath });
34
+ return;
35
+ }
36
+ if (request.method === 'GET' && url.pathname === '/state') {
37
+ sendJson(response, 200, store.getState(session));
38
+ return;
39
+ }
40
+ if (request.method === 'GET' && url.pathname === '/events') {
41
+ sendJson(response, 200, { events: store.listEvents() });
42
+ return;
43
+ }
44
+ if (request.method === 'GET' && url.pathname === '/events/stream') {
45
+ response.writeHead(200, {
46
+ 'Content-Type': 'text/event-stream',
47
+ 'Cache-Control': 'no-cache',
48
+ Connection: 'keep-alive',
49
+ 'X-Accel-Buffering': 'no',
50
+ });
51
+ response.write(`event: state\ndata: ${JSON.stringify(store.getState(session))}\n\n`);
52
+ clients.add(response);
53
+ request.on('close', () => clients.delete(response));
54
+ return;
55
+ }
56
+ if (request.method === 'POST' && url.pathname === '/run') {
57
+ if (running) {
58
+ sendJson(response, 409, { error: 'A runtime run is already active.' });
59
+ return;
60
+ }
61
+ running = true;
62
+ currentAbortController = new AbortController();
63
+ try {
64
+ const body = await readJson(request);
65
+ const input = String(body.input ?? body.prompt ?? '').trim();
66
+ if (!input) {
67
+ running = false;
68
+ currentAbortController = null;
69
+ sendJson(response, 400, { error: 'Missing input.' });
70
+ return;
71
+ }
72
+ run(body, { signal: currentAbortController.signal })
73
+ .catch((err) => {
74
+ session._onRuntimeError?.(err);
75
+ })
76
+ .finally(() => {
77
+ running = false;
78
+ currentAbortController = null;
79
+ });
80
+ sendJson(response, 202, { accepted: true });
81
+ } catch (err) {
82
+ running = false;
83
+ currentAbortController = null;
84
+ throw err;
85
+ }
86
+ return;
87
+ }
88
+ if (request.method === 'POST' && url.pathname === '/cancel') {
89
+ if (!running || !currentAbortController) {
90
+ sendJson(response, 200, { cancelled: false, reason: 'no active run' });
91
+ return;
92
+ }
93
+ currentAbortController.abort();
94
+ await cancel?.();
95
+ sendJson(response, 202, { cancelled: true });
96
+ return;
97
+ }
98
+
99
+ sendJson(response, 404, { error: 'Not found' });
100
+ } catch (err) {
101
+ sendJson(response, 500, { error: err instanceof Error ? err.message : String(err) });
102
+ }
103
+ });
104
+
105
+ return new Promise((resolve, reject) => {
106
+ server.once('error', reject);
107
+ server.listen(port, host, () => {
108
+ server.off('error', reject);
109
+ const address = server.address();
110
+ resolve({
111
+ host,
112
+ port: typeof address === 'object' && address ? address.port : port,
113
+ publish,
114
+ close: () => new Promise((closeResolve, closeReject) => {
115
+ for (const response of clients) response.end();
116
+ clients.clear();
117
+ server.close((err) => (err ? closeReject(err) : closeResolve()));
118
+ }),
119
+ });
120
+ });
121
+ });
122
+ }
123
+
124
+ function isAuthorized(request, token) {
125
+ if (!token) return true;
126
+ const authorization = request.headers.authorization ?? '';
127
+ if (authorization === `Bearer ${token}`) return true;
128
+ return request.headers['x-runtime-token'] === token;
129
+ }
130
+
131
+ function sendJson(response, statusCode, value) {
132
+ response.writeHead(statusCode, { 'Content-Type': 'application/json' });
133
+ response.end(`${JSON.stringify(value)}\n`);
134
+ }
135
+
136
+ function readJson(request) {
137
+ return new Promise((resolve, reject) => {
138
+ let raw = '';
139
+ request.setEncoding('utf8');
140
+ request.on('data', (chunk) => {
141
+ raw += chunk;
142
+ if (raw.length > 1024 * 1024) {
143
+ reject(new Error('Request body too large.'));
144
+ request.destroy();
145
+ }
146
+ });
147
+ request.on('end', () => {
148
+ if (!raw.trim()) {
149
+ resolve({});
150
+ return;
151
+ }
152
+ try {
153
+ resolve(JSON.parse(raw));
154
+ } catch (err) {
155
+ reject(err);
156
+ }
157
+ });
158
+ request.on('error', reject);
159
+ });
160
+ }