@dotdrelle/wiki-manager 0.7.3 → 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/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 +274 -53
- package/src/commands/slash.js +1 -1
- package/src/core/agentEvents.js +124 -4
- package/src/core/agentEvents.test.js +145 -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/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 +85 -29
- package/src/runtime/server.test.js +255 -0
- package/src/runtime/store.js +178 -39
- package/src/runtime/store.test.js +338 -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
|
@@ -31,6 +31,7 @@ test('runtime server accepts only one active run', async (t) => {
|
|
|
31
31
|
|
|
32
32
|
try {
|
|
33
33
|
const url = `http://127.0.0.1:${handle.port}/run`;
|
|
34
|
+
let acceptedRun = null;
|
|
34
35
|
const [first, second] = await Promise.all([
|
|
35
36
|
fetch(url, {
|
|
36
37
|
method: 'POST',
|
|
@@ -45,9 +46,263 @@ test('runtime server accepts only one active run', async (t) => {
|
|
|
45
46
|
]);
|
|
46
47
|
|
|
47
48
|
assert.deepEqual([first.status, second.status].sort(), [202, 409]);
|
|
49
|
+
const accepted = first.status === 202 ? first : second;
|
|
50
|
+
acceptedRun = await accepted.json();
|
|
51
|
+
assert.equal(acceptedRun.accepted, true);
|
|
52
|
+
assert.match(acceptedRun.runId, /^[0-9a-f-]{36}$/);
|
|
48
53
|
assert.equal(runCount, 1);
|
|
49
54
|
} finally {
|
|
50
55
|
releaseRun?.();
|
|
51
56
|
await handle.close();
|
|
52
57
|
}
|
|
53
58
|
});
|
|
59
|
+
|
|
60
|
+
test('runtime server returns the accepted run id and passes it to the runner', async (t) => {
|
|
61
|
+
let receivedBody = null;
|
|
62
|
+
let handle;
|
|
63
|
+
try {
|
|
64
|
+
handle = await startRuntimeServer({
|
|
65
|
+
host: '127.0.0.1',
|
|
66
|
+
port: 0,
|
|
67
|
+
store: {
|
|
68
|
+
dbPath: ':memory:',
|
|
69
|
+
getState: () => ({ status: 'idle' }),
|
|
70
|
+
listEvents: () => [],
|
|
71
|
+
},
|
|
72
|
+
session: {},
|
|
73
|
+
run: async (context, body) => {
|
|
74
|
+
receivedBody = body;
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err?.code === 'EPERM') {
|
|
79
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/run`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: { 'Content-Type': 'application/json' },
|
|
89
|
+
body: JSON.stringify({ input: 'build', workspace: 'juno', evaluate: false, replans: 1 }),
|
|
90
|
+
});
|
|
91
|
+
assert.equal(response.status, 202);
|
|
92
|
+
const body = await response.json();
|
|
93
|
+
assert.equal(body.accepted, true);
|
|
94
|
+
assert.match(body.runId, /^[0-9a-f-]{36}$/);
|
|
95
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
96
|
+
assert.equal(receivedBody.runId, body.runId);
|
|
97
|
+
assert.equal(receivedBody.workspace, 'juno');
|
|
98
|
+
assert.equal(receivedBody.evaluate, false);
|
|
99
|
+
assert.equal(receivedBody.replans, 1);
|
|
100
|
+
} finally {
|
|
101
|
+
await handle.close();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('runtime server isolates active runs by workspace', async (t) => {
|
|
106
|
+
const releases = new Map();
|
|
107
|
+
const runWorkspaces = [];
|
|
108
|
+
const contexts = new Map();
|
|
109
|
+
let handle;
|
|
110
|
+
try {
|
|
111
|
+
handle = await startRuntimeServer({
|
|
112
|
+
host: '127.0.0.1',
|
|
113
|
+
port: 0,
|
|
114
|
+
store: {
|
|
115
|
+
dbPath: ':memory:',
|
|
116
|
+
getState: (session) => ({ status: session?.running ? 'running' : 'idle' }),
|
|
117
|
+
listEvents: () => [],
|
|
118
|
+
},
|
|
119
|
+
getContext: async (workspace) => {
|
|
120
|
+
if (!contexts.has(workspace)) {
|
|
121
|
+
contexts.set(workspace, {
|
|
122
|
+
workspace,
|
|
123
|
+
session: { workspace },
|
|
124
|
+
running: false,
|
|
125
|
+
currentAbortController: null,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return contexts.get(workspace);
|
|
129
|
+
},
|
|
130
|
+
run: async (context) => {
|
|
131
|
+
runWorkspaces.push(context.workspace);
|
|
132
|
+
context.session.running = true;
|
|
133
|
+
await new Promise((resolve) => { releases.set(context.workspace, resolve); });
|
|
134
|
+
context.session.running = false;
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err?.code === 'EPERM') {
|
|
139
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const url = `http://127.0.0.1:${handle.port}/run`;
|
|
147
|
+
const [juno, docs] = await Promise.all([
|
|
148
|
+
fetch(`${url}?workspace=juno`, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: { 'Content-Type': 'application/json' },
|
|
151
|
+
body: JSON.stringify({ input: 'first' }),
|
|
152
|
+
}),
|
|
153
|
+
fetch(`${url}?workspace=docs`, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { 'Content-Type': 'application/json' },
|
|
156
|
+
body: JSON.stringify({ input: 'second' }),
|
|
157
|
+
}),
|
|
158
|
+
]);
|
|
159
|
+
assert.deepEqual([juno.status, docs.status], [202, 202]);
|
|
160
|
+
|
|
161
|
+
const conflict = await fetch(`${url}?workspace=juno`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
headers: { 'Content-Type': 'application/json' },
|
|
164
|
+
body: JSON.stringify({ input: 'third' }),
|
|
165
|
+
});
|
|
166
|
+
assert.equal(conflict.status, 409);
|
|
167
|
+
assert.deepEqual(runWorkspaces.sort(), ['docs', 'juno']);
|
|
168
|
+
} finally {
|
|
169
|
+
releases.get('juno')?.();
|
|
170
|
+
releases.get('docs')?.();
|
|
171
|
+
await handle.close();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('runtime server filters state and events by workspace', async (t) => {
|
|
176
|
+
let stateWorkspace = null;
|
|
177
|
+
let eventWorkspace = null;
|
|
178
|
+
let handle;
|
|
179
|
+
try {
|
|
180
|
+
handle = await startRuntimeServer({
|
|
181
|
+
host: '127.0.0.1',
|
|
182
|
+
port: 0,
|
|
183
|
+
store: {
|
|
184
|
+
dbPath: ':memory:',
|
|
185
|
+
getState: (_session, options) => {
|
|
186
|
+
stateWorkspace = options.workspace;
|
|
187
|
+
return { status: 'idle', workspace: options.workspace };
|
|
188
|
+
},
|
|
189
|
+
listEvents: (options) => {
|
|
190
|
+
eventWorkspace = options.workspace;
|
|
191
|
+
return [{ id: 'e1', workspace: options.workspace }];
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
getContext: async (workspace) => ({
|
|
195
|
+
workspace,
|
|
196
|
+
session: { workspace },
|
|
197
|
+
running: false,
|
|
198
|
+
currentAbortController: null,
|
|
199
|
+
}),
|
|
200
|
+
run: async () => {},
|
|
201
|
+
});
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (err?.code === 'EPERM') {
|
|
204
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const state = await fetch(`http://127.0.0.1:${handle.port}/state?workspace=juno`);
|
|
212
|
+
assert.equal(state.status, 200);
|
|
213
|
+
assert.equal((await state.json()).workspace, 'juno');
|
|
214
|
+
assert.equal(stateWorkspace, 'juno');
|
|
215
|
+
|
|
216
|
+
const events = await fetch(`http://127.0.0.1:${handle.port}/events?workspace=docs`);
|
|
217
|
+
assert.equal(events.status, 200);
|
|
218
|
+
assert.deepEqual(await events.json(), { events: [{ id: 'e1', workspace: 'docs' }] });
|
|
219
|
+
assert.equal(eventWorkspace, 'docs');
|
|
220
|
+
} finally {
|
|
221
|
+
await handle.close();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('runtime server exposes manual resume endpoint', async (t) => {
|
|
226
|
+
let resumedWorkspace = null;
|
|
227
|
+
let handle;
|
|
228
|
+
try {
|
|
229
|
+
handle = await startRuntimeServer({
|
|
230
|
+
host: '127.0.0.1',
|
|
231
|
+
port: 0,
|
|
232
|
+
store: {
|
|
233
|
+
dbPath: ':memory:',
|
|
234
|
+
getState: () => ({ status: 'idle' }),
|
|
235
|
+
listEvents: () => [],
|
|
236
|
+
},
|
|
237
|
+
run: async () => {},
|
|
238
|
+
resume: async ({ workspace }) => {
|
|
239
|
+
resumedWorkspace = workspace;
|
|
240
|
+
return { resumed: 1, interrupted: 0, workspaces: [{ workspace, resumed: true }] };
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (err?.code === 'EPERM') {
|
|
245
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/resume?workspace=juno`, {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
});
|
|
255
|
+
assert.equal(response.status, 202);
|
|
256
|
+
assert.equal(resumedWorkspace, 'juno');
|
|
257
|
+
assert.deepEqual(await response.json(), {
|
|
258
|
+
resumed: 1,
|
|
259
|
+
interrupted: 0,
|
|
260
|
+
workspaces: [{ workspace: 'juno', resumed: true }],
|
|
261
|
+
});
|
|
262
|
+
} finally {
|
|
263
|
+
await handle.close();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('runtime server exposes approval endpoint', async (t) => {
|
|
268
|
+
let approved = null;
|
|
269
|
+
let handle;
|
|
270
|
+
try {
|
|
271
|
+
handle = await startRuntimeServer({
|
|
272
|
+
host: '127.0.0.1',
|
|
273
|
+
port: 0,
|
|
274
|
+
store: {
|
|
275
|
+
dbPath: ':memory:',
|
|
276
|
+
getState: () => ({ status: 'idle' }),
|
|
277
|
+
listEvents: () => [],
|
|
278
|
+
},
|
|
279
|
+
run: async () => {},
|
|
280
|
+
approve: async (request) => {
|
|
281
|
+
approved = request;
|
|
282
|
+
return { approved: true, runId: request.runId, itemId: request.itemId };
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
} catch (err) {
|
|
286
|
+
if (err?.code === 'EPERM') {
|
|
287
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
throw err;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/approve?workspace=juno&runId=run-1&itemId=item-1`, {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
});
|
|
297
|
+
assert.equal(response.status, 202);
|
|
298
|
+
assert.deepEqual(approved, {
|
|
299
|
+
workspace: 'juno',
|
|
300
|
+
runId: 'run-1',
|
|
301
|
+
itemId: 'item-1',
|
|
302
|
+
approvalId: null,
|
|
303
|
+
});
|
|
304
|
+
assert.deepEqual(await response.json(), { approved: true, runId: 'run-1', itemId: 'item-1' });
|
|
305
|
+
} finally {
|
|
306
|
+
await handle.close();
|
|
307
|
+
}
|
|
308
|
+
});
|
package/src/runtime/store.js
CHANGED
|
@@ -3,17 +3,23 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
4
|
import { applyAgentProjectionToSession, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
|
|
5
5
|
import { defaultRuntimeStateDir } from '../core/env.js';
|
|
6
|
+
import { projectQueue } from '../core/jobQueue.js';
|
|
6
7
|
|
|
7
8
|
export { defaultRuntimeStateDir };
|
|
8
9
|
|
|
9
10
|
const NON_PERSISTED_EVENT_TYPES = new Set(['runtime_log']);
|
|
10
11
|
|
|
11
|
-
const
|
|
12
|
+
const RUN_STATUS_BY_EVENT = {
|
|
12
13
|
run_done: 'done',
|
|
13
14
|
run_error: 'error',
|
|
14
15
|
run_cancelled: 'cancelled',
|
|
16
|
+
run_pending_approval: 'pending_approval',
|
|
17
|
+
run_approved: 'running',
|
|
15
18
|
};
|
|
16
19
|
|
|
20
|
+
const RECOVERABLE_RUN_STATUSES = ['running', 'waiting', 'pending_approval'];
|
|
21
|
+
export const RECOVERABLE_QUEUE_STATUSES = ['waiting', 'queued', 'starting', 'running', 'blocked', 'pending_approval'];
|
|
22
|
+
|
|
17
23
|
export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName = 'runtime.db' } = {}) {
|
|
18
24
|
const resolvedStateDir = resolve(stateDir);
|
|
19
25
|
mkdirSync(resolvedStateDir, { recursive: true });
|
|
@@ -23,11 +29,13 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
23
29
|
db.exec('PRAGMA foreign_keys = ON');
|
|
24
30
|
db.exec(`
|
|
25
31
|
CREATE TABLE IF NOT EXISTS events (
|
|
26
|
-
|
|
32
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
|
+
id TEXT NOT NULL UNIQUE,
|
|
27
34
|
ts TEXT NOT NULL,
|
|
28
35
|
type TEXT NOT NULL,
|
|
29
36
|
run_id TEXT,
|
|
30
37
|
turn_id TEXT,
|
|
38
|
+
workspace TEXT,
|
|
31
39
|
origin TEXT,
|
|
32
40
|
payload TEXT NOT NULL
|
|
33
41
|
);
|
|
@@ -35,6 +43,7 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
35
43
|
CREATE INDEX IF NOT EXISTS idx_events_run_id ON events(run_id);
|
|
36
44
|
CREATE TABLE IF NOT EXISTS runs (
|
|
37
45
|
id TEXT PRIMARY KEY,
|
|
46
|
+
workspace TEXT,
|
|
38
47
|
status TEXT NOT NULL,
|
|
39
48
|
input TEXT,
|
|
40
49
|
created_at TEXT NOT NULL,
|
|
@@ -58,31 +67,69 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
58
67
|
updated_at TEXT NOT NULL
|
|
59
68
|
);
|
|
60
69
|
`);
|
|
70
|
+
ensureColumn(db, 'events', 'sequence', 'INTEGER');
|
|
71
|
+
ensureColumn(db, 'events', 'workspace', 'TEXT');
|
|
72
|
+
ensureColumn(db, 'runs', 'workspace', 'TEXT');
|
|
73
|
+
backfillEventSequence(db);
|
|
74
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_events_workspace ON events(workspace)');
|
|
75
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_events_sequence ON events(sequence)');
|
|
61
76
|
|
|
62
77
|
let lastEventId = null;
|
|
78
|
+
let lastEventSequence = null;
|
|
63
79
|
|
|
64
80
|
const insertEvent = db.prepare(`
|
|
65
|
-
INSERT OR IGNORE INTO events (id, ts, type, run_id, turn_id, origin, payload)
|
|
66
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
81
|
+
INSERT OR IGNORE INTO events (sequence, id, ts, type, run_id, turn_id, workspace, origin, payload)
|
|
82
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
67
83
|
`);
|
|
84
|
+
const nextEventSequenceStatement = db.prepare('SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM events');
|
|
68
85
|
const listEventsStatement = db.prepare(`
|
|
69
|
-
SELECT id, ts, type, run_id, turn_id, origin, payload
|
|
86
|
+
SELECT sequence, id, ts, type, run_id, turn_id, workspace, origin, payload
|
|
87
|
+
FROM events
|
|
88
|
+
ORDER BY sequence ASC
|
|
89
|
+
`);
|
|
90
|
+
const listEventsByWorkspaceStatement = db.prepare(`
|
|
91
|
+
SELECT sequence, id, ts, type, run_id, turn_id, workspace, origin, payload
|
|
70
92
|
FROM events
|
|
71
|
-
|
|
93
|
+
WHERE workspace = ?
|
|
94
|
+
ORDER BY sequence ASC
|
|
72
95
|
`);
|
|
73
96
|
const upsertRun = db.prepare(`
|
|
74
|
-
INSERT INTO runs (id, status, input, created_at, updated_at)
|
|
75
|
-
VALUES (?, ?, ?, ?, ?)
|
|
97
|
+
INSERT INTO runs (id, workspace, status, input, created_at, updated_at)
|
|
98
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
76
99
|
ON CONFLICT(id) DO UPDATE SET
|
|
100
|
+
workspace = COALESCE(excluded.workspace, runs.workspace),
|
|
77
101
|
status = excluded.status,
|
|
78
102
|
input = COALESCE(excluded.input, runs.input),
|
|
79
103
|
updated_at = excluded.updated_at
|
|
80
104
|
`);
|
|
81
105
|
const listRunsStatement = db.prepare(`
|
|
82
|
-
SELECT id, status, input, created_at, updated_at
|
|
106
|
+
SELECT id, workspace, status, input, created_at, updated_at
|
|
83
107
|
FROM runs
|
|
84
108
|
ORDER BY created_at DESC
|
|
85
109
|
`);
|
|
110
|
+
const listRunsByWorkspaceStatement = db.prepare(`
|
|
111
|
+
SELECT id, workspace, status, input, created_at, updated_at
|
|
112
|
+
FROM runs
|
|
113
|
+
WHERE workspace = ?
|
|
114
|
+
ORDER BY created_at DESC
|
|
115
|
+
`);
|
|
116
|
+
const listRecoverableRunsStatement = db.prepare(`
|
|
117
|
+
SELECT id, workspace, status, input, created_at, updated_at
|
|
118
|
+
FROM runs
|
|
119
|
+
WHERE status IN (${RECOVERABLE_RUN_STATUSES.map(() => '?').join(', ')})
|
|
120
|
+
ORDER BY created_at ASC
|
|
121
|
+
`);
|
|
122
|
+
const listRecoverableRunsByWorkspaceStatement = db.prepare(`
|
|
123
|
+
SELECT id, workspace, status, input, created_at, updated_at
|
|
124
|
+
FROM runs
|
|
125
|
+
WHERE workspace = ? AND status IN (${RECOVERABLE_RUN_STATUSES.map(() => '?').join(', ')})
|
|
126
|
+
ORDER BY created_at ASC
|
|
127
|
+
`);
|
|
128
|
+
const interruptRunsStatement = db.prepare(`
|
|
129
|
+
UPDATE runs
|
|
130
|
+
SET status = 'interrupted', updated_at = ?
|
|
131
|
+
WHERE workspace = ? AND status IN (${RECOVERABLE_RUN_STATUSES.map(() => '?').join(', ')})
|
|
132
|
+
`);
|
|
86
133
|
const upsertQueueItem = db.prepare(`
|
|
87
134
|
INSERT INTO queue_items (
|
|
88
135
|
id, workspace, server, tool, args, lock_key, status, reason, job_id, activity_key,
|
|
@@ -109,6 +156,11 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
109
156
|
DELETE FROM queue_items
|
|
110
157
|
WHERE id NOT IN (SELECT value FROM json_each(?))
|
|
111
158
|
`);
|
|
159
|
+
const deleteMissingQueueItemsForWorkspace = db.prepare(`
|
|
160
|
+
DELETE FROM queue_items
|
|
161
|
+
WHERE workspace = ? AND id NOT IN (SELECT value FROM json_each(?))
|
|
162
|
+
`);
|
|
163
|
+
const clearQueueItemsForWorkspace = db.prepare('DELETE FROM queue_items WHERE workspace = ?');
|
|
112
164
|
const clearQueueItems = db.prepare('DELETE FROM queue_items');
|
|
113
165
|
const listQueueStatement = db.prepare(`
|
|
114
166
|
SELECT id, workspace, server, tool, args, lock_key, status, reason, job_id, activity_key,
|
|
@@ -116,33 +168,58 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
116
168
|
FROM queue_items
|
|
117
169
|
ORDER BY COALESCE(created_at, updated_at) ASC, id ASC
|
|
118
170
|
`);
|
|
171
|
+
const listQueueByWorkspaceStatement = db.prepare(`
|
|
172
|
+
SELECT id, workspace, server, tool, args, lock_key, status, reason, job_id, activity_key,
|
|
173
|
+
error, created_at, started_at, finished_at, updated_at
|
|
174
|
+
FROM queue_items
|
|
175
|
+
WHERE workspace = ?
|
|
176
|
+
ORDER BY COALESCE(created_at, updated_at) ASC, id ASC
|
|
177
|
+
`);
|
|
178
|
+
const listRecoverableWorkspacesStatement = db.prepare(`
|
|
179
|
+
SELECT DISTINCT workspace FROM runs
|
|
180
|
+
WHERE workspace IS NOT NULL AND status IN (${RECOVERABLE_RUN_STATUSES.map(() => '?').join(', ')})
|
|
181
|
+
UNION
|
|
182
|
+
SELECT DISTINCT workspace FROM queue_items
|
|
183
|
+
WHERE workspace IS NOT NULL AND status IN (${RECOVERABLE_QUEUE_STATUSES.map(() => '?').join(', ')})
|
|
184
|
+
ORDER BY workspace
|
|
185
|
+
`);
|
|
119
186
|
|
|
120
187
|
function persistEvent(event) {
|
|
121
188
|
if (NON_PERSISTED_EVENT_TYPES.has(event.type)) return event;
|
|
122
|
-
|
|
189
|
+
const ws = event.workspace ?? event.payload?.workspace ?? null;
|
|
190
|
+
const sequence = nextEventSequenceStatement.get().next_sequence;
|
|
191
|
+
const result = insertEvent.run(
|
|
192
|
+
sequence,
|
|
123
193
|
event.id,
|
|
124
194
|
event.ts,
|
|
125
195
|
event.type,
|
|
126
196
|
event.runId ?? null,
|
|
127
197
|
event.turnId ?? null,
|
|
198
|
+
ws,
|
|
128
199
|
event.origin ?? null,
|
|
129
200
|
JSON.stringify(event.payload ?? {}),
|
|
130
201
|
);
|
|
131
|
-
|
|
202
|
+
if (Number(result.changes ?? 0) > 0) {
|
|
203
|
+
event.sequence = sequence;
|
|
204
|
+
lastEventId = event.id;
|
|
205
|
+
lastEventSequence = sequence;
|
|
206
|
+
}
|
|
132
207
|
if (event.type === 'run_started') {
|
|
133
208
|
persistRun({
|
|
134
209
|
id: event.runId ?? event.payload?.runId ?? event.id,
|
|
135
210
|
status: 'running',
|
|
136
211
|
input: event.payload?.input ?? null,
|
|
212
|
+
workspace: ws,
|
|
137
213
|
createdAt: event.ts,
|
|
138
214
|
updatedAt: event.ts,
|
|
139
215
|
});
|
|
140
|
-
} else if (
|
|
216
|
+
} else if (RUN_STATUS_BY_EVENT[event.type]) {
|
|
141
217
|
const runId = event.runId ?? event.payload?.runId ?? null;
|
|
142
218
|
if (runId) {
|
|
143
219
|
persistRun({
|
|
144
220
|
id: runId,
|
|
145
|
-
status:
|
|
221
|
+
status: RUN_STATUS_BY_EVENT[event.type],
|
|
222
|
+
workspace: ws,
|
|
146
223
|
updatedAt: event.ts,
|
|
147
224
|
});
|
|
148
225
|
}
|
|
@@ -150,19 +227,26 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
150
227
|
return event;
|
|
151
228
|
}
|
|
152
229
|
|
|
153
|
-
function persistRun({ id, status, input = null, createdAt = null, updatedAt = null }) {
|
|
230
|
+
function persistRun({ id, status, input = null, workspace = null, createdAt = null, updatedAt = null }) {
|
|
154
231
|
if (!id) return;
|
|
155
232
|
const now = new Date().toISOString();
|
|
156
|
-
upsertRun.run(id, status, input, createdAt ?? now, updatedAt ?? now);
|
|
233
|
+
upsertRun.run(id, workspace, status, input, createdAt ?? now, updatedAt ?? now);
|
|
157
234
|
}
|
|
158
235
|
|
|
159
|
-
function listEvents() {
|
|
160
|
-
|
|
236
|
+
function listEvents({ workspace = null } = {}) {
|
|
237
|
+
const rows = workspace
|
|
238
|
+
? listEventsByWorkspaceStatement.all(workspace)
|
|
239
|
+
: listEventsStatement.all();
|
|
240
|
+
return rows.map(rowToEvent);
|
|
161
241
|
}
|
|
162
242
|
|
|
163
|
-
function listRuns() {
|
|
164
|
-
|
|
243
|
+
function listRuns({ workspace = null } = {}) {
|
|
244
|
+
const rows = workspace
|
|
245
|
+
? listRunsByWorkspaceStatement.all(workspace)
|
|
246
|
+
: listRunsStatement.all();
|
|
247
|
+
return rows.map((row) => ({
|
|
165
248
|
id: row.id,
|
|
249
|
+
workspace: row.workspace ?? null,
|
|
166
250
|
status: row.status,
|
|
167
251
|
input: row.input,
|
|
168
252
|
createdAt: row.created_at,
|
|
@@ -170,19 +254,48 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
170
254
|
}));
|
|
171
255
|
}
|
|
172
256
|
|
|
173
|
-
function
|
|
257
|
+
function listRecoverableRuns({ workspace = null } = {}) {
|
|
258
|
+
const rows = workspace
|
|
259
|
+
? listRecoverableRunsByWorkspaceStatement.all(workspace, ...RECOVERABLE_RUN_STATUSES)
|
|
260
|
+
: listRecoverableRunsStatement.all(...RECOVERABLE_RUN_STATUSES);
|
|
261
|
+
return rows.map((row) => ({
|
|
262
|
+
id: row.id,
|
|
263
|
+
workspace: row.workspace ?? null,
|
|
264
|
+
status: row.status,
|
|
265
|
+
input: row.input,
|
|
266
|
+
createdAt: row.created_at,
|
|
267
|
+
updatedAt: row.updated_at,
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function listRecoverableWorkspaces() {
|
|
272
|
+
return listRecoverableWorkspacesStatement
|
|
273
|
+
.all(...RECOVERABLE_RUN_STATUSES, ...RECOVERABLE_QUEUE_STATUSES)
|
|
274
|
+
.map((row) => row.workspace);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function interruptRuns({ workspace, reason = 'Runtime restart recovery failed.' } = {}) {
|
|
278
|
+
if (!workspace) return 0;
|
|
279
|
+
const now = new Date().toISOString();
|
|
280
|
+
const result = interruptRunsStatement.run(now, workspace, ...RECOVERABLE_RUN_STATUSES);
|
|
281
|
+
return Number(result.changes ?? 0);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function saveQueue(queue = [], { workspace = null } = {}) {
|
|
174
285
|
const items = Array.isArray(queue) ? queue : [];
|
|
175
286
|
const now = new Date().toISOString();
|
|
176
287
|
db.exec('BEGIN');
|
|
177
288
|
try {
|
|
178
289
|
if (items.length === 0) {
|
|
179
|
-
|
|
290
|
+
if (workspace) clearQueueItemsForWorkspace.run(workspace);
|
|
291
|
+
else clearQueueItems.run();
|
|
180
292
|
} else {
|
|
181
|
-
|
|
293
|
+
if (workspace) deleteMissingQueueItemsForWorkspace.run(workspace, JSON.stringify(items.map((item) => item.id)));
|
|
294
|
+
else deleteMissingQueueItems.run(JSON.stringify(items.map((item) => item.id)));
|
|
182
295
|
for (const item of items) {
|
|
183
296
|
upsertQueueItem.run(
|
|
184
297
|
item.id,
|
|
185
|
-
item.workspace ?? null,
|
|
298
|
+
item.workspace ?? workspace ?? null,
|
|
186
299
|
item.server ?? 'production',
|
|
187
300
|
item.tool ?? 'production_start_job',
|
|
188
301
|
JSON.stringify(item.args ?? {}),
|
|
@@ -206,8 +319,11 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
206
319
|
}
|
|
207
320
|
}
|
|
208
321
|
|
|
209
|
-
function listQueue() {
|
|
210
|
-
|
|
322
|
+
function listQueue({ workspace = null } = {}) {
|
|
323
|
+
const rows = workspace
|
|
324
|
+
? listQueueByWorkspaceStatement.all(workspace)
|
|
325
|
+
: listQueueStatement.all();
|
|
326
|
+
return rows.map((row) => ({
|
|
211
327
|
id: row.id,
|
|
212
328
|
workspace: row.workspace ?? null,
|
|
213
329
|
server: row.server,
|
|
@@ -226,34 +342,38 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
226
342
|
}));
|
|
227
343
|
}
|
|
228
344
|
|
|
229
|
-
function replayEvents(session) {
|
|
230
|
-
const events = listEvents();
|
|
345
|
+
function replayEvents(session, { workspace = null } = {}) {
|
|
346
|
+
const events = listEvents({ workspace });
|
|
231
347
|
for (const event of events) {
|
|
232
348
|
dispatchAgentEvent(session, event);
|
|
233
349
|
}
|
|
234
|
-
if (events.length > 0)
|
|
350
|
+
if (events.length > 0) {
|
|
351
|
+
lastEventId = events.at(-1).id;
|
|
352
|
+
lastEventSequence = events.at(-1).sequence ?? null;
|
|
353
|
+
}
|
|
235
354
|
return session.agentProjection ?? reduceAgentEvents([]);
|
|
236
355
|
}
|
|
237
356
|
|
|
238
|
-
function getProjection() {
|
|
239
|
-
return reduceAgentEvents(listEvents());
|
|
357
|
+
function getProjection({ workspace = null } = {}) {
|
|
358
|
+
return reduceAgentEvents(listEvents({ workspace }));
|
|
240
359
|
}
|
|
241
360
|
|
|
242
|
-
function getState(session = null) {
|
|
243
|
-
const
|
|
244
|
-
const
|
|
361
|
+
function getState(session = null, { workspace = null } = {}) {
|
|
362
|
+
const events = session?.agentProjection ? null : listEvents({ workspace });
|
|
363
|
+
const projection = session?.agentProjection ?? reduceAgentEvents(events);
|
|
364
|
+
const rawQueue = session?.queueStore?.list() ?? listQueue({ workspace });
|
|
245
365
|
return {
|
|
246
366
|
...projection,
|
|
247
|
-
runs: listRuns(),
|
|
248
|
-
queue:
|
|
249
|
-
eventsCursor:
|
|
367
|
+
runs: listRuns({ workspace }),
|
|
368
|
+
queue: projectQueue(projection.plan, rawQueue, { workspace }),
|
|
369
|
+
eventsCursor: session?.agentEvents?.at(-1)?.sequence ?? events?.at(-1)?.sequence ?? lastEventSequence,
|
|
250
370
|
};
|
|
251
371
|
}
|
|
252
372
|
|
|
253
|
-
function hydrateSession(session) {
|
|
254
|
-
const projection = replayEvents(session);
|
|
373
|
+
function hydrateSession(session, { workspace = null } = {}) {
|
|
374
|
+
const projection = replayEvents(session, { workspace });
|
|
255
375
|
applyAgentProjectionToSession(session, projection);
|
|
256
|
-
session.jobQueue = listQueue();
|
|
376
|
+
session.jobQueue = listQueue({ workspace });
|
|
257
377
|
return projection;
|
|
258
378
|
}
|
|
259
379
|
|
|
@@ -269,6 +389,9 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
269
389
|
persistRun,
|
|
270
390
|
listEvents,
|
|
271
391
|
listRuns,
|
|
392
|
+
listRecoverableRuns,
|
|
393
|
+
listRecoverableWorkspaces,
|
|
394
|
+
interruptRuns,
|
|
272
395
|
saveQueue,
|
|
273
396
|
listQueue,
|
|
274
397
|
replayEvents,
|
|
@@ -281,12 +404,28 @@ export function openRuntimeStore({ stateDir = defaultRuntimeStateDir(), fileName
|
|
|
281
404
|
|
|
282
405
|
function rowToEvent(row) {
|
|
283
406
|
return {
|
|
407
|
+
sequence: row.sequence ?? null,
|
|
284
408
|
id: row.id,
|
|
285
409
|
ts: row.ts,
|
|
286
410
|
type: row.type,
|
|
287
411
|
origin: row.origin ?? 'system',
|
|
288
412
|
runId: row.run_id ?? null,
|
|
289
413
|
turnId: row.turn_id ?? null,
|
|
414
|
+
workspace: row.workspace ?? null,
|
|
290
415
|
payload: row.payload ? JSON.parse(row.payload) : {},
|
|
291
416
|
};
|
|
292
417
|
}
|
|
418
|
+
|
|
419
|
+
function ensureColumn(db, table, column, definition) {
|
|
420
|
+
const existing = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
421
|
+
if (existing.some((row) => row.name === column)) return;
|
|
422
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function backfillEventSequence(db) {
|
|
426
|
+
db.exec(`
|
|
427
|
+
UPDATE events
|
|
428
|
+
SET sequence = rowid
|
|
429
|
+
WHERE sequence IS NULL
|
|
430
|
+
`);
|
|
431
|
+
}
|