@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
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import { mkdtempSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { DatabaseSync } from 'node:sqlite';
5
6
  import test from 'node:test';
6
7
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
7
8
  import { openRuntimeStore } from './store.js';
@@ -24,7 +25,7 @@ test('runtime store persists and replays agent events into a projection', () =>
24
25
  reopened.hydrateSession(replayedSession);
25
26
  assert.equal(replayedSession.headlessPlan.length, 1);
26
27
  assert.equal(replayedSession.headlessPlan[0].description, 'Ingest');
27
- assert.equal(reopened.getState(replayedSession).eventsCursor, event.id);
28
+ assert.equal(reopened.getState(replayedSession).eventsCursor, 1);
28
29
  reopened.close();
29
30
  });
30
31
 
@@ -34,33 +35,281 @@ test('runtime store persists duplicate events idempotently', () => {
34
35
  const event = createAgentEvent('run_started', {
35
36
  origin: 'test',
36
37
  runId: 'run-1',
37
- payload: { input: 'build wiki' },
38
+ turnId: 'run-1:turn-0',
39
+ workspace: 'juno',
40
+ payload: { input: 'build wiki', workspace: 'juno' },
38
41
  });
39
42
 
40
43
  store.persistEvent(event);
41
44
  store.persistEvent(event);
42
45
 
43
46
  assert.equal(store.listEvents().length, 1);
47
+ assert.equal(store.listEvents()[0].sequence, 1);
48
+ assert.equal(store.getState().eventsCursor, 1);
44
49
  assert.equal(store.listRuns()[0].id, 'run-1');
50
+ assert.equal(store.listRuns()[0].workspace, 'juno');
45
51
  assert.equal(store.listRuns()[0].status, 'running');
46
52
  store.close();
47
53
  });
48
54
 
55
+ test('runtime store persists run identity fields on events', () => {
56
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
57
+ const store = openRuntimeStore({ stateDir });
58
+ store.persistEvent(createAgentEvent('assistant_message', {
59
+ origin: 'agent',
60
+ runId: 'run-2',
61
+ turnId: 'run-2:turn-1',
62
+ workspace: 'docs',
63
+ payload: { content: 'done' },
64
+ }));
65
+
66
+ const [event] = store.listEvents();
67
+ assert.equal(event.runId, 'run-2');
68
+ assert.equal(event.turnId, 'run-2:turn-1');
69
+ assert.equal(event.workspace, 'docs');
70
+ store.close();
71
+ });
72
+
73
+ test('runtime store replays evaluator verdict into state', () => {
74
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
75
+ const store = openRuntimeStore({ stateDir });
76
+ store.persistEvent(createAgentEvent('run_started', {
77
+ origin: 'runtime',
78
+ runId: 'run-eval',
79
+ turnId: 'run-eval:turn-0',
80
+ workspace: 'docs',
81
+ payload: { input: 'build docs', workspace: 'docs' },
82
+ }));
83
+ store.persistEvent(createAgentEvent('run_evaluated', {
84
+ origin: 'runtime',
85
+ runId: 'run-eval',
86
+ turnId: 'run-eval:turn-1',
87
+ workspace: 'docs',
88
+ payload: {
89
+ ok: true,
90
+ reason: 'Task complete.',
91
+ suggestedAction: null,
92
+ },
93
+ }));
94
+ store.close();
95
+
96
+ const reopened = openRuntimeStore({ stateDir });
97
+ const session = { activities: {}, headlessPlan: null };
98
+ reopened.hydrateSession(session, { workspace: 'docs' });
99
+
100
+ assert.deepEqual(reopened.getState(session, { workspace: 'docs' }).evaluation, {
101
+ ok: true,
102
+ reason: 'Task complete.',
103
+ suggestedAction: null,
104
+ runId: 'run-eval',
105
+ });
106
+ reopened.close();
107
+ });
108
+
109
+ test('runtime store replays replan trace into state', () => {
110
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
111
+ const store = openRuntimeStore({ stateDir });
112
+ store.persistEvent(createAgentEvent('run_started', {
113
+ origin: 'runtime',
114
+ runId: 'run-replan',
115
+ turnId: 'run-replan:turn-0',
116
+ workspace: 'docs',
117
+ payload: { input: 'build docs', workspace: 'docs' },
118
+ }));
119
+ store.persistEvent(createAgentEvent('run_replanned', {
120
+ origin: 'runtime',
121
+ runId: 'run-replan',
122
+ turnId: 'run-replan:turn-1',
123
+ workspace: 'docs',
124
+ payload: {
125
+ reason: 'Build failed.',
126
+ plan: ['Retry build'],
127
+ replansLeft: 1,
128
+ },
129
+ }));
130
+ store.close();
131
+
132
+ const reopened = openRuntimeStore({ stateDir });
133
+ const session = { activities: {}, headlessPlan: null };
134
+ reopened.hydrateSession(session, { workspace: 'docs' });
135
+
136
+ assert.deepEqual(reopened.getState(session, { workspace: 'docs' }).replans, [{
137
+ reason: 'Build failed.',
138
+ plan: ['Retry build'],
139
+ replansLeft: 1,
140
+ runId: 'run-replan',
141
+ }]);
142
+ reopened.close();
143
+ });
144
+
145
+ test('runtime store tracks pending approval run status', () => {
146
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
147
+ const store = openRuntimeStore({ stateDir });
148
+ store.persistEvent(createAgentEvent('run_started', {
149
+ origin: 'runtime',
150
+ runId: 'run-approval',
151
+ workspace: 'docs',
152
+ payload: { input: 'build docs', workspace: 'docs' },
153
+ }));
154
+ store.persistEvent(createAgentEvent('run_pending_approval', {
155
+ origin: 'runtime',
156
+ runId: 'run-approval',
157
+ workspace: 'docs',
158
+ payload: { approvalId: 'approval-1', runId: 'run-approval', plan: ['Build'] },
159
+ }));
160
+
161
+ assert.equal(store.listRuns({ workspace: 'docs' })[0].status, 'pending_approval');
162
+
163
+ store.persistEvent(createAgentEvent('run_approved', {
164
+ origin: 'runtime',
165
+ runId: 'run-approval',
166
+ workspace: 'docs',
167
+ payload: { approvalId: 'approval-1', runId: 'run-approval' },
168
+ }));
169
+
170
+ assert.equal(store.listRuns({ workspace: 'docs' })[0].status, 'running');
171
+ assert.equal(store.getState(null, { workspace: 'docs' }).approvals[0].status, 'approved');
172
+ store.close();
173
+ });
174
+
175
+ test('runtime store orders events by durable sequence', () => {
176
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
177
+ const store = openRuntimeStore({ stateDir });
178
+ const ts = '2026-01-01T00:00:00.000Z';
179
+
180
+ store.persistEvent({
181
+ id: 'event-b',
182
+ ts,
183
+ type: 'user_message',
184
+ origin: 'test',
185
+ runId: 'run-seq',
186
+ turnId: 'run-seq:turn-0',
187
+ workspace: 'docs',
188
+ payload: { content: 'first' },
189
+ });
190
+ store.persistEvent({
191
+ id: 'event-a',
192
+ ts,
193
+ type: 'assistant_message',
194
+ origin: 'test',
195
+ runId: 'run-seq',
196
+ turnId: 'run-seq:turn-1',
197
+ workspace: 'docs',
198
+ payload: { content: 'second' },
199
+ });
200
+ store.persistEvent({
201
+ id: 'event-c',
202
+ ts,
203
+ type: 'assistant_message',
204
+ origin: 'test',
205
+ runId: 'run-other',
206
+ turnId: 'run-other:turn-1',
207
+ workspace: 'other',
208
+ payload: { content: 'third' },
209
+ });
210
+
211
+ const events = store.listEvents({ workspace: 'docs' });
212
+ assert.deepEqual(events.map((event) => event.id), ['event-b', 'event-a']);
213
+ assert.deepEqual(events.map((event) => event.sequence), [1, 2]);
214
+ assert.equal(store.getState(null, { workspace: 'docs' }).eventsCursor, 2);
215
+ store.close();
216
+ });
217
+
49
218
  test('runtime store persists cancelled run status', () => {
50
219
  const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
51
220
  const store = openRuntimeStore({ stateDir });
52
221
  store.persistEvent(createAgentEvent('run_started', {
53
222
  origin: 'test',
54
223
  runId: 'run-1',
55
- payload: { input: 'build wiki' },
224
+ turnId: 'run-1:turn-0',
225
+ workspace: 'juno',
226
+ payload: { input: 'build wiki', workspace: 'juno' },
56
227
  }));
57
228
  store.persistEvent(createAgentEvent('run_cancelled', {
58
229
  origin: 'test',
59
230
  runId: 'run-1',
60
- payload: { runId: 'run-1' },
231
+ turnId: 'run-1:turn-1',
232
+ workspace: 'juno',
233
+ payload: { runId: 'run-1', workspace: 'juno' },
61
234
  }));
62
235
 
63
236
  assert.equal(store.listRuns()[0].status, 'cancelled');
237
+ assert.equal(store.listRuns()[0].workspace, 'juno');
238
+ store.close();
239
+ });
240
+
241
+ test('runtime store migrates legacy databases without workspace columns', () => {
242
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
243
+ const db = new DatabaseSync(join(stateDir, 'runtime.db'));
244
+ db.exec(`
245
+ CREATE TABLE events (
246
+ id TEXT PRIMARY KEY,
247
+ ts TEXT NOT NULL,
248
+ type TEXT NOT NULL,
249
+ run_id TEXT,
250
+ turn_id TEXT,
251
+ origin TEXT,
252
+ payload TEXT NOT NULL
253
+ );
254
+ CREATE TABLE runs (
255
+ id TEXT PRIMARY KEY,
256
+ status TEXT NOT NULL,
257
+ input TEXT,
258
+ created_at TEXT NOT NULL,
259
+ updated_at TEXT NOT NULL
260
+ );
261
+ `);
262
+ db.close();
263
+
264
+ const store = openRuntimeStore({ stateDir });
265
+ store.persistEvent(createAgentEvent('run_started', {
266
+ origin: 'test',
267
+ runId: 'run-legacy',
268
+ turnId: 'run-legacy:turn-0',
269
+ workspace: 'legacy',
270
+ payload: { input: 'migrate', workspace: 'legacy' },
271
+ }));
272
+
273
+ assert.equal(store.listEvents()[0].workspace, 'legacy');
274
+ assert.equal(store.listEvents()[0].sequence, 1);
275
+ assert.equal(store.listRuns()[0].workspace, 'legacy');
276
+ store.close();
277
+ });
278
+
279
+ test('runtime store backfills sequence for legacy event rows', () => {
280
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
281
+ const db = new DatabaseSync(join(stateDir, 'runtime.db'));
282
+ db.exec(`
283
+ CREATE TABLE events (
284
+ id TEXT PRIMARY KEY,
285
+ ts TEXT NOT NULL,
286
+ type TEXT NOT NULL,
287
+ run_id TEXT,
288
+ turn_id TEXT,
289
+ workspace TEXT,
290
+ origin TEXT,
291
+ payload TEXT NOT NULL
292
+ );
293
+ CREATE TABLE runs (
294
+ id TEXT PRIMARY KEY,
295
+ workspace TEXT,
296
+ status TEXT NOT NULL,
297
+ input TEXT,
298
+ created_at TEXT NOT NULL,
299
+ updated_at TEXT NOT NULL
300
+ );
301
+ INSERT INTO events (id, ts, type, run_id, turn_id, workspace, origin, payload)
302
+ VALUES
303
+ ('legacy-b', '2026-01-01T00:00:00.000Z', 'user_message', 'run-legacy', 'run-legacy:turn-0', 'docs', 'test', '{"content":"first"}'),
304
+ ('legacy-a', '2026-01-01T00:00:00.000Z', 'assistant_message', 'run-legacy', 'run-legacy:turn-1', 'docs', 'test', '{"content":"second"}');
305
+ `);
306
+ db.close();
307
+
308
+ const store = openRuntimeStore({ stateDir });
309
+ const events = store.listEvents({ workspace: 'docs' });
310
+
311
+ assert.deepEqual(events.map((event) => event.id), ['legacy-b', 'legacy-a']);
312
+ assert.deepEqual(events.map((event) => event.sequence), [1, 2]);
64
313
  store.close();
65
314
  });
66
315
 
@@ -101,3 +350,113 @@ test('runtime store persists and hydrates queue items', () => {
101
350
  assert.equal(reopened.getState(session).queue[0].workspace, 'docs');
102
351
  reopened.close();
103
352
  });
353
+
354
+ test('runtime store filters events runs and queue by workspace', () => {
355
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
356
+ const store = openRuntimeStore({ stateDir });
357
+ store.persistEvent(createAgentEvent('run_started', {
358
+ origin: 'test',
359
+ runId: 'run-juno',
360
+ turnId: 'run-juno:turn-0',
361
+ workspace: 'juno',
362
+ payload: { input: 'juno', workspace: 'juno' },
363
+ }));
364
+ store.persistEvent(createAgentEvent('run_started', {
365
+ origin: 'test',
366
+ runId: 'run-docs',
367
+ turnId: 'run-docs:turn-0',
368
+ workspace: 'docs',
369
+ payload: { input: 'docs', workspace: 'docs' },
370
+ }));
371
+ store.saveQueue([{ id: 'q-juno', workspace: 'juno', status: 'waiting' }], { workspace: 'juno' });
372
+ store.saveQueue([{ id: 'q-docs', workspace: 'docs', status: 'waiting' }], { workspace: 'docs' });
373
+
374
+ assert.deepEqual(store.listEvents({ workspace: 'juno' }).map((event) => event.runId), ['run-juno']);
375
+ assert.deepEqual(store.listRuns({ workspace: 'docs' }).map((run) => run.id), ['run-docs']);
376
+ assert.deepEqual(store.listQueue({ workspace: 'juno' }).map((item) => item.id), ['q-juno']);
377
+
378
+ const session = { activities: {}, headlessPlan: null };
379
+ store.hydrateSession(session, { workspace: 'docs' });
380
+ assert.equal(session.jobQueue[0].id, 'q-docs');
381
+ assert.deepEqual(store.getState(session, { workspace: 'docs' }).runs.map((run) => run.id), ['run-docs']);
382
+ store.close();
383
+ });
384
+
385
+ test('runtime state exposes queue as blocked jobs and pending plan steps', () => {
386
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
387
+ const store = openRuntimeStore({ stateDir });
388
+ const session = { activities: {}, headlessPlan: null };
389
+
390
+ store.persistEvent(dispatchAgentEvent(session, createAgentEvent('plan_set', {
391
+ origin: 'tool',
392
+ workspace: 'docs',
393
+ payload: {
394
+ steps: [
395
+ { step: 1, description: 'Analyze', status: 'done' },
396
+ { step: 2, description: 'Build', status: 'pending' },
397
+ { step: 3, description: 'Verify', status: 'pending' },
398
+ ],
399
+ },
400
+ })));
401
+ store.saveQueue([
402
+ { id: 'q-wait', workspace: 'docs', status: 'waiting', args: { type: 'blocked' } },
403
+ { id: 'q-run', workspace: 'docs', status: 'running', args: { type: 'active' } },
404
+ { id: 'q-done', workspace: 'docs', status: 'done', args: { type: 'done' } },
405
+ ], { workspace: 'docs' });
406
+
407
+ const queue = store.getState(session, { workspace: 'docs' }).queue;
408
+ assert.deepEqual(queue.map((item) => item.id), ['q-wait', 'plan-2', 'plan-3']);
409
+ assert.equal(queue[0].queueType, 'blocked_job');
410
+ assert.equal(queue[1].queueType, 'pending_step');
411
+ assert.equal(queue[1].args.type, 'Build');
412
+ store.close();
413
+ });
414
+
415
+ test('runtime store persists and replays control queue events without mixing MCP queue', () => {
416
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
417
+ const store = openRuntimeStore({ stateDir });
418
+ store.persistEvent(createAgentEvent('control_enqueued', {
419
+ origin: 'runtime',
420
+ workspace: 'docs',
421
+ payload: { id: 'control-docs', workspace: 'docs', input: 'later' },
422
+ }));
423
+ store.persistEvent(createAgentEvent('control_enqueued', {
424
+ origin: 'runtime',
425
+ workspace: 'juno',
426
+ payload: { id: 'control-juno', workspace: 'juno', input: 'other' },
427
+ }));
428
+ store.close();
429
+
430
+ const reopened = openRuntimeStore({ stateDir });
431
+ const session = { activities: {}, headlessPlan: null };
432
+ reopened.hydrateSession(session, { workspace: 'docs' });
433
+ const state = reopened.getState(session, { workspace: 'docs' });
434
+ assert.deepEqual(state.controlQueue.map((item) => item.id), ['control-docs']);
435
+ assert.deepEqual(state.queue, []);
436
+ assert.deepEqual(session.controlQueue.map((item) => item.id), ['control-docs']);
437
+ reopened.close();
438
+ });
439
+
440
+ test('runtime store identifies recoverable workspaces and interrupts stale runs', () => {
441
+ const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
442
+ const store = openRuntimeStore({ stateDir });
443
+ store.persistRun({
444
+ id: 'run-juno',
445
+ workspace: 'juno',
446
+ status: 'running',
447
+ input: 'build juno',
448
+ });
449
+ store.persistRun({
450
+ id: 'run-done',
451
+ workspace: 'docs',
452
+ status: 'done',
453
+ input: 'done',
454
+ });
455
+ store.saveQueue([{ id: 'q-docs', workspace: 'docs', status: 'waiting' }], { workspace: 'docs' });
456
+
457
+ assert.deepEqual(store.listRecoverableWorkspaces(), ['docs', 'juno']);
458
+ assert.deepEqual(store.listRecoverableRuns({ workspace: 'juno' }).map((run) => run.id), ['run-juno']);
459
+ assert.equal(store.interruptRuns({ workspace: 'juno' }), 1);
460
+ assert.equal(store.listRuns({ workspace: 'juno' })[0].status, 'interrupted');
461
+ store.close();
462
+ });
@@ -80,6 +80,12 @@ export async function pollActivitiesOnce(session, {
80
80
  await startNextQueuedJob(session, {
81
81
  addLog: (message) => emitRuntimeLog(session, message),
82
82
  });
83
+ const remainingPollable = sessionActivities(session).filter((a) => !a.terminal && a.poll);
84
+ if (remainingPollable.length === 0 && typeof session._onActivitiesTerminal === 'function') {
85
+ const cb = session._onActivitiesTerminal;
86
+ delete session._onActivitiesTerminal;
87
+ cb(session);
88
+ }
83
89
  }
84
90
  }
85
91
  } catch (err) {
@@ -41,6 +41,71 @@ test('pollActivitiesOnce updates activity through the event reducer', async () =
41
41
  assert.ok(session.agentProjection.logs.some((line) => line.includes('activity:')));
42
42
  });
43
43
 
44
+ test('pollActivitiesOnce retries transient MCP poll failures', async () => {
45
+ const originalFetch = globalThis.fetch;
46
+ let attempts = 0;
47
+ globalThis.fetch = async () => {
48
+ attempts += 1;
49
+ if (attempts === 1) {
50
+ return {
51
+ ok: false,
52
+ status: 503,
53
+ headers: { get: () => null },
54
+ text: async () => 'temporarily unavailable',
55
+ };
56
+ }
57
+ return {
58
+ ok: true,
59
+ status: 200,
60
+ headers: { get: () => null },
61
+ text: async () => JSON.stringify({
62
+ result: {
63
+ content: [{ type: 'text', text: JSON.stringify({
64
+ _activity: {
65
+ id: 'job-retry',
66
+ source: 'production',
67
+ status: 'done',
68
+ terminal: true,
69
+ },
70
+ }) }],
71
+ },
72
+ }),
73
+ };
74
+ };
75
+
76
+ const session = {
77
+ mcp: {
78
+ production: {
79
+ status: 'connected',
80
+ url: 'http://127.0.0.1:3000/mcp/',
81
+ retry: { maxAttempts: 2, backoffMs: 0 },
82
+ },
83
+ },
84
+ activities: {},
85
+ headlessPlan: null,
86
+ jobQueue: [],
87
+ };
88
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
89
+ payload: {
90
+ activity: {
91
+ id: 'job-retry',
92
+ source: 'production',
93
+ status: 'running',
94
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: 'job-retry' }, intervalMs: 0 },
95
+ },
96
+ },
97
+ }));
98
+
99
+ try {
100
+ await pollActivitiesOnce(session);
101
+ const key = Object.keys(session.activities)[0];
102
+ assert.equal(attempts, 2);
103
+ assert.equal(session.activities[key].status, 'done');
104
+ } finally {
105
+ globalThis.fetch = originalFetch;
106
+ }
107
+ });
108
+
44
109
  test('startActivitySupervisor passes the active run signal to background polls', async () => {
45
110
  const session = {
46
111
  mcp: { production: { status: 'connected' } },
@@ -89,6 +154,82 @@ test('startActivitySupervisor passes the active run signal to background polls',
89
154
  }
90
155
  });
91
156
 
157
+ test('pollActivitiesOnce fires _onActivitiesTerminal when last activity becomes terminal', async () => {
158
+ let fired = false;
159
+ const session = {
160
+ mcp: { production: { status: 'connected' } },
161
+ activities: {},
162
+ headlessPlan: null,
163
+ jobQueue: [],
164
+ _onActivitiesTerminal: () => { fired = true; },
165
+ };
166
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
167
+ payload: {
168
+ activity: {
169
+ id: 'job-hook',
170
+ source: 'production',
171
+ status: 'running',
172
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: 'job-hook' }, intervalMs: 0 },
173
+ },
174
+ },
175
+ }));
176
+ const key = Object.keys(session.activities)[0];
177
+ session.activities[key].lastPolledAt = '1970-01-01T00:00:00.000Z';
178
+
179
+ await pollActivitiesOnce(session, {
180
+ callTool: async () => ({
181
+ content: [{ type: 'text', text: JSON.stringify({
182
+ _activity: { id: 'job-hook', source: 'production', status: 'done', terminal: true },
183
+ }) }],
184
+ }),
185
+ });
186
+
187
+ assert.equal(fired, true);
188
+ assert.equal(session._onActivitiesTerminal, undefined);
189
+ });
190
+
191
+ test('pollActivitiesOnce does not fire _onActivitiesTerminal while other activities remain', async () => {
192
+ let fired = false;
193
+ const session = {
194
+ mcp: { production: { status: 'connected' } },
195
+ activities: {},
196
+ headlessPlan: null,
197
+ jobQueue: [],
198
+ _onActivitiesTerminal: () => { fired = true; },
199
+ };
200
+ for (const id of ['job-a', 'job-b']) {
201
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
202
+ payload: {
203
+ activity: {
204
+ id,
205
+ source: 'production',
206
+ status: 'running',
207
+ poll: { server: 'production', tool: 'production_job_status', args: { jobId: id }, intervalMs: 0 },
208
+ },
209
+ },
210
+ }));
211
+ }
212
+ for (const key of Object.keys(session.activities)) {
213
+ session.activities[key].lastPolledAt = '1970-01-01T00:00:00.000Z';
214
+ }
215
+
216
+ let callCount = 0;
217
+ await pollActivitiesOnce(session, {
218
+ callTool: async (_mcp, _server, _tool, args) => {
219
+ callCount += 1;
220
+ const terminal = args.jobId === 'job-a';
221
+ return {
222
+ content: [{ type: 'text', text: JSON.stringify({
223
+ _activity: { id: args.jobId, source: 'production', status: terminal ? 'done' : 'running', terminal },
224
+ }) }],
225
+ };
226
+ },
227
+ });
228
+
229
+ assert.equal(fired, false);
230
+ assert.equal(callCount, 2);
231
+ });
232
+
92
233
  async function waitFor(predicate, timeoutMs = 250) {
93
234
  const deadline = Date.now() + timeoutMs;
94
235
  while (Date.now() < deadline) {
@@ -68,7 +68,7 @@ function activityColor(status: string) {
68
68
 
69
69
  function queueColor(status: string) {
70
70
  const value = String(status ?? '').toLowerCase();
71
- if (value === 'waiting') return '#FBBF24';
71
+ if (value === 'waiting' || value === 'pending_approval') return '#FBBF24';
72
72
  return activityColor(status);
73
73
  }
74
74
 
package/src/shell/repl.js CHANGED
@@ -15,7 +15,7 @@ import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
15
15
  import { listSkills } from '../core/skills.js';
16
16
  import { listWikircProfiles } from '../core/wikirc.js';
17
17
  import { listWorkspaces } from '../core/workspaces.js';
18
- import { fetchRuntimeState, postRuntimeCancel, postRuntimeRun, streamRuntimeEvents } from '../runtime/client.js';
18
+ import { fetchRuntimeState, postRuntimeApprove, postRuntimeCancel, postRuntimeRun, streamRuntimeEvents } from '../runtime/client.js';
19
19
 
20
20
  marked.use(markedTerminal());
21
21
  // marked-terminal's text renderer extracts token.text (raw string) instead of
@@ -74,6 +74,7 @@ const COMMAND_COMPLETION_DESCRIPTIONS = {
74
74
  '/chat': 'Switch free text to direct LLM chat without tools.',
75
75
  '/agent': 'Switch free text to the LangGraph agent with tools.',
76
76
  '/openui': 'Open the workspace web UI in the browser.',
77
+ '/approve': 'Approve a pending runtime run or tool.',
77
78
  };
78
79
 
79
80
  const SUBCOMMAND_COMPLETION_DESCRIPTIONS = {
@@ -110,7 +111,7 @@ export function createSession() {
110
111
  wikircConfig: null,
111
112
  language: null,
112
113
  mcp: null,
113
- commands: ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'upload', 'uploads', 'clear', 'chat', 'agent', 'openui', 'queue'],
114
+ commands: ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'upload', 'uploads', 'clear', 'chat', 'agent', 'openui', 'queue', 'approve'],
114
115
  chatMode: true,
115
116
  llm: null,
116
117
  activities: {},
@@ -1242,7 +1243,7 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1242
1243
 
1243
1244
  function syncRuntimeState() {
1244
1245
  if (!runtime?.url) return;
1245
- void fetchRuntimeState({ url: runtime.url })
1246
+ void fetchRuntimeState({ url: runtime.url, workspace: session.workspace ?? null })
1246
1247
  .then((state) => {
1247
1248
  runtimePollingActive = true;
1248
1249
  applyRuntimeStateToShellSession(session, state);
@@ -1263,7 +1264,7 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1263
1264
  if (!runtime?.url || runtimeStreamStopped) return;
1264
1265
  runtimeStreamAbort = new AbortController();
1265
1266
  try {
1266
- for await (const event of streamRuntimeEvents({ url: runtime.url, signal: runtimeStreamAbort.signal })) {
1267
+ for await (const event of streamRuntimeEvents({ url: runtime.url, signal: runtimeStreamAbort.signal, workspace: session.workspace ?? null })) {
1267
1268
  runtimePollingActive = true;
1268
1269
  if (event.type === 'state') {
1269
1270
  applyRuntimeStateToShellSession(session, event.data);
@@ -1424,7 +1425,7 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1424
1425
  return;
1425
1426
  }
1426
1427
  if (runtime?.url && runtimeRunActive(session)) {
1427
- void postRuntimeCancel({ url: runtime.url })
1428
+ void postRuntimeCancel({ url: runtime.url, workspace: session.workspace ?? null })
1428
1429
  .then(() => {
1429
1430
  activityLines = [...activityLines, 'runtime: cancel requested'].slice(-LOWER_DETAIL_ROWS);
1430
1431
  rerender();
@@ -1482,7 +1483,22 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
1482
1483
  session._abortSignal = currentAbortController.signal;
1483
1484
  let aborted = false;
1484
1485
  try {
1485
- if (runtime?.url && !session.chatMode && !line.trim().startsWith('/')) {
1486
+ if (runtime?.url && line.trim().startsWith('/approve')) {
1487
+ const parts = line.trim().split(/\s+/).slice(1);
1488
+ const kind = ['run', 'item', 'approval'].includes(parts[0]) ? parts.shift() : 'run';
1489
+ const id = parts[0];
1490
+ if (!id) {
1491
+ conversationMessages(session).push({ role: 'command', content: 'Usage: /approve [run|item|approval] <id>' });
1492
+ } else {
1493
+ const result = await postRuntimeApprove({
1494
+ url: runtime.url,
1495
+ workspace: session.workspace ?? null,
1496
+ ...(kind === 'item' ? { itemId: id } : kind === 'approval' ? { approvalId: id } : { runId: id }),
1497
+ });
1498
+ conversationMessages(session).push({ role: 'command', content: `Approval ${result.approved ? 'accepted' : 'not found'}: ${id}` });
1499
+ syncRuntimeState();
1500
+ }
1501
+ } else if (runtime?.url && !session.chatMode && !line.trim().startsWith('/')) {
1486
1502
  await postRuntimeRun(line, {
1487
1503
  url: runtime.url,
1488
1504
  workspace: session.workspace ?? null,
@@ -53,7 +53,7 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
53
53
 
54
54
  function abort() {
55
55
  if (props.runtimeUrl) {
56
- void postRuntimeCancel({ url: props.runtimeUrl })
56
+ void postRuntimeCancel({ url: props.runtimeUrl, workspace: props.session.workspace ?? null })
57
57
  .then(() => props.addLog('runtime: cancel requested'))
58
58
  .catch((err) => props.addLog(`runtime cancel error: ${err instanceof Error ? err.message : String(err)}`));
59
59
  }