@dotdrelle/wiki-manager 0.6.47 → 0.7.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/bin/wiki-manager.js +2 -2
- package/docker-compose.yml +25 -1
- package/package.json +2 -2
- package/src/cli/wiki-manager.js +236 -129
- package/src/core/activity.js +14 -0
- package/src/core/agentEvents.js +24 -2
- package/src/core/agentLoop.js +164 -0
- package/src/core/agentLoop.test.js +85 -0
- package/src/core/cacert.js +4 -3
- package/src/core/compose.js +2 -1
- 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 +18 -4
- package/src/core/mcp.js +1 -1
- package/src/core/queueStore.js +27 -0
- package/src/core/queueStore.test.js +31 -0
- package/src/core/workspaces.js +8 -1
- package/src/runtime/auth.js +35 -0
- package/src/runtime/auth.test.js +21 -0
- package/src/runtime/client.js +108 -0
- package/src/runtime/lifecycle.js +60 -0
- package/src/runtime/queueStore.js +6 -0
- package/src/runtime/runner.js +73 -0
- package/src/runtime/server.js +160 -0
- package/src/runtime/server.test.js +53 -0
- package/src/runtime/store.js +292 -0
- package/src/runtime/store.test.js +103 -0
- package/src/runtime/supervisor.js +98 -0
- package/src/runtime/supervisor.test.js +99 -0
- package/src/shell/repl.js +131 -17
- 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 +146 -11
- package/wiki-workspace +23 -18
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { applyAgentProjectionToSession, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
|
|
5
|
+
import { defaultRuntimeStateDir } from '../core/env.js';
|
|
6
|
+
|
|
7
|
+
export { defaultRuntimeStateDir };
|
|
8
|
+
|
|
9
|
+
const NON_PERSISTED_EVENT_TYPES = new Set(['runtime_log']);
|
|
10
|
+
|
|
11
|
+
const RUN_STATUS_BY_TERMINAL_EVENT = {
|
|
12
|
+
run_done: 'done',
|
|
13
|
+
run_error: 'error',
|
|
14
|
+
run_cancelled: 'cancelled',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName = 'runtime.db' } = {}) {
|
|
18
|
+
const resolvedStateDir = resolve(stateDir);
|
|
19
|
+
mkdirSync(resolvedStateDir, { recursive: true });
|
|
20
|
+
const dbPath = join(resolvedStateDir, fileName);
|
|
21
|
+
const db = new DatabaseSync(dbPath);
|
|
22
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
23
|
+
db.exec('PRAGMA foreign_keys = ON');
|
|
24
|
+
db.exec(`
|
|
25
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
ts TEXT NOT NULL,
|
|
28
|
+
type TEXT NOT NULL,
|
|
29
|
+
run_id TEXT,
|
|
30
|
+
turn_id TEXT,
|
|
31
|
+
origin TEXT,
|
|
32
|
+
payload TEXT NOT NULL
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_events_run_id ON events(run_id);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
status TEXT NOT NULL,
|
|
39
|
+
input TEXT,
|
|
40
|
+
created_at TEXT NOT NULL,
|
|
41
|
+
updated_at TEXT NOT NULL
|
|
42
|
+
);
|
|
43
|
+
CREATE TABLE IF NOT EXISTS queue_items (
|
|
44
|
+
id TEXT PRIMARY KEY,
|
|
45
|
+
workspace TEXT,
|
|
46
|
+
server TEXT NOT NULL,
|
|
47
|
+
tool TEXT NOT NULL,
|
|
48
|
+
args TEXT NOT NULL,
|
|
49
|
+
lock_key TEXT,
|
|
50
|
+
status TEXT NOT NULL,
|
|
51
|
+
reason TEXT,
|
|
52
|
+
job_id TEXT,
|
|
53
|
+
activity_key TEXT,
|
|
54
|
+
error TEXT,
|
|
55
|
+
created_at TEXT,
|
|
56
|
+
started_at TEXT,
|
|
57
|
+
finished_at TEXT,
|
|
58
|
+
updated_at TEXT NOT NULL
|
|
59
|
+
);
|
|
60
|
+
`);
|
|
61
|
+
|
|
62
|
+
let lastEventId = null;
|
|
63
|
+
|
|
64
|
+
const insertEvent = db.prepare(`
|
|
65
|
+
INSERT OR IGNORE INTO events (id, ts, type, run_id, turn_id, origin, payload)
|
|
66
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
67
|
+
`);
|
|
68
|
+
const listEventsStatement = db.prepare(`
|
|
69
|
+
SELECT id, ts, type, run_id, turn_id, origin, payload
|
|
70
|
+
FROM events
|
|
71
|
+
ORDER BY ts ASC, id ASC
|
|
72
|
+
`);
|
|
73
|
+
const upsertRun = db.prepare(`
|
|
74
|
+
INSERT INTO runs (id, status, input, created_at, updated_at)
|
|
75
|
+
VALUES (?, ?, ?, ?, ?)
|
|
76
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
77
|
+
status = excluded.status,
|
|
78
|
+
input = COALESCE(excluded.input, runs.input),
|
|
79
|
+
updated_at = excluded.updated_at
|
|
80
|
+
`);
|
|
81
|
+
const listRunsStatement = db.prepare(`
|
|
82
|
+
SELECT id, status, input, created_at, updated_at
|
|
83
|
+
FROM runs
|
|
84
|
+
ORDER BY created_at DESC
|
|
85
|
+
`);
|
|
86
|
+
const upsertQueueItem = db.prepare(`
|
|
87
|
+
INSERT INTO queue_items (
|
|
88
|
+
id, workspace, server, tool, args, lock_key, status, reason, job_id, activity_key,
|
|
89
|
+
error, created_at, started_at, finished_at, updated_at
|
|
90
|
+
)
|
|
91
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
92
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
93
|
+
workspace = excluded.workspace,
|
|
94
|
+
server = excluded.server,
|
|
95
|
+
tool = excluded.tool,
|
|
96
|
+
args = excluded.args,
|
|
97
|
+
lock_key = excluded.lock_key,
|
|
98
|
+
status = excluded.status,
|
|
99
|
+
reason = excluded.reason,
|
|
100
|
+
job_id = excluded.job_id,
|
|
101
|
+
activity_key = excluded.activity_key,
|
|
102
|
+
error = excluded.error,
|
|
103
|
+
created_at = excluded.created_at,
|
|
104
|
+
started_at = excluded.started_at,
|
|
105
|
+
finished_at = excluded.finished_at,
|
|
106
|
+
updated_at = excluded.updated_at
|
|
107
|
+
`);
|
|
108
|
+
const deleteMissingQueueItems = db.prepare(`
|
|
109
|
+
DELETE FROM queue_items
|
|
110
|
+
WHERE id NOT IN (SELECT value FROM json_each(?))
|
|
111
|
+
`);
|
|
112
|
+
const clearQueueItems = db.prepare('DELETE FROM queue_items');
|
|
113
|
+
const listQueueStatement = db.prepare(`
|
|
114
|
+
SELECT id, workspace, server, tool, args, lock_key, status, reason, job_id, activity_key,
|
|
115
|
+
error, created_at, started_at, finished_at, updated_at
|
|
116
|
+
FROM queue_items
|
|
117
|
+
ORDER BY COALESCE(created_at, updated_at) ASC, id ASC
|
|
118
|
+
`);
|
|
119
|
+
|
|
120
|
+
function persistEvent(event) {
|
|
121
|
+
if (NON_PERSISTED_EVENT_TYPES.has(event.type)) return event;
|
|
122
|
+
insertEvent.run(
|
|
123
|
+
event.id,
|
|
124
|
+
event.ts,
|
|
125
|
+
event.type,
|
|
126
|
+
event.runId ?? null,
|
|
127
|
+
event.turnId ?? null,
|
|
128
|
+
event.origin ?? null,
|
|
129
|
+
JSON.stringify(event.payload ?? {}),
|
|
130
|
+
);
|
|
131
|
+
lastEventId = event.id;
|
|
132
|
+
if (event.type === 'run_started') {
|
|
133
|
+
persistRun({
|
|
134
|
+
id: event.runId ?? event.payload?.runId ?? event.id,
|
|
135
|
+
status: 'running',
|
|
136
|
+
input: event.payload?.input ?? null,
|
|
137
|
+
createdAt: event.ts,
|
|
138
|
+
updatedAt: event.ts,
|
|
139
|
+
});
|
|
140
|
+
} else if (RUN_STATUS_BY_TERMINAL_EVENT[event.type]) {
|
|
141
|
+
const runId = event.runId ?? event.payload?.runId ?? null;
|
|
142
|
+
if (runId) {
|
|
143
|
+
persistRun({
|
|
144
|
+
id: runId,
|
|
145
|
+
status: RUN_STATUS_BY_TERMINAL_EVENT[event.type],
|
|
146
|
+
updatedAt: event.ts,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return event;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function persistRun({ id, status, input = null, createdAt = null, updatedAt = null }) {
|
|
154
|
+
if (!id) return;
|
|
155
|
+
const now = new Date().toISOString();
|
|
156
|
+
upsertRun.run(id, status, input, createdAt ?? now, updatedAt ?? now);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function listEvents() {
|
|
160
|
+
return listEventsStatement.all().map(rowToEvent);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function listRuns() {
|
|
164
|
+
return listRunsStatement.all().map((row) => ({
|
|
165
|
+
id: row.id,
|
|
166
|
+
status: row.status,
|
|
167
|
+
input: row.input,
|
|
168
|
+
createdAt: row.created_at,
|
|
169
|
+
updatedAt: row.updated_at,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function saveQueue(queue = []) {
|
|
174
|
+
const items = Array.isArray(queue) ? queue : [];
|
|
175
|
+
const now = new Date().toISOString();
|
|
176
|
+
db.exec('BEGIN');
|
|
177
|
+
try {
|
|
178
|
+
if (items.length === 0) {
|
|
179
|
+
clearQueueItems.run();
|
|
180
|
+
} else {
|
|
181
|
+
deleteMissingQueueItems.run(JSON.stringify(items.map((item) => item.id)));
|
|
182
|
+
for (const item of items) {
|
|
183
|
+
upsertQueueItem.run(
|
|
184
|
+
item.id,
|
|
185
|
+
item.workspace ?? null,
|
|
186
|
+
item.server ?? 'production',
|
|
187
|
+
item.tool ?? 'production_start_job',
|
|
188
|
+
JSON.stringify(item.args ?? {}),
|
|
189
|
+
item.lockKey ?? null,
|
|
190
|
+
item.status ?? 'waiting',
|
|
191
|
+
item.reason ?? null,
|
|
192
|
+
item.jobId ?? null,
|
|
193
|
+
item.activityKey ?? null,
|
|
194
|
+
item.error ?? null,
|
|
195
|
+
item.createdAt ?? now,
|
|
196
|
+
item.startedAt ?? null,
|
|
197
|
+
item.finishedAt ?? null,
|
|
198
|
+
now,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
db.exec('COMMIT');
|
|
203
|
+
} catch (err) {
|
|
204
|
+
db.exec('ROLLBACK');
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function listQueue() {
|
|
210
|
+
return listQueueStatement.all().map((row) => ({
|
|
211
|
+
id: row.id,
|
|
212
|
+
workspace: row.workspace ?? null,
|
|
213
|
+
server: row.server,
|
|
214
|
+
tool: row.tool,
|
|
215
|
+
args: row.args ? JSON.parse(row.args) : {},
|
|
216
|
+
lockKey: row.lock_key ?? null,
|
|
217
|
+
status: row.status,
|
|
218
|
+
reason: row.reason ?? null,
|
|
219
|
+
jobId: row.job_id ?? undefined,
|
|
220
|
+
activityKey: row.activity_key ?? undefined,
|
|
221
|
+
error: row.error ?? undefined,
|
|
222
|
+
createdAt: row.created_at ?? undefined,
|
|
223
|
+
startedAt: row.started_at ?? undefined,
|
|
224
|
+
finishedAt: row.finished_at ?? undefined,
|
|
225
|
+
updatedAt: row.updated_at,
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function replayEvents(session) {
|
|
230
|
+
const events = listEvents();
|
|
231
|
+
for (const event of events) {
|
|
232
|
+
dispatchAgentEvent(session, event);
|
|
233
|
+
}
|
|
234
|
+
if (events.length > 0) lastEventId = events.at(-1).id;
|
|
235
|
+
return session.agentProjection ?? reduceAgentEvents([]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function getProjection() {
|
|
239
|
+
return reduceAgentEvents(listEvents());
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function getState(session = null) {
|
|
243
|
+
const projection = session?.agentProjection ?? reduceAgentEvents(listEvents());
|
|
244
|
+
const queue = session?.queueStore?.list() ?? listQueue();
|
|
245
|
+
return {
|
|
246
|
+
...projection,
|
|
247
|
+
runs: listRuns(),
|
|
248
|
+
queue: queue.map((item) => ({ ...item })),
|
|
249
|
+
eventsCursor: lastEventId,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function hydrateSession(session) {
|
|
254
|
+
const projection = replayEvents(session);
|
|
255
|
+
applyAgentProjectionToSession(session, projection);
|
|
256
|
+
session.jobQueue = listQueue();
|
|
257
|
+
return projection;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function close() {
|
|
261
|
+
db.close();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
db,
|
|
266
|
+
dbPath,
|
|
267
|
+
stateDir: resolvedStateDir,
|
|
268
|
+
persistEvent,
|
|
269
|
+
persistRun,
|
|
270
|
+
listEvents,
|
|
271
|
+
listRuns,
|
|
272
|
+
saveQueue,
|
|
273
|
+
listQueue,
|
|
274
|
+
replayEvents,
|
|
275
|
+
hydrateSession,
|
|
276
|
+
getProjection,
|
|
277
|
+
getState,
|
|
278
|
+
close,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function rowToEvent(row) {
|
|
283
|
+
return {
|
|
284
|
+
id: row.id,
|
|
285
|
+
ts: row.ts,
|
|
286
|
+
type: row.type,
|
|
287
|
+
origin: row.origin ?? 'system',
|
|
288
|
+
runId: row.run_id ?? null,
|
|
289
|
+
turnId: row.turn_id ?? null,
|
|
290
|
+
payload: row.payload ? JSON.parse(row.payload) : {},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
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 test from 'node:test';
|
|
6
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
7
|
+
import { openRuntimeStore } from './store.js';
|
|
8
|
+
|
|
9
|
+
test('runtime store persists and replays agent events into a projection', () => {
|
|
10
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
11
|
+
const store = openRuntimeStore({ stateDir });
|
|
12
|
+
const session = { activities: {}, headlessPlan: null };
|
|
13
|
+
|
|
14
|
+
const event = createAgentEvent('plan_set', {
|
|
15
|
+
origin: 'test',
|
|
16
|
+
payload: { steps: [{ description: 'Ingest', status: 'running' }] },
|
|
17
|
+
});
|
|
18
|
+
store.persistEvent(dispatchAgentEvent(session, event));
|
|
19
|
+
assert.equal(store.listEvents().length, 1);
|
|
20
|
+
store.close();
|
|
21
|
+
|
|
22
|
+
const reopened = openRuntimeStore({ stateDir });
|
|
23
|
+
const replayedSession = { activities: {}, headlessPlan: null };
|
|
24
|
+
reopened.hydrateSession(replayedSession);
|
|
25
|
+
assert.equal(replayedSession.headlessPlan.length, 1);
|
|
26
|
+
assert.equal(replayedSession.headlessPlan[0].description, 'Ingest');
|
|
27
|
+
assert.equal(reopened.getState(replayedSession).eventsCursor, event.id);
|
|
28
|
+
reopened.close();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('runtime store persists duplicate events idempotently', () => {
|
|
32
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
33
|
+
const store = openRuntimeStore({ stateDir });
|
|
34
|
+
const event = createAgentEvent('run_started', {
|
|
35
|
+
origin: 'test',
|
|
36
|
+
runId: 'run-1',
|
|
37
|
+
payload: { input: 'build wiki' },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
store.persistEvent(event);
|
|
41
|
+
store.persistEvent(event);
|
|
42
|
+
|
|
43
|
+
assert.equal(store.listEvents().length, 1);
|
|
44
|
+
assert.equal(store.listRuns()[0].id, 'run-1');
|
|
45
|
+
assert.equal(store.listRuns()[0].status, 'running');
|
|
46
|
+
store.close();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('runtime store persists cancelled run status', () => {
|
|
50
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
51
|
+
const store = openRuntimeStore({ stateDir });
|
|
52
|
+
store.persistEvent(createAgentEvent('run_started', {
|
|
53
|
+
origin: 'test',
|
|
54
|
+
runId: 'run-1',
|
|
55
|
+
payload: { input: 'build wiki' },
|
|
56
|
+
}));
|
|
57
|
+
store.persistEvent(createAgentEvent('run_cancelled', {
|
|
58
|
+
origin: 'test',
|
|
59
|
+
runId: 'run-1',
|
|
60
|
+
payload: { runId: 'run-1' },
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
assert.equal(store.listRuns()[0].status, 'cancelled');
|
|
64
|
+
store.close();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('runtime store does not persist runtime logs', () => {
|
|
68
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
69
|
+
const store = openRuntimeStore({ stateDir });
|
|
70
|
+
store.persistEvent(createAgentEvent('runtime_log', {
|
|
71
|
+
origin: 'runtime',
|
|
72
|
+
payload: { message: 'agentic-loop: turn 1/20' },
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
assert.equal(store.listEvents().length, 0);
|
|
76
|
+
store.close();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('runtime store persists and hydrates queue items', () => {
|
|
80
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'wiki-manager-runtime-'));
|
|
81
|
+
const store = openRuntimeStore({ stateDir });
|
|
82
|
+
store.saveQueue([{
|
|
83
|
+
id: 'q-1',
|
|
84
|
+
workspace: 'docs',
|
|
85
|
+
server: 'production',
|
|
86
|
+
tool: 'production_start_job',
|
|
87
|
+
args: { type: 'build', steps: ['ingest'] },
|
|
88
|
+
lockKey: 'production:docs',
|
|
89
|
+
status: 'waiting',
|
|
90
|
+
reason: 'workspace_busy',
|
|
91
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
92
|
+
}]);
|
|
93
|
+
store.close();
|
|
94
|
+
|
|
95
|
+
const reopened = openRuntimeStore({ stateDir });
|
|
96
|
+
const session = { activities: {}, headlessPlan: null };
|
|
97
|
+
reopened.hydrateSession(session);
|
|
98
|
+
assert.equal(session.jobQueue.length, 1);
|
|
99
|
+
assert.equal(session.jobQueue[0].id, 'q-1');
|
|
100
|
+
assert.equal(session.jobQueue[0].args.type, 'build');
|
|
101
|
+
assert.equal(reopened.getState(session).queue[0].workspace, 'docs');
|
|
102
|
+
reopened.close();
|
|
103
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
emitRuntimeLog(session, `activity poll error ${key}: ${err instanceof Error ? err.message : String(err)}`);
|
|
87
|
+
} finally {
|
|
88
|
+
pollBusy.delete(key);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function emitRuntimeLog(session, message) {
|
|
94
|
+
dispatchAgentEvent(session, createAgentEvent('runtime_log', {
|
|
95
|
+
origin: 'runtime',
|
|
96
|
+
payload: { message },
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
|
+
import { pollActivitiesOnce, startActivitySupervisor } from './supervisor.js';
|
|
5
|
+
|
|
6
|
+
test('pollActivitiesOnce updates activity through the event reducer', async () => {
|
|
7
|
+
const session = {
|
|
8
|
+
mcp: { production: { status: 'connected' } },
|
|
9
|
+
activities: {},
|
|
10
|
+
headlessPlan: null,
|
|
11
|
+
jobQueue: [],
|
|
12
|
+
};
|
|
13
|
+
dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
|
|
14
|
+
payload: {
|
|
15
|
+
activity: {
|
|
16
|
+
id: 'job-1',
|
|
17
|
+
source: 'production',
|
|
18
|
+
status: 'running',
|
|
19
|
+
poll: { server: 'production', tool: 'production_job_status', args: { jobId: 'job-1' }, intervalMs: 1000 },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
const key = Object.keys(session.activities)[0];
|
|
24
|
+
session.activities[key].lastPolledAt = '1970-01-01T00:00:00.000Z';
|
|
25
|
+
|
|
26
|
+
await pollActivitiesOnce(session, {
|
|
27
|
+
callTool: async () => ({
|
|
28
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
29
|
+
_activity: {
|
|
30
|
+
id: 'job-1',
|
|
31
|
+
source: 'production',
|
|
32
|
+
status: 'done',
|
|
33
|
+
terminal: true,
|
|
34
|
+
},
|
|
35
|
+
}) }],
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
assert.equal(session.activities[key].status, 'done');
|
|
40
|
+
assert.equal(session.activities[key].terminal, true);
|
|
41
|
+
assert.ok(session.agentProjection.logs.some((line) => line.includes('activity:')));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('startActivitySupervisor passes the active run signal to background polls', async () => {
|
|
45
|
+
const session = {
|
|
46
|
+
mcp: { production: { status: 'connected' } },
|
|
47
|
+
activities: {},
|
|
48
|
+
headlessPlan: null,
|
|
49
|
+
jobQueue: [],
|
|
50
|
+
};
|
|
51
|
+
dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
|
|
52
|
+
payload: {
|
|
53
|
+
activity: {
|
|
54
|
+
id: 'job-2',
|
|
55
|
+
source: 'production',
|
|
56
|
+
status: 'running',
|
|
57
|
+
poll: { server: 'production', tool: 'production_job_status', args: { jobId: 'job-2' }, intervalMs: 0 },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const signals = [];
|
|
64
|
+
const supervisor = startActivitySupervisor(session, {
|
|
65
|
+
intervalMs: 10,
|
|
66
|
+
queueIntervalMs: 1000,
|
|
67
|
+
callTool: async (_mcp, _server, _tool, _args, signal) => {
|
|
68
|
+
signals.push(signal ?? null);
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
71
|
+
_activity: {
|
|
72
|
+
id: 'job-2',
|
|
73
|
+
source: 'production',
|
|
74
|
+
status: 'running',
|
|
75
|
+
terminal: false,
|
|
76
|
+
poll: { server: 'production', tool: 'production_job_status', args: { jobId: 'job-2' }, intervalMs: 0 },
|
|
77
|
+
},
|
|
78
|
+
}) }],
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
supervisor.setRunSignal(controller.signal);
|
|
85
|
+
await waitFor(() => signals.includes(controller.signal));
|
|
86
|
+
assert.ok(signals.includes(controller.signal));
|
|
87
|
+
} finally {
|
|
88
|
+
supervisor.stop();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
async function waitFor(predicate, timeoutMs = 250) {
|
|
93
|
+
const deadline = Date.now() + timeoutMs;
|
|
94
|
+
while (Date.now() < deadline) {
|
|
95
|
+
if (predicate()) return;
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
97
|
+
}
|
|
98
|
+
assert.fail('Timed out waiting for condition.');
|
|
99
|
+
}
|