wicked-crew 0.3.2 → 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 +12 -3
- package/dist/api/requirements.d.ts.map +1 -1
- package/dist/api/requirements.js +34 -37
- package/dist/api/requirements.js.map +1 -1
- package/dist/api/routes.d.ts +2 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +388 -41
- 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-CjUiA3ex.css +0 -32
- package/dist/studio/assets/index-DlHYzFvv.js +0 -420
package/dist/api/routes.js
CHANGED
|
@@ -5,11 +5,10 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
5
5
|
import { promises as fsp } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { CoreAdapter } from '../core/adapter.js';
|
|
8
|
+
import { ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError } from '../core/adapter.js';
|
|
9
|
+
import { codeGraphDb, requirementsGraph } from '../core/repoPaths.js';
|
|
11
10
|
import { buildEvidenceBundle, evidenceFilename } from './evidence.js';
|
|
12
|
-
|
|
11
|
+
import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
|
|
13
12
|
const V = '/api/v1';
|
|
14
13
|
// Daemon version reported by /health — read from package.json so it never drifts
|
|
15
14
|
// from the shipped version across releases. Resolves the package root from the
|
|
@@ -33,6 +32,25 @@ function sortActionableFirst(views) {
|
|
|
33
32
|
function message(err) {
|
|
34
33
|
return err instanceof Error ? err.message : String(err);
|
|
35
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
|
+
}
|
|
36
54
|
// Repo names become directory components under ~/.wicked/repos/ — reject anything
|
|
37
55
|
// that would allow path traversal (slashes, dots-only segments, control chars).
|
|
38
56
|
const SAFE_REPO_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
@@ -49,6 +67,7 @@ const RegisterRepoSchema = z
|
|
|
49
67
|
rootPath: z.string().optional(),
|
|
50
68
|
gitUrl: z.string().optional(),
|
|
51
69
|
})
|
|
70
|
+
.strict()
|
|
52
71
|
.refine((d) => {
|
|
53
72
|
const hasRemote = typeof d.gitUrl === 'string' && d.gitUrl.length > 0;
|
|
54
73
|
const hasLocal = typeof d.rootPath === 'string' && d.rootPath.length > 0;
|
|
@@ -56,6 +75,19 @@ const RegisterRepoSchema = z
|
|
|
56
75
|
// or rootPath alone (register existing local repo) — all valid.
|
|
57
76
|
return hasRemote || hasLocal;
|
|
58
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
|
+
*/
|
|
59
91
|
const LaunchSchema = z.object({
|
|
60
92
|
problem: z.string().min(1),
|
|
61
93
|
sessionId: z.string().min(1).optional(),
|
|
@@ -64,16 +96,16 @@ const LaunchSchema = z.object({
|
|
|
64
96
|
humanConfirm: z.string().min(1).optional(),
|
|
65
97
|
repoRef: z.string().min(1).optional(),
|
|
66
98
|
workflow: z.string().min(1).optional(),
|
|
67
|
-
});
|
|
99
|
+
}).strict();
|
|
68
100
|
const GateSchema = z.object({
|
|
69
101
|
approve: z.boolean(),
|
|
70
102
|
amend: z.string().optional(),
|
|
71
|
-
});
|
|
103
|
+
}).strict();
|
|
72
104
|
const InjectSchema = z.object({
|
|
73
105
|
message: z.string().min(1),
|
|
74
106
|
/** `"all"` broadcasts to every active worker; any other value is a CLI key. */
|
|
75
107
|
target: z.string().min(1).default('all'),
|
|
76
|
-
});
|
|
108
|
+
}).strict();
|
|
77
109
|
const OpenTerminalSchema = z.object({
|
|
78
110
|
cwd: z.string().min(1),
|
|
79
111
|
cmd: z.array(z.string().min(1)).min(1).optional(),
|
|
@@ -82,16 +114,16 @@ const OpenTerminalSchema = z.object({
|
|
|
82
114
|
// Optional so omission is the SAFE governed default (§7 — `false` is never a
|
|
83
115
|
// default; the ungoverned operator shell must opt in explicitly).
|
|
84
116
|
governed: z.boolean().optional(),
|
|
85
|
-
});
|
|
117
|
+
}).strict();
|
|
86
118
|
const ResizeTerminalSchema = z.object({
|
|
87
119
|
cols: z.number().int().positive(),
|
|
88
120
|
rows: z.number().int().positive(),
|
|
89
|
-
});
|
|
121
|
+
}).strict();
|
|
90
122
|
/**
|
|
91
123
|
* The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
|
|
92
124
|
* core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
|
|
93
125
|
*/
|
|
94
|
-
export function registerRoutes(app, adapter, gateCache) {
|
|
126
|
+
export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
95
127
|
// Liveness — also proves the actor + event pump are up.
|
|
96
128
|
app.get(`${V}/health`, async () => {
|
|
97
129
|
const ping = await adapter.ping();
|
|
@@ -111,7 +143,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
111
143
|
app.post(`${V}/repos`, async (req, reply) => {
|
|
112
144
|
const parsed = RegisterRepoSchema.safeParse(req.body);
|
|
113
145
|
if (!parsed.success) {
|
|
114
|
-
return reply.code(400).send(
|
|
146
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
115
147
|
}
|
|
116
148
|
const { name, rootPath, gitUrl } = parsed.data;
|
|
117
149
|
try {
|
|
@@ -162,7 +194,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
162
194
|
app.post(`${V}/runs`, async (req, reply) => {
|
|
163
195
|
const parsed = LaunchSchema.safeParse(req.body);
|
|
164
196
|
if (!parsed.success) {
|
|
165
|
-
return reply.code(400).send(
|
|
197
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
166
198
|
}
|
|
167
199
|
const b = parsed.data;
|
|
168
200
|
const input = {
|
|
@@ -188,10 +220,12 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
188
220
|
return reply.code(busy ? 409 : 400).send({ error: msg });
|
|
189
221
|
}
|
|
190
222
|
});
|
|
191
|
-
// 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.
|
|
192
225
|
app.get(`${V}/runs`, async () => {
|
|
193
226
|
const views = await adapter.sessionsDetail();
|
|
194
227
|
gateCache.reconcile(views);
|
|
228
|
+
elicitationCache.reconcile(views);
|
|
195
229
|
return { runs: sortActionableFirst(views) };
|
|
196
230
|
});
|
|
197
231
|
// One run's detail.
|
|
@@ -206,6 +240,99 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
206
240
|
// A unit's captured transcript. unitKey is the suffix after `<run>:` — `u<ord>` for free-text
|
|
207
241
|
// runs, `<phase_id>` for workflow runs (e.g. "survey", "coverage"). Strip any accidental
|
|
208
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
|
+
});
|
|
209
336
|
app.get(`${V}/runs/:id/units/:unitKey/output`, async (req, reply) => {
|
|
210
337
|
const { id, unitKey } = req.params;
|
|
211
338
|
const suffix = unitKey.startsWith(`${id}:`) ? unitKey.slice(id.length + 1) : unitKey;
|
|
@@ -213,15 +340,15 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
213
340
|
return reply.send({ output });
|
|
214
341
|
});
|
|
215
342
|
// The whole run as one auditable JSON attachment: the run, its units (each with
|
|
216
|
-
// the captured transcript), and the
|
|
217
|
-
//
|
|
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.
|
|
218
345
|
app.get(`${V}/runs/:id/evidence`, async (req, reply) => {
|
|
219
346
|
const { id } = req.params;
|
|
220
347
|
const views = await adapter.sessionsDetail();
|
|
221
348
|
const run = views.find((v) => v.session.id === id);
|
|
222
349
|
if (!run)
|
|
223
350
|
return reply.code(404).send({ error: 'Run not found' });
|
|
224
|
-
const bundle = await buildEvidenceBundle(run, (unitId) => adapter.workOutput(unitId));
|
|
351
|
+
const bundle = await buildEvidenceBundle(run, (unitId) => adapter.workOutput(unitId), (runId) => adapter.runEvents(runId));
|
|
225
352
|
return reply
|
|
226
353
|
.header('Content-Disposition', `attachment; filename="${evidenceFilename(id)}"`)
|
|
227
354
|
.send(bundle);
|
|
@@ -231,7 +358,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
231
358
|
const { id } = req.params;
|
|
232
359
|
const parsed = GateSchema.safeParse(req.body);
|
|
233
360
|
if (!parsed.success) {
|
|
234
|
-
return reply.code(400).send(
|
|
361
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
235
362
|
}
|
|
236
363
|
const views = await adapter.sessionsDetail();
|
|
237
364
|
const run = views.find((v) => v.session.id === id);
|
|
@@ -290,7 +417,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
290
417
|
const { id } = req.params;
|
|
291
418
|
const parsed = InjectSchema.safeParse(req.body);
|
|
292
419
|
if (!parsed.success) {
|
|
293
|
-
return reply.code(400).send(
|
|
420
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
294
421
|
}
|
|
295
422
|
const ids = await adapter.sessions();
|
|
296
423
|
if (!ids.includes(id))
|
|
@@ -303,14 +430,188 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
303
430
|
return reply.code(409).send({ error: message(err) });
|
|
304
431
|
}
|
|
305
432
|
});
|
|
306
|
-
// The
|
|
307
|
-
//
|
|
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.
|
|
308
439
|
app.get(`${V}/runs/:id/gate`, async (req, reply) => {
|
|
309
440
|
const { id } = req.params;
|
|
310
|
-
const
|
|
311
|
-
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') {
|
|
458
|
+
return reply.code(404).send({ error: 'No open gate for this run' });
|
|
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.
|
|
312
473
|
return reply.code(404).send({ error: 'No open gate for this run' });
|
|
313
|
-
|
|
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 };
|
|
314
615
|
});
|
|
315
616
|
// ── Governance reads (crew#40) ──────────────────────────────────────────────
|
|
316
617
|
app.get(`${V}/governance/policies`, async () => {
|
|
@@ -348,6 +649,32 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
348
649
|
return reply.code(400).send({ error: message(err) });
|
|
349
650
|
}
|
|
350
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
|
+
});
|
|
351
678
|
app.get(`${V}/governance/rules/preview`, async (req, reply) => {
|
|
352
679
|
const q = req.query;
|
|
353
680
|
try {
|
|
@@ -435,16 +762,31 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
435
762
|
const repo = repos.find((r) => r.id === id);
|
|
436
763
|
if (!repo)
|
|
437
764
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
438
|
-
const graphPath =
|
|
439
|
-
const dbPath =
|
|
765
|
+
const graphPath = requirementsGraph(repo);
|
|
766
|
+
const dbPath = codeGraphDb(repo);
|
|
440
767
|
// Coverage from the live estate store — computed by wicked-core governance layer.
|
|
441
768
|
let coverage = null;
|
|
442
769
|
if (existsSync(dbPath)) {
|
|
443
770
|
try {
|
|
444
|
-
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 });
|
|
445
772
|
coverage = JSON.parse(stdout);
|
|
446
773
|
}
|
|
447
|
-
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
|
+
}
|
|
448
790
|
}
|
|
449
791
|
try {
|
|
450
792
|
const content = await fsp.readFile(graphPath, 'utf8');
|
|
@@ -468,7 +810,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
468
810
|
domain: z.string().optional(),
|
|
469
811
|
offset: z.coerce.number().int().min(0).default(0),
|
|
470
812
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
471
|
-
});
|
|
813
|
+
}).strict();
|
|
472
814
|
app.get(`${V}/repos/:id/requirements`, async (req, reply) => {
|
|
473
815
|
const { id } = req.params;
|
|
474
816
|
const repos = await adapter.listRepos();
|
|
@@ -477,9 +819,9 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
477
819
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
478
820
|
const parsed = ReqQuerySchema.safeParse(req.query);
|
|
479
821
|
if (!parsed.success) {
|
|
480
|
-
return reply.code(400).send(
|
|
822
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid query'));
|
|
481
823
|
}
|
|
482
|
-
const page = await listRequirements(repo
|
|
824
|
+
const page = await listRequirements(repo, parsed.data);
|
|
483
825
|
if (page === null) {
|
|
484
826
|
return reply.code(404).send({ error: 'requirements_graph.json not generated for this repo yet' });
|
|
485
827
|
}
|
|
@@ -498,7 +840,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
498
840
|
catch {
|
|
499
841
|
return reply.code(400).send({ error: 'Malformed requirement key encoding' });
|
|
500
842
|
}
|
|
501
|
-
const detail = await getRequirement(repo
|
|
843
|
+
const detail = await getRequirement(repo, decoded);
|
|
502
844
|
if (detail === null)
|
|
503
845
|
return reply.code(404).send({ error: 'Requirement not found' });
|
|
504
846
|
return { requirement: detail };
|
|
@@ -510,6 +852,11 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
510
852
|
status: z.enum(['active', 'deprecated', 'review']).optional(),
|
|
511
853
|
risk: z.boolean().optional(),
|
|
512
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.
|
|
513
860
|
.refine((b) => Object.keys(b).length > 0, { message: 'empty patch' });
|
|
514
861
|
app.patch(`${V}/repos/:id/requirements/:key`, async (req, reply) => {
|
|
515
862
|
const { id, key } = req.params;
|
|
@@ -519,7 +866,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
519
866
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
520
867
|
const parsed = ReqPatchSchema.safeParse(req.body);
|
|
521
868
|
if (!parsed.success) {
|
|
522
|
-
return reply.code(400).send(
|
|
869
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid patch'));
|
|
523
870
|
}
|
|
524
871
|
let decodedKey;
|
|
525
872
|
try {
|
|
@@ -528,7 +875,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
528
875
|
catch {
|
|
529
876
|
return reply.code(400).send({ error: 'Malformed requirement key encoding' });
|
|
530
877
|
}
|
|
531
|
-
const detail = await patchRequirement(repo
|
|
878
|
+
const detail = await patchRequirement(repo, decodedKey, parsed.data);
|
|
532
879
|
if (detail === null)
|
|
533
880
|
return reply.code(404).send({ error: 'Requirement not found' });
|
|
534
881
|
return { requirement: detail };
|
|
@@ -539,7 +886,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
539
886
|
const repo = repos.find((r) => r.id === id);
|
|
540
887
|
if (!repo)
|
|
541
888
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
542
|
-
const dbPath =
|
|
889
|
+
const dbPath = codeGraphDb(repo);
|
|
543
890
|
if (!existsSync(dbPath)) {
|
|
544
891
|
return reply.send({ graph: null });
|
|
545
892
|
}
|
|
@@ -556,7 +903,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
556
903
|
if (q.focus !== undefined && q.focus.trim() !== '') {
|
|
557
904
|
args.push('--focus', q.focus.trim());
|
|
558
905
|
}
|
|
559
|
-
const { stdout } = await
|
|
906
|
+
const { stdout } = await execCapped('wicked-estate', args, {
|
|
560
907
|
timeout: 30_000,
|
|
561
908
|
cwd: repo.root_path,
|
|
562
909
|
});
|
|
@@ -588,12 +935,12 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
588
935
|
const repo = repos.find((r) => r.id === id);
|
|
589
936
|
if (!repo)
|
|
590
937
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
591
|
-
const dbPath =
|
|
938
|
+
const dbPath = codeGraphDb(repo);
|
|
592
939
|
if (!existsSync(dbPath)) {
|
|
593
940
|
return reply.code(404).send({ error: 'Code graph not built for this repo yet' });
|
|
594
941
|
}
|
|
595
942
|
try {
|
|
596
|
-
const { stdout } = await
|
|
943
|
+
const { stdout } = await execCapped('wicked-estate', ['blast-radius', q.name.trim(), '--db', dbPath, '--json'], { timeout: 30_000, cwd: repo.root_path });
|
|
597
944
|
return reply.send(JSON.parse(stdout));
|
|
598
945
|
}
|
|
599
946
|
catch (err) {
|
|
@@ -607,7 +954,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
607
954
|
if (!repo)
|
|
608
955
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
609
956
|
try {
|
|
610
|
-
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 });
|
|
611
958
|
const commits = stdout.trim().split('\n').filter(Boolean).map((line) => {
|
|
612
959
|
const parts = line.split('\x1f');
|
|
613
960
|
return {
|
|
@@ -639,7 +986,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
639
986
|
if (!repo)
|
|
640
987
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
641
988
|
try {
|
|
642
|
-
const { stdout } = await
|
|
989
|
+
const { stdout } = await execCapped('git', ['shortlog', '-sne', '-n', '--no-merges', 'HEAD'], { timeout: 10_000, cwd: repo.root_path });
|
|
643
990
|
// Output: " 42\tFull Name <email@example.com>"
|
|
644
991
|
const contributors = stdout.trim().split('\n').filter(Boolean).slice(0, 10).map((line) => {
|
|
645
992
|
const m = line.match(/^\s*(\d+)\s+(.+?)\s+<([^>]+)>/);
|
|
@@ -671,7 +1018,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
671
1018
|
app.post(`${V}/terminals`, async (req, reply) => {
|
|
672
1019
|
const parsed = OpenTerminalSchema.safeParse(req.body);
|
|
673
1020
|
if (!parsed.success) {
|
|
674
|
-
return reply.code(400).send(
|
|
1021
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
675
1022
|
}
|
|
676
1023
|
const b = parsed.data;
|
|
677
1024
|
try {
|
|
@@ -688,7 +1035,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
688
1035
|
const { id } = req.params;
|
|
689
1036
|
const parsed = ResizeTerminalSchema.safeParse(req.body);
|
|
690
1037
|
if (!parsed.success) {
|
|
691
|
-
return reply.code(400).send(
|
|
1038
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
692
1039
|
}
|
|
693
1040
|
try {
|
|
694
1041
|
const status = await adapter.resizeTerminal(id, parsed.data.cols, parsed.data.rows);
|