@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.
- package/.env.example +20 -0
- package/README.md +50 -1
- package/docker-compose.yml +1 -23
- package/mcp.endpoints.example.json +13 -0
- package/package.json +2 -2
- package/src/agent/graph.js +101 -15
- package/src/agent/graph.test.js +145 -0
- package/src/cli/wiki-manager.js +306 -53
- package/src/commands/slash.js +4 -24
- package/src/core/agentEvents.js +169 -4
- package/src/core/agentEvents.test.js +176 -4
- package/src/core/agentLoop.js +3 -0
- package/src/core/compose.js +1 -2
- package/src/core/dockerCompose.test.js +5 -5
- package/src/core/jobQueue.js +29 -12
- package/src/core/mcp.js +120 -10
- package/src/core/mcp.test.js +121 -1
- package/src/core/plan.js +33 -0
- package/src/core/queueStore.test.js +1 -0
- package/src/core/sessionConfig.js +24 -0
- package/src/core/wikiWorkspace.test.js +24 -0
- package/src/runtime/approvals.js +113 -0
- package/src/runtime/auth.test.js +8 -0
- package/src/runtime/client.js +52 -6
- package/src/runtime/lifecycle.js +27 -3
- package/src/runtime/queueStore.js +3 -3
- package/src/runtime/runner.js +340 -0
- package/src/runtime/runner.test.js +270 -0
- package/src/runtime/server.js +252 -33
- package/src/runtime/server.test.js +577 -0
- package/src/runtime/store.js +181 -39
- package/src/runtime/store.test.js +363 -4
- package/src/runtime/supervisor.js +6 -0
- package/src/runtime/supervisor.test.js +141 -0
- package/src/shell/RightPane.tsx +1 -1
- package/src/shell/repl.js +22 -6
- package/src/shell/useAgent.ts +1 -1
- package/src/shell/useSession.ts +10 -5
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/shell/RightPane.tsx
CHANGED
|
@@ -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 &&
|
|
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,
|
package/src/shell/useAgent.ts
CHANGED
|
@@ -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
|
}
|