@os-eco/overstory-cli 0.9.3 → 0.10.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/README.md +49 -18
- package/agents/builder.md +9 -8
- package/agents/coordinator.md +6 -6
- package/agents/lead.md +98 -82
- package/agents/merger.md +25 -14
- package/agents/reviewer.md +22 -16
- package/agents/scout.md +17 -12
- package/package.json +6 -3
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/headless-mail-injector.test.ts +448 -0
- package/src/agents/headless-mail-injector.ts +211 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +514 -14
- package/src/agents/hooks-deployer.ts +141 -0
- package/src/agents/overlay.test.ts +4 -4
- package/src/agents/overlay.ts +30 -8
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +1450 -0
- package/src/agents/turn-runner.ts +1166 -0
- package/src/commands/clean.ts +56 -1
- package/src/commands/completions.test.ts +4 -1
- package/src/commands/coordinator.test.ts +127 -0
- package/src/commands/coordinator.ts +205 -6
- package/src/commands/dashboard.test.ts +188 -0
- package/src/commands/dashboard.ts +13 -3
- package/src/commands/doctor.ts +94 -77
- package/src/commands/group.test.ts +94 -0
- package/src/commands/group.ts +49 -20
- package/src/commands/init.test.ts +8 -0
- package/src/commands/init.ts +8 -1
- package/src/commands/log.test.ts +56 -11
- package/src/commands/log.ts +134 -69
- package/src/commands/mail.test.ts +162 -0
- package/src/commands/mail.ts +64 -9
- package/src/commands/merge.test.ts +112 -1
- package/src/commands/merge.ts +17 -4
- package/src/commands/monitor.ts +2 -1
- package/src/commands/nudge.test.ts +351 -4
- package/src/commands/nudge.ts +356 -34
- package/src/commands/run.test.ts +43 -7
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +408 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1323 -0
- package/src/commands/serve/rest.ts +708 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +565 -0
- package/src/commands/sling.test.ts +85 -1
- package/src/commands/sling.ts +153 -64
- package/src/commands/status.test.ts +9 -0
- package/src/commands/status.ts +12 -4
- package/src/commands/stop.test.ts +174 -1
- package/src/commands/stop.ts +107 -8
- package/src/commands/supervisor.ts +2 -1
- package/src/commands/watch.test.ts +49 -4
- package/src/commands/watch.ts +153 -28
- package/src/commands/worktree.test.ts +319 -3
- package/src/commands/worktree.ts +86 -0
- package/src/config.test.ts +78 -0
- package/src/config.ts +43 -1
- package/src/doctor/consistency.test.ts +106 -0
- package/src/doctor/consistency.ts +50 -3
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/types.ts +2 -1
- package/src/doctor/watchdog.ts +57 -1
- package/src/events/tailer.test.ts +234 -1
- package/src/events/tailer.ts +90 -0
- package/src/index.ts +53 -6
- package/src/json.ts +29 -0
- package/src/mail/client.ts +15 -2
- package/src/mail/store.test.ts +82 -0
- package/src/mail/store.ts +41 -4
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/claude.test.ts +791 -1
- package/src/runtimes/claude.ts +323 -1
- package/src/runtimes/connections.test.ts +141 -1
- package/src/runtimes/connections.ts +73 -4
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/types.ts +10 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/sessions/store.test.ts +390 -24
- package/src/sessions/store.ts +184 -19
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/types.ts +56 -1
- package/src/utils/pid.test.ts +85 -1
- package/src/utils/pid.ts +86 -1
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/watchdog/daemon.test.ts +1520 -411
- package/src/watchdog/daemon.ts +442 -83
- package/src/watchdog/health.test.ts +157 -0
- package/src/watchdog/health.ts +92 -25
- package/src/worktree/process.test.ts +71 -0
- package/src/worktree/process.ts +25 -5
- package/src/worktree/tmux.test.ts +39 -0
- package/src/worktree/tmux.ts +23 -3
- package/templates/CLAUDE.md.tmpl +19 -8
- package/templates/overlay.md.tmpl +3 -2
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST API handlers for `ov serve`.
|
|
3
|
+
*
|
|
4
|
+
* Registers read-only endpoints that surface data from existing SQLite stores
|
|
5
|
+
* (EventStore, MailStore, SessionStore, RunStore). No new persistence.
|
|
6
|
+
*
|
|
7
|
+
* Route registration via registerApiHandler — no changes to serve.ts required
|
|
8
|
+
* beyond the single registerRestApi() call.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { AgentError, OverstoryError, ValidationError } from "../../errors.ts";
|
|
13
|
+
import { createEventStore } from "../../events/store.ts";
|
|
14
|
+
import { apiError, apiJson } from "../../json.ts";
|
|
15
|
+
import type { MailStore } from "../../mail/store.ts";
|
|
16
|
+
import { createMailStore } from "../../mail/store.ts";
|
|
17
|
+
import type { SessionStore } from "../../sessions/store.ts";
|
|
18
|
+
import { createRunStore, createSessionStore } from "../../sessions/store.ts";
|
|
19
|
+
import type { EventStore, RunStore } from "../../types.ts";
|
|
20
|
+
import { registerApiHandler } from "../serve.ts";
|
|
21
|
+
import {
|
|
22
|
+
askCoordinatorAction,
|
|
23
|
+
ConflictError,
|
|
24
|
+
type CoordinatorActionDeps,
|
|
25
|
+
checkCoordinatorComplete,
|
|
26
|
+
getCoordinatorState,
|
|
27
|
+
sendToCoordinator,
|
|
28
|
+
startCoordinatorHeadless,
|
|
29
|
+
stopCoordinator,
|
|
30
|
+
} from "./coordinator-actions.ts";
|
|
31
|
+
import { deleteMail, replyMail, sendMail } from "./mail-actions.ts";
|
|
32
|
+
|
|
33
|
+
// ─── Cursor helpers ───────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
type Cursor = { ts: string; id: string };
|
|
36
|
+
|
|
37
|
+
function encodeCursor(c: Cursor): string {
|
|
38
|
+
return Buffer.from(JSON.stringify(c)).toString("base64url");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function decodeCursor(s: string): Cursor {
|
|
42
|
+
let parsed: unknown;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(Buffer.from(s, "base64url").toString("utf-8"));
|
|
45
|
+
} catch {
|
|
46
|
+
throw new ValidationError("Invalid cursor", { field: "cursor", value: s });
|
|
47
|
+
}
|
|
48
|
+
const p = parsed as Record<string, unknown>;
|
|
49
|
+
if (typeof p.ts !== "string" || typeof p.id !== "string") {
|
|
50
|
+
throw new ValidationError("Invalid cursor", { field: "cursor", value: s });
|
|
51
|
+
}
|
|
52
|
+
return { ts: p.ts, id: p.id };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseLimitAndCursor(params: URLSearchParams): { limit: number; cursor: Cursor | null } {
|
|
56
|
+
const limitStr = params.get("limit");
|
|
57
|
+
const limit = limitStr !== null ? Number.parseInt(limitStr, 10) : 100;
|
|
58
|
+
if (Number.isNaN(limit) || limit < 1 || limit > 500) {
|
|
59
|
+
throw new ValidationError(`Invalid limit: ${limitStr ?? "undefined"}`, {
|
|
60
|
+
field: "limit",
|
|
61
|
+
value: limitStr,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const cursorStr = params.get("cursor");
|
|
66
|
+
const cursor = cursorStr !== null ? decodeCursor(cursorStr) : null;
|
|
67
|
+
|
|
68
|
+
return { limit, cursor };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Generic paginator (for string-id collections) ───────────────────────────
|
|
72
|
+
|
|
73
|
+
interface PaginateResult<T> {
|
|
74
|
+
page: T[];
|
|
75
|
+
nextCursor: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Client-side paginator for pre-sorted collections with string IDs.
|
|
80
|
+
* direction="asc": keep items where (ts > cursorTs) OR (ts === cursorTs AND id > cursorId)
|
|
81
|
+
* direction="desc": keep items where (ts < cursorTs) OR (ts === cursorTs AND id < cursorId)
|
|
82
|
+
*/
|
|
83
|
+
function paginateItems<T extends { id: string }>(
|
|
84
|
+
items: T[],
|
|
85
|
+
cursor: Cursor | null,
|
|
86
|
+
limit: number,
|
|
87
|
+
getTs: (item: T) => string,
|
|
88
|
+
direction: "asc" | "desc",
|
|
89
|
+
): PaginateResult<T> {
|
|
90
|
+
let filtered = items;
|
|
91
|
+
|
|
92
|
+
if (cursor !== null) {
|
|
93
|
+
const { ts: cTs, id: cId } = cursor;
|
|
94
|
+
if (direction === "asc") {
|
|
95
|
+
filtered = items.filter((item) => {
|
|
96
|
+
const ts = getTs(item);
|
|
97
|
+
if (ts > cTs) return true;
|
|
98
|
+
if (ts === cTs && item.id > cId) return true;
|
|
99
|
+
return false;
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
filtered = items.filter((item) => {
|
|
103
|
+
const ts = getTs(item);
|
|
104
|
+
if (ts < cTs) return true;
|
|
105
|
+
if (ts === cTs && item.id < cId) return true;
|
|
106
|
+
return false;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const page = filtered.slice(0, limit);
|
|
112
|
+
const hasMore = filtered.length > limit;
|
|
113
|
+
const lastItem = page[page.length - 1];
|
|
114
|
+
const nextCursor =
|
|
115
|
+
hasMore && lastItem !== undefined
|
|
116
|
+
? encodeCursor({ ts: getTs(lastItem), id: lastItem.id })
|
|
117
|
+
: null;
|
|
118
|
+
|
|
119
|
+
return { page, nextCursor };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Error → HTTP status ──────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
function statusFromError(err: OverstoryError): number {
|
|
125
|
+
if (err instanceof ValidationError) return 400;
|
|
126
|
+
if (err instanceof ConflictError) return 409;
|
|
127
|
+
// AgentError with "not running" message — surface as 409 (preconditions
|
|
128
|
+
// failed) so the UI can offer a "start coordinator" affordance instead of
|
|
129
|
+
// treating it as a server-side fault.
|
|
130
|
+
if (err instanceof AgentError && /not running/i.test(err.message)) return 409;
|
|
131
|
+
return 500;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Stores ───────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
export interface RestApiDeps {
|
|
137
|
+
_runStore?: RunStore;
|
|
138
|
+
_sessionStore?: SessionStore;
|
|
139
|
+
_eventStore?: EventStore;
|
|
140
|
+
_mailStore?: MailStore;
|
|
141
|
+
_projectRoot?: string;
|
|
142
|
+
/**
|
|
143
|
+
* Override coordinator action functions (used by tests). When omitted, the
|
|
144
|
+
* production functions imported from ./coordinator-actions.ts are called.
|
|
145
|
+
*/
|
|
146
|
+
_coordinatorActions?: {
|
|
147
|
+
getCoordinatorState?: typeof getCoordinatorState;
|
|
148
|
+
sendToCoordinator?: typeof sendToCoordinator;
|
|
149
|
+
askCoordinatorAction?: typeof askCoordinatorAction;
|
|
150
|
+
checkCoordinatorComplete?: typeof checkCoordinatorComplete;
|
|
151
|
+
startCoordinatorHeadless?: typeof startCoordinatorHeadless;
|
|
152
|
+
stopCoordinator?: typeof stopCoordinator;
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Override the deps passed to coordinator-actions. When omitted, the actions
|
|
156
|
+
* use deps.projectRoot to open their own short-lived stores. Tests pass
|
|
157
|
+
* `_sessionStore` / `_mailStore` so actions reuse the test harness DBs.
|
|
158
|
+
*/
|
|
159
|
+
_coordinatorActionDeps?: Partial<CoordinatorActionDeps>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface Stores {
|
|
163
|
+
run: RunStore;
|
|
164
|
+
session: SessionStore;
|
|
165
|
+
event: EventStore;
|
|
166
|
+
mail: MailStore;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function openStores(projectRoot: string): Stores {
|
|
170
|
+
const ovDir = join(projectRoot, ".overstory");
|
|
171
|
+
const sessionsDb = join(ovDir, "sessions.db");
|
|
172
|
+
const eventsDb = join(ovDir, "events.db");
|
|
173
|
+
const mailDb = join(ovDir, "mail.db");
|
|
174
|
+
return {
|
|
175
|
+
run: createRunStore(sessionsDb),
|
|
176
|
+
session: createSessionStore(sessionsDb),
|
|
177
|
+
event: createEventStore(eventsDb),
|
|
178
|
+
mail: createMailStore(mailDb),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Route table ─────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
type RouteHandler = (
|
|
185
|
+
req: Request,
|
|
186
|
+
match: RegExpMatchArray,
|
|
187
|
+
params: URLSearchParams,
|
|
188
|
+
stores: Stores,
|
|
189
|
+
ctx: HandlerContext,
|
|
190
|
+
) => Promise<Response>;
|
|
191
|
+
|
|
192
|
+
interface Route {
|
|
193
|
+
method: string;
|
|
194
|
+
pattern: RegExp;
|
|
195
|
+
handler: RouteHandler;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Per-request context passed to every handler. Holds the resolved coordinator
|
|
200
|
+
* action functions (live or DI-overridden) and the deps that drive them.
|
|
201
|
+
*/
|
|
202
|
+
interface HandlerContext {
|
|
203
|
+
projectRoot: string;
|
|
204
|
+
coordinatorActions: {
|
|
205
|
+
getCoordinatorState: typeof getCoordinatorState;
|
|
206
|
+
sendToCoordinator: typeof sendToCoordinator;
|
|
207
|
+
askCoordinatorAction: typeof askCoordinatorAction;
|
|
208
|
+
checkCoordinatorComplete: typeof checkCoordinatorComplete;
|
|
209
|
+
startCoordinatorHeadless: typeof startCoordinatorHeadless;
|
|
210
|
+
stopCoordinator: typeof stopCoordinator;
|
|
211
|
+
};
|
|
212
|
+
coordinatorActionDeps: CoordinatorActionDeps;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Body parsing ────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
async function readJsonBody<T>(req: Request, validate: (v: unknown) => T): Promise<T> {
|
|
218
|
+
let raw: unknown;
|
|
219
|
+
try {
|
|
220
|
+
raw = await req.json();
|
|
221
|
+
} catch {
|
|
222
|
+
throw new ValidationError("Invalid JSON body");
|
|
223
|
+
}
|
|
224
|
+
return validate(raw);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function asObject(v: unknown, msg: string): Record<string, unknown> {
|
|
228
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) {
|
|
229
|
+
throw new ValidationError(msg);
|
|
230
|
+
}
|
|
231
|
+
return v as Record<string, unknown>;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function requireString(obj: Record<string, unknown>, field: string): string {
|
|
235
|
+
const v = obj[field];
|
|
236
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
237
|
+
throw new ValidationError(`Missing or invalid '${field}' (string)`, { field, value: v });
|
|
238
|
+
}
|
|
239
|
+
return v;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function optionalString(obj: Record<string, unknown>, field: string): string | undefined {
|
|
243
|
+
const v = obj[field];
|
|
244
|
+
if (v === undefined || v === null) return undefined;
|
|
245
|
+
if (typeof v !== "string") {
|
|
246
|
+
throw new ValidationError(`Invalid '${field}' (must be string)`, { field, value: v });
|
|
247
|
+
}
|
|
248
|
+
return v;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function optionalTimeoutSec(obj: Record<string, unknown>): number {
|
|
252
|
+
const v = obj.timeoutSec;
|
|
253
|
+
if (v === undefined || v === null) return 120;
|
|
254
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 1 || v > 600) {
|
|
255
|
+
throw new ValidationError("Invalid 'timeoutSec' (must be a number 1..600)", {
|
|
256
|
+
field: "timeoutSec",
|
|
257
|
+
value: v,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return v;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
|
264
|
+
let parsed: unknown;
|
|
265
|
+
try {
|
|
266
|
+
parsed = await req.json();
|
|
267
|
+
} catch (err) {
|
|
268
|
+
throw new ValidationError(
|
|
269
|
+
`Invalid JSON body: ${err instanceof Error ? err.message : String(err)}`,
|
|
270
|
+
{
|
|
271
|
+
field: "body",
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
276
|
+
throw new ValidationError("Request body must be a JSON object", { field: "body" });
|
|
277
|
+
}
|
|
278
|
+
return parsed as Record<string, unknown>;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ─── Individual handlers ──────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
async function handleGetRuns(
|
|
284
|
+
_req: Request,
|
|
285
|
+
_match: RegExpMatchArray,
|
|
286
|
+
params: URLSearchParams,
|
|
287
|
+
stores: Stores,
|
|
288
|
+
): Promise<Response> {
|
|
289
|
+
const { limit, cursor } = parseLimitAndCursor(params);
|
|
290
|
+
const all = stores.run.listRuns();
|
|
291
|
+
// Sort DESC by (startedAt, id)
|
|
292
|
+
const sorted = [...all].sort((a, b) => {
|
|
293
|
+
if (b.startedAt !== a.startedAt) return b.startedAt < a.startedAt ? -1 : 1;
|
|
294
|
+
return b.id < a.id ? -1 : 1;
|
|
295
|
+
});
|
|
296
|
+
const { page, nextCursor } = paginateItems(sorted, cursor, limit, (r) => r.startedAt, "desc");
|
|
297
|
+
return apiJson(page, { nextCursor });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function handleGetRun(
|
|
301
|
+
_req: Request,
|
|
302
|
+
match: RegExpMatchArray,
|
|
303
|
+
_params: URLSearchParams,
|
|
304
|
+
stores: Stores,
|
|
305
|
+
): Promise<Response> {
|
|
306
|
+
const id = match[1];
|
|
307
|
+
if (id === undefined) return apiError("Run ID required", 400);
|
|
308
|
+
const run = stores.run.getRun(id);
|
|
309
|
+
if (run === null) return apiError(`Run not found: ${id}`, 404);
|
|
310
|
+
const agents = stores.session.getByRun(id);
|
|
311
|
+
// Sort agents ASC by (startedAt, id)
|
|
312
|
+
const sortedAgents = [...agents].sort((a, b) => {
|
|
313
|
+
if (a.startedAt !== b.startedAt) return a.startedAt < b.startedAt ? -1 : 1;
|
|
314
|
+
return a.id < b.id ? -1 : 1;
|
|
315
|
+
});
|
|
316
|
+
return apiJson({ ...run, agents: sortedAgents });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function handleGetAgents(
|
|
320
|
+
_req: Request,
|
|
321
|
+
_match: RegExpMatchArray,
|
|
322
|
+
params: URLSearchParams,
|
|
323
|
+
stores: Stores,
|
|
324
|
+
): Promise<Response> {
|
|
325
|
+
const { limit, cursor } = parseLimitAndCursor(params);
|
|
326
|
+
const runId = params.get("run");
|
|
327
|
+
|
|
328
|
+
const all = runId !== null ? stores.session.getByRun(runId) : stores.session.getAll();
|
|
329
|
+
// Sort ASC by (startedAt, id)
|
|
330
|
+
const sorted = [...all].sort((a, b) => {
|
|
331
|
+
if (a.startedAt !== b.startedAt) return a.startedAt < b.startedAt ? -1 : 1;
|
|
332
|
+
return a.id < b.id ? -1 : 1;
|
|
333
|
+
});
|
|
334
|
+
const { page, nextCursor } = paginateItems(sorted, cursor, limit, (a) => a.startedAt, "asc");
|
|
335
|
+
return apiJson(page, { nextCursor });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function handleGetAgent(
|
|
339
|
+
_req: Request,
|
|
340
|
+
match: RegExpMatchArray,
|
|
341
|
+
_params: URLSearchParams,
|
|
342
|
+
stores: Stores,
|
|
343
|
+
): Promise<Response> {
|
|
344
|
+
const name = match[1];
|
|
345
|
+
if (name === undefined) return apiError("Agent name required", 400);
|
|
346
|
+
const agent = stores.session.getByName(decodeURIComponent(name));
|
|
347
|
+
if (agent === null) return apiError(`Agent not found: ${name}`, 404);
|
|
348
|
+
return apiJson(agent);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function handleGetEvents(
|
|
352
|
+
_req: Request,
|
|
353
|
+
_match: RegExpMatchArray,
|
|
354
|
+
params: URLSearchParams,
|
|
355
|
+
stores: Stores,
|
|
356
|
+
): Promise<Response> {
|
|
357
|
+
const { limit, cursor } = parseLimitAndCursor(params);
|
|
358
|
+
const agentFilter = params.get("agent");
|
|
359
|
+
const runFilter = params.get("run");
|
|
360
|
+
const sinceParam = params.get("since");
|
|
361
|
+
|
|
362
|
+
// Validate sinceParam as an ISO date if provided
|
|
363
|
+
if (sinceParam !== null && Number.isNaN(Date.parse(sinceParam))) {
|
|
364
|
+
throw new ValidationError(`Invalid since timestamp: ${sinceParam}`, {
|
|
365
|
+
field: "since",
|
|
366
|
+
value: sinceParam,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Effective since: cursor.ts > explicit since > default epoch
|
|
371
|
+
const effectiveSince = cursor !== null ? cursor.ts : (sinceParam ?? "1970-01-01T00:00:00.000Z");
|
|
372
|
+
|
|
373
|
+
// Over-fetch by 1 to detect next page
|
|
374
|
+
const fetchOpts = { since: effectiveSince, limit: limit + 1 };
|
|
375
|
+
|
|
376
|
+
let rawItems =
|
|
377
|
+
agentFilter !== null
|
|
378
|
+
? stores.event.getByAgent(agentFilter, fetchOpts)
|
|
379
|
+
: runFilter !== null
|
|
380
|
+
? stores.event.getByRun(runFilter, fetchOpts)
|
|
381
|
+
: stores.event.getTimeline(fetchOpts);
|
|
382
|
+
|
|
383
|
+
// Drop entries <= cursor.id (ties at cursor.ts)
|
|
384
|
+
if (cursor !== null) {
|
|
385
|
+
const cursorIdNum = Number.parseInt(cursor.id, 10);
|
|
386
|
+
rawItems = rawItems.filter((e) => e.id > cursorIdNum);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const page = rawItems.slice(0, limit);
|
|
390
|
+
const hasMore = rawItems.length > limit;
|
|
391
|
+
const lastItem = page[page.length - 1];
|
|
392
|
+
const nextCursor =
|
|
393
|
+
hasMore && lastItem !== undefined
|
|
394
|
+
? encodeCursor({ ts: lastItem.createdAt, id: String(lastItem.id) })
|
|
395
|
+
: null;
|
|
396
|
+
|
|
397
|
+
return apiJson(page, { nextCursor });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function handleGetMail(
|
|
401
|
+
_req: Request,
|
|
402
|
+
_match: RegExpMatchArray,
|
|
403
|
+
params: URLSearchParams,
|
|
404
|
+
stores: Stores,
|
|
405
|
+
): Promise<Response> {
|
|
406
|
+
const { limit, cursor } = parseLimitAndCursor(params);
|
|
407
|
+
const toFilter = params.get("to") ?? undefined;
|
|
408
|
+
const fromFilter = params.get("from") ?? undefined;
|
|
409
|
+
const unreadParam = params.get("unread");
|
|
410
|
+
const unreadFilter = unreadParam !== null ? unreadParam === "true" : undefined;
|
|
411
|
+
|
|
412
|
+
// Fetch a large window when cursor is present for client-side pagination
|
|
413
|
+
const fetchLimit = cursor !== null ? limit * 5 : undefined;
|
|
414
|
+
const all = stores.mail.getAll({
|
|
415
|
+
to: toFilter,
|
|
416
|
+
from: fromFilter,
|
|
417
|
+
unread: unreadFilter,
|
|
418
|
+
limit: fetchLimit,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// Already sorted DESC by createdAt from the store; sort explicitly for stability
|
|
422
|
+
const sorted = [...all].sort((a, b) => {
|
|
423
|
+
if (b.createdAt !== a.createdAt) return b.createdAt < a.createdAt ? -1 : 1;
|
|
424
|
+
return b.id < a.id ? -1 : 1;
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
const { page, nextCursor } = paginateItems(sorted, cursor, limit, (m) => m.createdAt, "desc");
|
|
428
|
+
return apiJson(page, { nextCursor });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function handleGetMailMessage(
|
|
432
|
+
_req: Request,
|
|
433
|
+
match: RegExpMatchArray,
|
|
434
|
+
_params: URLSearchParams,
|
|
435
|
+
stores: Stores,
|
|
436
|
+
): Promise<Response> {
|
|
437
|
+
const id = match[1];
|
|
438
|
+
if (id === undefined) return apiError("Message ID required", 400);
|
|
439
|
+
const message = stores.mail.getById(id);
|
|
440
|
+
if (message === null) return apiError(`Message not found: ${id}`, 404);
|
|
441
|
+
const thread = message.threadId !== null ? stores.mail.getByThread(message.threadId) : [message];
|
|
442
|
+
return apiJson({ message, thread });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function handleMarkMailRead(
|
|
446
|
+
_req: Request,
|
|
447
|
+
match: RegExpMatchArray,
|
|
448
|
+
_params: URLSearchParams,
|
|
449
|
+
stores: Stores,
|
|
450
|
+
): Promise<Response> {
|
|
451
|
+
const id = match[1];
|
|
452
|
+
if (id === undefined) return apiError("Message ID required", 400);
|
|
453
|
+
const message = stores.mail.getById(id);
|
|
454
|
+
if (message === null) return apiError(`Message not found: ${id}`, 404);
|
|
455
|
+
stores.mail.markRead(id);
|
|
456
|
+
return apiJson({ id, read: true });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function handleSendMail(
|
|
460
|
+
req: Request,
|
|
461
|
+
_match: RegExpMatchArray,
|
|
462
|
+
_params: URLSearchParams,
|
|
463
|
+
stores: Stores,
|
|
464
|
+
): Promise<Response> {
|
|
465
|
+
const body = await parseJsonBody(req);
|
|
466
|
+
const result = sendMail({ mail: stores.mail, session: stores.session }, body);
|
|
467
|
+
return apiJson(result);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function handleReplyMail(
|
|
471
|
+
req: Request,
|
|
472
|
+
match: RegExpMatchArray,
|
|
473
|
+
_params: URLSearchParams,
|
|
474
|
+
stores: Stores,
|
|
475
|
+
): Promise<Response> {
|
|
476
|
+
const id = match[1];
|
|
477
|
+
if (id === undefined) return apiError("Message ID required", 400);
|
|
478
|
+
if (stores.mail.getById(id) === null) {
|
|
479
|
+
return apiError(`Message not found: ${id}`, 404);
|
|
480
|
+
}
|
|
481
|
+
const body = await parseJsonBody(req);
|
|
482
|
+
const result = replyMail({ mail: stores.mail, session: stores.session }, id, body);
|
|
483
|
+
return apiJson(result);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function handleDeleteMail(
|
|
487
|
+
_req: Request,
|
|
488
|
+
match: RegExpMatchArray,
|
|
489
|
+
_params: URLSearchParams,
|
|
490
|
+
stores: Stores,
|
|
491
|
+
): Promise<Response> {
|
|
492
|
+
const id = match[1];
|
|
493
|
+
if (id === undefined) return apiError("Message ID required", 400);
|
|
494
|
+
const result = deleteMail({ mail: stores.mail, session: stores.session }, id);
|
|
495
|
+
if (result === null) return apiError(`Message not found: ${id}`, 404);
|
|
496
|
+
return apiJson(result);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ─── Coordinator handlers ─────────────────────────────────────────────────────
|
|
500
|
+
|
|
501
|
+
async function handleCoordinatorState(
|
|
502
|
+
_req: Request,
|
|
503
|
+
_match: RegExpMatchArray,
|
|
504
|
+
_params: URLSearchParams,
|
|
505
|
+
_stores: Stores,
|
|
506
|
+
ctx: HandlerContext,
|
|
507
|
+
): Promise<Response> {
|
|
508
|
+
const state = ctx.coordinatorActions.getCoordinatorState(ctx.coordinatorActionDeps);
|
|
509
|
+
return apiJson(state);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function handleCoordinatorSend(
|
|
513
|
+
req: Request,
|
|
514
|
+
_match: RegExpMatchArray,
|
|
515
|
+
_params: URLSearchParams,
|
|
516
|
+
_stores: Stores,
|
|
517
|
+
ctx: HandlerContext,
|
|
518
|
+
): Promise<Response> {
|
|
519
|
+
const { subject, body, from } = await readJsonBody(req, (raw) => {
|
|
520
|
+
const obj = asObject(raw, "Body must be a JSON object");
|
|
521
|
+
return {
|
|
522
|
+
subject: requireString(obj, "subject"),
|
|
523
|
+
body: requireString(obj, "body"),
|
|
524
|
+
from: optionalString(obj, "from"),
|
|
525
|
+
};
|
|
526
|
+
});
|
|
527
|
+
const result = await ctx.coordinatorActions.sendToCoordinator(
|
|
528
|
+
ctx.coordinatorActionDeps,
|
|
529
|
+
body,
|
|
530
|
+
from !== undefined ? { subject, from } : { subject },
|
|
531
|
+
);
|
|
532
|
+
return apiJson(result);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function handleCoordinatorAsk(
|
|
536
|
+
req: Request,
|
|
537
|
+
_match: RegExpMatchArray,
|
|
538
|
+
_params: URLSearchParams,
|
|
539
|
+
_stores: Stores,
|
|
540
|
+
ctx: HandlerContext,
|
|
541
|
+
): Promise<Response> {
|
|
542
|
+
const { subject, body, from, timeoutSec } = await readJsonBody(req, (raw) => {
|
|
543
|
+
const obj = asObject(raw, "Body must be a JSON object");
|
|
544
|
+
return {
|
|
545
|
+
subject: requireString(obj, "subject"),
|
|
546
|
+
body: requireString(obj, "body"),
|
|
547
|
+
from: optionalString(obj, "from"),
|
|
548
|
+
timeoutSec: optionalTimeoutSec(obj),
|
|
549
|
+
};
|
|
550
|
+
});
|
|
551
|
+
const result = await ctx.coordinatorActions.askCoordinatorAction(
|
|
552
|
+
ctx.coordinatorActionDeps,
|
|
553
|
+
body,
|
|
554
|
+
from !== undefined ? { subject, from, timeoutSec } : { subject, timeoutSec },
|
|
555
|
+
);
|
|
556
|
+
return apiJson(result);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function handleCoordinatorCheckComplete(
|
|
560
|
+
_req: Request,
|
|
561
|
+
_match: RegExpMatchArray,
|
|
562
|
+
_params: URLSearchParams,
|
|
563
|
+
_stores: Stores,
|
|
564
|
+
ctx: HandlerContext,
|
|
565
|
+
): Promise<Response> {
|
|
566
|
+
const result = await ctx.coordinatorActions.checkCoordinatorComplete(ctx.coordinatorActionDeps);
|
|
567
|
+
return apiJson(result);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function handleCoordinatorStart(
|
|
571
|
+
_req: Request,
|
|
572
|
+
_match: RegExpMatchArray,
|
|
573
|
+
_params: URLSearchParams,
|
|
574
|
+
_stores: Stores,
|
|
575
|
+
ctx: HandlerContext,
|
|
576
|
+
): Promise<Response> {
|
|
577
|
+
const result = await ctx.coordinatorActions.startCoordinatorHeadless(ctx.coordinatorActionDeps);
|
|
578
|
+
return apiJson(result);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function handleCoordinatorStop(
|
|
582
|
+
_req: Request,
|
|
583
|
+
_match: RegExpMatchArray,
|
|
584
|
+
_params: URLSearchParams,
|
|
585
|
+
_stores: Stores,
|
|
586
|
+
ctx: HandlerContext,
|
|
587
|
+
): Promise<Response> {
|
|
588
|
+
const result = await ctx.coordinatorActions.stopCoordinator(ctx.coordinatorActionDeps);
|
|
589
|
+
return apiJson(result);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ─── Route table ─────────────────────────────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
const ROUTES: Route[] = [
|
|
595
|
+
{ method: "GET", pattern: /^\/api\/runs$/, handler: handleGetRuns },
|
|
596
|
+
{ method: "GET", pattern: /^\/api\/runs\/([^/]+)$/, handler: handleGetRun },
|
|
597
|
+
{ method: "GET", pattern: /^\/api\/agents$/, handler: handleGetAgents },
|
|
598
|
+
{ method: "GET", pattern: /^\/api\/agents\/([^/]+)$/, handler: handleGetAgent },
|
|
599
|
+
{ method: "GET", pattern: /^\/api\/events$/, handler: handleGetEvents },
|
|
600
|
+
{ method: "GET", pattern: /^\/api\/mail$/, handler: handleGetMail },
|
|
601
|
+
{ method: "POST", pattern: /^\/api\/mail$/, handler: handleSendMail },
|
|
602
|
+
{ method: "GET", pattern: /^\/api\/mail\/([^/]+)$/, handler: handleGetMailMessage },
|
|
603
|
+
{ method: "DELETE", pattern: /^\/api\/mail\/([^/]+)$/, handler: handleDeleteMail },
|
|
604
|
+
{ method: "POST", pattern: /^\/api\/mail\/([^/]+)\/read$/, handler: handleMarkMailRead },
|
|
605
|
+
{ method: "POST", pattern: /^\/api\/mail\/([^/]+)\/reply$/, handler: handleReplyMail },
|
|
606
|
+
{ method: "GET", pattern: /^\/api\/coordinator\/state$/, handler: handleCoordinatorState },
|
|
607
|
+
{ method: "POST", pattern: /^\/api\/coordinator\/send$/, handler: handleCoordinatorSend },
|
|
608
|
+
{ method: "POST", pattern: /^\/api\/coordinator\/ask$/, handler: handleCoordinatorAsk },
|
|
609
|
+
{
|
|
610
|
+
method: "POST",
|
|
611
|
+
pattern: /^\/api\/coordinator\/check-complete$/,
|
|
612
|
+
handler: handleCoordinatorCheckComplete,
|
|
613
|
+
},
|
|
614
|
+
{ method: "POST", pattern: /^\/api\/coordinator\/start$/, handler: handleCoordinatorStart },
|
|
615
|
+
{ method: "POST", pattern: /^\/api\/coordinator\/stop$/, handler: handleCoordinatorStop },
|
|
616
|
+
];
|
|
617
|
+
|
|
618
|
+
// ─── Public registration ──────────────────────────────────────────────────────
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Register all REST API handlers with the serve scaffold.
|
|
622
|
+
* Deps allows injecting stores for testing; in production, stores are opened
|
|
623
|
+
* from projectRoot/.overstory/{sessions,events,mail}.db.
|
|
624
|
+
*/
|
|
625
|
+
export function registerRestApi(deps?: RestApiDeps): void {
|
|
626
|
+
let stores: Stores | null = null;
|
|
627
|
+
const projectRoot = deps?._projectRoot ?? process.cwd();
|
|
628
|
+
|
|
629
|
+
function getStores(): Stores {
|
|
630
|
+
if (stores !== null) return stores;
|
|
631
|
+
|
|
632
|
+
if (
|
|
633
|
+
deps?._runStore !== undefined &&
|
|
634
|
+
deps._sessionStore !== undefined &&
|
|
635
|
+
deps._eventStore !== undefined &&
|
|
636
|
+
deps._mailStore !== undefined
|
|
637
|
+
) {
|
|
638
|
+
stores = {
|
|
639
|
+
run: deps._runStore,
|
|
640
|
+
session: deps._sessionStore,
|
|
641
|
+
event: deps._eventStore,
|
|
642
|
+
mail: deps._mailStore,
|
|
643
|
+
};
|
|
644
|
+
} else {
|
|
645
|
+
stores = openStores(projectRoot);
|
|
646
|
+
}
|
|
647
|
+
return stores;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const overrides = deps?._coordinatorActions ?? {};
|
|
651
|
+
const coordinatorActions = {
|
|
652
|
+
getCoordinatorState: overrides.getCoordinatorState ?? getCoordinatorState,
|
|
653
|
+
sendToCoordinator: overrides.sendToCoordinator ?? sendToCoordinator,
|
|
654
|
+
askCoordinatorAction: overrides.askCoordinatorAction ?? askCoordinatorAction,
|
|
655
|
+
checkCoordinatorComplete: overrides.checkCoordinatorComplete ?? checkCoordinatorComplete,
|
|
656
|
+
startCoordinatorHeadless: overrides.startCoordinatorHeadless ?? startCoordinatorHeadless,
|
|
657
|
+
stopCoordinator: overrides.stopCoordinator ?? stopCoordinator,
|
|
658
|
+
};
|
|
659
|
+
const coordinatorActionDeps: CoordinatorActionDeps = {
|
|
660
|
+
projectRoot,
|
|
661
|
+
...(deps?._coordinatorActionDeps ?? {}),
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
registerApiHandler((req: Request): Response | Promise<Response> | null => {
|
|
665
|
+
const url = new URL(req.url);
|
|
666
|
+
const path = url.pathname;
|
|
667
|
+
|
|
668
|
+
// Two passes: first try to match path+method exactly. If none matches
|
|
669
|
+
// but the path matched some route, return 405 (method not allowed).
|
|
670
|
+
let matchedRoute: { route: Route; match: RegExpMatchArray } | null = null;
|
|
671
|
+
let pathMatched = false;
|
|
672
|
+
for (const route of ROUTES) {
|
|
673
|
+
const match = path.match(route.pattern);
|
|
674
|
+
if (match === null) continue;
|
|
675
|
+
pathMatched = true;
|
|
676
|
+
if (req.method === route.method) {
|
|
677
|
+
matchedRoute = { route, match };
|
|
678
|
+
break;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (matchedRoute !== null) {
|
|
683
|
+
const { route, match } = matchedRoute;
|
|
684
|
+
return (async (): Promise<Response> => {
|
|
685
|
+
try {
|
|
686
|
+
const ctx: HandlerContext = {
|
|
687
|
+
projectRoot,
|
|
688
|
+
coordinatorActions,
|
|
689
|
+
coordinatorActionDeps,
|
|
690
|
+
};
|
|
691
|
+
return await route.handler(req, match, url.searchParams, getStores(), ctx);
|
|
692
|
+
} catch (err) {
|
|
693
|
+
if (err instanceof OverstoryError) {
|
|
694
|
+
return apiError(err.message, statusFromError(err));
|
|
695
|
+
}
|
|
696
|
+
process.stderr.write(`REST handler error: ${String(err)}\n`);
|
|
697
|
+
return apiError("Internal server error", 500);
|
|
698
|
+
}
|
|
699
|
+
})();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (pathMatched) {
|
|
703
|
+
return apiError("Method not allowed", 405);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
return null;
|
|
707
|
+
});
|
|
708
|
+
}
|