@dotdrelle/wiki-manager 0.6.47 → 0.8.2
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 +35 -1
- package/bin/wiki-manager.js +2 -2
- package/docker-compose.yml +2 -0
- 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 +459 -131
- package/src/commands/slash.js +1 -1
- package/src/core/activity.js +14 -0
- package/src/core/agentEvents.js +148 -6
- package/src/core/agentEvents.test.js +145 -4
- package/src/core/agentLoop.js +167 -0
- package/src/core/agentLoop.test.js +85 -0
- package/src/core/cacert.js +4 -3
- package/src/core/dockerCompose.test.js +14 -0
- package/src/core/documentIntake.test.js +7 -7
- package/src/core/env.js +6 -0
- package/src/core/jobQueue.js +47 -16
- 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.js +27 -0
- package/src/core/queueStore.test.js +32 -0
- package/src/core/wikiWorkspace.test.js +24 -0
- package/src/core/workspaces.js +8 -1
- package/src/runtime/approvals.js +113 -0
- package/src/runtime/auth.js +35 -0
- package/src/runtime/auth.test.js +29 -0
- package/src/runtime/client.js +154 -0
- package/src/runtime/lifecycle.js +84 -0
- package/src/runtime/queueStore.js +6 -0
- package/src/runtime/runner.js +413 -0
- package/src/runtime/runner.test.js +270 -0
- package/src/runtime/server.js +216 -0
- package/src/runtime/server.test.js +308 -0
- package/src/runtime/store.js +431 -0
- package/src/runtime/store.test.js +437 -0
- package/src/runtime/supervisor.js +104 -0
- package/src/runtime/supervisor.test.js +240 -0
- package/src/shell/RightPane.tsx +1 -1
- package/src/shell/repl.js +148 -18
- package/src/shell/repl.test.js +38 -0
- package/src/shell/tui.tsx +4 -1
- package/src/shell/useAgent.ts +20 -2
- package/src/shell/useSession.ts +153 -13
- package/wiki-workspace +221 -22
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
8
|
+
import { openRuntimeStore } from './store.js';
|
|
9
|
+
|
|
10
|
+
test('runtime store persists and replays agent events into a projection', () => {
|
|
11
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
12
|
+
const store = openRuntimeStore({ stateDir });
|
|
13
|
+
const session = { activities: {}, headlessPlan: null };
|
|
14
|
+
|
|
15
|
+
const event = createAgentEvent('plan_set', {
|
|
16
|
+
origin: 'test',
|
|
17
|
+
payload: { steps: [{ description: 'Ingest', status: 'running' }] },
|
|
18
|
+
});
|
|
19
|
+
store.persistEvent(dispatchAgentEvent(session, event));
|
|
20
|
+
assert.equal(store.listEvents().length, 1);
|
|
21
|
+
store.close();
|
|
22
|
+
|
|
23
|
+
const reopened = openRuntimeStore({ stateDir });
|
|
24
|
+
const replayedSession = { activities: {}, headlessPlan: null };
|
|
25
|
+
reopened.hydrateSession(replayedSession);
|
|
26
|
+
assert.equal(replayedSession.headlessPlan.length, 1);
|
|
27
|
+
assert.equal(replayedSession.headlessPlan[0].description, 'Ingest');
|
|
28
|
+
assert.equal(reopened.getState(replayedSession).eventsCursor, 1);
|
|
29
|
+
reopened.close();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('runtime store persists duplicate events idempotently', () => {
|
|
33
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
34
|
+
const store = openRuntimeStore({ stateDir });
|
|
35
|
+
const event = createAgentEvent('run_started', {
|
|
36
|
+
origin: 'test',
|
|
37
|
+
runId: 'run-1',
|
|
38
|
+
turnId: 'run-1:turn-0',
|
|
39
|
+
workspace: 'juno',
|
|
40
|
+
payload: { input: 'build wiki', workspace: 'juno' },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
store.persistEvent(event);
|
|
44
|
+
store.persistEvent(event);
|
|
45
|
+
|
|
46
|
+
assert.equal(store.listEvents().length, 1);
|
|
47
|
+
assert.equal(store.listEvents()[0].sequence, 1);
|
|
48
|
+
assert.equal(store.getState().eventsCursor, 1);
|
|
49
|
+
assert.equal(store.listRuns()[0].id, 'run-1');
|
|
50
|
+
assert.equal(store.listRuns()[0].workspace, 'juno');
|
|
51
|
+
assert.equal(store.listRuns()[0].status, 'running');
|
|
52
|
+
store.close();
|
|
53
|
+
});
|
|
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
|
+
|
|
218
|
+
test('runtime store persists cancelled run status', () => {
|
|
219
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
220
|
+
const store = openRuntimeStore({ stateDir });
|
|
221
|
+
store.persistEvent(createAgentEvent('run_started', {
|
|
222
|
+
origin: 'test',
|
|
223
|
+
runId: 'run-1',
|
|
224
|
+
turnId: 'run-1:turn-0',
|
|
225
|
+
workspace: 'juno',
|
|
226
|
+
payload: { input: 'build wiki', workspace: 'juno' },
|
|
227
|
+
}));
|
|
228
|
+
store.persistEvent(createAgentEvent('run_cancelled', {
|
|
229
|
+
origin: 'test',
|
|
230
|
+
runId: 'run-1',
|
|
231
|
+
turnId: 'run-1:turn-1',
|
|
232
|
+
workspace: 'juno',
|
|
233
|
+
payload: { runId: 'run-1', workspace: 'juno' },
|
|
234
|
+
}));
|
|
235
|
+
|
|
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]);
|
|
313
|
+
store.close();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('runtime store does not persist runtime logs', () => {
|
|
317
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
318
|
+
const store = openRuntimeStore({ stateDir });
|
|
319
|
+
store.persistEvent(createAgentEvent('runtime_log', {
|
|
320
|
+
origin: 'runtime',
|
|
321
|
+
payload: { message: 'agentic-loop: turn 1/20' },
|
|
322
|
+
}));
|
|
323
|
+
|
|
324
|
+
assert.equal(store.listEvents().length, 0);
|
|
325
|
+
store.close();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('runtime store persists and hydrates queue items', () => {
|
|
329
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
330
|
+
const store = openRuntimeStore({ stateDir });
|
|
331
|
+
store.saveQueue([{
|
|
332
|
+
id: 'q-1',
|
|
333
|
+
workspace: 'docs',
|
|
334
|
+
server: 'production',
|
|
335
|
+
tool: 'production_start_job',
|
|
336
|
+
args: { type: 'build', steps: ['ingest'] },
|
|
337
|
+
lockKey: 'production:docs',
|
|
338
|
+
status: 'waiting',
|
|
339
|
+
reason: 'workspace_busy',
|
|
340
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
341
|
+
}]);
|
|
342
|
+
store.close();
|
|
343
|
+
|
|
344
|
+
const reopened = openRuntimeStore({ stateDir });
|
|
345
|
+
const session = { activities: {}, headlessPlan: null };
|
|
346
|
+
reopened.hydrateSession(session);
|
|
347
|
+
assert.equal(session.jobQueue.length, 1);
|
|
348
|
+
assert.equal(session.jobQueue[0].id, 'q-1');
|
|
349
|
+
assert.equal(session.jobQueue[0].args.type, 'build');
|
|
350
|
+
assert.equal(reopened.getState(session).queue[0].workspace, 'docs');
|
|
351
|
+
reopened.close();
|
|
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 identifies recoverable workspaces and interrupts stale runs', () => {
|
|
416
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
417
|
+
const store = openRuntimeStore({ stateDir });
|
|
418
|
+
store.persistRun({
|
|
419
|
+
id: 'run-juno',
|
|
420
|
+
workspace: 'juno',
|
|
421
|
+
status: 'running',
|
|
422
|
+
input: 'build juno',
|
|
423
|
+
});
|
|
424
|
+
store.persistRun({
|
|
425
|
+
id: 'run-done',
|
|
426
|
+
workspace: 'docs',
|
|
427
|
+
status: 'done',
|
|
428
|
+
input: 'done',
|
|
429
|
+
});
|
|
430
|
+
store.saveQueue([{ id: 'q-docs', workspace: 'docs', status: 'waiting' }], { workspace: 'docs' });
|
|
431
|
+
|
|
432
|
+
assert.deepEqual(store.listRecoverableWorkspaces(), ['docs', 'juno']);
|
|
433
|
+
assert.deepEqual(store.listRecoverableRuns({ workspace: 'juno' }).map((run) => run.id), ['run-juno']);
|
|
434
|
+
assert.equal(store.interruptRuns({ workspace: 'juno' }), 1);
|
|
435
|
+
assert.equal(store.listRuns({ workspace: 'juno' })[0].status, 'interrupted');
|
|
436
|
+
store.close();
|
|
437
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
2
|
+
import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
3
|
+
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
4
|
+
import { startNextQueuedJob, syncQueueWithActivity } from '../core/jobQueue.js';
|
|
5
|
+
|
|
6
|
+
export function startActivitySupervisor(session, {
|
|
7
|
+
intervalMs = 1000,
|
|
8
|
+
queueIntervalMs = 10000,
|
|
9
|
+
callTool = callMcpTool,
|
|
10
|
+
} = {}) {
|
|
11
|
+
const pollBusy = new Set();
|
|
12
|
+
let stopped = false;
|
|
13
|
+
let runSignal = null;
|
|
14
|
+
|
|
15
|
+
const pollTimer = setInterval(() => {
|
|
16
|
+
void pollActivitiesOnce(session, { pollBusy, callTool, signal: runSignal });
|
|
17
|
+
}, intervalMs);
|
|
18
|
+
|
|
19
|
+
const queueTimer = setInterval(() => {
|
|
20
|
+
if (session.jobQueue?.some((item) => item.status === 'waiting')) {
|
|
21
|
+
void startNextQueuedJob(session, {
|
|
22
|
+
addLog: (message) => emitRuntimeLog(session, message),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}, queueIntervalMs);
|
|
26
|
+
|
|
27
|
+
void pollActivitiesOnce(session, { pollBusy, callTool, signal: runSignal });
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
pollBusy,
|
|
31
|
+
setRunSignal(signal) {
|
|
32
|
+
runSignal = signal ?? null;
|
|
33
|
+
},
|
|
34
|
+
stop() {
|
|
35
|
+
if (stopped) return;
|
|
36
|
+
stopped = true;
|
|
37
|
+
clearInterval(pollTimer);
|
|
38
|
+
clearInterval(queueTimer);
|
|
39
|
+
pollBusy.clear();
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function pollActivitiesOnce(session, {
|
|
45
|
+
pollBusy = new Set(),
|
|
46
|
+
callTool = callMcpTool,
|
|
47
|
+
signal = null,
|
|
48
|
+
} = {}) {
|
|
49
|
+
for (const activity of sessionActivities(session)) {
|
|
50
|
+
if (signal?.aborted) break;
|
|
51
|
+
if (activity.terminal || !activity.poll) continue;
|
|
52
|
+
const key = activity.key ?? `${activity.poll.server}:${activity.id ?? activity.label}`;
|
|
53
|
+
if (pollBusy.has(key)) continue;
|
|
54
|
+
const endpoint = session.mcp?.[activity.poll.server];
|
|
55
|
+
if (!endpoint || endpoint.status !== 'connected') {
|
|
56
|
+
emitRuntimeLog(session, `activity: MCP server '${activity.poll.server}' not connected, cannot poll ${key}`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const intervalMs = activity.poll.intervalMs ?? 2500;
|
|
60
|
+
const lastPolledAt = Date.parse(activity.lastPolledAt ?? '0');
|
|
61
|
+
if (Date.now() - lastPolledAt < intervalMs) continue;
|
|
62
|
+
|
|
63
|
+
pollBusy.add(key);
|
|
64
|
+
activity.lastPolledAt = new Date().toISOString();
|
|
65
|
+
try {
|
|
66
|
+
const result = await callTool(session.mcp, activity.poll.server, activity.poll.tool, activity.poll.args ?? {}, signal);
|
|
67
|
+
const payload = parseJsonText(formatMcpToolResult(result));
|
|
68
|
+
const polledActivity = extractActivity(payload, {
|
|
69
|
+
server: activity.poll.server,
|
|
70
|
+
tool: activity.poll.tool,
|
|
71
|
+
});
|
|
72
|
+
if (polledActivity) {
|
|
73
|
+
dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
|
|
74
|
+
origin: 'runtime_poll',
|
|
75
|
+
payload: { activity: polledActivity },
|
|
76
|
+
}));
|
|
77
|
+
syncQueueWithActivity(session, polledActivity);
|
|
78
|
+
emitRuntimeLog(session, `activity: ${polledActivity.label} -> ${polledActivity.status}`);
|
|
79
|
+
if (polledActivity.terminal) {
|
|
80
|
+
await startNextQueuedJob(session, {
|
|
81
|
+
addLog: (message) => emitRuntimeLog(session, message),
|
|
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
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
emitRuntimeLog(session, `activity poll error ${key}: ${err instanceof Error ? err.message : String(err)}`);
|
|
93
|
+
} finally {
|
|
94
|
+
pollBusy.delete(key);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function emitRuntimeLog(session, message) {
|
|
100
|
+
dispatchAgentEvent(session, createAgentEvent('runtime_log', {
|
|
101
|
+
origin: 'runtime',
|
|
102
|
+
payload: { message },
|
|
103
|
+
}));
|
|
104
|
+
}
|