@dotdrelle/wiki-manager 0.7.3 → 0.9.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 (39) hide show
  1. package/.env.example +20 -0
  2. package/README.md +50 -1
  3. package/docker-compose.yml +1 -23
  4. package/mcp.endpoints.example.json +13 -0
  5. package/package.json +2 -2
  6. package/src/agent/graph.js +101 -15
  7. package/src/agent/graph.test.js +145 -0
  8. package/src/cli/wiki-manager.js +306 -53
  9. package/src/commands/slash.js +4 -24
  10. package/src/core/agentEvents.js +169 -4
  11. package/src/core/agentEvents.test.js +176 -4
  12. package/src/core/agentLoop.js +3 -0
  13. package/src/core/compose.js +1 -2
  14. package/src/core/dockerCompose.test.js +5 -5
  15. package/src/core/jobQueue.js +29 -12
  16. package/src/core/mcp.js +120 -10
  17. package/src/core/mcp.test.js +121 -1
  18. package/src/core/plan.js +33 -0
  19. package/src/core/queueStore.test.js +1 -0
  20. package/src/core/sessionConfig.js +24 -0
  21. package/src/core/wikiWorkspace.test.js +24 -0
  22. package/src/runtime/approvals.js +113 -0
  23. package/src/runtime/auth.test.js +8 -0
  24. package/src/runtime/client.js +52 -6
  25. package/src/runtime/lifecycle.js +27 -3
  26. package/src/runtime/queueStore.js +3 -3
  27. package/src/runtime/runner.js +340 -0
  28. package/src/runtime/runner.test.js +270 -0
  29. package/src/runtime/server.js +252 -33
  30. package/src/runtime/server.test.js +577 -0
  31. package/src/runtime/store.js +181 -39
  32. package/src/runtime/store.test.js +363 -4
  33. package/src/runtime/supervisor.js +6 -0
  34. package/src/runtime/supervisor.test.js +141 -0
  35. package/src/shell/RightPane.tsx +1 -1
  36. package/src/shell/repl.js +22 -6
  37. package/src/shell/useAgent.ts +1 -1
  38. package/src/shell/useSession.ts +10 -5
  39. package/wiki-workspace +198 -4
package/src/core/mcp.js CHANGED
@@ -70,11 +70,28 @@ function endpointStatus(configured, detail = '') {
70
70
  };
71
71
  }
72
72
 
73
+ function approvalToolsFor(serverName) {
74
+ const raw = envValue('WIKI_MANAGER_REQUIRE_APPROVAL_TOOLS');
75
+ if (!raw) return undefined;
76
+ const tools = String(raw)
77
+ .split(',')
78
+ .map((item) => item.trim())
79
+ .filter(Boolean)
80
+ .filter((item) => item === '*' || item.startsWith(`${serverName}.`) || !item.includes('.'))
81
+ .map((item) => item.startsWith(`${serverName}.`) ? item.slice(serverName.length + 1) : item);
82
+ return tools.length > 0 ? tools : undefined;
83
+ }
84
+
73
85
  const MCP_SERVICE_MAP = {
74
86
  wiki: 'mcp-http',
75
87
  production: 'production-mcp',
76
88
  };
77
89
 
90
+ const DEFAULT_MCP_RETRY_POLICY = {
91
+ maxAttempts: 2,
92
+ backoffMs: 500,
93
+ };
94
+
78
95
  export function buildMcpStatus(session) {
79
96
  const workspaceEnv = session.workspaceEnv ?? {};
80
97
  const wikiMcpToken = session.wikircConfig?.mcp?.accessKey;
@@ -91,6 +108,7 @@ export function buildMcpStatus(session) {
91
108
  ),
92
109
  url: workspaceEnv.WIKI_MCP_PORT ? `http://127.0.0.1:${workspaceEnv.WIKI_MCP_PORT}/mcp` : null,
93
110
  token: wikiMcpToken || null,
111
+ requireApproval: approvalToolsFor('wiki'),
94
112
  },
95
113
  production: {
96
114
  ...endpointStatus(
@@ -100,6 +118,7 @@ export function buildMcpStatus(session) {
100
118
  url: workspaceEnv.PRODUCTION_MCP_PORT ? `http://127.0.0.1:${workspaceEnv.PRODUCTION_MCP_PORT}/mcp/` : null,
101
119
  token: workspaceEnv.PRODUCTION_MCP_AUTH_TOKEN || null,
102
120
  activeConfigPath: session.wikirc?.fileName || null,
121
+ requireApproval: approvalToolsFor('production'),
103
122
  },
104
123
  ...external,
105
124
  };
@@ -211,7 +230,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
211
230
  params: {
212
231
  protocolVersion: '2025-06-18',
213
232
  capabilities: {},
214
- clientInfo: { name: 'wiki-manager', version: '0.7.3' },
233
+ clientInfo: { name: 'wiki-manager', version: '0.9.3' },
215
234
  },
216
235
  }),
217
236
  });
@@ -240,7 +259,7 @@ async function mcpRequest(endpoint, method, params, signal, options = {}) {
240
259
  }
241
260
  }
242
261
 
243
- export async function callMcpTool(mcpStatus, serverName, toolName, args = {}, signal) {
262
+ export async function callMcpTool(mcpStatus, serverName, toolName, args = {}, signal, options = {}) {
244
263
  const endpoint = mcpStatus?.[serverName];
245
264
  if (!endpoint) throw new Error(`Unknown MCP: ${serverName}`);
246
265
  if (endpoint.status !== 'connected') throw new Error(`MCP is not connected: ${serverName}`);
@@ -254,14 +273,17 @@ export async function callMcpTool(mcpStatus, serverName, toolName, args = {}, si
254
273
  ...(shouldInjectConfigPath ? { configPath: endpoint.activeConfigPath } : {}),
255
274
  };
256
275
  const timeoutMs = serverName === 'documents' && toolName === 'documents_convert_to_markdown' ? 600_000 : 8000;
257
- const payload = await mcpRequest(endpoint, 'tools/call', {
258
- name: toolName,
259
- arguments: toolArgs,
260
- }, signal, { timeoutMs });
261
- if (payload?.result?.isError) {
262
- throw new Error(formatMcpToolResult(payload.result));
263
- }
264
- return payload?.result ?? null;
276
+ const retry = resolveRetryPolicy(endpoint, toolName, options.retry);
277
+ return withRetry(async () => {
278
+ const payload = await mcpRequest(endpoint, 'tools/call', {
279
+ name: toolName,
280
+ arguments: toolArgs,
281
+ }, signal, { timeoutMs });
282
+ if (payload?.result?.isError) {
283
+ throw new Error(formatMcpToolResult(payload.result));
284
+ }
285
+ return payload?.result ?? null;
286
+ }, retry, { signal, onRetry: options.onRetry });
265
287
  }
266
288
 
267
289
  export function formatMcpToolResult(result) {
@@ -278,6 +300,94 @@ export function formatMcpToolResult(result) {
278
300
  .trim() || 'No result.';
279
301
  }
280
302
 
303
+ let _cachedEnvRetryPolicy = null;
304
+ function getEnvRetryPolicy() {
305
+ if (!_cachedEnvRetryPolicy) {
306
+ _cachedEnvRetryPolicy = {
307
+ maxAttempts: numberFromEnv('WIKI_MANAGER_MCP_RETRY_MAX_ATTEMPTS')
308
+ ?? numberFromEnv('WIKI_MANAGER_MCP_RETRY_ATTEMPTS')
309
+ ?? DEFAULT_MCP_RETRY_POLICY.maxAttempts,
310
+ backoffMs: numberFromEnv('WIKI_MANAGER_MCP_RETRY_BACKOFF_MS')
311
+ ?? DEFAULT_MCP_RETRY_POLICY.backoffMs,
312
+ };
313
+ }
314
+ return _cachedEnvRetryPolicy;
315
+ }
316
+
317
+ export function resolveRetryPolicy(endpoint = {}, toolName = null, override = null) {
318
+ const toolPolicy = toolName ? endpoint.toolRetries?.[toolName] : null;
319
+ return normalizeRetryPolicy(getEnvRetryPolicy(), endpoint.retry, toolPolicy, override);
320
+ }
321
+
322
+ function normalizeRetryPolicy(...policies) {
323
+ const merged = {};
324
+ for (const policy of policies) {
325
+ if (policy === false) {
326
+ merged.maxAttempts = 1;
327
+ continue;
328
+ }
329
+ if (!policy || typeof policy !== 'object') continue;
330
+ if (policy.maxAttempts != null) merged.maxAttempts = Number(policy.maxAttempts);
331
+ if (policy.backoffMs != null) merged.backoffMs = Number(policy.backoffMs);
332
+ }
333
+ const maxAttempts = Number.isFinite(merged.maxAttempts)
334
+ ? Math.max(1, Math.floor(merged.maxAttempts))
335
+ : DEFAULT_MCP_RETRY_POLICY.maxAttempts;
336
+ const backoffMs = Number.isFinite(merged.backoffMs)
337
+ ? Math.max(0, Math.floor(merged.backoffMs))
338
+ : DEFAULT_MCP_RETRY_POLICY.backoffMs;
339
+ return { maxAttempts, backoffMs };
340
+ }
341
+
342
+ function numberFromEnv(key) {
343
+ const raw = envValue(key);
344
+ if (raw == null || raw === '') return null;
345
+ const value = Number(raw);
346
+ return Number.isFinite(value) ? value : null;
347
+ }
348
+
349
+ async function withRetry(operation, policy, { signal = null, onRetry = null } = {}) {
350
+ let lastError;
351
+ for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
352
+ try {
353
+ return await operation({ attempt });
354
+ } catch (err) {
355
+ lastError = err;
356
+ if (attempt >= policy.maxAttempts || signal?.aborted) throw err;
357
+ onRetry?.({ attempt, maxAttempts: policy.maxAttempts, error: err });
358
+ await retryDelay(policy.backoffMs * (2 ** (attempt - 1)), signal);
359
+ }
360
+ }
361
+ throw lastError;
362
+ }
363
+
364
+ function retryDelay(ms, signal) {
365
+ if (ms <= 0) return Promise.resolve();
366
+ return new Promise((resolve, reject) => {
367
+ let timer = null;
368
+ const cleanup = () => {
369
+ if (timer) clearTimeout(timer);
370
+ signal?.removeEventListener('abort', onAbort);
371
+ };
372
+ const onAbort = () => {
373
+ cleanup();
374
+ const err = new Error('Operation aborted.');
375
+ err.name = 'AbortError';
376
+ reject(err);
377
+ };
378
+ timer = setTimeout(() => {
379
+ cleanup();
380
+ resolve();
381
+ }, ms);
382
+ if (!signal) return;
383
+ if (signal.aborted) {
384
+ onAbort();
385
+ return;
386
+ }
387
+ signal.addEventListener('abort', onAbort, { once: true });
388
+ });
389
+ }
390
+
281
391
  export async function discoverMcpTools(mcpStatus) {
282
392
  const next = {};
283
393
  await Promise.all(Object.entries(mcpStatus ?? {}).map(async ([name, value]) => {
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtemp, writeFile } from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
- import { buildMcpStatus, callMcpTool, discoverMcpTools } from './mcp.js';
6
+ import { buildMcpStatus, callMcpTool, discoverMcpTools, resolveRetryPolicy } from './mcp.js';
7
7
 
8
8
  test('buildMcpStatus reads external MCP endpoints from mcp.endpoints.json', async () => {
9
9
  const originalCwd = process.cwd();
@@ -163,6 +163,29 @@ test('buildMcpStatus uses active wikirc mcp.accessKey for wiki MCP', () => {
163
163
  assert.equal(status.wiki.token, 'wikirc-wiki-token');
164
164
  });
165
165
 
166
+ test('buildMcpStatus applies internal tool approval policy from env', () => {
167
+ const original = process.env.WIKI_MANAGER_REQUIRE_APPROVAL_TOOLS;
168
+ process.env.WIKI_MANAGER_REQUIRE_APPROVAL_TOOLS = 'production.production_start_job,wiki.wiki_search';
169
+ try {
170
+ const status = buildMcpStatus({
171
+ workspaceEnv: {
172
+ PRODUCTION_MCP_PORT: '3102',
173
+ PRODUCTION_MCP_AUTH_TOKEN: 'production-token',
174
+ WIKI_MCP_PORT: '3101',
175
+ },
176
+ wikircConfig: {
177
+ mcp: { accessKey: 'wiki-token' },
178
+ },
179
+ });
180
+
181
+ assert.deepEqual(status.production.requireApproval, ['production_start_job']);
182
+ assert.deepEqual(status.wiki.requireApproval, ['wiki_search']);
183
+ } finally {
184
+ if (original === undefined) delete process.env.WIKI_MANAGER_REQUIRE_APPROVAL_TOOLS;
185
+ else process.env.WIKI_MANAGER_REQUIRE_APPROVAL_TOOLS = original;
186
+ }
187
+ });
188
+
166
189
  test('callMcpTool injects active configPath for production_start_job', async () => {
167
190
  const originalFetch = globalThis.fetch;
168
191
  let requestBody = null;
@@ -269,6 +292,103 @@ test('callMcpTool sends configured endpoint headers', async () => {
269
292
  }
270
293
  });
271
294
 
295
+ test('callMcpTool retries transient MCP failures', async () => {
296
+ const originalFetch = globalThis.fetch;
297
+ let attempts = 0;
298
+ const retries = [];
299
+ globalThis.fetch = async () => {
300
+ attempts += 1;
301
+ if (attempts === 1) {
302
+ return {
303
+ ok: false,
304
+ status: 503,
305
+ headers: { get: () => null },
306
+ text: async () => 'temporarily unavailable',
307
+ };
308
+ }
309
+ return {
310
+ ok: true,
311
+ status: 200,
312
+ headers: { get: () => null },
313
+ text: async () => JSON.stringify({ result: { content: [{ type: 'text', text: '{"ok":true}' }] } }),
314
+ };
315
+ };
316
+
317
+ try {
318
+ const result = await callMcpTool(
319
+ {
320
+ production: {
321
+ status: 'connected',
322
+ url: 'http://127.0.0.1:3000/mcp/',
323
+ retry: { maxAttempts: 2, backoffMs: 0 },
324
+ },
325
+ },
326
+ 'production',
327
+ 'production_start_job',
328
+ { type: 'doctor' },
329
+ null,
330
+ { onRetry: (event) => retries.push(event) },
331
+ );
332
+
333
+ assert.equal(attempts, 2);
334
+ assert.equal(retries.length, 1);
335
+ assert.match(retries[0].error.message, /503/);
336
+ assert.equal(result.content[0].text, '{"ok":true}');
337
+ } finally {
338
+ globalThis.fetch = originalFetch;
339
+ }
340
+ });
341
+
342
+ test('callMcpTool retries tool result errors', async () => {
343
+ const originalFetch = globalThis.fetch;
344
+ let attempts = 0;
345
+ globalThis.fetch = async () => {
346
+ attempts += 1;
347
+ return {
348
+ ok: true,
349
+ status: 200,
350
+ headers: { get: () => null },
351
+ text: async () => JSON.stringify({
352
+ result: attempts === 1
353
+ ? { isError: true, content: [{ type: 'text', text: 'rate limited' }] }
354
+ : { content: [{ type: 'text', text: '{"ok":true}' }] },
355
+ }),
356
+ };
357
+ };
358
+
359
+ try {
360
+ const result = await callMcpTool(
361
+ {
362
+ production: {
363
+ status: 'connected',
364
+ url: 'http://127.0.0.1:3000/mcp/',
365
+ },
366
+ },
367
+ 'production',
368
+ 'production_start_job',
369
+ { type: 'doctor' },
370
+ null,
371
+ { retry: { maxAttempts: 2, backoffMs: 0 } },
372
+ );
373
+
374
+ assert.equal(attempts, 2);
375
+ assert.equal(result.content[0].text, '{"ok":true}');
376
+ } finally {
377
+ globalThis.fetch = originalFetch;
378
+ }
379
+ });
380
+
381
+ test('resolveRetryPolicy supports endpoint and tool overrides', () => {
382
+ const policy = resolveRetryPolicy({
383
+ retry: { maxAttempts: 2, backoffMs: 100 },
384
+ toolRetries: {
385
+ production_start_job: { maxAttempts: 4 },
386
+ },
387
+ }, 'production_start_job');
388
+
389
+ assert.deepEqual(policy, { maxAttempts: 4, backoffMs: 100 });
390
+ });
391
+
272
392
  test('discoverMcpTools downgrades connected endpoint when tool discovery fails', async () => {
273
393
  const originalFetch = globalThis.fetch;
274
394
  globalThis.fetch = async () => ({
package/src/core/plan.js CHANGED
@@ -1,6 +1,11 @@
1
1
  export function ensurePlanFromActivity(session, activity) {
2
2
  if (!activity) return;
3
3
  const actKey = activity.key ?? null;
4
+ if (session.headlessPlan?.some((step) => step.owner === 'orchestrator')) {
5
+ attachActivityToExistingPlan(session.headlessPlan, activity);
6
+ session._onPlanUpdate?.();
7
+ return;
8
+ }
4
9
  // Same activity still being tracked — preserve current plan state (polling update).
5
10
  if (session.headlessPlan && actKey !== null && session.headlessPlan[0]?._activityKey === actKey) return;
6
11
  const steps = activity.plan?.steps;
@@ -10,6 +15,8 @@ export function ensurePlanFromActivity(session, activity) {
10
15
  id: s.id ?? null,
11
16
  description: s.label,
12
17
  status: 'pending',
18
+ owner: 'activity',
19
+ ownerActivityKey: activity.key,
13
20
  _activityKey: activity.key,
14
21
  }));
15
22
  } else {
@@ -18,6 +25,8 @@ export function ensurePlanFromActivity(session, activity) {
18
25
  id: null,
19
26
  description: activity.label,
20
27
  status: 'pending',
28
+ owner: 'activity',
29
+ ownerActivityKey: activity.key,
21
30
  _activityKey: activity.key,
22
31
  }];
23
32
  }
@@ -179,3 +188,27 @@ function statusRank(status) {
179
188
  if (status === 'done') return 2;
180
189
  return 3;
181
190
  }
191
+
192
+ export function attachActivityToExistingPlan(plan, activity) {
193
+ const actKey = activity.key ?? activity.id ?? activity.jobId ?? null;
194
+ if (!actKey) return;
195
+ const matched = plan.find((step) => step.activityKey === actKey)
196
+ ?? plan.find((step) => step.ownerActivityKey === actKey)
197
+ ?? plan.find((step) => step.status === 'pending')
198
+ ?? plan.find((step) => step.status === 'running');
199
+ if (!matched) return;
200
+ matched.activityKey = actKey;
201
+ if (!matched.ownerActivityKey) matched.ownerActivityKey = actKey;
202
+ const failed = ['failed', 'error', 'cancelled', 'canceled'].includes(String(activity.status).toLowerCase());
203
+ if (activity.terminal) {
204
+ matched.status = failed ? 'failed' : 'done';
205
+ return;
206
+ }
207
+ if (!failed) {
208
+ for (const step of plan) {
209
+ if (step.status === 'failed') continue;
210
+ if (step.step < matched.step) step.status = 'done';
211
+ else if (step.step === matched.step) step.status = 'running';
212
+ }
213
+ }
214
+ }
@@ -25,6 +25,7 @@ test('job queue uses an injected queue store', () => {
25
25
  const item = enqueueProductionJob(session, { type: 'build' }, 'workspace_busy');
26
26
 
27
27
  assert.equal(item.workspace, 'docs');
28
+ assert.match(item.id, /^q-[0-9a-f-]{36}$/);
28
29
  assert.equal(queue.length, 1);
29
30
  assert.equal(ensureJobQueue(session), queue);
30
31
  assert.equal(changed, 1);
@@ -0,0 +1,24 @@
1
+ import { createLlmClientFromWikiConfig } from '../agent/llm.js';
2
+ import { loadWikircProfile, summarizeWikircConfig } from './wikirc.js';
3
+
4
+ export function applySessionWikircProfile(session, profileName = 'default') {
5
+ if (!session.workspacePath) {
6
+ throw new Error('No workspace loaded. Use /use <workspace>.');
7
+ }
8
+ const loaded = loadWikircProfile(session.workspacePath, profileName);
9
+ session.wikirc = {
10
+ profile: loaded.profile.name,
11
+ fileName: loaded.profile.fileName,
12
+ path: loaded.profile.path,
13
+ };
14
+ session.wikircConfig = loaded.config;
15
+ session.language = loaded.config?.language ?? null;
16
+ session.llm = createLlmClientFromWikiConfig(loaded.config);
17
+ if (session.mcp?.production) {
18
+ session.mcp.production.activeConfigPath = loaded.profile.fileName;
19
+ }
20
+ return {
21
+ summary: summarizeWikircConfig(loaded.profile, loaded.config),
22
+ config: loaded.config,
23
+ };
24
+ }
@@ -0,0 +1,24 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+
5
+ test('wiki-workspace autostarts host runtime before workspace services', async () => {
6
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
7
+
8
+ assert.match(script, /ensure_runtime_up\(\) \{/);
9
+ assert.match(script, /WIKI_MANAGER_RUNTIME_AUTOSTART:-1/);
10
+ assert.match(script, /Starting host agent-runtime/);
11
+ assert.match(script, /start_workspace_services\(\) \{\n ensure_runtime_up\n compose_for_workspace "\$1" up -d serve mcp-http production-mcp/);
12
+ assert.match(script, /start_workspace_services "\$workspace"\n\n local serve_port prod_port/);
13
+ assert.match(script, /start_workspace_services "\$workspace"\n local serve_port production_port/);
14
+ assert.match(script, /ensure_runtime_up\n printf 'Starting mcp-http/);
15
+ });
16
+
17
+ test('wiki-workspace checks runtime pid command before killing', async () => {
18
+ const script = await readFile(new URL('../../wiki-workspace', import.meta.url), 'utf8');
19
+
20
+ assert.match(script, /runtime_pid_command\(\) \{/);
21
+ assert.match(script, /runtime_pid_matches\(\) \{/);
22
+ assert.match(script, /if ! runtime_pid_matches; then\n printf 'refusing to stop pid/);
23
+ assert.match(script, /kill "\$\(cat "\$pid_file"\)"/);
24
+ });
@@ -0,0 +1,113 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
+
4
+ const DEFAULT_APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
5
+
6
+ export function createApprovalManager(session, {
7
+ defaultTimeoutMs = DEFAULT_APPROVAL_TIMEOUT_MS,
8
+ } = {}) {
9
+ const pending = new Map();
10
+
11
+ async function requestApproval(request = {}) {
12
+ const scope = request.scope === 'tool' ? 'tool' : 'run';
13
+ const approvalId = request.approvalId ?? randomUUID();
14
+ const runId = request.runId ?? session._currentRunIdentity?.runId ?? null;
15
+ const timeoutMs = Number.isFinite(Number(request.timeoutMs))
16
+ ? Math.max(1, Number(request.timeoutMs))
17
+ : defaultTimeoutMs;
18
+ const eventType = scope === 'tool' ? 'tool_pending_approval' : 'run_pending_approval';
19
+ const approvedType = scope === 'tool' ? 'tool_approved' : 'run_approved';
20
+
21
+ dispatchAgentEvent(session, createAgentEvent(eventType, {
22
+ origin: 'runtime',
23
+ runId,
24
+ payload: {
25
+ approvalId,
26
+ runId,
27
+ itemId: request.itemId ?? null,
28
+ reason: request.reason ?? null,
29
+ tool: request.tool ?? null,
30
+ plan: request.plan ?? null,
31
+ timeoutMs,
32
+ },
33
+ }));
34
+
35
+ await new Promise((resolve, reject) => {
36
+ const timer = setTimeout(() => {
37
+ pending.delete(approvalId);
38
+ const err = new Error(`Approval timed out: ${approvalId}`);
39
+ err.name = 'ApprovalError';
40
+ reject(err);
41
+ }, timeoutMs);
42
+ const cleanup = () => {
43
+ clearTimeout(timer);
44
+ request.signal?.removeEventListener('abort', onAbort);
45
+ };
46
+ const onAbort = () => {
47
+ pending.delete(approvalId);
48
+ cleanup();
49
+ const err = new Error(`Approval cancelled: ${approvalId}`);
50
+ err.name = 'AbortError';
51
+ reject(err);
52
+ };
53
+ pending.set(approvalId, {
54
+ approvalId,
55
+ scope,
56
+ runId,
57
+ itemId: request.itemId ?? null,
58
+ resolve: () => {
59
+ pending.delete(approvalId);
60
+ cleanup();
61
+ resolve();
62
+ },
63
+ });
64
+ if (request.signal?.aborted) {
65
+ onAbort();
66
+ return;
67
+ }
68
+ request.signal?.addEventListener('abort', onAbort, { once: true });
69
+ });
70
+
71
+ dispatchAgentEvent(session, createAgentEvent(approvedType, {
72
+ origin: 'runtime',
73
+ runId,
74
+ payload: {
75
+ approvalId,
76
+ runId,
77
+ itemId: request.itemId ?? null,
78
+ },
79
+ }));
80
+ return { approved: true, approvalId, runId, itemId: request.itemId ?? null };
81
+ }
82
+
83
+ function approve({ approvalId = null, runId = null, itemId = null } = {}) {
84
+ const entry = approvalId
85
+ ? pending.get(approvalId)
86
+ : [...pending.values()].find((item) =>
87
+ (runId && item.runId === runId) || (itemId && item.itemId === itemId),
88
+ );
89
+ if (!entry) return { approved: false, reason: 'approval not found' };
90
+ entry.resolve();
91
+ return {
92
+ approved: true,
93
+ approvalId: entry.approvalId,
94
+ runId: entry.runId,
95
+ itemId: entry.itemId,
96
+ };
97
+ }
98
+
99
+ function list() {
100
+ return [...pending.entries()].map(([approvalId, item]) => ({
101
+ approvalId,
102
+ scope: item.scope,
103
+ runId: item.runId,
104
+ itemId: item.itemId,
105
+ }));
106
+ }
107
+
108
+ return {
109
+ requestApproval,
110
+ approve,
111
+ list,
112
+ };
113
+ }
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import test from 'node:test';
6
6
  import { resolveRuntimeAuthToken } from './auth.js';
7
+ import { assertRuntimeNode, runtimeNodeExecutable } from './lifecycle.js';
7
8
 
8
9
  test('resolveRuntimeAuthToken: loopback host does not require token', () => {
9
10
  const result = resolveRuntimeAuthToken({ host: '127.0.0.1', explicitToken: null });
@@ -19,3 +20,10 @@ test('resolveRuntimeAuthToken: exposed host generates and reuses token', () => {
19
20
  assert.equal(second.source, 'file');
20
21
  assert.equal(second.token, first.token);
21
22
  });
23
+
24
+ test('runtime lifecycle uses a Node executable with node:sqlite support', async () => {
25
+ const runtimeNode = await assertRuntimeNode(runtimeNodeExecutable());
26
+
27
+ assert.ok(runtimeNode.executable);
28
+ assert.ok(Number(runtimeNode.version.split('.')[0]) >= 22);
29
+ });
@@ -4,6 +4,12 @@ function base(url) {
4
4
  return url.replace(/\/$/, '');
5
5
  }
6
6
 
7
+ function runtimeEndpoint(url, path, workspace = null) {
8
+ const endpoint = new URL(`${base(url)}${path}`);
9
+ if (workspace) endpoint.searchParams.set('workspace', workspace);
10
+ return endpoint.toString();
11
+ }
12
+
7
13
  export function runtimeUrlFromEnv() {
8
14
  return process.env.WIKI_MANAGER_RUNTIME_URL ?? 'http://127.0.0.1:7788';
9
15
  }
@@ -11,8 +17,9 @@ export function runtimeUrlFromEnv() {
11
17
  export async function fetchRuntimeState({
12
18
  url = runtimeUrlFromEnv(),
13
19
  token = runtimeToken(),
20
+ workspace = null,
14
21
  } = {}) {
15
- const response = await fetch(`${base(url)}/state`, {
22
+ const response = await fetch(runtimeEndpoint(url, '/state', workspace), {
16
23
  headers: runtimeHeaders(token),
17
24
  });
18
25
  if (!response.ok) throw new Error(`Runtime state failed: HTTP ${response.status}`);
@@ -22,8 +29,9 @@ export async function fetchRuntimeState({
22
29
  export async function checkRuntimeHealth({
23
30
  url = runtimeUrlFromEnv(),
24
31
  token = runtimeToken(),
32
+ workspace = null,
25
33
  } = {}) {
26
- const response = await fetch(`${base(url)}/health`, {
34
+ const response = await fetch(runtimeEndpoint(url, '/health', workspace), {
27
35
  headers: runtimeHeaders(token),
28
36
  });
29
37
  if (!response.ok) return null;
@@ -34,14 +42,16 @@ export async function postRuntimeRun(input, {
34
42
  url = runtimeUrlFromEnv(),
35
43
  token = runtimeToken(),
36
44
  workspace = null,
45
+ evaluate = undefined,
46
+ replans = undefined,
37
47
  } = {}) {
38
- const response = await fetch(`${base(url)}/run`, {
48
+ const response = await fetch(runtimeEndpoint(url, '/run', workspace), {
39
49
  method: 'POST',
40
50
  headers: {
41
51
  ...runtimeHeaders(token),
42
52
  'Content-Type': 'application/json',
43
53
  },
44
- body: JSON.stringify({ input, workspace }),
54
+ body: JSON.stringify(Object.assign({ input, workspace }, evaluate !== undefined && { evaluate }, replans !== undefined && { replans })),
45
55
  });
46
56
  if (!response.ok) throw new Error(`Runtime run failed: HTTP ${response.status}`);
47
57
  return response.json();
@@ -50,8 +60,9 @@ export async function postRuntimeRun(input, {
50
60
  export async function postRuntimeCancel({
51
61
  url = runtimeUrlFromEnv(),
52
62
  token = runtimeToken(),
63
+ workspace = null,
53
64
  } = {}) {
54
- const response = await fetch(`${base(url)}/cancel`, {
65
+ const response = await fetch(runtimeEndpoint(url, '/cancel', workspace), {
55
66
  method: 'POST',
56
67
  headers: runtimeHeaders(token),
57
68
  });
@@ -59,6 +70,40 @@ export async function postRuntimeCancel({
59
70
  return response.json();
60
71
  }
61
72
 
73
+ export async function postRuntimeResume({
74
+ url = runtimeUrlFromEnv(),
75
+ token = runtimeToken(),
76
+ workspace = null,
77
+ } = {}) {
78
+ const response = await fetch(runtimeEndpoint(url, '/resume', workspace), {
79
+ method: 'POST',
80
+ headers: runtimeHeaders(token),
81
+ });
82
+ if (!response.ok) throw new Error(`Runtime resume failed: HTTP ${response.status}`);
83
+ return response.json();
84
+ }
85
+
86
+ export async function postRuntimeApprove({
87
+ url = runtimeUrlFromEnv(),
88
+ token = runtimeToken(),
89
+ workspace = null,
90
+ runId = null,
91
+ itemId = null,
92
+ approvalId = null,
93
+ } = {}) {
94
+ const endpoint = runtimeEndpoint(url, '/approve', workspace);
95
+ const parsed = new URL(endpoint);
96
+ if (runId) parsed.searchParams.set('runId', runId);
97
+ if (itemId) parsed.searchParams.set('itemId', itemId);
98
+ if (approvalId) parsed.searchParams.set('approvalId', approvalId);
99
+ const response = await fetch(parsed.toString(), {
100
+ method: 'POST',
101
+ headers: runtimeHeaders(token),
102
+ });
103
+ if (!response.ok) throw new Error(`Runtime approve failed: HTTP ${response.status}`);
104
+ return response.json();
105
+ }
106
+
62
107
  function runtimeHeaders(token) {
63
108
  return token ? { Authorization: `Bearer ${token}` } : {};
64
109
  }
@@ -67,8 +112,9 @@ export async function* streamRuntimeEvents({
67
112
  url = runtimeUrlFromEnv(),
68
113
  token = runtimeToken(),
69
114
  signal = null,
115
+ workspace = null,
70
116
  } = {}) {
71
- const response = await fetch(`${base(url)}/events/stream`, {
117
+ const response = await fetch(runtimeEndpoint(url, '/events/stream', workspace), {
72
118
  headers: { ...runtimeHeaders(token), Accept: 'text/event-stream' },
73
119
  signal,
74
120
  });