@dotdrelle/wiki-manager 0.11.0 → 0.11.6

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.
@@ -3,6 +3,41 @@ import test from 'node:test';
3
3
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
4
  import { finishRuntimeRun, replanRuntimeRun, runRuntimeAgenticWorkflow, runRuntimeParallelPlan } from './runner.js';
5
5
 
6
+ test('runRuntimeAgenticWorkflow completes conversational turns without evaluation or replan', async () => {
7
+ const events = [];
8
+ const session = {
9
+ activities: {},
10
+ headlessPlan: null,
11
+ llm: {
12
+ async completeWithTools() {
13
+ assert.fail('conversation-only turn must not call evaluator or replanner');
14
+ },
15
+ },
16
+ _onAgentEvent: (event) => events.push(event),
17
+ };
18
+ const agent = {
19
+ async invoke() {
20
+ return { response: 'Salut.' };
21
+ },
22
+ };
23
+
24
+ const started = Date.now();
25
+ const result = await runRuntimeAgenticWorkflow(agent, session, 'salut', {
26
+ runId: 'run-chat',
27
+ timeoutMs: 1000,
28
+ maxTurns: 1,
29
+ maxReplans: 1,
30
+ });
31
+
32
+ assert.equal(result.ok, true);
33
+ assert.ok(Date.now() - started < 5000);
34
+ assert.equal(session.headlessPlan, null);
35
+ assert.equal(Object.keys(session.activities).length, 0);
36
+ assert.ok(events.some((event) => event.type === 'run_done'));
37
+ assert.equal(events.some((event) => event.type === 'run_evaluated'), false);
38
+ assert.equal(events.some((event) => event.type === 'run_replanned'), false);
39
+ });
40
+
6
41
  test('finishRuntimeRun emits evaluation before run_done', async () => {
7
42
  const events = [];
8
43
  const session = {
@@ -33,6 +68,47 @@ test('finishRuntimeRun emits evaluation before run_done', async () => {
33
68
  assert.equal(session.agentProjection.status, 'done');
34
69
  });
35
70
 
71
+ test('runRuntimeAgenticWorkflow clarifies vague evaluations without replan', async () => {
72
+ const events = [];
73
+ const session = {
74
+ activities: {},
75
+ headlessPlan: [{ step: 1, id: 'task', description: 'Task', status: 'done' }],
76
+ llm: {
77
+ async completeWithTools({ system }) {
78
+ assert.match(system, /strict evaluator/);
79
+ return { content: '{"ok":false,"reason":"demande vague / objectif indefini","suggestedAction":"clarifier l objectif"}' };
80
+ },
81
+ },
82
+ _onAgentEvent: (event) => events.push(event),
83
+ };
84
+ const agent = {
85
+ async invoke({ session: turnSession }) {
86
+ if (turnSession.headlessPlan === null) {
87
+ dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
88
+ origin: 'tool',
89
+ payload: { steps: [{ step: 1, id: 'task', description: 'Task', status: 'done' }] },
90
+ }));
91
+ }
92
+ return { response: 'Terminé.' };
93
+ },
94
+ };
95
+
96
+ const result = await runRuntimeAgenticWorkflow(agent, session, 'fais le truc', {
97
+ runId: 'run-vague',
98
+ timeoutMs: 1000,
99
+ maxTurns: 1,
100
+ maxReplans: 1,
101
+ });
102
+
103
+ assert.equal(result.ok, true);
104
+ assert.equal(result.clarified, true);
105
+ assert.ok(session.agentProjection.conversation.at(-1).content.includes('clarifier'));
106
+ assert.ok(events.some((event) => event.type === 'run_evaluated'));
107
+ assert.equal(events.some((event) => event.type === 'run_replanned'), false);
108
+ assert.equal(events.some((event) => event.type === 'tool_call_started'), false);
109
+ assert.equal(session.agentProjection.status, 'done');
110
+ });
111
+
36
112
  test('finishRuntimeRun turns negative evaluation into run_error', async () => {
37
113
  const events = [];
38
114
  const session = {
@@ -62,7 +138,7 @@ test('finishRuntimeRun turns negative evaluation into run_error', async () => {
62
138
  test('finishRuntimeRun falls back open when evaluator response is invalid', async () => {
63
139
  const session = {
64
140
  activities: {},
65
- headlessPlan: null,
141
+ headlessPlan: [{ step: 1, description: 'Do work', status: 'done' }],
66
142
  agentProjection: { conversation: [] },
67
143
  llm: {
68
144
  async completeWithTools() {
@@ -126,13 +202,20 @@ test('runRuntimeAgenticWorkflow replans after negative evaluation', async () =>
126
202
  const agent = {
127
203
  async invoke({ session: turnSession }) {
128
204
  turns += 1;
205
+ if (turnSession.headlessPlan === null) {
206
+ dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
207
+ origin: 'tool',
208
+ payload: { steps: [{ step: 1, id: 'initial', description: 'Initial work', status: 'done' }] },
209
+ }));
210
+ return { response: 'Initial done.' };
211
+ }
129
212
  if (turnSession.headlessPlan?.[0]?.status === 'pending') {
130
213
  dispatchAgentEvent(turnSession, createAgentEvent('plan_step_updated', {
131
214
  origin: 'tool',
132
215
  payload: { step: 1, status: 'done' },
133
216
  }));
134
217
  }
135
- return { response: turns === 1 ? 'Initial done.' : 'Export done.' };
218
+ return { response: 'Export done.' };
136
219
  },
137
220
  };
138
221
 
@@ -175,6 +258,27 @@ test('replanRuntimeRun preserves completed outputs when replacing remaining work
175
258
  assert.deepEqual(session.headlessPlan[1].dependsOn, ['export']);
176
259
  });
177
260
 
261
+ test('replanRuntimeRun requires runtime approval for mutating replanned work', async () => {
262
+ const session = {
263
+ activities: {},
264
+ headlessPlan: [{ step: 1, id: 'build', description: 'Build', status: 'failed' }],
265
+ llm: {
266
+ async completeWithTools() {
267
+ return { content: '{"steps":["Run production build"]}' };
268
+ },
269
+ },
270
+ };
271
+
272
+ const result = await replanRuntimeRun(session, 'Build deliverable', {
273
+ kind: 'evaluation',
274
+ reason: 'Build missing.',
275
+ }, { runId: 'run-replan-approval', replansLeft: 1 });
276
+
277
+ assert.equal(result.ok, true);
278
+ assert.equal(session._runApprovalRequired, true);
279
+ assert.equal(session._runApprovalResolved, false);
280
+ });
281
+
178
282
  test('runRuntimeAgenticWorkflow replans after terminal activity error', async () => {
179
283
  const originalFetch = globalThis.fetch;
180
284
  let pollAttempts = 0;
@@ -276,7 +380,13 @@ test('runRuntimeAgenticWorkflow stops after replan budget is exhausted', async (
276
380
  },
277
381
  };
278
382
  const agent = {
279
- async invoke() {
383
+ async invoke({ session: turnSession }) {
384
+ if (turnSession.headlessPlan === null) {
385
+ dispatchAgentEvent(turnSession, createAgentEvent('plan_set', {
386
+ origin: 'tool',
387
+ payload: { steps: [{ step: 1, id: 'task', description: 'Task', status: 'done' }] },
388
+ }));
389
+ }
280
390
  return { response: 'Done.' };
281
391
  },
282
392
  };
@@ -337,6 +447,232 @@ test('runRuntimeParallelPlan executes ready tasks concurrently in one parent run
337
447
  assert.ok(session.agentEvents.some((event) => event.taskId === 'b'));
338
448
  });
339
449
 
450
+ test('runRuntimeParallelPlan schedules a plan patch applied during an active parallel run', async () => {
451
+ const started = [];
452
+ const release = {};
453
+ const session = {
454
+ activities: {},
455
+ headlessPlan: [
456
+ { step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
457
+ { step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
458
+ ],
459
+ };
460
+ const agent = {
461
+ async invoke({ input }) {
462
+ const id = input.match(/Task id: ([^\n]+)/)?.[1];
463
+ started.push(id);
464
+ if (id === 'a' || id === 'b') {
465
+ await new Promise((resolve) => { release[id] = resolve; });
466
+ }
467
+ return { response: `done ${id}` };
468
+ },
469
+ };
470
+
471
+ const running = runRuntimeParallelPlan(agent, session, 'Do patched work', {
472
+ runId: 'run-patch-active',
473
+ timeoutMs: 1000,
474
+ maxTurns: 1,
475
+ concurrency: 2,
476
+ });
477
+ await waitFor(() => started.includes('a') && started.includes('b'));
478
+ const patch = {
479
+ targetRunId: 'run-patch-active',
480
+ basePlanRevision: session.agentProjection.planRevision,
481
+ operations: [
482
+ {
483
+ op: 'add_task',
484
+ task: {
485
+ id: 'c',
486
+ description: 'Task C added during run',
487
+ status: 'pending',
488
+ dependsOn: ['a'],
489
+ executor: null,
490
+ outputRefs: [],
491
+ },
492
+ },
493
+ ],
494
+ };
495
+ dispatchAgentEvent(session, createAgentEvent('plan_patch_proposed', {
496
+ origin: 'runtime',
497
+ runId: 'run-patch-active',
498
+ payload: { id: 'patch-active', patch },
499
+ }));
500
+ dispatchAgentEvent(session, createAgentEvent('plan_patch_approved', {
501
+ origin: 'runtime',
502
+ runId: 'run-patch-active',
503
+ payload: { patchId: 'patch-active' },
504
+ }));
505
+ dispatchAgentEvent(session, createAgentEvent('plan_patch_applied', {
506
+ origin: 'runtime',
507
+ runId: 'run-patch-active',
508
+ payload: { patchId: 'patch-active', patch },
509
+ }));
510
+
511
+ assert.deepEqual(session.headlessPlan.map((step) => step.id), ['a', 'b', 'c']);
512
+ release.a();
513
+ await waitFor(() => started.includes('c'));
514
+ release.b();
515
+
516
+ const result = await running;
517
+
518
+ assert.equal(result.ok, true);
519
+ assert.deepEqual(started, ['a', 'b', 'c']);
520
+ assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done', 'done']);
521
+ assert.equal(session.headlessPlan.find((step) => step.id === 'c').dependsOn[0], 'a');
522
+ });
523
+
524
+ test('runRuntimeParallelPlan rejects a stale patch during an active parallel run without silently applying it', async () => {
525
+ const release = {};
526
+ const session = {
527
+ activities: {},
528
+ headlessPlan: [
529
+ { step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
530
+ { step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
531
+ ],
532
+ };
533
+ const agent = {
534
+ async invoke({ input }) {
535
+ const id = input.match(/Task id: ([^\n]+)/)?.[1];
536
+ await new Promise((resolve) => { release[id] = resolve; });
537
+ return { response: `done ${id}` };
538
+ },
539
+ };
540
+
541
+ const running = runRuntimeParallelPlan(agent, session, 'Do stale patch work', {
542
+ runId: 'run-stale-patch',
543
+ timeoutMs: 1000,
544
+ maxTurns: 1,
545
+ concurrency: 2,
546
+ });
547
+ await waitFor(() => release.a && release.b);
548
+ const patch = {
549
+ targetRunId: 'run-stale-patch',
550
+ basePlanRevision: session.agentProjection.planRevision + 99,
551
+ operations: [
552
+ { op: 'add_task', task: { id: 'c', description: 'Should not apply', dependsOn: ['a'] } },
553
+ ],
554
+ };
555
+ dispatchAgentEvent(session, createAgentEvent('plan_patch_proposed', {
556
+ origin: 'runtime',
557
+ runId: 'run-stale-patch',
558
+ payload: { id: 'patch-stale', patch },
559
+ }));
560
+ dispatchAgentEvent(session, createAgentEvent('plan_patch_applied', {
561
+ origin: 'runtime',
562
+ runId: 'run-stale-patch',
563
+ payload: { patchId: 'patch-stale', patch },
564
+ }));
565
+
566
+ assert.deepEqual(session.headlessPlan.map((step) => step.id), ['a', 'b']);
567
+ assert.equal(session.agentProjection.planPatches.find((item) => item.id === 'patch-stale').status, 'rejected');
568
+ assert.equal(session.agentProjection.planPatches.find((item) => item.id === 'patch-stale').rejectionReason, 'revision_mismatch');
569
+ release.a();
570
+ release.b();
571
+
572
+ const result = await running;
573
+
574
+ assert.equal(result.ok, true);
575
+ assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done']);
576
+ });
577
+
578
+ test('runRuntimeParallelPlan aborts a running child task cancelled by plan patch without stopping siblings', async () => {
579
+ const started = [];
580
+ let bAborted = false;
581
+ const session = {
582
+ activities: {},
583
+ headlessPlan: [
584
+ { step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
585
+ { step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
586
+ ],
587
+ };
588
+ const agent = {
589
+ async invoke({ input, signal }) {
590
+ const id = input.match(/Task id: ([^\n]+)/)?.[1];
591
+ started.push(id);
592
+ if (id === 'b') {
593
+ await new Promise((resolve, reject) => {
594
+ signal.addEventListener('abort', () => {
595
+ bAborted = true;
596
+ const err = new Error('cancelled');
597
+ err.name = 'AbortError';
598
+ reject(err);
599
+ }, { once: true });
600
+ });
601
+ }
602
+ return { response: `done ${id}` };
603
+ },
604
+ };
605
+
606
+ const running = runRuntimeParallelPlan(agent, session, 'Cancel one child', {
607
+ runId: 'run-cancel-task',
608
+ timeoutMs: 1000,
609
+ maxTurns: 1,
610
+ concurrency: 2,
611
+ });
612
+ await waitFor(() => started.includes('a') && started.includes('b'));
613
+ await waitFor(() => session.headlessPlan.find((step) => step.id === 'a')?.status === 'done');
614
+ const patch = {
615
+ targetRunId: 'run-cancel-task',
616
+ basePlanRevision: session.agentProjection.planRevision,
617
+ operations: [{ op: 'cancel_task', taskId: 'b' }],
618
+ };
619
+ dispatchAgentEvent(session, createAgentEvent('plan_patch_applied', {
620
+ origin: 'runtime',
621
+ runId: 'run-cancel-task',
622
+ payload: { patchId: 'patch-cancel-b', patch },
623
+ }));
624
+
625
+ const result = await running;
626
+
627
+ assert.equal(result.ok, true);
628
+ assert.equal(bAborted, true);
629
+ assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'cancelled']);
630
+ assert.equal(session.agentProjection.planPatches.find((item) => item.id === 'patch-cancel-b').status, 'applied');
631
+ });
632
+
633
+ test('runRuntimeParallelPlan never exceeds the configured concurrency with twice the limit ready', async () => {
634
+ let active = 0;
635
+ let maxActive = 0;
636
+ const release = {};
637
+ const session = {
638
+ activities: {},
639
+ headlessPlan: Array.from({ length: 6 }, (_, index) => ({
640
+ step: index + 1,
641
+ id: `task-${index + 1}`,
642
+ description: `Task ${index + 1}`,
643
+ status: 'pending',
644
+ dependsOn: [],
645
+ })),
646
+ };
647
+ const agent = {
648
+ async invoke({ input }) {
649
+ const id = input.match(/Task id: ([^\n]+)/)?.[1];
650
+ active += 1;
651
+ maxActive = Math.max(maxActive, active);
652
+ await new Promise((resolve) => { release[id] = resolve; });
653
+ active -= 1;
654
+ return { response: `done ${id}` };
655
+ },
656
+ };
657
+
658
+ const running = runRuntimeParallelPlan(agent, session, 'Respect limit', {
659
+ runId: 'run-limit',
660
+ timeoutMs: 1000,
661
+ maxTurns: 1,
662
+ concurrency: 3,
663
+ });
664
+ for (let batch = 0; batch < 2; batch += 1) {
665
+ await waitFor(() => Object.keys(release).length >= (batch + 1) * 3);
666
+ for (const id of Object.keys(release).slice(batch * 3, (batch + 1) * 3)) release[id]();
667
+ }
668
+
669
+ const result = await running;
670
+
671
+ assert.equal(result.ok, true);
672
+ assert.equal(maxActive, 3);
673
+ assert.deepEqual(session.headlessPlan.map((step) => step.status), ['done', 'done', 'done', 'done', 'done', 'done']);
674
+ });
675
+
340
676
  test('runRuntimeParallelPlan serializes conflicting write locks', async () => {
341
677
  let active = 0;
342
678
  let maxActive = 0;
@@ -441,6 +777,52 @@ test('runRuntimeParallelPlan propagates parent cancellation to child tasks', asy
441
777
  assert.equal(session.headlessPlan[0].status, 'cancelled');
442
778
  });
443
779
 
780
+ test('runRuntimeParallelPlan aborts every active child on parent cancellation and records terminal updates after running updates', async () => {
781
+ const controller = new AbortController();
782
+ const aborted = [];
783
+ const session = {
784
+ activities: {},
785
+ headlessPlan: [
786
+ { step: 1, id: 'a', description: 'Task A', status: 'pending', dependsOn: [] },
787
+ { step: 2, id: 'b', description: 'Task B', status: 'pending', dependsOn: [] },
788
+ { step: 3, id: 'c', description: 'Task C', status: 'pending', dependsOn: [] },
789
+ ],
790
+ };
791
+ const agent = {
792
+ async invoke({ input, signal }) {
793
+ const id = input.match(/Task id: ([^\n]+)/)?.[1];
794
+ await new Promise((resolve, reject) => {
795
+ signal.addEventListener('abort', () => {
796
+ aborted.push(id);
797
+ const err = new Error('cancelled');
798
+ err.name = 'AbortError';
799
+ reject(err);
800
+ }, { once: true });
801
+ });
802
+ return { response: `done ${id}` };
803
+ },
804
+ };
805
+
806
+ const running = runRuntimeParallelPlan(agent, session, 'Cancel all children', {
807
+ runId: 'run-cancel-all',
808
+ signal: controller.signal,
809
+ timeoutMs: 1000,
810
+ maxTurns: 1,
811
+ concurrency: 3,
812
+ });
813
+ await waitFor(() => session.headlessPlan.every((step) => step.status === 'running'));
814
+ controller.abort();
815
+
816
+ await assert.rejects(running, { name: 'AbortError' });
817
+ assert.deepEqual(aborted.sort(), ['a', 'b', 'c']);
818
+ assert.deepEqual(session.headlessPlan.map((step) => step.status), ['cancelled', 'cancelled', 'cancelled']);
819
+ const updates = session.agentEvents
820
+ .filter((event) => event.type === 'plan_step_updated')
821
+ .map((event) => `${event.taskId}:${event.payload.status}`);
822
+ assert.deepEqual(updates.slice(0, 3).sort(), ['a:running', 'b:running', 'c:running']);
823
+ assert.deepEqual(updates.slice(3).sort(), ['a:cancelled', 'b:cancelled', 'c:cancelled']);
824
+ });
825
+
444
826
  test('runRuntimeAgenticWorkflow hands off to the parallel scheduler when turn 1 sets a multi-task ready plan', async () => {
445
827
  const session = {
446
828
  activities: {},
@@ -6,7 +6,7 @@ import { validateContractInDev } from '../contracts/schemas.js';
6
6
  import { runtimeTokenFromEnv } from './auth.js';
7
7
 
8
8
  export function startRuntimeServer({
9
- host = '0.0.0.0',
9
+ host = '127.0.0.1',
10
10
  port = 7788,
11
11
  token = runtimeTokenFromEnv(),
12
12
  store,
@@ -18,6 +18,7 @@ export function startRuntimeServer({
18
18
  approve,
19
19
  configProfiles,
20
20
  useConfigProfile,
21
+ exitOnShutdown = process.env.WIKI_MANAGER_RUNTIME_CHILD === '1',
21
22
  } = {}) {
22
23
  const clients = new Set();
23
24
  const defaultContext = { workspace: null, session, running: false, currentAbortController: null, currentRunId: null };
@@ -31,6 +32,13 @@ export function startRuntimeServer({
31
32
  }
32
33
  }
33
34
 
35
+ function publishState(workspace = null, context = null) {
36
+ for (const client of clients) {
37
+ if (client.workspace && client.workspace !== workspace) continue;
38
+ client.response.write(`event: state\ndata: ${JSON.stringify(runtimeState(context, store, { workspace, session }))}\n\n`);
39
+ }
40
+ }
41
+
34
42
  const server = createServer(async (request, response) => {
35
43
  try {
36
44
  if (!isAuthorized(request, token)) {
@@ -223,6 +231,23 @@ export function startRuntimeServer({
223
231
  sendJson(response, 202, { cancelled: true, workspace: context.workspace ?? workspace ?? null });
224
232
  return;
225
233
  }
234
+ if (request.method === 'POST' && url.pathname === '/shutdown') {
235
+ const workspace = workspaceFromUrl(url);
236
+ const context = await resolveContext({ workspace });
237
+ if (context?.running && context.currentAbortController) {
238
+ context.currentAbortController.abort();
239
+ await cancel?.(context);
240
+ }
241
+ sendJson(response, 202, { shutdown: true });
242
+ setImmediate(() => {
243
+ for (const client of clients) client.response.end();
244
+ clients.clear();
245
+ server.close(() => {
246
+ if (exitOnShutdown) process.exit(0);
247
+ });
248
+ });
249
+ return;
250
+ }
226
251
  if (request.method === 'POST' && url.pathname === '/resume') {
227
252
  const workspace = workspaceFromUrl(url);
228
253
  const result = await resume?.({ workspace });
@@ -307,6 +332,7 @@ export function startRuntimeServer({
307
332
  context.currentAbortController = null;
308
333
  context.currentRunId = null;
309
334
  context.currentRunWorkspace = null;
335
+ publishState(runWorkspace, context);
310
336
  void startNextControlRequest(context);
311
337
  });
312
338
  return { accepted: true, runId, workspace: runWorkspace };
@@ -610,7 +636,7 @@ function classifyControlMessage(input, status, forcedIntent = null) {
610
636
  if (['observe', 'converse', 'mutate', 'enqueue'].includes(intent)) {
611
637
  return { kind: intent, confidence: 1, reason: 'explicit_intent' };
612
638
  }
613
- if (/\b(o[uù] en est|status|statut|progress|progression|build|run|job|queue|file|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
639
+ if (/\b(o[uù] en es[t-]|status|statut|progress|progression|build|run|job|queue|file|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
614
640
  return { kind: 'observe', confidence: 0.86, reason: 'status_or_explanation_request' };
615
641
  }
616
642
  if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
@@ -46,6 +46,44 @@ test('runtime server checks bearer and x-runtime-token credentials', async (t) =
46
46
  }
47
47
  });
48
48
 
49
+ test('runtime server shutdown endpoint closes the listener', async (t) => {
50
+ let handle;
51
+ try {
52
+ handle = await startRuntimeServer({
53
+ host: '127.0.0.1',
54
+ port: 0,
55
+ token: 'runtime-secret',
56
+ exitOnShutdown: false,
57
+ store: {
58
+ dbPath: ':memory:',
59
+ getState: () => ({ status: 'idle' }),
60
+ listEvents: () => [],
61
+ },
62
+ session: {},
63
+ run: async () => {},
64
+ });
65
+ } catch (err) {
66
+ if (err?.code === 'EPERM') {
67
+ t.skip('network listen is not permitted in this sandbox');
68
+ return;
69
+ }
70
+ throw err;
71
+ }
72
+
73
+ const url = `http://127.0.0.1:${handle.port}`;
74
+ const response = await fetch(`${url}/shutdown`, {
75
+ method: 'POST',
76
+ headers: { authorization: 'Bearer runtime-secret' },
77
+ });
78
+ assert.equal(response.status, 202);
79
+ assert.equal((await response.json()).shutdown, true);
80
+
81
+ await new Promise((resolve) => setTimeout(resolve, 20));
82
+ await assert.rejects(fetch(`${url}/health`, {
83
+ headers: { authorization: 'Bearer runtime-secret' },
84
+ }));
85
+ });
86
+
49
87
  test('runtime server accepts only one active run', async (t) => {
50
88
  let releaseRun;
51
89
  let runCount = 0;
@@ -1,4 +1,4 @@
1
- import { mkdirSync } from 'node:fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { DatabaseSync } from 'node:sqlite';
4
4
  import { applyAgentProjectionToSession, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
@@ -9,6 +9,9 @@ import { projectWorkflow } from '../core/workflow.js';
9
9
  export { defaultRuntimeStateDir };
10
10
 
11
11
  const NON_PERSISTED_EVENT_TYPES = new Set(['runtime_log']);
12
+ export const RUNTIME_STORE_SCHEMA_VERSION = 1;
13
+ const RUNTIME_RETENTION_DAYS = 30;
14
+ const TERMINAL_RUN_STATUSES = ['done', 'error', 'cancelled', 'interrupted'];
12
15
 
13
16
  const RUN_STATUS_BY_EVENT = {
14
17
  run_done: 'done',
@@ -21,13 +24,20 @@ const RUN_STATUS_BY_EVENT = {
21
24
  const RECOVERABLE_RUN_STATUSES = ['running', 'waiting', 'pending_approval'];
22
25
  export const RECOVERABLE_QUEUE_STATUSES = ['waiting', 'queued', 'starting', 'running', 'blocked', 'pending_approval'];
23
26
 
24
- export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName = 'runtime.db' } = {}) {
27
+ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName = 'runtime.db', metaPath = null } = {}) {
25
28
  const resolvedStateDir = resolve(stateDir);
26
29
  mkdirSync(resolvedStateDir, { recursive: true });
27
30
  const dbPath = join(resolvedStateDir, fileName);
28
31
  const db = new DatabaseSync(dbPath);
29
32
  db.exec('PRAGMA journal_mode = WAL');
30
33
  db.exec('PRAGMA foreign_keys = ON');
34
+ try {
35
+ ensureKnownStoreVersion(db, dbPath);
36
+ ensureRuntimeMeta(metaPath ?? defaultRuntimeMetaPath(resolvedStateDir));
37
+ } catch (error) {
38
+ db.close();
39
+ throw error;
40
+ }
31
41
  db.exec(`
32
42
  CREATE TABLE IF NOT EXISTS events (
33
43
  sequence INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -76,6 +86,8 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
76
86
  backfillEventSequence(db);
77
87
  db.exec('CREATE INDEX IF NOT EXISTS idx_events_workspace ON events(workspace)');
78
88
  db.exec('CREATE INDEX IF NOT EXISTS idx_events_sequence ON events(sequence)');
89
+ db.exec(`PRAGMA user_version = ${RUNTIME_STORE_SCHEMA_VERSION}`);
90
+ purgeOldTerminalRuns(db);
79
91
 
80
92
  let lastEventId = null;
81
93
  let lastEventSequence = null;
@@ -423,6 +435,64 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
423
435
  };
424
436
  }
425
437
 
438
+ function defaultRuntimeMetaPath(stateDir) {
439
+ return join(dirname(stateDir), '.wiki', 'meta.json');
440
+ }
441
+
442
+ function ensureKnownStoreVersion(db, dbPath) {
443
+ const row = db.prepare('PRAGMA user_version').get();
444
+ const version = Number(row?.user_version ?? 0);
445
+ if (version > RUNTIME_STORE_SCHEMA_VERSION) {
446
+ throw new Error(`Unsupported runtime store schema version ${version} in ${dbPath}; this manager supports version ${RUNTIME_STORE_SCHEMA_VERSION}. Upgrade llm-wiki-manager before opening this runtime store.`);
447
+ }
448
+ }
449
+
450
+ function ensureRuntimeMeta(metaPath) {
451
+ mkdirSync(dirname(metaPath), { recursive: true });
452
+ if (!existsSync(metaPath)) {
453
+ writeFileSync(metaPath, `${JSON.stringify({ schemaVersion: RUNTIME_STORE_SCHEMA_VERSION }, null, 2)}\n`, 'utf8');
454
+ return;
455
+ }
456
+ let parsed;
457
+ try {
458
+ parsed = JSON.parse(readFileSync(metaPath, 'utf8'));
459
+ } catch (error) {
460
+ throw new Error(`Invalid runtime metadata file ${metaPath}: ${error instanceof Error ? error.message : String(error)}`);
461
+ }
462
+ const version = Number(parsed?.schemaVersion ?? 0);
463
+ if (version > RUNTIME_STORE_SCHEMA_VERSION) {
464
+ throw new Error(`Unsupported runtime metadata schemaVersion ${version} in ${metaPath}; this manager supports version ${RUNTIME_STORE_SCHEMA_VERSION}. Upgrade llm-wiki-manager before opening this runtime state.`);
465
+ }
466
+ if (!version) {
467
+ writeFileSync(metaPath, `${JSON.stringify({ ...parsed, schemaVersion: RUNTIME_STORE_SCHEMA_VERSION }, null, 2)}\n`, 'utf8');
468
+ }
469
+ }
470
+
471
+ function purgeOldTerminalRuns(db, now = new Date()) {
472
+ const cutoff = new Date(now.getTime() - RUNTIME_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString();
473
+ const placeholders = TERMINAL_RUN_STATUSES.map(() => '?').join(', ');
474
+ const oldRuns = db
475
+ .prepare(`SELECT id FROM runs WHERE status IN (${placeholders}) AND updated_at < ?`)
476
+ .all(...TERMINAL_RUN_STATUSES, cutoff)
477
+ .map((row) => row.id);
478
+ if (oldRuns.length === 0) return 0;
479
+ db.exec('BEGIN');
480
+ try {
481
+ const deleteEvents = db.prepare('DELETE FROM events WHERE run_id = ? OR json_extract(payload, \'$.runId\') = ?');
482
+ const deleteRun = db.prepare('DELETE FROM runs WHERE id = ?');
483
+ for (const runId of oldRuns) {
484
+ deleteEvents.run(runId, runId);
485
+ deleteRun.run(runId);
486
+ }
487
+ db.exec('COMMIT');
488
+ } catch (error) {
489
+ db.exec('ROLLBACK');
490
+ throw error;
491
+ }
492
+ db.exec('VACUUM');
493
+ return oldRuns.length;
494
+ }
495
+
426
496
  function rowToEvent(row) {
427
497
  return {
428
498
  sequence: row.sequence ?? null,