@dotdrelle/wiki-manager 0.12.1 → 0.12.8
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.
- package/README.md +49 -8
- package/bin/wiki-manager +23 -12
- package/docker-compose.yml +0 -1
- package/mcp.endpoints.example.json +0 -6
- package/package.json +3 -2
- package/src/activity/activityAggregator.js +34 -3
- package/src/activity/activityAggregator.test.js +32 -0
- package/src/agent/graph.js +272 -22
- package/src/agent/graph.test.js +357 -1
- package/src/cli/wiki-manager.js +33 -2
- package/src/commands/slash.js +74 -1
- package/src/commands/slash.test.js +36 -0
- package/src/core/activity.js +4 -0
- package/src/core/activity.test.js +9 -1
- package/src/core/agentEvents.js +30 -0
- package/src/core/agentEvents.test.js +42 -1
- package/src/core/buildInfo.js +58 -0
- package/src/core/buildInfo.json +4 -0
- package/src/core/buildInfo.test.js +14 -0
- package/src/core/env.js +30 -1
- package/src/core/mcp.js +21 -1
- package/src/core/mcp.test.js +25 -0
- package/src/core/runtimeLog.js +6 -1
- package/src/core/runtimeLog.test.js +3 -1
- package/src/runtime/client.js +22 -0
- package/src/runtime/controlMessages.js +2 -2
- package/src/runtime/recoveryManager.js +54 -0
- package/src/runtime/recoveryManager.test.js +73 -0
- package/src/runtime/runner.js +49 -13
- package/src/runtime/runner.test.js +196 -0
- package/src/runtime/server.js +67 -8
- package/src/runtime/server.test.js +223 -6
- package/src/runtime/store.js +48 -3
- package/src/runtime/store.test.js +32 -0
- package/src/runtime/supervisor.js +77 -1
- package/src/shell/RightPane.tsx +124 -31
- package/src/shell/SetupWizard.tsx +13 -1
- package/src/shell/repl.js +78 -9
- package/src/shell/repl.test.js +114 -1
- package/src/shell/tui.tsx +4 -1
- package/src/shell/useAgent.ts +32 -3
- package/src/shell/useSession.ts +14 -1
- package/wiki-workspace +33 -0
|
@@ -224,15 +224,18 @@ test('runtime server accepts only one active run', async (t) => {
|
|
|
224
224
|
fetch(url, {
|
|
225
225
|
method: 'POST',
|
|
226
226
|
headers: { 'Content-Type': 'application/json' },
|
|
227
|
-
|
|
227
|
+
// Messages during an active run are now CLASSIFIED (observe answers
|
|
228
|
+
// immediately, cancel aborts…) — enqueueing a future run is the
|
|
229
|
+
// explicit intent.
|
|
230
|
+
body: JSON.stringify({ input: 'second', intent: 'enqueue' }),
|
|
228
231
|
}),
|
|
229
232
|
]);
|
|
230
233
|
|
|
231
234
|
assert.deepEqual([first.status, second.status], [202, 202]);
|
|
232
235
|
const bodies = [await first.json(), await second.json()];
|
|
233
|
-
const acceptedRun = bodies.find((body) => body.
|
|
234
|
-
const queuedRun = bodies.find((body) => body.
|
|
235
|
-
assert.equal(queuedRun.
|
|
236
|
+
const acceptedRun = bodies.find((body) => body.runId && body.accepted);
|
|
237
|
+
const queuedRun = bodies.find((body) => body.kind === 'enqueue_run');
|
|
238
|
+
assert.equal(queuedRun.accepted, true);
|
|
236
239
|
assert.equal(acceptedRun.accepted, true);
|
|
237
240
|
assert.match(acceptedRun.runId, /^[0-9a-f-]{36}$/);
|
|
238
241
|
assert.equal(runCount, 1);
|
|
@@ -287,6 +290,158 @@ test('runtime server returns the accepted run id and passes it to the runner', a
|
|
|
287
290
|
}
|
|
288
291
|
});
|
|
289
292
|
|
|
293
|
+
test('runtime server kill aborts active run and interrupts workspace work', async (t) => {
|
|
294
|
+
let abortSeen = false;
|
|
295
|
+
let cancelCalled = false;
|
|
296
|
+
let interruptArgs = null;
|
|
297
|
+
let taskCancelArgs = null;
|
|
298
|
+
let handle;
|
|
299
|
+
try {
|
|
300
|
+
handle = await startRuntimeServer({
|
|
301
|
+
host: '127.0.0.1',
|
|
302
|
+
port: 0,
|
|
303
|
+
store: {
|
|
304
|
+
dbPath: ':memory:',
|
|
305
|
+
getState: () => ({ status: 'running' }),
|
|
306
|
+
listEvents: () => [],
|
|
307
|
+
interruptRuns: (args) => {
|
|
308
|
+
interruptArgs = args;
|
|
309
|
+
return 1;
|
|
310
|
+
},
|
|
311
|
+
cancelActiveTasksForInterruptedRuns: (args) => {
|
|
312
|
+
taskCancelArgs = args;
|
|
313
|
+
return 3;
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
session: { controlQueue: [{ id: 'control-1', workspace: 'docs', status: 'queued' }] },
|
|
317
|
+
run: async (_context, _body, { signal }) => {
|
|
318
|
+
signal.addEventListener('abort', () => { abortSeen = true; }, { once: true });
|
|
319
|
+
await new Promise(() => {});
|
|
320
|
+
},
|
|
321
|
+
cancel: async () => {
|
|
322
|
+
cancelCalled = true;
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if (err?.code === 'EPERM') {
|
|
327
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
throw err;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const base = `http://127.0.0.1:${handle.port}`;
|
|
335
|
+
const runResponse = await fetch(`${base}/run`, {
|
|
336
|
+
method: 'POST',
|
|
337
|
+
headers: { 'Content-Type': 'application/json' },
|
|
338
|
+
body: JSON.stringify({ input: 'build', workspace: 'docs' }),
|
|
339
|
+
});
|
|
340
|
+
assert.equal(runResponse.status, 202);
|
|
341
|
+
|
|
342
|
+
const killResponse = await fetch(`${base}/kill?workspace=docs`, { method: 'POST' });
|
|
343
|
+
assert.equal(killResponse.status, 202);
|
|
344
|
+
assert.deepEqual(await killResponse.json(), {
|
|
345
|
+
killed: true,
|
|
346
|
+
workspace: 'docs',
|
|
347
|
+
runId: null,
|
|
348
|
+
runs: 1,
|
|
349
|
+
tasks: 3,
|
|
350
|
+
queued: 1,
|
|
351
|
+
});
|
|
352
|
+
assert.equal(abortSeen, true);
|
|
353
|
+
assert.equal(cancelCalled, true);
|
|
354
|
+
assert.equal(interruptArgs.workspace, 'docs');
|
|
355
|
+
assert.equal(interruptArgs.runId, null);
|
|
356
|
+
assert.deepEqual(taskCancelArgs, { workspace: 'docs', runId: null });
|
|
357
|
+
} finally {
|
|
358
|
+
await handle.close();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('runtime server kill succeeds without an active run', async (t) => {
|
|
363
|
+
let handle;
|
|
364
|
+
try {
|
|
365
|
+
handle = await startRuntimeServer({
|
|
366
|
+
host: '127.0.0.1',
|
|
367
|
+
port: 0,
|
|
368
|
+
store: {
|
|
369
|
+
dbPath: ':memory:',
|
|
370
|
+
getState: () => ({ status: 'idle' }),
|
|
371
|
+
listEvents: () => [],
|
|
372
|
+
interruptRuns: () => 0,
|
|
373
|
+
cancelActiveTasksForInterruptedRuns: () => 0,
|
|
374
|
+
},
|
|
375
|
+
session: {},
|
|
376
|
+
run: async () => {},
|
|
377
|
+
});
|
|
378
|
+
} catch (err) {
|
|
379
|
+
if (err?.code === 'EPERM') {
|
|
380
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/kill?workspace=docs`, { method: 'POST' });
|
|
388
|
+
assert.equal(response.status, 202);
|
|
389
|
+
assert.deepEqual(await response.json(), {
|
|
390
|
+
killed: true,
|
|
391
|
+
workspace: 'docs',
|
|
392
|
+
runId: null,
|
|
393
|
+
runs: 0,
|
|
394
|
+
tasks: 0,
|
|
395
|
+
queued: 0,
|
|
396
|
+
});
|
|
397
|
+
} finally {
|
|
398
|
+
await handle.close();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('runtime server kill can target a specific run id', async (t) => {
|
|
403
|
+
let interruptArgs = null;
|
|
404
|
+
let taskCancelArgs = null;
|
|
405
|
+
let handle;
|
|
406
|
+
try {
|
|
407
|
+
handle = await startRuntimeServer({
|
|
408
|
+
host: '127.0.0.1',
|
|
409
|
+
port: 0,
|
|
410
|
+
store: {
|
|
411
|
+
dbPath: ':memory:',
|
|
412
|
+
getState: () => ({ status: 'idle' }),
|
|
413
|
+
listEvents: () => [],
|
|
414
|
+
interruptRuns: (args) => {
|
|
415
|
+
interruptArgs = args;
|
|
416
|
+
return 1;
|
|
417
|
+
},
|
|
418
|
+
cancelActiveTasksForInterruptedRuns: (args) => {
|
|
419
|
+
taskCancelArgs = args;
|
|
420
|
+
return 2;
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
session: {},
|
|
424
|
+
run: async () => {},
|
|
425
|
+
});
|
|
426
|
+
} catch (err) {
|
|
427
|
+
if (err?.code === 'EPERM') {
|
|
428
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
throw err;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/kill?workspace=docs&runId=run-1`, { method: 'POST' });
|
|
436
|
+
assert.equal(response.status, 202);
|
|
437
|
+
assert.equal((await response.json()).runId, 'run-1');
|
|
438
|
+
assert.equal(interruptArgs.runId, 'run-1');
|
|
439
|
+
assert.deepEqual(taskCancelArgs, { workspace: 'docs', runId: 'run-1' });
|
|
440
|
+
} finally {
|
|
441
|
+
await handle.close();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
290
445
|
test('runtime server state exposes active run identity while running', async (t) => {
|
|
291
446
|
let releaseRun;
|
|
292
447
|
const context = {
|
|
@@ -398,10 +553,10 @@ test('runtime server isolates active runs by workspace', async (t) => {
|
|
|
398
553
|
const queued = await fetch(`${url}?workspace=acme`, {
|
|
399
554
|
method: 'POST',
|
|
400
555
|
headers: { 'Content-Type': 'application/json' },
|
|
401
|
-
body: JSON.stringify({ input: 'third' }),
|
|
556
|
+
body: JSON.stringify({ input: 'third', intent: 'enqueue' }),
|
|
402
557
|
});
|
|
403
558
|
assert.equal(queued.status, 202);
|
|
404
|
-
assert.equal((await queued.json()).
|
|
559
|
+
assert.equal((await queued.json()).kind, 'enqueue_run');
|
|
405
560
|
assert.deepEqual(runWorkspaces.sort(), ['acme', 'docs']);
|
|
406
561
|
} finally {
|
|
407
562
|
releases.get('acme')?.();
|
|
@@ -1179,3 +1334,65 @@ test('runtime server rejects config switching while a run is active', async (t)
|
|
|
1179
1334
|
await handle.close();
|
|
1180
1335
|
}
|
|
1181
1336
|
});
|
|
1337
|
+
|
|
1338
|
+
test('runtime server answers control messages posted to /run during an active run', async (t) => {
|
|
1339
|
+
let releaseRun;
|
|
1340
|
+
let handle;
|
|
1341
|
+
try {
|
|
1342
|
+
handle = await startRuntimeServer({
|
|
1343
|
+
host: '127.0.0.1',
|
|
1344
|
+
port: 0,
|
|
1345
|
+
store: {
|
|
1346
|
+
dbPath: ':memory:',
|
|
1347
|
+
getState: () => ({ status: 'running' }),
|
|
1348
|
+
listEvents: () => [],
|
|
1349
|
+
},
|
|
1350
|
+
session: {},
|
|
1351
|
+
run: async () => {
|
|
1352
|
+
await new Promise((resolve) => { releaseRun = resolve; });
|
|
1353
|
+
},
|
|
1354
|
+
});
|
|
1355
|
+
} catch (err) {
|
|
1356
|
+
if (err?.code === 'EPERM') {
|
|
1357
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
throw err;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
try {
|
|
1364
|
+
const url = `http://127.0.0.1:${handle.port}/run`;
|
|
1365
|
+
const started = await fetch(url, {
|
|
1366
|
+
method: 'POST',
|
|
1367
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1368
|
+
body: JSON.stringify({ input: 'lance le pipeline' }),
|
|
1369
|
+
});
|
|
1370
|
+
assert.equal(started.status, 202);
|
|
1371
|
+
|
|
1372
|
+
// A status question must be answered NOW, not parked until the run ends
|
|
1373
|
+
// (regression: serve UI got no result until the job finished/stopped).
|
|
1374
|
+
const observe = await fetch(url, {
|
|
1375
|
+
method: 'POST',
|
|
1376
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1377
|
+
body: JSON.stringify({ input: 'où en est le run ?' }),
|
|
1378
|
+
});
|
|
1379
|
+
assert.equal(observe.status, 200);
|
|
1380
|
+
const observeBody = await observe.json();
|
|
1381
|
+
assert.equal(observeBody.kind, 'observe');
|
|
1382
|
+
assert.ok(String(observeBody.explanation ?? '').length > 0, 'observe must carry an explanation');
|
|
1383
|
+
|
|
1384
|
+
// A cancel must abort the active run instead of being enqueued.
|
|
1385
|
+
const cancel = await fetch(url, {
|
|
1386
|
+
method: 'POST',
|
|
1387
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1388
|
+
body: JSON.stringify({ input: 'annule le run' }),
|
|
1389
|
+
});
|
|
1390
|
+
assert.equal(cancel.status, 200);
|
|
1391
|
+
const cancelBody = await cancel.json();
|
|
1392
|
+
assert.equal(cancelBody.kind, 'cancel');
|
|
1393
|
+
assert.equal(cancelBody.accepted, true);
|
|
1394
|
+
} finally {
|
|
1395
|
+
releaseRun?.();
|
|
1396
|
+
await handle.close();
|
|
1397
|
+
}
|
|
1398
|
+
});
|
package/src/runtime/store.js
CHANGED
|
@@ -279,6 +279,11 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
279
279
|
SET status = 'interrupted', updated_at = ?
|
|
280
280
|
WHERE workspace = ? AND status IN (${RECOVERABLE_RUN_STATUSES.map(() => '?').join(', ')})
|
|
281
281
|
`);
|
|
282
|
+
const interruptRunByIdStatement = db.prepare(`
|
|
283
|
+
UPDATE runs
|
|
284
|
+
SET status = 'interrupted', updated_at = ?
|
|
285
|
+
WHERE id = ? AND (? IS NULL OR workspace = ?) AND status IN (${RECOVERABLE_RUN_STATUSES.map(() => '?').join(', ')})
|
|
286
|
+
`);
|
|
282
287
|
const upsertQueueItem = db.prepare(`
|
|
283
288
|
INSERT INTO queue_items (
|
|
284
289
|
id, workspace, server, tool, args, lock_key, status, reason, job_id, activity_key,
|
|
@@ -413,6 +418,25 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
413
418
|
`);
|
|
414
419
|
const deleteTaskDependenciesStatement = db.prepare('DELETE FROM task_dependencies WHERE run_id = ? AND task_id = ?');
|
|
415
420
|
const updateTaskStatusStatement = db.prepare('UPDATE tasks SET status = ?, declaration = ?, updated_at = ? WHERE run_id = ? AND id = ?');
|
|
421
|
+
const listActiveTasksForInterruptedRunsStatement = db.prepare(`
|
|
422
|
+
SELECT tasks.*
|
|
423
|
+
FROM tasks
|
|
424
|
+
JOIN runs ON runs.id = tasks.run_id
|
|
425
|
+
WHERE runs.workspace = ?
|
|
426
|
+
AND runs.status = 'interrupted'
|
|
427
|
+
AND tasks.status IN ('pending', 'pending_approval', 'waiting_approval', 'running', 'starting', 'blocked')
|
|
428
|
+
ORDER BY tasks.run_id ASC, tasks.id ASC
|
|
429
|
+
`);
|
|
430
|
+
const listActiveTasksForInterruptedRunByIdStatement = db.prepare(`
|
|
431
|
+
SELECT tasks.*
|
|
432
|
+
FROM tasks
|
|
433
|
+
JOIN runs ON runs.id = tasks.run_id
|
|
434
|
+
WHERE runs.id = ?
|
|
435
|
+
AND (? IS NULL OR runs.workspace = ?)
|
|
436
|
+
AND runs.status = 'interrupted'
|
|
437
|
+
AND tasks.status IN ('pending', 'pending_approval', 'waiting_approval', 'running', 'starting', 'blocked')
|
|
438
|
+
ORDER BY tasks.run_id ASC, tasks.id ASC
|
|
439
|
+
`);
|
|
416
440
|
const insertTaskDependencyStatement = db.prepare(`
|
|
417
441
|
INSERT OR IGNORE INTO task_dependencies (run_id, task_id, depends_on_task_id, created_at)
|
|
418
442
|
VALUES (?, ?, ?, ?)
|
|
@@ -619,13 +643,33 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
619
643
|
.map((row) => row.workspace);
|
|
620
644
|
}
|
|
621
645
|
|
|
622
|
-
function interruptRuns({ workspace, reason = 'Runtime restart recovery failed.' } = {}) {
|
|
623
|
-
if (!workspace) return 0;
|
|
646
|
+
function interruptRuns({ workspace, runId = null, reason = 'Runtime restart recovery failed.' } = {}) {
|
|
647
|
+
if (!workspace && !runId) return 0;
|
|
624
648
|
const now = new Date().toISOString();
|
|
625
|
-
const result =
|
|
649
|
+
const result = runId
|
|
650
|
+
? interruptRunByIdStatement.run(now, String(runId), workspace ?? null, workspace ?? null, ...RECOVERABLE_RUN_STATUSES)
|
|
651
|
+
: interruptRunsStatement.run(now, workspace, ...RECOVERABLE_RUN_STATUSES);
|
|
626
652
|
return Number(result.changes ?? 0);
|
|
627
653
|
}
|
|
628
654
|
|
|
655
|
+
function cancelActiveTasksForInterruptedRuns({ workspace = null, runId = null } = {}) {
|
|
656
|
+
if (!workspace && !runId) return 0;
|
|
657
|
+
const now = new Date().toISOString();
|
|
658
|
+
const tasks = runId
|
|
659
|
+
? listActiveTasksForInterruptedRunByIdStatement.all(String(runId), workspace ?? null, workspace ?? null).map(rowToTask)
|
|
660
|
+
: listActiveTasksForInterruptedRunsStatement.all(workspace).map(rowToTask);
|
|
661
|
+
for (const task of tasks) {
|
|
662
|
+
updateTaskStatusStatement.run(
|
|
663
|
+
'cancelled',
|
|
664
|
+
JSON.stringify({ ...task.declaration, status: 'cancelled' }),
|
|
665
|
+
now,
|
|
666
|
+
task.runId,
|
|
667
|
+
task.id,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
return tasks.length;
|
|
671
|
+
}
|
|
672
|
+
|
|
629
673
|
function saveQueue(queue = [], { workspace = null } = {}) {
|
|
630
674
|
const items = Array.isArray(queue) ? queue : [];
|
|
631
675
|
const now = new Date().toISOString();
|
|
@@ -1121,6 +1165,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
1121
1165
|
listRecoverableRuns,
|
|
1122
1166
|
listRecoverableWorkspaces,
|
|
1123
1167
|
interruptRuns,
|
|
1168
|
+
cancelActiveTasksForInterruptedRuns,
|
|
1124
1169
|
saveQueue,
|
|
1125
1170
|
listQueue,
|
|
1126
1171
|
listTasks,
|
|
@@ -606,6 +606,38 @@ test('runtime store persists cancelled run status', () => {
|
|
|
606
606
|
store.close();
|
|
607
607
|
});
|
|
608
608
|
|
|
609
|
+
test('runtime store interrupts runs by runId and does not recover killed runs after reopen', () => {
|
|
610
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
611
|
+
const store = openRuntimeStore({ stateDir });
|
|
612
|
+
for (const runId of ['run-kill', 'run-keep']) {
|
|
613
|
+
store.persistEvent(createAgentEvent('run_started', {
|
|
614
|
+
origin: 'test',
|
|
615
|
+
runId,
|
|
616
|
+
workspace: 'docs',
|
|
617
|
+
payload: { input: runId, workspace: 'docs' },
|
|
618
|
+
}));
|
|
619
|
+
store.persistEvent(createAgentEvent('task.created', {
|
|
620
|
+
origin: 'test',
|
|
621
|
+
runId,
|
|
622
|
+
taskId: `${runId}:task`,
|
|
623
|
+
workspace: 'docs',
|
|
624
|
+
payload: { runId, task: { ...plannedTask(`${runId}:task`), status: 'running' } },
|
|
625
|
+
}));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
assert.equal(store.interruptRuns({ workspace: 'docs', runId: 'run-kill' }), 1);
|
|
629
|
+
assert.equal(store.cancelActiveTasksForInterruptedRuns({ workspace: 'docs', runId: 'run-kill' }), 1);
|
|
630
|
+
assert.equal(store.listRuns().find((run) => run.id === 'run-kill').status, 'interrupted');
|
|
631
|
+
assert.equal(store.listRuns().find((run) => run.id === 'run-keep').status, 'running');
|
|
632
|
+
assert.equal(store.listTasks({ runId: 'run-kill' })[0].status, 'cancelled');
|
|
633
|
+
assert.equal(store.listTasks({ runId: 'run-keep' })[0].status, 'running');
|
|
634
|
+
store.close();
|
|
635
|
+
|
|
636
|
+
const reopened = openRuntimeStore({ stateDir });
|
|
637
|
+
assert.deepEqual(reopened.listRecoverableRuns({ workspace: 'docs' }).map((run) => run.id), ['run-keep']);
|
|
638
|
+
reopened.close();
|
|
639
|
+
});
|
|
640
|
+
|
|
609
641
|
test('runtime store migrates legacy databases without workspace columns', () => {
|
|
610
642
|
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
611
643
|
const db = new DatabaseSync(join(stateDir, 'runtime.db'));
|
|
@@ -98,8 +98,55 @@ export async function pollActivitiesOnce(session, {
|
|
|
98
98
|
payload: { activity: polledActivity },
|
|
99
99
|
}));
|
|
100
100
|
syncQueueWithActivity(session, polledActivity);
|
|
101
|
-
|
|
101
|
+
// Only log when something actually changed: the poll fires every
|
|
102
|
+
// second and an unchanged "activity: X -> running" line repeated
|
|
103
|
+
// dozens of times drowned Logs/Trace and looked like a frozen system.
|
|
104
|
+
// The line itself carries the operational signal (current file,
|
|
105
|
+
// percent, batch, last trace event, retry/backoff) so the panel
|
|
106
|
+
// reads as a narrative of the job, not a heartbeat.
|
|
107
|
+
const progress = payload?.progress ?? polledActivity.progress ?? {};
|
|
108
|
+
const percent = Number(progress.percent ?? polledActivity.progress?.percent);
|
|
109
|
+
const detail = progress.detail ?? progress.step ?? '';
|
|
110
|
+
// The trace's batchIndex is 0-based and the human detail already says
|
|
111
|
+
// "Batch 1/2" — printing both gave "Batch 1/2 · batch 0/2". Display
|
|
112
|
+
// 1-based, and only when the detail doesn't already carry it.
|
|
113
|
+
const batch = progress.batchIndex != null && progress.batchCount != null && !/batch/i.test(String(detail))
|
|
114
|
+
? `batch ${Number(progress.batchIndex) + 1}/${progress.batchCount}`
|
|
115
|
+
: null;
|
|
116
|
+
const lastEvent = progress.lastEvent ? String(progress.lastEvent) : null;
|
|
117
|
+
const retry = progress.retryAt
|
|
118
|
+
? `retry ${progress.retryAt}`
|
|
119
|
+
: (progress.waitMs ? `wait ${progress.waitMs}ms` : null);
|
|
120
|
+
const progressKey = `${polledActivity.status}:${Number.isFinite(percent) ? Math.round(percent) : ''}:${detail}:${batch ?? ''}:${lastEvent ?? ''}:${retry ?? ''}`;
|
|
121
|
+
session._activityLogKeys ??= {};
|
|
122
|
+
if (session._activityLogKeys[key] !== progressKey) {
|
|
123
|
+
session._activityLogKeys[key] = progressKey;
|
|
124
|
+
const progressLabel = [
|
|
125
|
+
Number.isFinite(percent) ? `${Math.round(percent)}%` : null,
|
|
126
|
+
detail ? String(detail) : null,
|
|
127
|
+
batch,
|
|
128
|
+
lastEvent,
|
|
129
|
+
retry,
|
|
130
|
+
].filter(Boolean).join(' · ');
|
|
131
|
+
emitRuntimeLog(session, `activity: ${polledActivity.label} -> ${polledActivity.status}${progressLabel ? ` (${progressLabel})` : ''}`);
|
|
132
|
+
}
|
|
133
|
+
// Stream NEW job-log lines into Logs/Trace: document names, chunking,
|
|
134
|
+
// LLM call/token summaries and errors live in the job log, not in the
|
|
135
|
+
// status fields. Diff against the last line already shown.
|
|
136
|
+
const logTail = Array.isArray(payload?.logTail) ? payload.logTail.map(String) : [];
|
|
137
|
+
if (logTail.length > 0) {
|
|
138
|
+
session._activityLogCursors ??= {};
|
|
139
|
+
const lastSeen = session._activityLogCursors[key];
|
|
140
|
+
const lastIndex = lastSeen ? logTail.lastIndexOf(lastSeen) : -1;
|
|
141
|
+
for (const line of logTail.slice(lastIndex + 1)) {
|
|
142
|
+
const clean = line.trim();
|
|
143
|
+
if (clean) emitRuntimeLog(session, `job ${polledActivity.id ?? key}: ${clean}`);
|
|
144
|
+
}
|
|
145
|
+
session._activityLogCursors[key] = logTail.at(-1);
|
|
146
|
+
}
|
|
102
147
|
if (polledActivity.terminal) {
|
|
148
|
+
delete session._activityLogKeys[key];
|
|
149
|
+
delete session._activityLogCursors?.[key];
|
|
103
150
|
await startNextQueuedJob(session, {
|
|
104
151
|
addLog: (message) => emitRuntimeLog(session, message),
|
|
105
152
|
});
|
|
@@ -134,3 +181,32 @@ function registryIntervalFromEnv() {
|
|
|
134
181
|
const value = Number(process.env.WIKI_MANAGER_AGENT_REGISTRY_INTERVAL_MS);
|
|
135
182
|
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 60000;
|
|
136
183
|
}
|
|
184
|
+
|
|
185
|
+
// Cancelling a runtime run must also cancel the asynchronous jobs it started:
|
|
186
|
+
// the run's abort only stopped the manager-side loop while the agent kept
|
|
187
|
+
// executing (an ingest subprocess ran on for minutes after "cancel requested",
|
|
188
|
+
// with the panels frozen on its last known state). Best effort: prefer the
|
|
189
|
+
// orchestration contract's agent_cancel, fall back to legacy *_cancel_job.
|
|
190
|
+
export async function cancelActiveActivityJobs(session, { callTool = callMcpTool } = {}) {
|
|
191
|
+
const cancelled = [];
|
|
192
|
+
for (const activity of sessionActivities(session)) {
|
|
193
|
+
if (activity.terminal || !activity.poll) continue;
|
|
194
|
+
const jobId = activity.poll.args?.jobId ?? activity.id ?? null;
|
|
195
|
+
const server = activity.poll.server;
|
|
196
|
+
const endpoint = session.mcp?.[server];
|
|
197
|
+
if (!jobId || !endpoint || endpoint.status !== 'connected') continue;
|
|
198
|
+
const names = (endpoint.tools ?? []).map((tool) => String(tool.name ?? ''));
|
|
199
|
+
const cancelTool = names.find((name) => /(^|__)agent_cancel$/.test(name))
|
|
200
|
+
?? names.find((name) => /_cancel_job$/.test(name))
|
|
201
|
+
?? names.find((name) => /(^|__)[a-z]*_?cancel$/.test(name));
|
|
202
|
+
if (!cancelTool) continue;
|
|
203
|
+
try {
|
|
204
|
+
await callTool(session.mcp, server, cancelTool, { jobId });
|
|
205
|
+
emitRuntimeLog(session, `cancel: requested ${server}.${cancelTool} for job ${jobId}`);
|
|
206
|
+
cancelled.push({ server, jobId });
|
|
207
|
+
} catch (err) {
|
|
208
|
+
emitRuntimeLog(session, `cancel: ${server}.${cancelTool} failed for ${jobId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return cancelled;
|
|
212
|
+
}
|