wicked-crew 0.3.1 → 0.4.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/dist/api/elicitation-cache.d.ts +93 -0
- package/dist/api/elicitation-cache.d.ts.map +1 -0
- package/dist/api/elicitation-cache.js +128 -0
- package/dist/api/elicitation-cache.js.map +1 -0
- package/dist/api/evidence.d.ts +37 -38
- package/dist/api/evidence.d.ts.map +1 -1
- package/dist/api/evidence.js +40 -36
- package/dist/api/evidence.js.map +1 -1
- package/dist/api/gate-cache.d.ts +29 -13
- package/dist/api/gate-cache.d.ts.map +1 -1
- package/dist/api/gate-cache.js +82 -36
- package/dist/api/gate-cache.js.map +1 -1
- package/dist/api/requirements.d.ts +66 -0
- package/dist/api/requirements.d.ts.map +1 -0
- package/dist/api/requirements.js +328 -0
- package/dist/api/requirements.js.map +1 -0
- package/dist/api/routes.d.ts +2 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +494 -34
- package/dist/api/routes.js.map +1 -1
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +13 -6
- package/dist/api/server.js.map +1 -1
- package/dist/core/adapter.d.ts +94 -6
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +386 -86
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/exec.d.ts +37 -0
- package/dist/core/exec.d.ts.map +1 -0
- package/dist/core/exec.js +97 -0
- package/dist/core/exec.js.map +1 -0
- package/dist/core/repoPaths.d.ts +37 -0
- package/dist/core/repoPaths.d.ts.map +1 -0
- package/dist/core/repoPaths.js +48 -0
- package/dist/core/repoPaths.js.map +1 -0
- package/dist/core/types.d.ts +81 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/studio/assets/index-Ci_R6ARr.js +423 -0
- package/dist/studio/assets/index-Dio_c1Q3.css +32 -0
- package/dist/studio/index.html +2 -2
- package/package.json +14 -13
- package/dist/studio/assets/index-BCcaLGxk.js +0 -420
- package/dist/studio/assets/index-vsyAOtPq.css +0 -32
package/dist/api/routes.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { listRequirements, getRequirement, patchRequirement } from './requirements.js';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { readFileSync, existsSync } from 'node:fs';
|
|
4
5
|
import { promises as fsp } from 'node:fs';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { CoreAdapter } from '../core/adapter.js';
|
|
8
|
+
import { ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError } from '../core/adapter.js';
|
|
9
|
+
import { codeGraphDb, requirementsGraph } from '../core/repoPaths.js';
|
|
10
10
|
import { buildEvidenceBundle, evidenceFilename } from './evidence.js';
|
|
11
|
-
|
|
11
|
+
import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
|
|
12
12
|
const V = '/api/v1';
|
|
13
13
|
// Daemon version reported by /health — read from package.json so it never drifts
|
|
14
14
|
// from the shipped version across releases. Resolves the package root from the
|
|
@@ -32,6 +32,25 @@ function sortActionableFirst(views) {
|
|
|
32
32
|
function message(err) {
|
|
33
33
|
return err instanceof Error ? err.message : String(err);
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Turns a failed parse into a 400 body, naming any field the schema does not know.
|
|
37
|
+
*
|
|
38
|
+
* Every schema here is `.strict()`, because zod's default is to STRIP unknown keys — which made a
|
|
39
|
+
* misspelled optional field a silent behaviour change (FINDING-031). `POST /runs {"clis":[...],
|
|
40
|
+
* "workflowId":"feature"}` — core's field names, not the HTTP layer's — answered `201` and ran the
|
|
41
|
+
* full roster with no workflow. Rejecting is only half the fix: a bare "Invalid request body" leaves
|
|
42
|
+
* the caller comparing their JSON against the source, so the unknown keys are named in `error`
|
|
43
|
+
* itself, where a human and a `curl | jq .error` both see it without reading `details`.
|
|
44
|
+
*/
|
|
45
|
+
function invalidBody(err, what) {
|
|
46
|
+
const unknown = err.issues.flatMap((i) => (i.code === 'unrecognized_keys' ? i.keys : []));
|
|
47
|
+
const error = unknown.length > 0
|
|
48
|
+
? `${what}: unknown field${unknown.length > 1 ? 's' : ''} ${unknown
|
|
49
|
+
.map((k) => `\`${k}\``)
|
|
50
|
+
.join(', ')} — this endpoint does not accept ${unknown.length > 1 ? 'them' : 'it'}, and ignoring ${unknown.length > 1 ? 'them' : 'it'} would run a different request than you sent`
|
|
51
|
+
: what;
|
|
52
|
+
return { error, details: err.issues };
|
|
53
|
+
}
|
|
35
54
|
// Repo names become directory components under ~/.wicked/repos/ — reject anything
|
|
36
55
|
// that would allow path traversal (slashes, dots-only segments, control chars).
|
|
37
56
|
const SAFE_REPO_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
@@ -48,6 +67,7 @@ const RegisterRepoSchema = z
|
|
|
48
67
|
rootPath: z.string().optional(),
|
|
49
68
|
gitUrl: z.string().optional(),
|
|
50
69
|
})
|
|
70
|
+
.strict()
|
|
51
71
|
.refine((d) => {
|
|
52
72
|
const hasRemote = typeof d.gitUrl === 'string' && d.gitUrl.length > 0;
|
|
53
73
|
const hasLocal = typeof d.rootPath === 'string' && d.rootPath.length > 0;
|
|
@@ -55,6 +75,19 @@ const RegisterRepoSchema = z
|
|
|
55
75
|
// or rootPath alone (register existing local repo) — all valid.
|
|
56
76
|
return hasRemote || hasLocal;
|
|
57
77
|
}, { message: 'Provide gitUrl (remote clone) or rootPath (local registration), or both.' });
|
|
78
|
+
/**
|
|
79
|
+
* The launch body. Every field but `problem` is optional, which is what made stripping dangerous:
|
|
80
|
+
* omitting one is a legitimate request that gets an engine default, so a MISSPELLED one was
|
|
81
|
+
* indistinguishable from an omitted one and the run went ahead on a configuration the caller never
|
|
82
|
+
* asked for (FINDING-031).
|
|
83
|
+
*
|
|
84
|
+
* The names differ from core's on purpose and this is the trap worth knowing about: core's
|
|
85
|
+
* `LaunchSpec` takes `clis` (an array) and `workflow`, this takes `clisJson` (a JSON *string*) and
|
|
86
|
+
* `workflow`, and the `/ws` `sessionStarted` frame reports the chosen workflow as `workflowId`. A
|
|
87
|
+
* caller who reads the event stream to learn the field names arrives at `workflowId`, which this
|
|
88
|
+
* schema does not accept — so `.strict()` is what turns that trip into a 400 instead of an
|
|
89
|
+
* unworkflowed run reported as `201`.
|
|
90
|
+
*/
|
|
58
91
|
const LaunchSchema = z.object({
|
|
59
92
|
problem: z.string().min(1),
|
|
60
93
|
sessionId: z.string().min(1).optional(),
|
|
@@ -63,16 +96,16 @@ const LaunchSchema = z.object({
|
|
|
63
96
|
humanConfirm: z.string().min(1).optional(),
|
|
64
97
|
repoRef: z.string().min(1).optional(),
|
|
65
98
|
workflow: z.string().min(1).optional(),
|
|
66
|
-
});
|
|
99
|
+
}).strict();
|
|
67
100
|
const GateSchema = z.object({
|
|
68
101
|
approve: z.boolean(),
|
|
69
102
|
amend: z.string().optional(),
|
|
70
|
-
});
|
|
103
|
+
}).strict();
|
|
71
104
|
const InjectSchema = z.object({
|
|
72
105
|
message: z.string().min(1),
|
|
73
106
|
/** `"all"` broadcasts to every active worker; any other value is a CLI key. */
|
|
74
107
|
target: z.string().min(1).default('all'),
|
|
75
|
-
});
|
|
108
|
+
}).strict();
|
|
76
109
|
const OpenTerminalSchema = z.object({
|
|
77
110
|
cwd: z.string().min(1),
|
|
78
111
|
cmd: z.array(z.string().min(1)).min(1).optional(),
|
|
@@ -81,16 +114,16 @@ const OpenTerminalSchema = z.object({
|
|
|
81
114
|
// Optional so omission is the SAFE governed default (§7 — `false` is never a
|
|
82
115
|
// default; the ungoverned operator shell must opt in explicitly).
|
|
83
116
|
governed: z.boolean().optional(),
|
|
84
|
-
});
|
|
117
|
+
}).strict();
|
|
85
118
|
const ResizeTerminalSchema = z.object({
|
|
86
119
|
cols: z.number().int().positive(),
|
|
87
120
|
rows: z.number().int().positive(),
|
|
88
|
-
});
|
|
121
|
+
}).strict();
|
|
89
122
|
/**
|
|
90
123
|
* The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
|
|
91
124
|
* core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
|
|
92
125
|
*/
|
|
93
|
-
export function registerRoutes(app, adapter, gateCache) {
|
|
126
|
+
export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
94
127
|
// Liveness — also proves the actor + event pump are up.
|
|
95
128
|
app.get(`${V}/health`, async () => {
|
|
96
129
|
const ping = await adapter.ping();
|
|
@@ -110,7 +143,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
110
143
|
app.post(`${V}/repos`, async (req, reply) => {
|
|
111
144
|
const parsed = RegisterRepoSchema.safeParse(req.body);
|
|
112
145
|
if (!parsed.success) {
|
|
113
|
-
return reply.code(400).send(
|
|
146
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
114
147
|
}
|
|
115
148
|
const { name, rootPath, gitUrl } = parsed.data;
|
|
116
149
|
try {
|
|
@@ -161,7 +194,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
161
194
|
app.post(`${V}/runs`, async (req, reply) => {
|
|
162
195
|
const parsed = LaunchSchema.safeParse(req.body);
|
|
163
196
|
if (!parsed.success) {
|
|
164
|
-
return reply.code(400).send(
|
|
197
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
165
198
|
}
|
|
166
199
|
const b = parsed.data;
|
|
167
200
|
const input = {
|
|
@@ -187,10 +220,12 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
187
220
|
return reply.code(busy ? 409 : 400).send({ error: msg });
|
|
188
221
|
}
|
|
189
222
|
});
|
|
190
|
-
// Run list (replaces GET /sessions). Actionable-first; reconciles the gate
|
|
223
|
+
// Run list (replaces GET /sessions). Actionable-first; reconciles the gate and elicitation caches
|
|
224
|
+
// so that terminal-run entries are pruned even when their terminal CoreEvent was missed.
|
|
191
225
|
app.get(`${V}/runs`, async () => {
|
|
192
226
|
const views = await adapter.sessionsDetail();
|
|
193
227
|
gateCache.reconcile(views);
|
|
228
|
+
elicitationCache.reconcile(views);
|
|
194
229
|
return { runs: sortActionableFirst(views) };
|
|
195
230
|
});
|
|
196
231
|
// One run's detail.
|
|
@@ -205,6 +240,99 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
205
240
|
// A unit's captured transcript. unitKey is the suffix after `<run>:` — `u<ord>` for free-text
|
|
206
241
|
// runs, `<phase_id>` for workflow runs (e.g. "survey", "coverage"). Strip any accidental
|
|
207
242
|
// `<id>:` prefix so both `survey` and `run-1:survey` resolve to the same key.
|
|
243
|
+
// ── Chat sessions (crew#165): warm ACP seat pool + group fan-out (core#134) ──
|
|
244
|
+
// A chat is NOT a run: no council, no gates, no units. Seats warm on open;
|
|
245
|
+
// messages fan out to warm seats; replies stream on /ws as chatDelta/chatReply.
|
|
246
|
+
const ChatOpenSchema = z.object({
|
|
247
|
+
chatId: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional(),
|
|
248
|
+
clis: z.array(z.string().min(1)).min(1).max(8).optional(),
|
|
249
|
+
repoRef: z.string().optional(),
|
|
250
|
+
}).strict();
|
|
251
|
+
app.post(`${V}/chats`, async (req, reply) => {
|
|
252
|
+
const parsed = ChatOpenSchema.safeParse(req.body ?? {});
|
|
253
|
+
if (!parsed.success) {
|
|
254
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
255
|
+
}
|
|
256
|
+
const b = parsed.data;
|
|
257
|
+
const chatId = b.chatId ?? randomUUID();
|
|
258
|
+
let cwd;
|
|
259
|
+
if (b.repoRef !== undefined) {
|
|
260
|
+
const repos = await adapter.listRepos();
|
|
261
|
+
const repo = repos.find((r) => r.id === b.repoRef);
|
|
262
|
+
if (!repo)
|
|
263
|
+
return reply.code(404).send({ error: `Repo ${b.repoRef} not found` });
|
|
264
|
+
cwd = repo.root_path;
|
|
265
|
+
}
|
|
266
|
+
const clis = b.clis ??
|
|
267
|
+
CoreAdapter.roster()
|
|
268
|
+
.map((s) => s.key)
|
|
269
|
+
.filter((k) => typeof k === 'string');
|
|
270
|
+
try {
|
|
271
|
+
const seats = await adapter.chatOpen(chatId, clis, cwd);
|
|
272
|
+
return reply.code(201).send({ chatId, seats });
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
return reply.code(400).send({ error: message(err) });
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
const ChatMessageSchema = z.object({
|
|
279
|
+
text: z.string().min(1).max(65536),
|
|
280
|
+
targets: z.array(z.string().min(1)).min(1).max(8).optional(),
|
|
281
|
+
}).strict();
|
|
282
|
+
app.post(`${V}/chats/:id/messages`, async (req, reply) => {
|
|
283
|
+
const { id } = req.params;
|
|
284
|
+
const parsed = ChatMessageSchema.safeParse(req.body);
|
|
285
|
+
if (!parsed.success) {
|
|
286
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const seats = await adapter.chatSend(id, parsed.data.text, parsed.data.targets);
|
|
290
|
+
return reply.code(202).send({ seats });
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
const msg = message(err);
|
|
294
|
+
return reply.code(/no warm seats/.test(msg) ? 409 : 400).send({ error: msg });
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
// Enumerate live chats (FINDING-027 gap 4). Chat sessions deliberately outlive the page, and
|
|
298
|
+
// their ids are minted client-side — so before this route the only record of an orphaned seat
|
|
299
|
+
// lived in the tab that abandoned it, and an operator could not reclaim one without restarting
|
|
300
|
+
// the daemon. Registered BEFORE `/chats/:id` is irrelevant to fastify (it routes on the literal
|
|
301
|
+
// segment first), but the order reads the way the routes nest.
|
|
302
|
+
app.get(`${V}/chats`, async (_req, reply) => {
|
|
303
|
+
try {
|
|
304
|
+
return { chats: await adapter.chatList() };
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
// A build that cannot do chat is a capability gap, not a bad request: 501 tells an operator to
|
|
308
|
+
// upgrade rather than to fix a call that was already correct. Branching on the type, not on
|
|
309
|
+
// the message — regexing the text here caught the missing-binding phrasing and missed the
|
|
310
|
+
// engine's own ("chat unsupported: engine spawned without the ACP runner"), so half of one
|
|
311
|
+
// condition answered 400.
|
|
312
|
+
return reply
|
|
313
|
+
.code(err instanceof ChatUnsupportedError ? 501 : 400)
|
|
314
|
+
.send({ error: message(err) });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
app.get(`${V}/chats/:id`, async (req, reply) => {
|
|
318
|
+
const { id } = req.params;
|
|
319
|
+
try {
|
|
320
|
+
return { chatId: id, seats: await adapter.chatSeats(id) };
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
return reply.code(400).send({ error: message(err) });
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
app.delete(`${V}/chats/:id`, async (req, reply) => {
|
|
327
|
+
const { id } = req.params;
|
|
328
|
+
try {
|
|
329
|
+
await adapter.chatClose(id);
|
|
330
|
+
return { ok: true };
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
return reply.code(400).send({ error: message(err) });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
208
336
|
app.get(`${V}/runs/:id/units/:unitKey/output`, async (req, reply) => {
|
|
209
337
|
const { id, unitKey } = req.params;
|
|
210
338
|
const suffix = unitKey.startsWith(`${id}:`) ? unitKey.slice(id.length + 1) : unitKey;
|
|
@@ -212,15 +340,15 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
212
340
|
return reply.send({ output });
|
|
213
341
|
});
|
|
214
342
|
// The whole run as one auditable JSON attachment: the run, its units (each with
|
|
215
|
-
// the captured transcript), and the
|
|
216
|
-
//
|
|
343
|
+
// the captured transcript), and the decision trail read back from core's durable
|
|
344
|
+
// per-run event log — what actually happened, not a re-derivation of it.
|
|
217
345
|
app.get(`${V}/runs/:id/evidence`, async (req, reply) => {
|
|
218
346
|
const { id } = req.params;
|
|
219
347
|
const views = await adapter.sessionsDetail();
|
|
220
348
|
const run = views.find((v) => v.session.id === id);
|
|
221
349
|
if (!run)
|
|
222
350
|
return reply.code(404).send({ error: 'Run not found' });
|
|
223
|
-
const bundle = await buildEvidenceBundle(run, (unitId) => adapter.workOutput(unitId));
|
|
351
|
+
const bundle = await buildEvidenceBundle(run, (unitId) => adapter.workOutput(unitId), (runId) => adapter.runEvents(runId));
|
|
224
352
|
return reply
|
|
225
353
|
.header('Content-Disposition', `attachment; filename="${evidenceFilename(id)}"`)
|
|
226
354
|
.send(bundle);
|
|
@@ -230,7 +358,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
230
358
|
const { id } = req.params;
|
|
231
359
|
const parsed = GateSchema.safeParse(req.body);
|
|
232
360
|
if (!parsed.success) {
|
|
233
|
-
return reply.code(400).send(
|
|
361
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
234
362
|
}
|
|
235
363
|
const views = await adapter.sessionsDetail();
|
|
236
364
|
const run = views.find((v) => v.session.id === id);
|
|
@@ -289,7 +417,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
289
417
|
const { id } = req.params;
|
|
290
418
|
const parsed = InjectSchema.safeParse(req.body);
|
|
291
419
|
if (!parsed.success) {
|
|
292
|
-
return reply.code(400).send(
|
|
420
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
293
421
|
}
|
|
294
422
|
const ids = await adapter.sessions();
|
|
295
423
|
if (!ids.includes(id))
|
|
@@ -302,14 +430,188 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
302
430
|
return reply.code(409).send({ error: message(err) });
|
|
303
431
|
}
|
|
304
432
|
});
|
|
305
|
-
// The
|
|
306
|
-
//
|
|
433
|
+
// The gate prompt for a paused run, so a fresh browser can render the gate after a late join.
|
|
434
|
+
//
|
|
435
|
+
// Cache first, durable event log second (FINDING-051). The cache is process-lifetime, so before
|
|
436
|
+
// the fallback existed a daemon restart left every parked run unable to say what it was asking —
|
|
437
|
+
// not because the prompt was gone (core records `awaitingHuman` to the log) but because nothing
|
|
438
|
+
// read it. A restart is routine: deploy, crash, laptop sleep.
|
|
307
439
|
app.get(`${V}/runs/:id/gate`, async (req, reply) => {
|
|
308
440
|
const { id } = req.params;
|
|
309
|
-
const
|
|
310
|
-
if (
|
|
441
|
+
const cached = gateCache.get(id);
|
|
442
|
+
if (cached)
|
|
443
|
+
return { runId: id, ...cached };
|
|
444
|
+
// A miss is not evidence of anything on its own, so ask the run what it is doing before paying
|
|
445
|
+
// to replay it. Only `awaiting_human` can have an open gate, and that answer is definitive:
|
|
446
|
+
// every other status is a 404 that needs no log at all — which is also the overwhelmingly
|
|
447
|
+
// common case here, since studio polls this route for runs that are merely finished.
|
|
448
|
+
//
|
|
449
|
+
// Reading status first is therefore CHEAPER than not reading it, the opposite of what the first
|
|
450
|
+
// cut of this assumed: it trades one `sessionsDetail()` for replaying an entire event history,
|
|
451
|
+
// and it was that skipped check which made a completed run answer 503 on any build without the
|
|
452
|
+
// binding (CI caught it) — an error where the honest, knowable answer was "no gate".
|
|
453
|
+
const views = await adapter.sessionsDetail();
|
|
454
|
+
const run = views.find((v) => v.session.id === id);
|
|
455
|
+
if (!run)
|
|
456
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
457
|
+
if (run.session.status !== 'awaiting_human') {
|
|
311
458
|
return reply.code(404).send({ error: 'No open gate for this run' });
|
|
312
|
-
|
|
459
|
+
}
|
|
460
|
+
const events = await adapter.runEvents(id);
|
|
461
|
+
if (events === null) {
|
|
462
|
+
// Now — and only now — 503 is the honest answer: this run really is holding for a human, and
|
|
463
|
+
// this build cannot say what it is asking. Distinct cause, distinct message; answering "no
|
|
464
|
+
// gate" here would report a capability gap as a fact about the run (the FINDING-050 shape).
|
|
465
|
+
return reply.code(503).send({
|
|
466
|
+
error: 'Gate history is unavailable: this wicked-core build has no event-log read binding',
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
const replayed = gateCache.rebuild(id, events);
|
|
470
|
+
if (!replayed) {
|
|
471
|
+
// Parked, with a history that records no open gate. Pre-log runs land here (their prompt is
|
|
472
|
+
// genuinely lost), as does a gate whose `awaitingHuman` predates the log's retention.
|
|
473
|
+
return reply.code(404).send({ error: 'No open gate for this run' });
|
|
474
|
+
}
|
|
475
|
+
return { runId: id, ...replayed };
|
|
476
|
+
});
|
|
477
|
+
// ── Elicitation (DES-002) ────────────────────────────────────────────────────
|
|
478
|
+
//
|
|
479
|
+
// GET returns the current pending elicitation prompt for a run (display-store read).
|
|
480
|
+
// POST resolves it: the body carries the elicitationId to guard against stale tabs,
|
|
481
|
+
// the action (accept|decline|cancel), and — for accept — the operator's response.
|
|
482
|
+
app.get(`${V}/runs/:id/elicitation`, async (req, reply) => {
|
|
483
|
+
const { id } = req.params;
|
|
484
|
+
const entry = elicitationCache.get(id);
|
|
485
|
+
// `entry` already carries `runId`; return it directly to avoid TS2783
|
|
486
|
+
// ("runId is specified more than once") from a redundant spread.
|
|
487
|
+
if (entry)
|
|
488
|
+
return entry;
|
|
489
|
+
// Cache miss: check existence before returning 404.
|
|
490
|
+
// Use sessions() (IDs only) — cheaper than sessionsDetail() on this read path.
|
|
491
|
+
const ids = await adapter.sessions();
|
|
492
|
+
if (!ids.includes(id))
|
|
493
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
494
|
+
return reply.code(404).send({ error: 'No pending elicitation for this run' });
|
|
495
|
+
});
|
|
496
|
+
const ElicitationRespondSchema = z
|
|
497
|
+
.object({
|
|
498
|
+
/** Guards against stale-tab submissions: must match the current elicitation's id. */
|
|
499
|
+
elicitationId: z.string().min(1),
|
|
500
|
+
action: z.enum(['accept', 'decline', 'cancel']),
|
|
501
|
+
/** Required when action is "accept" (response must be non-empty); must be absent for decline/cancel. */
|
|
502
|
+
content: z.object({ response: z.string().min(1) }).optional(),
|
|
503
|
+
})
|
|
504
|
+
.strict()
|
|
505
|
+
.superRefine((val, ctx) => {
|
|
506
|
+
if (val.action !== 'accept' && val.content !== undefined) {
|
|
507
|
+
ctx.addIssue({
|
|
508
|
+
code: z.ZodIssueCode.custom,
|
|
509
|
+
path: ['content'],
|
|
510
|
+
message: 'content must only be provided when action is "accept"',
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
app.post(`${V}/runs/:id/elicitation`, async (req, reply) => {
|
|
515
|
+
const { id } = req.params;
|
|
516
|
+
// 1. Validate body.
|
|
517
|
+
const parsed = ElicitationRespondSchema.safeParse(req.body);
|
|
518
|
+
if (!parsed.success) {
|
|
519
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
520
|
+
}
|
|
521
|
+
const body = parsed.data;
|
|
522
|
+
// 1b. Accept requires content.response — validated here, before any state query, so
|
|
523
|
+
// the 400 is deterministic regardless of run-existence / cache state (P2).
|
|
524
|
+
if (body.action === 'accept' && !body.content?.response) {
|
|
525
|
+
return reply.code(400).send({ error: 'action:accept requires content.response' });
|
|
526
|
+
}
|
|
527
|
+
// 2. Existence check (IDs only — cheaper than sessionsDetail).
|
|
528
|
+
const ids = await adapter.sessions();
|
|
529
|
+
if (!ids.includes(id))
|
|
530
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
531
|
+
// 3. Atomically take the pending elicitation.
|
|
532
|
+
const taken = elicitationCache.take(id);
|
|
533
|
+
if (!taken)
|
|
534
|
+
return reply.code(409).send({ error: 'No pending elicitation for this run' });
|
|
535
|
+
// 4. Stale-tab check: submitted elicitationId must match the one we just took.
|
|
536
|
+
if (body.elicitationId !== taken.entry.elicitationId) {
|
|
537
|
+
elicitationCache.restoreIfUnchanged(id, taken.entry, taken.gen);
|
|
538
|
+
return reply
|
|
539
|
+
.code(409)
|
|
540
|
+
.send({ error: 'Elicitation superseded; fetch the current prompt and resubmit' });
|
|
541
|
+
}
|
|
542
|
+
// 5. Accept-specific validation.
|
|
543
|
+
if (body.action === 'accept') {
|
|
544
|
+
// 5a. content.response must be present.
|
|
545
|
+
if (typeof body.content?.response !== 'string') {
|
|
546
|
+
elicitationCache.restoreIfUnchanged(id, taken.entry, taken.gen);
|
|
547
|
+
return reply.code(400).send({ error: 'action:accept requires content.response' });
|
|
548
|
+
}
|
|
549
|
+
// 5b. Enum check: if the schema constrained the response, honour it.
|
|
550
|
+
if (taken.entry.options !== null &&
|
|
551
|
+
!taken.entry.options.includes(body.content.response)) {
|
|
552
|
+
elicitationCache.restoreIfUnchanged(id, taken.entry, taken.gen);
|
|
553
|
+
return reply.code(400).send({ error: 'response must be one of the allowed options' });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const response = body.action === 'accept' ? body.content.response : null;
|
|
557
|
+
// 6. Forward to the actor.
|
|
558
|
+
try {
|
|
559
|
+
await adapter.resolveElicitation(id, taken.entry.elicitationId, body.action, response);
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
elicitationCache.restoreIfUnchanged(id, taken.entry, taken.gen);
|
|
563
|
+
const code = err instanceof ElicitationUnsupportedError ? 501 : 500;
|
|
564
|
+
return reply.code(code).send({ error: message(err) });
|
|
565
|
+
}
|
|
566
|
+
// 7. Done.
|
|
567
|
+
return { status: 'resolved' };
|
|
568
|
+
});
|
|
569
|
+
// The durable history of one run (FINDING-057).
|
|
570
|
+
//
|
|
571
|
+
// `/ws` is a live tap and explicitly replays nothing on late join, so until this route existed
|
|
572
|
+
// the event log had exactly one reader — the gate route above, which reads it for a single
|
|
573
|
+
// `awaitingHuman` frame and discards the rest. Everything else a run recorded (routing councils,
|
|
574
|
+
// gate verdicts, per-unit cost) was write-only: observable if you happened to be attached while
|
|
575
|
+
// it streamed, and unrecoverable afterwards. That makes an incident un-investigable and a run's
|
|
576
|
+
// audit trail unciteable, which is the opposite of what a durable log is for.
|
|
577
|
+
//
|
|
578
|
+
// `type` filters server-side because the alternative is shipping an entire run's history to
|
|
579
|
+
// answer "what did the gates decide" — the log already excludes high-volume frames
|
|
580
|
+
// (`is_high_volume` drops CLI/chat deltas and terminal output), so what remains is the
|
|
581
|
+
// lifecycle, but a long run is still thousands of frames.
|
|
582
|
+
app.get(`${V}/runs/:id/events`, async (req, reply) => {
|
|
583
|
+
const { id } = req.params;
|
|
584
|
+
// Fastify's default querystring parser yields a string for `?type=a` and an ARRAY for a
|
|
585
|
+
// repeated `?type=a&type=b`. Typing it as `string` and calling `.split` would have thrown a
|
|
586
|
+
// 500 on the repeated form — a caller asking for two types the obvious way.
|
|
587
|
+
const { type } = req.query;
|
|
588
|
+
// IDs only: this is an existence check, and `sessionsDetail()` would load and parse every
|
|
589
|
+
// run's full unit detail to answer it — the same reason `/runs/:id/inject` uses `sessions()`.
|
|
590
|
+
const ids = await adapter.sessions();
|
|
591
|
+
if (!ids.includes(id)) {
|
|
592
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
593
|
+
}
|
|
594
|
+
const events = await adapter.runEvents(id);
|
|
595
|
+
if (events === null) {
|
|
596
|
+
// Same shape as the gate route's 503, and for the same reason: "no events" would report a
|
|
597
|
+
// missing binding as a fact about the run. The run may well have a rich history.
|
|
598
|
+
return reply.code(503).send({
|
|
599
|
+
error: 'Run history is unavailable: this wicked-core build has no event-log read binding',
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
// An empty array here is a real answer, not a failure: runs that predate the log have no
|
|
603
|
+
// history, and saying so is the honest response.
|
|
604
|
+
// Both spellings of "several types" mean the same thing: `?type=a,b` and `?type=a&type=b`.
|
|
605
|
+
// An empty filter (`?type=`, or only separators) is treated as NO filter rather than as a
|
|
606
|
+
// filter matching nothing — "show me events of no type" is not a question anyone asks, and
|
|
607
|
+
// answering it with an empty list looks identical to a run that recorded nothing.
|
|
608
|
+
const names = (Array.isArray(type) ? type : type === undefined ? [] : [type])
|
|
609
|
+
.flatMap((t) => t.split(','))
|
|
610
|
+
.map((t) => t.trim())
|
|
611
|
+
.filter(Boolean);
|
|
612
|
+
const wanted = names.length > 0 ? new Set(names) : null;
|
|
613
|
+
const filtered = wanted ? events.filter((e) => wanted.has(e.type)) : events;
|
|
614
|
+
return { runId: id, total: events.length, returned: filtered.length, events: filtered };
|
|
313
615
|
});
|
|
314
616
|
// ── Governance reads (crew#40) ──────────────────────────────────────────────
|
|
315
617
|
app.get(`${V}/governance/policies`, async () => {
|
|
@@ -347,6 +649,32 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
347
649
|
return reply.code(400).send({ error: message(err) });
|
|
348
650
|
}
|
|
349
651
|
});
|
|
652
|
+
// Retire, not delete. The record survives so past decisions citing it stay explicable; it just
|
|
653
|
+
// stops being enforced (FINDING-038 — a mis-authored policy otherwise denied forever).
|
|
654
|
+
app.delete(`${V}/governance/policies/:id`, async (req, reply) => {
|
|
655
|
+
const { id } = req.params;
|
|
656
|
+
try {
|
|
657
|
+
const existed = await adapter.retirePolicy(id);
|
|
658
|
+
if (!existed)
|
|
659
|
+
return reply.code(404).send({ error: `policy '${id}' not found` });
|
|
660
|
+
return { status: 'retired', id };
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
return reply.code(400).send({ error: message(err) });
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
app.delete(`${V}/governance/rules/:id`, async (req, reply) => {
|
|
667
|
+
const { id } = req.params;
|
|
668
|
+
try {
|
|
669
|
+
const existed = await adapter.retireConformanceRule(id);
|
|
670
|
+
if (!existed)
|
|
671
|
+
return reply.code(404).send({ error: `conformance rule '${id}' not found` });
|
|
672
|
+
return { status: 'retired', id };
|
|
673
|
+
}
|
|
674
|
+
catch (err) {
|
|
675
|
+
return reply.code(400).send({ error: message(err) });
|
|
676
|
+
}
|
|
677
|
+
});
|
|
350
678
|
app.get(`${V}/governance/rules/preview`, async (req, reply) => {
|
|
351
679
|
const q = req.query;
|
|
352
680
|
try {
|
|
@@ -434,16 +762,31 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
434
762
|
const repo = repos.find((r) => r.id === id);
|
|
435
763
|
if (!repo)
|
|
436
764
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
437
|
-
const graphPath =
|
|
438
|
-
const dbPath =
|
|
765
|
+
const graphPath = requirementsGraph(repo);
|
|
766
|
+
const dbPath = codeGraphDb(repo);
|
|
439
767
|
// Coverage from the live estate store — computed by wicked-core governance layer.
|
|
440
768
|
let coverage = null;
|
|
441
769
|
if (existsSync(dbPath)) {
|
|
442
770
|
try {
|
|
443
|
-
const { stdout } = await
|
|
771
|
+
const { stdout } = await execCapped(process.env['WICKED_CORE_EXE'] ?? 'wicked-core', ['coverage', '--db', dbPath, '--json'], { timeout: 20_000, cwd: repo.root_path });
|
|
444
772
|
coverage = JSON.parse(stdout);
|
|
445
773
|
}
|
|
446
|
-
catch {
|
|
774
|
+
catch (err) {
|
|
775
|
+
// The bare `catch {}` here claimed "store not yet indexed" for EVERY failure — a comment
|
|
776
|
+
// asserting a diagnosis the code never checked. An output overflow, a timeout and a missing
|
|
777
|
+
// binary all became a silent null, so the panel showed "no coverage" for reasons that are
|
|
778
|
+
// not the same problem and do not have the same fix (FINDING-016, and FINDING-050's shape:
|
|
779
|
+
// distinct causes collapsed into one outcome).
|
|
780
|
+
//
|
|
781
|
+
// Coverage stays optional — this endpoint must not 500 because the store is not indexed yet,
|
|
782
|
+
// which is a legitimate and common state. But a cause that is NOT that gets said out loud.
|
|
783
|
+
if (err instanceof ExecOutputTooLarge) {
|
|
784
|
+
req.log.warn({ err: err.message }, 'coverage output exceeded the buffer cap');
|
|
785
|
+
}
|
|
786
|
+
else if (!(err instanceof SyntaxError)) {
|
|
787
|
+
req.log.debug({ err: message(err) }, 'coverage unavailable');
|
|
788
|
+
}
|
|
789
|
+
}
|
|
447
790
|
}
|
|
448
791
|
try {
|
|
449
792
|
const content = await fsp.readFile(graphPath, 'utf8');
|
|
@@ -458,20 +801,112 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
458
801
|
// ── Repo code graph (via wicked-estate graph-view) ──────────────────────────
|
|
459
802
|
// Delegates to the estate CLI so the query goes through the proper service
|
|
460
803
|
// layer (store-seam aware, overlay edges included). Postgres-safe.
|
|
804
|
+
// ── Requirements management (server-side search over requirements_graph.json +
|
|
805
|
+
// operator overrides sidecar; see api/requirements.ts) ─────────────────────
|
|
806
|
+
const ReqQuerySchema = z.object({
|
|
807
|
+
q: z.string().optional(),
|
|
808
|
+
risk: z.enum(['risk', 'no-risk']).optional(),
|
|
809
|
+
category: z.enum(['functional', 'config-data']).optional(),
|
|
810
|
+
domain: z.string().optional(),
|
|
811
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
812
|
+
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
813
|
+
}).strict();
|
|
814
|
+
app.get(`${V}/repos/:id/requirements`, async (req, reply) => {
|
|
815
|
+
const { id } = req.params;
|
|
816
|
+
const repos = await adapter.listRepos();
|
|
817
|
+
const repo = repos.find((r) => r.id === id);
|
|
818
|
+
if (!repo)
|
|
819
|
+
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
820
|
+
const parsed = ReqQuerySchema.safeParse(req.query);
|
|
821
|
+
if (!parsed.success) {
|
|
822
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid query'));
|
|
823
|
+
}
|
|
824
|
+
const page = await listRequirements(repo, parsed.data);
|
|
825
|
+
if (page === null) {
|
|
826
|
+
return reply.code(404).send({ error: 'requirements_graph.json not generated for this repo yet' });
|
|
827
|
+
}
|
|
828
|
+
return page;
|
|
829
|
+
});
|
|
830
|
+
app.get(`${V}/repos/:id/requirements/:key`, async (req, reply) => {
|
|
831
|
+
const { id, key } = req.params;
|
|
832
|
+
const repos = await adapter.listRepos();
|
|
833
|
+
const repo = repos.find((r) => r.id === id);
|
|
834
|
+
if (!repo)
|
|
835
|
+
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
836
|
+
let decoded;
|
|
837
|
+
try {
|
|
838
|
+
decoded = decodeURIComponent(key);
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
return reply.code(400).send({ error: 'Malformed requirement key encoding' });
|
|
842
|
+
}
|
|
843
|
+
const detail = await getRequirement(repo, decoded);
|
|
844
|
+
if (detail === null)
|
|
845
|
+
return reply.code(404).send({ error: 'Requirement not found' });
|
|
846
|
+
return { requirement: detail };
|
|
847
|
+
});
|
|
848
|
+
const ReqPatchSchema = z
|
|
849
|
+
.object({
|
|
850
|
+
title: z.string().min(1).max(500).optional(),
|
|
851
|
+
notes: z.string().max(5000).optional(),
|
|
852
|
+
status: z.enum(['active', 'deprecated', 'review']).optional(),
|
|
853
|
+
risk: z.boolean().optional(),
|
|
854
|
+
})
|
|
855
|
+
.strict()
|
|
856
|
+
// `.strict()` BEFORE `.refine()`: the refine runs on the parsed object, so with stripping still
|
|
857
|
+
// in effect `{"title":"x","note":"y"}` would pass the non-empty check having silently discarded
|
|
858
|
+
// the edit the caller cared about. Every field here is optional, which is exactly the shape
|
|
859
|
+
// FINDING-031 makes dangerous.
|
|
860
|
+
.refine((b) => Object.keys(b).length > 0, { message: 'empty patch' });
|
|
861
|
+
app.patch(`${V}/repos/:id/requirements/:key`, async (req, reply) => {
|
|
862
|
+
const { id, key } = req.params;
|
|
863
|
+
const repos = await adapter.listRepos();
|
|
864
|
+
const repo = repos.find((r) => r.id === id);
|
|
865
|
+
if (!repo)
|
|
866
|
+
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
867
|
+
const parsed = ReqPatchSchema.safeParse(req.body);
|
|
868
|
+
if (!parsed.success) {
|
|
869
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid patch'));
|
|
870
|
+
}
|
|
871
|
+
let decodedKey;
|
|
872
|
+
try {
|
|
873
|
+
decodedKey = decodeURIComponent(key);
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
return reply.code(400).send({ error: 'Malformed requirement key encoding' });
|
|
877
|
+
}
|
|
878
|
+
const detail = await patchRequirement(repo, decodedKey, parsed.data);
|
|
879
|
+
if (detail === null)
|
|
880
|
+
return reply.code(404).send({ error: 'Requirement not found' });
|
|
881
|
+
return { requirement: detail };
|
|
882
|
+
});
|
|
461
883
|
app.get(`${V}/repos/:id/graph`, async (req, reply) => {
|
|
462
884
|
const { id } = req.params;
|
|
463
885
|
const repos = await adapter.listRepos();
|
|
464
886
|
const repo = repos.find((r) => r.id === id);
|
|
465
887
|
if (!repo)
|
|
466
888
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
467
|
-
const dbPath =
|
|
889
|
+
const dbPath = codeGraphDb(repo);
|
|
468
890
|
if (!existsSync(dbPath)) {
|
|
469
891
|
return reply.send({ graph: null });
|
|
470
892
|
}
|
|
471
893
|
try {
|
|
472
894
|
const settings = await adapter.getSettings();
|
|
473
|
-
const
|
|
474
|
-
const
|
|
895
|
+
const q = req.query;
|
|
896
|
+
const parsedLimit = q.limit !== undefined ? Number.parseInt(q.limit, 10) : NaN;
|
|
897
|
+
const nodeLimit = String(Number.isFinite(parsedLimit) && parsedLimit > 0 && parsedLimit <= 1000
|
|
898
|
+
? parsedLimit
|
|
899
|
+
: settings.graphNodeLimit);
|
|
900
|
+
const args = ['graph-view', '--limit', nodeLimit, '--db', dbPath];
|
|
901
|
+
// FOCUS (ego-graph) mode: seed the slice from one symbol and expand its
|
|
902
|
+
// neighbourhood — the navigation primitive (estate graph-view --focus).
|
|
903
|
+
if (q.focus !== undefined && q.focus.trim() !== '') {
|
|
904
|
+
args.push('--focus', q.focus.trim());
|
|
905
|
+
}
|
|
906
|
+
const { stdout } = await execCapped('wicked-estate', args, {
|
|
907
|
+
timeout: 30_000,
|
|
908
|
+
cwd: repo.root_path,
|
|
909
|
+
});
|
|
475
910
|
const raw = JSON.parse(stdout);
|
|
476
911
|
const fileCount = new Set(raw.nodes.map((n) => n.file)).size;
|
|
477
912
|
return reply.send({
|
|
@@ -487,6 +922,31 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
487
922
|
}
|
|
488
923
|
});
|
|
489
924
|
// ── Git history (last 20 commits via git log) ─────────────────────────────
|
|
925
|
+
// ── Blast radius for a symbol (via wicked-estate blast-radius --json).
|
|
926
|
+
// Carries the honesty contract through: dependents PLUS the unresolved-call
|
|
927
|
+
// count — an empty dependents list must never read as "safe to change".
|
|
928
|
+
app.get(`${V}/repos/:id/graph/blast-radius`, async (req, reply) => {
|
|
929
|
+
const { id } = req.params;
|
|
930
|
+
const q = req.query;
|
|
931
|
+
if (q.name === undefined || q.name.trim() === '') {
|
|
932
|
+
return reply.code(400).send({ error: 'name query parameter required' });
|
|
933
|
+
}
|
|
934
|
+
const repos = await adapter.listRepos();
|
|
935
|
+
const repo = repos.find((r) => r.id === id);
|
|
936
|
+
if (!repo)
|
|
937
|
+
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
938
|
+
const dbPath = codeGraphDb(repo);
|
|
939
|
+
if (!existsSync(dbPath)) {
|
|
940
|
+
return reply.code(404).send({ error: 'Code graph not built for this repo yet' });
|
|
941
|
+
}
|
|
942
|
+
try {
|
|
943
|
+
const { stdout } = await execCapped('wicked-estate', ['blast-radius', q.name.trim(), '--db', dbPath, '--json'], { timeout: 30_000, cwd: repo.root_path });
|
|
944
|
+
return reply.send(JSON.parse(stdout));
|
|
945
|
+
}
|
|
946
|
+
catch (err) {
|
|
947
|
+
return reply.code(500).send({ error: message(err) });
|
|
948
|
+
}
|
|
949
|
+
});
|
|
490
950
|
app.get(`${V}/repos/:id/git-history`, async (req, reply) => {
|
|
491
951
|
const { id } = req.params;
|
|
492
952
|
const repos = await adapter.listRepos();
|
|
@@ -494,7 +954,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
494
954
|
if (!repo)
|
|
495
955
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
496
956
|
try {
|
|
497
|
-
const { stdout } = await
|
|
957
|
+
const { stdout } = await execCapped('git', ['log', '--pretty=format:%H\x1f%h\x1f%s\x1f%an\x1f%ar', '-n', '20'], { timeout: 10_000, cwd: repo.root_path });
|
|
498
958
|
const commits = stdout.trim().split('\n').filter(Boolean).map((line) => {
|
|
499
959
|
const parts = line.split('\x1f');
|
|
500
960
|
return {
|
|
@@ -526,7 +986,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
526
986
|
if (!repo)
|
|
527
987
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
528
988
|
try {
|
|
529
|
-
const { stdout } = await
|
|
989
|
+
const { stdout } = await execCapped('git', ['shortlog', '-sne', '-n', '--no-merges', 'HEAD'], { timeout: 10_000, cwd: repo.root_path });
|
|
530
990
|
// Output: " 42\tFull Name <email@example.com>"
|
|
531
991
|
const contributors = stdout.trim().split('\n').filter(Boolean).slice(0, 10).map((line) => {
|
|
532
992
|
const m = line.match(/^\s*(\d+)\s+(.+?)\s+<([^>]+)>/);
|
|
@@ -558,7 +1018,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
558
1018
|
app.post(`${V}/terminals`, async (req, reply) => {
|
|
559
1019
|
const parsed = OpenTerminalSchema.safeParse(req.body);
|
|
560
1020
|
if (!parsed.success) {
|
|
561
|
-
return reply.code(400).send(
|
|
1021
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
562
1022
|
}
|
|
563
1023
|
const b = parsed.data;
|
|
564
1024
|
try {
|
|
@@ -575,7 +1035,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
575
1035
|
const { id } = req.params;
|
|
576
1036
|
const parsed = ResizeTerminalSchema.safeParse(req.body);
|
|
577
1037
|
if (!parsed.success) {
|
|
578
|
-
return reply.code(400).send(
|
|
1038
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
579
1039
|
}
|
|
580
1040
|
try {
|
|
581
1041
|
const status = await adapter.resizeTerminal(id, parsed.data.cols, parsed.data.rows);
|