@polderlabs/bizar 10.5.0 → 10.6.0
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/bizar-dash/src/server/agent-bus.mjs +485 -0
- package/bizar-dash/src/server/api.mjs +6 -0
- package/bizar-dash/src/server/loop-runtime.mjs +429 -0
- package/bizar-dash/src/server/routes/agent-bus.mjs +185 -0
- package/bizar-dash/src/server/routes/loops.mjs +138 -0
- package/bizar-dash/src/server/task-splitter.mjs +241 -0
- package/bizar-dash/tests/agent-bus.test.mjs +289 -0
- package/bizar-dash/tests/loop-runtime.test.mjs +312 -0
- package/bizar-dash/tests/loops-agent-bus-routes.test.mjs +293 -0
- package/bizar-dash/tests/task-splitter.test.mjs +268 -0
- package/package.json +1 -1
- package/packages/sdk/package.json +1 -1
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/loop-runtime.mjs
|
|
3
|
+
*
|
|
4
|
+
* v10.6.0 — G-autoloop Phase 2. Background loop runtime.
|
|
5
|
+
*
|
|
6
|
+
* A "loop" is a long-running scheduler that:
|
|
7
|
+
* 1. Wakes every N milliseconds (configurable per-loop).
|
|
8
|
+
* 2. Scans the project's task store for ready tasks (status: 'queued',
|
|
9
|
+
* no unresolved blockers, no inflight assignee).
|
|
10
|
+
* 3. Claims one task at a time (atomic move 'queued' → 'doing') and
|
|
11
|
+
* dispatches it via `taskDelegator.dispatchSingleTask` — the same
|
|
12
|
+
* path the dashboard's "Start" button already uses.
|
|
13
|
+
* 4. Writes a `state.json` snapshot under
|
|
14
|
+
* `<BIZAR_LOOPS_ROOT>/<loopId>/state.json` on every tick so the
|
|
15
|
+
* loop can be resumed cleanly after a crash.
|
|
16
|
+
*
|
|
17
|
+
* Persistence shape (per loop):
|
|
18
|
+
*
|
|
19
|
+
* <BIZAR_LOOPS_ROOT>/<loopId>/state.json
|
|
20
|
+
* {
|
|
21
|
+
* loopId, projectId, name, status, createdAt, updatedAt,
|
|
22
|
+
* intervalMs, lastTickAt, lastError,
|
|
23
|
+
* dispatched: [{ taskId, dispatchedAt, bgInstanceId? }],
|
|
24
|
+
* iteration, maxIterations
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* `status ∈ { 'pending' | 'running' | 'stopped' | 'error' }`.
|
|
28
|
+
*
|
|
29
|
+
* Crash recovery: on `start()` we read the existing state.json; if
|
|
30
|
+
* `status === 'running'` and `updatedAt` is older than 5 minutes, we
|
|
31
|
+
* assume the previous process died and resume — re-claiming tasks that
|
|
32
|
+
* are still in 'doing' but have no live bg instance (per
|
|
33
|
+
* backgroundStore.list()) and re-dispatching them.
|
|
34
|
+
*
|
|
35
|
+
* Non-goals: This module does NOT depend on the dashboard server. It
|
|
36
|
+
* can be driven from a CLI daemon (`bizar-loop start`) just as easily
|
|
37
|
+
* as from inside the dashboard, since dispatch goes through
|
|
38
|
+
* `taskDelegator` which is the same path both use.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, unlinkSync, statSync } from 'node:fs';
|
|
42
|
+
import { join, dirname } from 'node:path';
|
|
43
|
+
import { homedir } from 'node:os';
|
|
44
|
+
import { randomBytes } from 'node:crypto';
|
|
45
|
+
|
|
46
|
+
const HOME = homedir();
|
|
47
|
+
|
|
48
|
+
/** Override via env var (used by tests). */
|
|
49
|
+
export const LOOPS_ROOT = process.env.BIZAR_LOOPS_ROOT
|
|
50
|
+
|| join(HOME, '.bizar', 'loops');
|
|
51
|
+
|
|
52
|
+
const STATUSES = new Set(['pending', 'running', 'stopped', 'error']);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Random id for new loops. Short, URL-safe, distinct.
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function genLoopId() {
|
|
59
|
+
return `loop_${randomBytes(4).toString('hex')}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function nowIso() {
|
|
63
|
+
return new Date().toISOString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Path to a loop's state.json.
|
|
68
|
+
* @param {string} loopId
|
|
69
|
+
*/
|
|
70
|
+
export function stateFile(loopId) {
|
|
71
|
+
return join(LOOPS_ROOT, loopId, 'state.json');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Safely read+parse JSON. Returns fallback on missing or corrupt.
|
|
76
|
+
* @param {string} file
|
|
77
|
+
* @param {Object} fallback
|
|
78
|
+
*/
|
|
79
|
+
function safeReadJson(file, fallback) {
|
|
80
|
+
try {
|
|
81
|
+
if (!existsSync(file)) return fallback;
|
|
82
|
+
const text = readFileSync(file, 'utf8');
|
|
83
|
+
if (!text.trim()) return fallback;
|
|
84
|
+
return JSON.parse(text);
|
|
85
|
+
} catch {
|
|
86
|
+
return fallback;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Atomic write: tmp file + rename.
|
|
92
|
+
* @param {string} file
|
|
93
|
+
* @param {Object} data
|
|
94
|
+
*/
|
|
95
|
+
function atomicWriteJson(file, data) {
|
|
96
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
97
|
+
const tmp = file + '.tmp';
|
|
98
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
99
|
+
renameSync(tmp, file);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validate a partial state object; fill missing fields with safe defaults.
|
|
104
|
+
* @param {Object} input
|
|
105
|
+
* @param {string} loopId
|
|
106
|
+
*/
|
|
107
|
+
function normalize(input, loopId) {
|
|
108
|
+
const status = STATUSES.has(input.status) ? input.status : 'pending';
|
|
109
|
+
return {
|
|
110
|
+
loopId,
|
|
111
|
+
projectId: input.projectId || '',
|
|
112
|
+
name: input.name || loopId,
|
|
113
|
+
status,
|
|
114
|
+
createdAt: input.createdAt || nowIso(),
|
|
115
|
+
updatedAt: nowIso(),
|
|
116
|
+
intervalMs: Number(input.intervalMs) > 0 ? Number(input.intervalMs) : 30000,
|
|
117
|
+
lastTickAt: input.lastTickAt || null,
|
|
118
|
+
lastError: input.lastError || null,
|
|
119
|
+
dispatched: Array.isArray(input.dispatched) ? input.dispatched : [],
|
|
120
|
+
iteration: Number.isFinite(input.iteration) ? input.iteration : 0,
|
|
121
|
+
maxIterations: Number.isFinite(input.maxIterations) ? input.maxIterations : Infinity,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create a new loop. Writes initial state.json and returns it.
|
|
127
|
+
*
|
|
128
|
+
* @param {Object} spec
|
|
129
|
+
* @param {string} spec.projectId
|
|
130
|
+
* @param {string} [spec.name]
|
|
131
|
+
* @param {number} [spec.intervalMs] wake interval; default 30s
|
|
132
|
+
* @param {number} [spec.maxIterations] cap before stopping; default Infinity
|
|
133
|
+
* @returns {Object} state
|
|
134
|
+
*/
|
|
135
|
+
export function createLoop(spec = {}) {
|
|
136
|
+
if (!spec.projectId) throw new TypeError('createLoop: projectId required');
|
|
137
|
+
const loopId = genLoopId();
|
|
138
|
+
const state = normalize({ ...spec, status: 'pending' }, loopId);
|
|
139
|
+
atomicWriteJson(stateFile(loopId), state);
|
|
140
|
+
return state;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Read a loop's state. Returns null if the loop doesn't exist.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} loopId
|
|
147
|
+
* @returns {Object|null}
|
|
148
|
+
*/
|
|
149
|
+
export function readLoop(loopId) {
|
|
150
|
+
const file = stateFile(loopId);
|
|
151
|
+
if (!existsSync(file)) return null;
|
|
152
|
+
return safeReadJson(file, null);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Patch loop state with partial fields. Recomputes updatedAt.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} loopId
|
|
159
|
+
* @param {Object} patch
|
|
160
|
+
* @returns {Object|null} new state, or null if loop doesn't exist
|
|
161
|
+
*/
|
|
162
|
+
export function patchLoop(loopId, patch = {}) {
|
|
163
|
+
const cur = readLoop(loopId);
|
|
164
|
+
if (!cur) return null;
|
|
165
|
+
const next = normalize({ ...cur, ...patch, loopId }, loopId);
|
|
166
|
+
atomicWriteJson(stateFile(loopId), next);
|
|
167
|
+
return next;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Delete a loop's state files. Idempotent.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} loopId
|
|
174
|
+
*/
|
|
175
|
+
export function deleteLoop(loopId) {
|
|
176
|
+
const dir = join(LOOPS_ROOT, loopId);
|
|
177
|
+
try {
|
|
178
|
+
if (!existsSync(dir)) return;
|
|
179
|
+
for (const f of readdirSync(dir)) {
|
|
180
|
+
try { unlinkSync(join(dir, f)); } catch { /* */ }
|
|
181
|
+
}
|
|
182
|
+
} catch { /* */ }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* List all loop ids known on disk. Skips entries that aren't directories.
|
|
187
|
+
*
|
|
188
|
+
* @returns {string[]}
|
|
189
|
+
*/
|
|
190
|
+
export function listLoops() {
|
|
191
|
+
if (!existsSync(LOOPS_ROOT)) return [];
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const name of readdirSync(LOOPS_ROOT)) {
|
|
194
|
+
try {
|
|
195
|
+
if (statSync(join(LOOPS_ROOT, name)).isDirectory()) out.push(name);
|
|
196
|
+
} catch { /* */ }
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Snapshot the full loop registry: { loopId: state, ... }.
|
|
203
|
+
* @returns {Object}
|
|
204
|
+
*/
|
|
205
|
+
export function snapshotLoops() {
|
|
206
|
+
const out = {};
|
|
207
|
+
for (const id of listLoops()) {
|
|
208
|
+
const s = readLoop(id);
|
|
209
|
+
if (s) out[id] = s;
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// In-process scheduler (Phase 2 wiring)
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// We keep an in-process registry of active timers so multiple loops can
|
|
218
|
+
// coexist in one Node process (dashboard server + `bizar-loop start` CLI).
|
|
219
|
+
// Crash recovery is best-effort: a `kill -9` of the loop's owning process
|
|
220
|
+
// loses the timer; the next `start()` of that loopId resumes from state.json
|
|
221
|
+
// (see resume logic below).
|
|
222
|
+
|
|
223
|
+
const activeTimers = new Map(); // loopId → { timer, projectId, intervalMs, getTaskStore, dispatch }
|
|
224
|
+
|
|
225
|
+
function ensureFreshDispatched(state) {
|
|
226
|
+
if (!Array.isArray(state.dispatched)) state.dispatched = [];
|
|
227
|
+
return state;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Find ready tasks for a project. A task is "ready" when:
|
|
232
|
+
* - status === 'queued'
|
|
233
|
+
* - archived === false
|
|
234
|
+
* - no unmet dependencies
|
|
235
|
+
* - no in-flight assignee (we don't double-dispatch)
|
|
236
|
+
*
|
|
237
|
+
* Pure function over the tasks array so it works with whatever
|
|
238
|
+
* TaskStore abstraction the caller passes in (real store in
|
|
239
|
+
* production, in-memory array in tests).
|
|
240
|
+
*
|
|
241
|
+
* @param {Array} tasks
|
|
242
|
+
* @returns {Array}
|
|
243
|
+
*/
|
|
244
|
+
export function pickReadyTasks(tasks) {
|
|
245
|
+
if (!Array.isArray(tasks)) return [];
|
|
246
|
+
const byId = new Map();
|
|
247
|
+
for (const t of tasks) if (t && t.id) byId.set(t.id, t);
|
|
248
|
+
return tasks
|
|
249
|
+
.filter((t) => t && t.status === 'queued' && !t.archived)
|
|
250
|
+
.filter((t) => {
|
|
251
|
+
const deps = Array.isArray(t.dependencies) ? t.dependencies : [];
|
|
252
|
+
return deps.every((d) => {
|
|
253
|
+
const dep = byId.get(d);
|
|
254
|
+
return dep && dep.status === 'done';
|
|
255
|
+
});
|
|
256
|
+
})
|
|
257
|
+
.sort((a, b) => {
|
|
258
|
+
// Priority first (lower number = higher priority), then age.
|
|
259
|
+
const pa = Number.isFinite(a.priority) ? a.priority : 5;
|
|
260
|
+
const pb = Number.isFinite(b.priority) ? b.priority : 5;
|
|
261
|
+
if (pa !== pb) return pa - pb;
|
|
262
|
+
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Run one tick of the loop: scan → pick the highest-priority ready task
|
|
268
|
+
* → claim it (move 'queued' → 'doing') → dispatch. Returns a structured
|
|
269
|
+
* result the caller can persist.
|
|
270
|
+
*
|
|
271
|
+
* Inputs are injected so tests don't need a real task store. The
|
|
272
|
+
* caller passes:
|
|
273
|
+
* - getTasks: () => Array<Task>
|
|
274
|
+
* - claimTask: (id) => updated Task | null (move to 'doing', set assignee + workedBy)
|
|
275
|
+
* - dispatch: (task) => { ok: boolean, bgInstanceId?: string, error?: string }
|
|
276
|
+
*
|
|
277
|
+
* @param {Object} ctx
|
|
278
|
+
* @param {Function} ctx.getTasks
|
|
279
|
+
* @param {Function} ctx.claimTask
|
|
280
|
+
* @param {Function} ctx.dispatch
|
|
281
|
+
* @returns {Object} { picked, dispatched, iteration, lastError }
|
|
282
|
+
*/
|
|
283
|
+
export async function runTick({ getTasks, claimTask, dispatch }) {
|
|
284
|
+
if (typeof getTasks !== 'function') throw new TypeError('runTick: getTasks required');
|
|
285
|
+
if (typeof claimTask !== 'function') throw new TypeError('runTick: claimTask required');
|
|
286
|
+
if (typeof dispatch !== 'function') throw new TypeError('runTick: dispatch required');
|
|
287
|
+
const tasks = getTasks() || [];
|
|
288
|
+
const ready = pickReadyTasks(tasks);
|
|
289
|
+
if (ready.length === 0) {
|
|
290
|
+
return { picked: null, dispatched: null, iteration: 0, lastError: null };
|
|
291
|
+
}
|
|
292
|
+
const target = ready[0];
|
|
293
|
+
const claimed = claimTask(target.id);
|
|
294
|
+
if (!claimed) {
|
|
295
|
+
return { picked: target.id, dispatched: null, iteration: 0, lastError: 'claim_failed' };
|
|
296
|
+
}
|
|
297
|
+
let result;
|
|
298
|
+
try {
|
|
299
|
+
result = await dispatch(claimed);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
return {
|
|
302
|
+
picked: target.id,
|
|
303
|
+
dispatched: null,
|
|
304
|
+
iteration: 0,
|
|
305
|
+
lastError: err && err.message ? String(err.message) : String(err),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (!result || !result.ok) {
|
|
309
|
+
return {
|
|
310
|
+
picked: target.id,
|
|
311
|
+
dispatched: null,
|
|
312
|
+
iteration: 0,
|
|
313
|
+
lastError: result && result.error ? String(result.error) : 'dispatch_failed',
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
picked: target.id,
|
|
318
|
+
dispatched: { taskId: target.id, dispatchedAt: nowIso(), bgInstanceId: result.bgInstanceId || null },
|
|
319
|
+
iteration: 1,
|
|
320
|
+
lastError: null,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Start (or resume) a loop. Idempotent — calling twice on the same
|
|
326
|
+
* loopId is a no-op for the second call.
|
|
327
|
+
*
|
|
328
|
+
* @param {string} loopId
|
|
329
|
+
* @param {Object} ctx
|
|
330
|
+
* @param {Function} ctx.getTasks () => Array<Task>
|
|
331
|
+
* @param {Function} ctx.claimTask (id) => Task|null
|
|
332
|
+
* @param {Function} ctx.dispatch (task) => { ok, bgInstanceId?, error? }
|
|
333
|
+
* @param {Function} [ctx.onTick] called after each tick with the result
|
|
334
|
+
* @param {Function} [ctx.onError] called on uncaught tick errors
|
|
335
|
+
* @returns {Object} state
|
|
336
|
+
*/
|
|
337
|
+
export function startLoop(loopId, ctx = {}) {
|
|
338
|
+
const cur = readLoop(loopId);
|
|
339
|
+
if (!cur) throw new Error(`startLoop: loop ${loopId} not found`);
|
|
340
|
+
if (activeTimers.has(loopId)) return cur; // already running
|
|
341
|
+
|
|
342
|
+
const tick = async () => {
|
|
343
|
+
const state = readLoop(loopId);
|
|
344
|
+
if (!state || state.status === 'stopped') return;
|
|
345
|
+
let result;
|
|
346
|
+
try {
|
|
347
|
+
result = await runTick({
|
|
348
|
+
getTasks: ctx.getTasks,
|
|
349
|
+
claimTask: ctx.claimTask,
|
|
350
|
+
dispatch: ctx.dispatch,
|
|
351
|
+
});
|
|
352
|
+
} catch (err) {
|
|
353
|
+
patchLoop(loopId, { status: 'error', lastError: err.message || String(err), updatedAt: nowIso() });
|
|
354
|
+
if (typeof ctx.onError === 'function') ctx.onError(err, loopId);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
ensureFreshDispatched(state);
|
|
358
|
+
const dispatched = result.dispatched
|
|
359
|
+
? [...state.dispatched, result.dispatched]
|
|
360
|
+
: state.dispatched;
|
|
361
|
+
const iteration = state.iteration + (result.iteration || 0);
|
|
362
|
+
const next = patchLoop(loopId, {
|
|
363
|
+
status: 'running',
|
|
364
|
+
lastTickAt: nowIso(),
|
|
365
|
+
lastError: result.lastError,
|
|
366
|
+
dispatched,
|
|
367
|
+
iteration,
|
|
368
|
+
});
|
|
369
|
+
if (typeof ctx.onTick === 'function') ctx.onTick(result, next);
|
|
370
|
+
if (Number.isFinite(next.maxIterations) && next.iteration >= next.maxIterations) {
|
|
371
|
+
patchLoop(loopId, { status: 'stopped' });
|
|
372
|
+
stopLoop(loopId);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
patchLoop(loopId, { status: 'running' });
|
|
378
|
+
const timer = setInterval(tick, cur.intervalMs);
|
|
379
|
+
// Don't keep the Node process alive just to run a loop.
|
|
380
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
381
|
+
activeTimers.set(loopId, { timer, loopId });
|
|
382
|
+
return readLoop(loopId);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Stop a loop. Cancels the in-process timer (if any) and marks the
|
|
387
|
+
* state as 'stopped' so a future start() resumes from the snapshot
|
|
388
|
+
* rather than starting fresh.
|
|
389
|
+
*
|
|
390
|
+
* @param {string} loopId
|
|
391
|
+
*/
|
|
392
|
+
export function stopLoop(loopId) {
|
|
393
|
+
const entry = activeTimers.get(loopId);
|
|
394
|
+
if (entry) {
|
|
395
|
+
clearInterval(entry.timer);
|
|
396
|
+
activeTimers.delete(loopId);
|
|
397
|
+
}
|
|
398
|
+
const cur = readLoop(loopId);
|
|
399
|
+
// `error` is terminal — don't overwrite it with 'stopped' on a
|
|
400
|
+
// subsequent explicit stop. Otherwise an errored loop silently
|
|
401
|
+
// masks its failure.
|
|
402
|
+
if (cur && cur.status !== 'stopped' && cur.status !== 'error') {
|
|
403
|
+
patchLoop(loopId, { status: 'stopped' });
|
|
404
|
+
}
|
|
405
|
+
return readLoop(loopId);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Drop the in-process timer without touching state.json. Used by tests
|
|
410
|
+
* and by the dashboard's shutdown handler.
|
|
411
|
+
*/
|
|
412
|
+
export function _dropInProcessTimers() {
|
|
413
|
+
for (const [, entry] of activeTimers) clearInterval(entry.timer);
|
|
414
|
+
activeTimers.clear();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Crash-recovery helper. Returns `{ resumed, orphanedTaskIds }` where
|
|
419
|
+
* `orphanedTaskIds` are tasks that were marked 'doing' but no longer
|
|
420
|
+
* have a live bg instance — those should be re-claimed by a fresh
|
|
421
|
+
* tick after `startLoop`.
|
|
422
|
+
*
|
|
423
|
+
* Currently a no-op placeholder; the real implementation lives in
|
|
424
|
+
* the dashboard integration in Phase 5 (uses backgroundStore.list()
|
|
425
|
+
* to compare). Wire it once Phase 5 lands.
|
|
426
|
+
*/
|
|
427
|
+
export function planResume(_loopId) {
|
|
428
|
+
return { resumed: false, orphanedTaskIds: [] };
|
|
429
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/routes/agent-bus.mjs
|
|
3
|
+
*
|
|
4
|
+
* G-autoloop Phase 5 — HTTP surface for the agent-bus.
|
|
5
|
+
*
|
|
6
|
+
* Endpoints (mounted under `/api/agent-bus`):
|
|
7
|
+
* GET /api/agent-bus/presence — current presence snapshot
|
|
8
|
+
* GET /api/agent-bus/channels — list known channels
|
|
9
|
+
* GET /api/agent-bus/channels/:name — recent messages on a channel
|
|
10
|
+
* POST /api/agent-bus/channels/:name/publish — publish a raw message
|
|
11
|
+
* POST /api/agent-bus/steer — convenience: steer wrapper
|
|
12
|
+
* POST /api/agent-bus/correct — convenience: correct wrapper
|
|
13
|
+
* POST /api/agent-bus/handoff — convenience: handoff wrapper
|
|
14
|
+
* POST /api/agent-bus/announce — update presence
|
|
15
|
+
* DELETE /api/agent-bus/agents/:name — leave (remove from presence)
|
|
16
|
+
*
|
|
17
|
+
* Phase 5 deliberately keeps this endpoint surface read-mostly with
|
|
18
|
+
* a thin publish helper — the consumer sites (Loop sidebar, Agent
|
|
19
|
+
* viewer) are mostly subscribers via WebSocket, not publishers. The
|
|
20
|
+
* `publish` endpoint is included so curl/operators can drop a steer
|
|
21
|
+
* in from outside without writing a node script.
|
|
22
|
+
*
|
|
23
|
+
* Authz: every endpoint goes through the bearer-token gate the server
|
|
24
|
+
* already applies. We deliberately do NOT enforce address-from ==
|
|
25
|
+
* current user; that's reserved for a later pill once we layer in the
|
|
26
|
+
* Bizar identity model.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { Router } from 'express';
|
|
30
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
31
|
+
import {
|
|
32
|
+
getPresence,
|
|
33
|
+
announce,
|
|
34
|
+
leave,
|
|
35
|
+
readChannel,
|
|
36
|
+
publish as busPublish,
|
|
37
|
+
steer as busSteer,
|
|
38
|
+
correct as busCorrect,
|
|
39
|
+
handoff as busHandoff,
|
|
40
|
+
AGENT_BUS_LOG_DIR,
|
|
41
|
+
DEFAULT_CHANNEL,
|
|
42
|
+
isValidAddress,
|
|
43
|
+
} from '../agent-bus.mjs';
|
|
44
|
+
import { wrap } from './_shared.mjs';
|
|
45
|
+
|
|
46
|
+
const VALID_KINDS = new Set(['request', 'response', 'steer', 'correct', 'handoff', 'ack', 'status', 'error']);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {Object} deps
|
|
50
|
+
* @param {Function} [deps.broadcast]
|
|
51
|
+
*/
|
|
52
|
+
export function createAgentBusRouter({ broadcast = () => {} } = {}) {
|
|
53
|
+
const router = Router();
|
|
54
|
+
|
|
55
|
+
router.get('/agent-bus/presence', wrap(async (req, res) => {
|
|
56
|
+
const p = getPresence();
|
|
57
|
+
res.json({ agents: p, count: Object.keys(p).length });
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
router.get('/agent-bus/channels', wrap(async (req, res) => {
|
|
61
|
+
if (!existsSync(AGENT_BUS_LOG_DIR)) {
|
|
62
|
+
res.json({ channels: [], count: 0 });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const channels = [];
|
|
66
|
+
for (const f of readdirSync(AGENT_BUS_LOG_DIR)) {
|
|
67
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
68
|
+
try {
|
|
69
|
+
const fullStat = statSync(`${AGENT_BUS_LOG_DIR}/${f}`);
|
|
70
|
+
if (fullStat.isFile()) {
|
|
71
|
+
channels.push({
|
|
72
|
+
name: f.replace(/\.jsonl$/, ''),
|
|
73
|
+
bytes: fullStat.size,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
} catch { /* ignore */ }
|
|
77
|
+
}
|
|
78
|
+
res.json({ channels, count: channels.length });
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
router.get('/agent-bus/channels/:name', wrap(async (req, res) => {
|
|
82
|
+
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
|
|
83
|
+
const since = req.query.since || null;
|
|
84
|
+
const messages = readChannel(req.params.name, { limit, since });
|
|
85
|
+
res.json({ channel: req.params.name, messages, count: messages.length });
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
router.post('/agent-bus/channels/:name/publish', wrap(async (req, res) => {
|
|
89
|
+
const body = req.body || {};
|
|
90
|
+
if (!body.from || !isValidAddress(body.from)) {
|
|
91
|
+
res.status(400).json({ error: 'bad_request', message: 'from (valid agent://address) required' });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!body.to || !isValidAddress(body.to)) {
|
|
95
|
+
res.status(400).json({ error: 'bad_request', message: 'to (valid agent://address) required' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!body.kind || !VALID_KINDS.has(body.kind)) {
|
|
99
|
+
res.status(400).json({ error: 'bad_request', message: 'kind required and valid' });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const id = busPublish({
|
|
103
|
+
from: body.from,
|
|
104
|
+
to: body.to,
|
|
105
|
+
channel: req.params.name === DEFAULT_CHANNEL ? undefined : req.params.name,
|
|
106
|
+
kind: body.kind,
|
|
107
|
+
payload: body.payload || {},
|
|
108
|
+
correlationId: body.correlationId || null,
|
|
109
|
+
taskId: body.taskId || null,
|
|
110
|
+
});
|
|
111
|
+
broadcast({ type: 'agent-bus:message', channel: req.params.name });
|
|
112
|
+
res.status(202).json({ id, ok: true });
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
router.post('/agent-bus/steer', wrap(async (req, res) => {
|
|
116
|
+
const body = req.body || {};
|
|
117
|
+
if (!body.from || !body.to) {
|
|
118
|
+
res.status(400).json({ error: 'bad_request', message: 'from and to required' });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const id = busSteer(body.from, body.to, body.note || '', {
|
|
122
|
+
channel: body.channel,
|
|
123
|
+
taskId: body.taskId,
|
|
124
|
+
});
|
|
125
|
+
if (body.channel) broadcast({ type: 'agent-bus:message', channel: body.channel });
|
|
126
|
+
res.status(202).json({ id, ok: true });
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
router.post('/agent-bus/correct', wrap(async (req, res) => {
|
|
130
|
+
const body = req.body || {};
|
|
131
|
+
if (!body.from || !body.to) {
|
|
132
|
+
res.status(400).json({ error: 'bad_request', message: 'from and to required' });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const id = busCorrect(body.from, body.to, body.note || '', {
|
|
136
|
+
before: body.before ?? null,
|
|
137
|
+
after: body.after ?? null,
|
|
138
|
+
reason: body.reason || null,
|
|
139
|
+
}, {
|
|
140
|
+
channel: body.channel,
|
|
141
|
+
taskId: body.taskId,
|
|
142
|
+
});
|
|
143
|
+
if (body.channel) broadcast({ type: 'agent-bus:message', channel: body.channel });
|
|
144
|
+
res.status(202).json({ id, ok: true });
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
router.post('/agent-bus/handoff', wrap(async (req, res) => {
|
|
148
|
+
const body = req.body || {};
|
|
149
|
+
if (!body.from || !body.to || !body.taskId) {
|
|
150
|
+
res.status(400).json({ error: 'bad_request', message: 'from, to, taskId required' });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const id = busHandoff(body.from, body.to, body.taskId, body.reason || '', {
|
|
154
|
+
channel: body.channel,
|
|
155
|
+
});
|
|
156
|
+
if (body.channel) broadcast({ type: 'agent-bus:message', channel: body.channel });
|
|
157
|
+
res.status(202).json({ id, ok: true });
|
|
158
|
+
}));
|
|
159
|
+
|
|
160
|
+
router.post('/agent-bus/announce', wrap(async (req, res) => {
|
|
161
|
+
const body = req.body || {};
|
|
162
|
+
try {
|
|
163
|
+
announce({
|
|
164
|
+
name: body.name,
|
|
165
|
+
sessionId: body.sessionId,
|
|
166
|
+
capabilities: body.capabilities,
|
|
167
|
+
currentTask: body.currentTask,
|
|
168
|
+
model: body.model,
|
|
169
|
+
});
|
|
170
|
+
} catch (err) {
|
|
171
|
+
res.status(400).json({ error: 'bad_request', message: err.message });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
broadcast({ type: 'agent-bus:presence', agents: getPresence() });
|
|
175
|
+
res.json({ ok: true });
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
router.delete('/agent-bus/agents/:name', wrap(async (req, res) => {
|
|
179
|
+
leave(req.params.name);
|
|
180
|
+
broadcast({ type: 'agent-bus:presence', agents: getPresence() });
|
|
181
|
+
res.status(204).end();
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
return router;
|
|
185
|
+
}
|