wicked-crew 0.3.2 → 0.5.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/api-prefix.d.ts +13 -0
- package/dist/api/api-prefix.d.ts.map +1 -0
- package/dist/api/api-prefix.js +13 -0
- package/dist/api/api-prefix.js.map +1 -0
- 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 +48 -38
- package/dist/api/evidence.d.ts.map +1 -1
- package/dist/api/evidence.js +48 -39
- 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 +3 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +468 -51
- 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/api/unit-output.d.ts +62 -0
- package/dist/api/unit-output.d.ts.map +1 -0
- package/dist/api/unit-output.js +108 -0
- package/dist/api/unit-output.js.map +1 -0
- package/dist/core/adapter.d.ts +136 -6
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +512 -89
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/bridge-path.d.ts +17 -6
- package/dist/core/bridge-path.d.ts.map +1 -1
- package/dist/core/bridge-path.js +17 -6
- package/dist/core/bridge-path.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 +90 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/studio/assets/index-DaaUU8Ep.css +32 -0
- package/dist/studio/assets/index-Fu5DRC00.js +423 -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,12 +5,16 @@ 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 {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
import { ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError, humanGatePhaseIds } from '../core/adapter.js';
|
|
9
|
+
import { codeGraphDb, requirementsGraph } from '../core/repoPaths.js';
|
|
10
|
+
import { buildEvidenceBundle, coreUnitId, evidenceFilename } from './evidence.js';
|
|
11
|
+
import { outputUnavailableReason, resolveUnit, unitKeysFor } from './unit-output.js';
|
|
12
|
+
import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
|
|
13
|
+
// Re-exported so existing `import { API_PREFIX } from './routes.js'` callers keep working; the
|
|
14
|
+
// value lives in the leaf module api-prefix.ts to keep unit-output.ts out of this file's cycle.
|
|
15
|
+
export { API_PREFIX } from './api-prefix.js';
|
|
16
|
+
import { API_PREFIX } from './api-prefix.js';
|
|
17
|
+
const V = API_PREFIX;
|
|
14
18
|
// Daemon version reported by /health — read from package.json so it never drifts
|
|
15
19
|
// from the shipped version across releases. Resolves the package root from the
|
|
16
20
|
// compiled module location (dist/api/routes.js → ../../package.json) and works the
|
|
@@ -33,6 +37,25 @@ function sortActionableFirst(views) {
|
|
|
33
37
|
function message(err) {
|
|
34
38
|
return err instanceof Error ? err.message : String(err);
|
|
35
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Turns a failed parse into a 400 body, naming any field the schema does not know.
|
|
42
|
+
*
|
|
43
|
+
* Every schema here is `.strict()`, because zod's default is to STRIP unknown keys — which made a
|
|
44
|
+
* misspelled optional field a silent behaviour change (FINDING-031). `POST /runs {"clis":[...],
|
|
45
|
+
* "workflowId":"feature"}` — core's field names, not the HTTP layer's — answered `201` and ran the
|
|
46
|
+
* full roster with no workflow. Rejecting is only half the fix: a bare "Invalid request body" leaves
|
|
47
|
+
* the caller comparing their JSON against the source, so the unknown keys are named in `error`
|
|
48
|
+
* itself, where a human and a `curl | jq .error` both see it without reading `details`.
|
|
49
|
+
*/
|
|
50
|
+
function invalidBody(err, what) {
|
|
51
|
+
const unknown = err.issues.flatMap((i) => (i.code === 'unrecognized_keys' ? i.keys : []));
|
|
52
|
+
const error = unknown.length > 0
|
|
53
|
+
? `${what}: unknown field${unknown.length > 1 ? 's' : ''} ${unknown
|
|
54
|
+
.map((k) => `\`${k}\``)
|
|
55
|
+
.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`
|
|
56
|
+
: what;
|
|
57
|
+
return { error, details: err.issues };
|
|
58
|
+
}
|
|
36
59
|
// Repo names become directory components under ~/.wicked/repos/ — reject anything
|
|
37
60
|
// that would allow path traversal (slashes, dots-only segments, control chars).
|
|
38
61
|
const SAFE_REPO_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
@@ -49,6 +72,7 @@ const RegisterRepoSchema = z
|
|
|
49
72
|
rootPath: z.string().optional(),
|
|
50
73
|
gitUrl: z.string().optional(),
|
|
51
74
|
})
|
|
75
|
+
.strict()
|
|
52
76
|
.refine((d) => {
|
|
53
77
|
const hasRemote = typeof d.gitUrl === 'string' && d.gitUrl.length > 0;
|
|
54
78
|
const hasLocal = typeof d.rootPath === 'string' && d.rootPath.length > 0;
|
|
@@ -56,6 +80,19 @@ const RegisterRepoSchema = z
|
|
|
56
80
|
// or rootPath alone (register existing local repo) — all valid.
|
|
57
81
|
return hasRemote || hasLocal;
|
|
58
82
|
}, { message: 'Provide gitUrl (remote clone) or rootPath (local registration), or both.' });
|
|
83
|
+
/**
|
|
84
|
+
* The launch body. Every field but `problem` is optional, which is what made stripping dangerous:
|
|
85
|
+
* omitting one is a legitimate request that gets an engine default, so a MISSPELLED one was
|
|
86
|
+
* indistinguishable from an omitted one and the run went ahead on a configuration the caller never
|
|
87
|
+
* asked for (FINDING-031).
|
|
88
|
+
*
|
|
89
|
+
* The names differ from core's on purpose and this is the trap worth knowing about: core's
|
|
90
|
+
* `LaunchSpec` takes `clis` (an array) and `workflow`, this takes `clisJson` (a JSON *string*) and
|
|
91
|
+
* `workflow`, and the `/ws` `sessionStarted` frame reports the chosen workflow as `workflowId`. A
|
|
92
|
+
* caller who reads the event stream to learn the field names arrives at `workflowId`, which this
|
|
93
|
+
* schema does not accept — so `.strict()` is what turns that trip into a 400 instead of an
|
|
94
|
+
* unworkflowed run reported as `201`.
|
|
95
|
+
*/
|
|
59
96
|
const LaunchSchema = z.object({
|
|
60
97
|
problem: z.string().min(1),
|
|
61
98
|
sessionId: z.string().min(1).optional(),
|
|
@@ -64,16 +101,16 @@ const LaunchSchema = z.object({
|
|
|
64
101
|
humanConfirm: z.string().min(1).optional(),
|
|
65
102
|
repoRef: z.string().min(1).optional(),
|
|
66
103
|
workflow: z.string().min(1).optional(),
|
|
67
|
-
});
|
|
104
|
+
}).strict();
|
|
68
105
|
const GateSchema = z.object({
|
|
69
106
|
approve: z.boolean(),
|
|
70
107
|
amend: z.string().optional(),
|
|
71
|
-
});
|
|
108
|
+
}).strict();
|
|
72
109
|
const InjectSchema = z.object({
|
|
73
110
|
message: z.string().min(1),
|
|
74
111
|
/** `"all"` broadcasts to every active worker; any other value is a CLI key. */
|
|
75
112
|
target: z.string().min(1).default('all'),
|
|
76
|
-
});
|
|
113
|
+
}).strict();
|
|
77
114
|
const OpenTerminalSchema = z.object({
|
|
78
115
|
cwd: z.string().min(1),
|
|
79
116
|
cmd: z.array(z.string().min(1)).min(1).optional(),
|
|
@@ -82,16 +119,16 @@ const OpenTerminalSchema = z.object({
|
|
|
82
119
|
// Optional so omission is the SAFE governed default (§7 — `false` is never a
|
|
83
120
|
// default; the ungoverned operator shell must opt in explicitly).
|
|
84
121
|
governed: z.boolean().optional(),
|
|
85
|
-
});
|
|
122
|
+
}).strict();
|
|
86
123
|
const ResizeTerminalSchema = z.object({
|
|
87
124
|
cols: z.number().int().positive(),
|
|
88
125
|
rows: z.number().int().positive(),
|
|
89
|
-
});
|
|
126
|
+
}).strict();
|
|
90
127
|
/**
|
|
91
128
|
* The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
|
|
92
129
|
* core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
|
|
93
130
|
*/
|
|
94
|
-
export function registerRoutes(app, adapter, gateCache) {
|
|
131
|
+
export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
95
132
|
// Liveness — also proves the actor + event pump are up.
|
|
96
133
|
app.get(`${V}/health`, async () => {
|
|
97
134
|
const ping = await adapter.ping();
|
|
@@ -111,7 +148,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
111
148
|
app.post(`${V}/repos`, async (req, reply) => {
|
|
112
149
|
const parsed = RegisterRepoSchema.safeParse(req.body);
|
|
113
150
|
if (!parsed.success) {
|
|
114
|
-
return reply.code(400).send(
|
|
151
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
115
152
|
}
|
|
116
153
|
const { name, rootPath, gitUrl } = parsed.data;
|
|
117
154
|
try {
|
|
@@ -162,7 +199,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
162
199
|
app.post(`${V}/runs`, async (req, reply) => {
|
|
163
200
|
const parsed = LaunchSchema.safeParse(req.body);
|
|
164
201
|
if (!parsed.success) {
|
|
165
|
-
return reply.code(400).send(
|
|
202
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
166
203
|
}
|
|
167
204
|
const b = parsed.data;
|
|
168
205
|
const input = {
|
|
@@ -188,10 +225,12 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
188
225
|
return reply.code(busy ? 409 : 400).send({ error: msg });
|
|
189
226
|
}
|
|
190
227
|
});
|
|
191
|
-
// Run list (replaces GET /sessions). Actionable-first; reconciles the gate
|
|
228
|
+
// Run list (replaces GET /sessions). Actionable-first; reconciles the gate and elicitation caches
|
|
229
|
+
// so that terminal-run entries are pruned even when their terminal CoreEvent was missed.
|
|
192
230
|
app.get(`${V}/runs`, async () => {
|
|
193
231
|
const views = await adapter.sessionsDetail();
|
|
194
232
|
gateCache.reconcile(views);
|
|
233
|
+
elicitationCache.reconcile(views);
|
|
195
234
|
return { runs: sortActionableFirst(views) };
|
|
196
235
|
});
|
|
197
236
|
// One run's detail.
|
|
@@ -203,25 +242,137 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
203
242
|
return reply.code(404).send({ error: 'Run not found' });
|
|
204
243
|
return { run };
|
|
205
244
|
});
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
245
|
+
// ── Chat sessions (crew#165): warm ACP seat pool + group fan-out (core#134) ──
|
|
246
|
+
// A chat is NOT a run: no council, no gates, no units. Seats warm on open;
|
|
247
|
+
// messages fan out to warm seats; replies stream on /ws as chatDelta/chatReply.
|
|
248
|
+
const ChatOpenSchema = z.object({
|
|
249
|
+
chatId: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional(),
|
|
250
|
+
clis: z.array(z.string().min(1)).min(1).max(8).optional(),
|
|
251
|
+
repoRef: z.string().optional(),
|
|
252
|
+
}).strict();
|
|
253
|
+
app.post(`${V}/chats`, async (req, reply) => {
|
|
254
|
+
const parsed = ChatOpenSchema.safeParse(req.body ?? {});
|
|
255
|
+
if (!parsed.success) {
|
|
256
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
257
|
+
}
|
|
258
|
+
const b = parsed.data;
|
|
259
|
+
const chatId = b.chatId ?? randomUUID();
|
|
260
|
+
let cwd;
|
|
261
|
+
if (b.repoRef !== undefined) {
|
|
262
|
+
const repos = await adapter.listRepos();
|
|
263
|
+
const repo = repos.find((r) => r.id === b.repoRef);
|
|
264
|
+
if (!repo)
|
|
265
|
+
return reply.code(404).send({ error: `Repo ${b.repoRef} not found` });
|
|
266
|
+
cwd = repo.root_path;
|
|
267
|
+
}
|
|
268
|
+
const clis = b.clis ??
|
|
269
|
+
CoreAdapter.roster()
|
|
270
|
+
.map((s) => s.key)
|
|
271
|
+
.filter((k) => typeof k === 'string');
|
|
272
|
+
try {
|
|
273
|
+
const seats = await adapter.chatOpen(chatId, clis, cwd);
|
|
274
|
+
return reply.code(201).send({ chatId, seats });
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
return reply.code(400).send({ error: message(err) });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
const ChatMessageSchema = z.object({
|
|
281
|
+
text: z.string().min(1).max(65536),
|
|
282
|
+
targets: z.array(z.string().min(1)).min(1).max(8).optional(),
|
|
283
|
+
}).strict();
|
|
284
|
+
app.post(`${V}/chats/:id/messages`, async (req, reply) => {
|
|
285
|
+
const { id } = req.params;
|
|
286
|
+
const parsed = ChatMessageSchema.safeParse(req.body);
|
|
287
|
+
if (!parsed.success) {
|
|
288
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const seats = await adapter.chatSend(id, parsed.data.text, parsed.data.targets);
|
|
292
|
+
return reply.code(202).send({ seats });
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
const msg = message(err);
|
|
296
|
+
return reply.code(/no warm seats/.test(msg) ? 409 : 400).send({ error: msg });
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
// Enumerate live chats (FINDING-027 gap 4). Chat sessions deliberately outlive the page, and
|
|
300
|
+
// their ids are minted client-side — so before this route the only record of an orphaned seat
|
|
301
|
+
// lived in the tab that abandoned it, and an operator could not reclaim one without restarting
|
|
302
|
+
// the daemon. Registered BEFORE `/chats/:id` is irrelevant to fastify (it routes on the literal
|
|
303
|
+
// segment first), but the order reads the way the routes nest.
|
|
304
|
+
app.get(`${V}/chats`, async (_req, reply) => {
|
|
305
|
+
try {
|
|
306
|
+
return { chats: await adapter.chatList() };
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
// A build that cannot do chat is a capability gap, not a bad request: 501 tells an operator to
|
|
310
|
+
// upgrade rather than to fix a call that was already correct. Branching on the type, not on
|
|
311
|
+
// the message — regexing the text here caught the missing-binding phrasing and missed the
|
|
312
|
+
// engine's own ("chat unsupported: engine spawned without the ACP runner"), so half of one
|
|
313
|
+
// condition answered 400.
|
|
314
|
+
return reply
|
|
315
|
+
.code(err instanceof ChatUnsupportedError ? 501 : 400)
|
|
316
|
+
.send({ error: message(err) });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
app.get(`${V}/chats/:id`, async (req, reply) => {
|
|
320
|
+
const { id } = req.params;
|
|
321
|
+
try {
|
|
322
|
+
return { chatId: id, seats: await adapter.chatSeats(id) };
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
return reply.code(400).send({ error: message(err) });
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
app.delete(`${V}/chats/:id`, async (req, reply) => {
|
|
329
|
+
const { id } = req.params;
|
|
330
|
+
try {
|
|
331
|
+
await adapter.chatClose(id);
|
|
332
|
+
return { ok: true };
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
return reply.code(400).send({ error: message(err) });
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
// A unit's captured transcript. `unitKey` accepts the fully-qualified id (`run-1:survey`),
|
|
339
|
+
// the id suffix (`survey`, `u3`), or the ordinal (`u3`, `3`) — see `resolveUnit`.
|
|
340
|
+
//
|
|
341
|
+
// A `null` body is never bare here. An unknown run or an unknown key is a 404 that names the
|
|
342
|
+
// keys this run does have; a unit that exists but has no stored transcript answers 200 with
|
|
343
|
+
// `outputUnavailable` saying WHICH cause applies — denied, not finished, or a store that
|
|
344
|
+
// disagrees with itself. It previously answered `200 {"output": null}` to all four, so the
|
|
345
|
+
// failed unit an operator opens during triage was the one that told them nothing (FINDING-006).
|
|
209
346
|
app.get(`${V}/runs/:id/units/:unitKey/output`, async (req, reply) => {
|
|
210
347
|
const { id, unitKey } = req.params;
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
|
|
348
|
+
const views = await adapter.sessionsDetail();
|
|
349
|
+
const run = views.find((v) => v.session.id === id);
|
|
350
|
+
if (!run)
|
|
351
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
352
|
+
const unit = resolveUnit(run, unitKey);
|
|
353
|
+
if (!unit) {
|
|
354
|
+
return reply.code(404).send({
|
|
355
|
+
error: `Unit '${unitKey}' not found in run ${id}`,
|
|
356
|
+
units: unitKeysFor(run),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
// Keyed off the unit RECORD (the same derivation the evidence bundle uses), not off the
|
|
360
|
+
// caller's path segment — the two disagreeing is what made a resolvable unit unreadable.
|
|
361
|
+
const output = await adapter.workOutput(coreUnitId(id, unit));
|
|
362
|
+
if (output !== null)
|
|
363
|
+
return reply.send({ output });
|
|
364
|
+
return reply.send({ output: null, outputUnavailable: outputUnavailableReason(unit) });
|
|
214
365
|
});
|
|
215
366
|
// The whole run as one auditable JSON attachment: the run, its units (each with
|
|
216
|
-
// the captured transcript), and the
|
|
217
|
-
//
|
|
367
|
+
// the captured transcript), and the decision trail read back from core's durable
|
|
368
|
+
// per-run event log — what actually happened, not a re-derivation of it.
|
|
218
369
|
app.get(`${V}/runs/:id/evidence`, async (req, reply) => {
|
|
219
370
|
const { id } = req.params;
|
|
220
371
|
const views = await adapter.sessionsDetail();
|
|
221
372
|
const run = views.find((v) => v.session.id === id);
|
|
222
373
|
if (!run)
|
|
223
374
|
return reply.code(404).send({ error: 'Run not found' });
|
|
224
|
-
const bundle = await buildEvidenceBundle(run, (unitId) => adapter.workOutput(unitId));
|
|
375
|
+
const bundle = await buildEvidenceBundle(run, (unitId) => adapter.workOutput(unitId), (runId) => adapter.runEvents(runId));
|
|
225
376
|
return reply
|
|
226
377
|
.header('Content-Disposition', `attachment; filename="${evidenceFilename(id)}"`)
|
|
227
378
|
.send(bundle);
|
|
@@ -231,7 +382,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
231
382
|
const { id } = req.params;
|
|
232
383
|
const parsed = GateSchema.safeParse(req.body);
|
|
233
384
|
if (!parsed.success) {
|
|
234
|
-
return reply.code(400).send(
|
|
385
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
235
386
|
}
|
|
236
387
|
const views = await adapter.sessionsDetail();
|
|
237
388
|
const run = views.find((v) => v.session.id === id);
|
|
@@ -290,7 +441,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
290
441
|
const { id } = req.params;
|
|
291
442
|
const parsed = InjectSchema.safeParse(req.body);
|
|
292
443
|
if (!parsed.success) {
|
|
293
|
-
return reply.code(400).send(
|
|
444
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
294
445
|
}
|
|
295
446
|
const ids = await adapter.sessions();
|
|
296
447
|
if (!ids.includes(id))
|
|
@@ -303,14 +454,188 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
303
454
|
return reply.code(409).send({ error: message(err) });
|
|
304
455
|
}
|
|
305
456
|
});
|
|
306
|
-
// The
|
|
307
|
-
//
|
|
457
|
+
// The gate prompt for a paused run, so a fresh browser can render the gate after a late join.
|
|
458
|
+
//
|
|
459
|
+
// Cache first, durable event log second (FINDING-051). The cache is process-lifetime, so before
|
|
460
|
+
// the fallback existed a daemon restart left every parked run unable to say what it was asking —
|
|
461
|
+
// not because the prompt was gone (core records `awaitingHuman` to the log) but because nothing
|
|
462
|
+
// read it. A restart is routine: deploy, crash, laptop sleep.
|
|
308
463
|
app.get(`${V}/runs/:id/gate`, async (req, reply) => {
|
|
309
464
|
const { id } = req.params;
|
|
310
|
-
const
|
|
311
|
-
if (
|
|
465
|
+
const cached = gateCache.get(id);
|
|
466
|
+
if (cached)
|
|
467
|
+
return { runId: id, ...cached };
|
|
468
|
+
// A miss is not evidence of anything on its own, so ask the run what it is doing before paying
|
|
469
|
+
// to replay it. Only `awaiting_human` can have an open gate, and that answer is definitive:
|
|
470
|
+
// every other status is a 404 that needs no log at all — which is also the overwhelmingly
|
|
471
|
+
// common case here, since studio polls this route for runs that are merely finished.
|
|
472
|
+
//
|
|
473
|
+
// Reading status first is therefore CHEAPER than not reading it, the opposite of what the first
|
|
474
|
+
// cut of this assumed: it trades one `sessionsDetail()` for replaying an entire event history,
|
|
475
|
+
// and it was that skipped check which made a completed run answer 503 on any build without the
|
|
476
|
+
// binding (CI caught it) — an error where the honest, knowable answer was "no gate".
|
|
477
|
+
const views = await adapter.sessionsDetail();
|
|
478
|
+
const run = views.find((v) => v.session.id === id);
|
|
479
|
+
if (!run)
|
|
480
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
481
|
+
if (run.session.status !== 'awaiting_human') {
|
|
312
482
|
return reply.code(404).send({ error: 'No open gate for this run' });
|
|
313
|
-
|
|
483
|
+
}
|
|
484
|
+
const events = await adapter.runEvents(id);
|
|
485
|
+
if (events === null) {
|
|
486
|
+
// Now — and only now — 503 is the honest answer: this run really is holding for a human, and
|
|
487
|
+
// this build cannot say what it is asking. Distinct cause, distinct message; answering "no
|
|
488
|
+
// gate" here would report a capability gap as a fact about the run (the FINDING-050 shape).
|
|
489
|
+
return reply.code(503).send({
|
|
490
|
+
error: 'Gate history is unavailable: this wicked-core build has no event-log read binding',
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
const replayed = gateCache.rebuild(id, events);
|
|
494
|
+
if (!replayed) {
|
|
495
|
+
// Parked, with a history that records no open gate. Pre-log runs land here (their prompt is
|
|
496
|
+
// genuinely lost), as does a gate whose `awaitingHuman` predates the log's retention.
|
|
497
|
+
return reply.code(404).send({ error: 'No open gate for this run' });
|
|
498
|
+
}
|
|
499
|
+
return { runId: id, ...replayed };
|
|
500
|
+
});
|
|
501
|
+
// ── Elicitation (DES-002) ────────────────────────────────────────────────────
|
|
502
|
+
//
|
|
503
|
+
// GET returns the current pending elicitation prompt for a run (display-store read).
|
|
504
|
+
// POST resolves it: the body carries the elicitationId to guard against stale tabs,
|
|
505
|
+
// the action (accept|decline|cancel), and — for accept — the operator's response.
|
|
506
|
+
app.get(`${V}/runs/:id/elicitation`, async (req, reply) => {
|
|
507
|
+
const { id } = req.params;
|
|
508
|
+
const entry = elicitationCache.get(id);
|
|
509
|
+
// `entry` already carries `runId`; return it directly to avoid TS2783
|
|
510
|
+
// ("runId is specified more than once") from a redundant spread.
|
|
511
|
+
if (entry)
|
|
512
|
+
return entry;
|
|
513
|
+
// Cache miss: check existence before returning 404.
|
|
514
|
+
// Use sessions() (IDs only) — cheaper than sessionsDetail() on this read path.
|
|
515
|
+
const ids = await adapter.sessions();
|
|
516
|
+
if (!ids.includes(id))
|
|
517
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
518
|
+
return reply.code(404).send({ error: 'No pending elicitation for this run' });
|
|
519
|
+
});
|
|
520
|
+
const ElicitationRespondSchema = z
|
|
521
|
+
.object({
|
|
522
|
+
/** Guards against stale-tab submissions: must match the current elicitation's id. */
|
|
523
|
+
elicitationId: z.string().min(1),
|
|
524
|
+
action: z.enum(['accept', 'decline', 'cancel']),
|
|
525
|
+
/** Required when action is "accept" (response must be non-empty); must be absent for decline/cancel. */
|
|
526
|
+
content: z.object({ response: z.string().min(1) }).optional(),
|
|
527
|
+
})
|
|
528
|
+
.strict()
|
|
529
|
+
.superRefine((val, ctx) => {
|
|
530
|
+
if (val.action !== 'accept' && val.content !== undefined) {
|
|
531
|
+
ctx.addIssue({
|
|
532
|
+
code: z.ZodIssueCode.custom,
|
|
533
|
+
path: ['content'],
|
|
534
|
+
message: 'content must only be provided when action is "accept"',
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
app.post(`${V}/runs/:id/elicitation`, async (req, reply) => {
|
|
539
|
+
const { id } = req.params;
|
|
540
|
+
// 1. Validate body.
|
|
541
|
+
const parsed = ElicitationRespondSchema.safeParse(req.body);
|
|
542
|
+
if (!parsed.success) {
|
|
543
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
544
|
+
}
|
|
545
|
+
const body = parsed.data;
|
|
546
|
+
// 1b. Accept requires content.response — validated here, before any state query, so
|
|
547
|
+
// the 400 is deterministic regardless of run-existence / cache state (P2).
|
|
548
|
+
if (body.action === 'accept' && !body.content?.response) {
|
|
549
|
+
return reply.code(400).send({ error: 'action:accept requires content.response' });
|
|
550
|
+
}
|
|
551
|
+
// 2. Existence check (IDs only — cheaper than sessionsDetail).
|
|
552
|
+
const ids = await adapter.sessions();
|
|
553
|
+
if (!ids.includes(id))
|
|
554
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
555
|
+
// 3. Atomically take the pending elicitation.
|
|
556
|
+
const taken = elicitationCache.take(id);
|
|
557
|
+
if (!taken)
|
|
558
|
+
return reply.code(409).send({ error: 'No pending elicitation for this run' });
|
|
559
|
+
// 4. Stale-tab check: submitted elicitationId must match the one we just took.
|
|
560
|
+
if (body.elicitationId !== taken.entry.elicitationId) {
|
|
561
|
+
elicitationCache.restoreIfUnchanged(id, taken.entry, taken.gen);
|
|
562
|
+
return reply
|
|
563
|
+
.code(409)
|
|
564
|
+
.send({ error: 'Elicitation superseded; fetch the current prompt and resubmit' });
|
|
565
|
+
}
|
|
566
|
+
// 5. Accept-specific validation.
|
|
567
|
+
if (body.action === 'accept') {
|
|
568
|
+
// 5a. content.response must be present.
|
|
569
|
+
if (typeof body.content?.response !== 'string') {
|
|
570
|
+
elicitationCache.restoreIfUnchanged(id, taken.entry, taken.gen);
|
|
571
|
+
return reply.code(400).send({ error: 'action:accept requires content.response' });
|
|
572
|
+
}
|
|
573
|
+
// 5b. Enum check: if the schema constrained the response, honour it.
|
|
574
|
+
if (taken.entry.options !== null &&
|
|
575
|
+
!taken.entry.options.includes(body.content.response)) {
|
|
576
|
+
elicitationCache.restoreIfUnchanged(id, taken.entry, taken.gen);
|
|
577
|
+
return reply.code(400).send({ error: 'response must be one of the allowed options' });
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const response = body.action === 'accept' ? body.content.response : null;
|
|
581
|
+
// 6. Forward to the actor.
|
|
582
|
+
try {
|
|
583
|
+
await adapter.resolveElicitation(id, taken.entry.elicitationId, body.action, response);
|
|
584
|
+
}
|
|
585
|
+
catch (err) {
|
|
586
|
+
elicitationCache.restoreIfUnchanged(id, taken.entry, taken.gen);
|
|
587
|
+
const code = err instanceof ElicitationUnsupportedError ? 501 : 500;
|
|
588
|
+
return reply.code(code).send({ error: message(err) });
|
|
589
|
+
}
|
|
590
|
+
// 7. Done.
|
|
591
|
+
return { status: 'resolved' };
|
|
592
|
+
});
|
|
593
|
+
// The durable history of one run (FINDING-057).
|
|
594
|
+
//
|
|
595
|
+
// `/ws` is a live tap and explicitly replays nothing on late join, so until this route existed
|
|
596
|
+
// the event log had exactly one reader — the gate route above, which reads it for a single
|
|
597
|
+
// `awaitingHuman` frame and discards the rest. Everything else a run recorded (routing councils,
|
|
598
|
+
// gate verdicts, per-unit cost) was write-only: observable if you happened to be attached while
|
|
599
|
+
// it streamed, and unrecoverable afterwards. That makes an incident un-investigable and a run's
|
|
600
|
+
// audit trail unciteable, which is the opposite of what a durable log is for.
|
|
601
|
+
//
|
|
602
|
+
// `type` filters server-side because the alternative is shipping an entire run's history to
|
|
603
|
+
// answer "what did the gates decide" — the log already excludes high-volume frames
|
|
604
|
+
// (`is_high_volume` drops CLI/chat deltas and terminal output), so what remains is the
|
|
605
|
+
// lifecycle, but a long run is still thousands of frames.
|
|
606
|
+
app.get(`${V}/runs/:id/events`, async (req, reply) => {
|
|
607
|
+
const { id } = req.params;
|
|
608
|
+
// Fastify's default querystring parser yields a string for `?type=a` and an ARRAY for a
|
|
609
|
+
// repeated `?type=a&type=b`. Typing it as `string` and calling `.split` would have thrown a
|
|
610
|
+
// 500 on the repeated form — a caller asking for two types the obvious way.
|
|
611
|
+
const { type } = req.query;
|
|
612
|
+
// IDs only: this is an existence check, and `sessionsDetail()` would load and parse every
|
|
613
|
+
// run's full unit detail to answer it — the same reason `/runs/:id/inject` uses `sessions()`.
|
|
614
|
+
const ids = await adapter.sessions();
|
|
615
|
+
if (!ids.includes(id)) {
|
|
616
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
617
|
+
}
|
|
618
|
+
const events = await adapter.runEvents(id);
|
|
619
|
+
if (events === null) {
|
|
620
|
+
// Same shape as the gate route's 503, and for the same reason: "no events" would report a
|
|
621
|
+
// missing binding as a fact about the run. The run may well have a rich history.
|
|
622
|
+
return reply.code(503).send({
|
|
623
|
+
error: 'Run history is unavailable: this wicked-core build has no event-log read binding',
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
// An empty array here is a real answer, not a failure: runs that predate the log have no
|
|
627
|
+
// history, and saying so is the honest response.
|
|
628
|
+
// Both spellings of "several types" mean the same thing: `?type=a,b` and `?type=a&type=b`.
|
|
629
|
+
// An empty filter (`?type=`, or only separators) is treated as NO filter rather than as a
|
|
630
|
+
// filter matching nothing — "show me events of no type" is not a question anyone asks, and
|
|
631
|
+
// answering it with an empty list looks identical to a run that recorded nothing.
|
|
632
|
+
const names = (Array.isArray(type) ? type : type === undefined ? [] : [type])
|
|
633
|
+
.flatMap((t) => t.split(','))
|
|
634
|
+
.map((t) => t.trim())
|
|
635
|
+
.filter(Boolean);
|
|
636
|
+
const wanted = names.length > 0 ? new Set(names) : null;
|
|
637
|
+
const filtered = wanted ? events.filter((e) => wanted.has(e.type)) : events;
|
|
638
|
+
return { runId: id, total: events.length, returned: filtered.length, events: filtered };
|
|
314
639
|
});
|
|
315
640
|
// ── Governance reads (crew#40) ──────────────────────────────────────────────
|
|
316
641
|
app.get(`${V}/governance/policies`, async () => {
|
|
@@ -325,10 +650,54 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
325
650
|
const claims = await adapter.listConformanceClaims();
|
|
326
651
|
return { claims };
|
|
327
652
|
});
|
|
328
|
-
|
|
653
|
+
// Normalize a `?repo=` query: Fastify parses a repeated `?repo=a&repo=b` into a `string[]` (take
|
|
654
|
+
// the first), and the value is TRIMMED so it's both validated and PASSED trimmed — a `?repo=%20x%20`
|
|
655
|
+
// must not pass the guard then miss the registry lookup on whitespace (Copilot #227).
|
|
656
|
+
const repoParam = (req) => {
|
|
657
|
+
const raw = req.query?.repo;
|
|
658
|
+
const first = Array.isArray(raw) ? raw[0] : raw;
|
|
659
|
+
const trimmed = first?.trim();
|
|
660
|
+
return trimmed ? trimmed : undefined;
|
|
661
|
+
};
|
|
662
|
+
// An unknown repo is the CALLER's error (404); a binding/parse failure is OURS (500). Don't collapse
|
|
663
|
+
// them (Copilot #227). Core spells the not-found case `no registered repo '<ref>'`.
|
|
664
|
+
const repoReadStatus = (msg) => (/no registered repo/i.test(msg) ? 404 : 500);
|
|
665
|
+
app.get(`${V}/governance/coverage`, async (req, reply) => {
|
|
666
|
+
// FINDING-009: with `?repo=<ref>`, report coverage over THAT repo's own code graph. The bare
|
|
667
|
+
// endpoint reads the daemon store and reports a vacuous `coverage: 1.0` that names no repo — the
|
|
668
|
+
// real gate lives per-repo. An unknown repo is an ERROR in core (never a silent vacuous report).
|
|
669
|
+
const repo = repoParam(req);
|
|
670
|
+
if (repo) {
|
|
671
|
+
try {
|
|
672
|
+
const report = await adapter.getCoverageReportForRepo(repo);
|
|
673
|
+
return { report };
|
|
674
|
+
}
|
|
675
|
+
catch (err) {
|
|
676
|
+
const msg = message(err);
|
|
677
|
+
return reply.code(repoReadStatus(msg)).send({ error: msg });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
329
680
|
const report = await adapter.getCoverageReport();
|
|
330
681
|
return { report };
|
|
331
682
|
});
|
|
683
|
+
app.get(`${V}/governance/graph`, async (req, reply) => {
|
|
684
|
+
// #122: node-count-by-kind summary of a repo's OWN code graph. Repo-scoped ONLY — there is no
|
|
685
|
+
// meaningful daemon-wide graph view (the daemon store holds run/governance nodes, not a repo's
|
|
686
|
+
// code graph), so a missing `?repo=` is a 400, an unknown repo a 404, and an engine/binding
|
|
687
|
+
// failure a 500 (not masked as 404).
|
|
688
|
+
const repo = repoParam(req);
|
|
689
|
+
if (!repo) {
|
|
690
|
+
return reply.code(400).send({ error: 'governance/graph requires a ?repo=<ref>' });
|
|
691
|
+
}
|
|
692
|
+
try {
|
|
693
|
+
const kinds = await adapter.getGraphKindsForRepo(repo);
|
|
694
|
+
return { kinds };
|
|
695
|
+
}
|
|
696
|
+
catch (err) {
|
|
697
|
+
const msg = message(err);
|
|
698
|
+
return reply.code(repoReadStatus(msg)).send({ error: msg });
|
|
699
|
+
}
|
|
700
|
+
});
|
|
332
701
|
// ── Governance writes (crew#42) ────────────────────────────────────────────
|
|
333
702
|
app.post(`${V}/governance/policies`, async (req, reply) => {
|
|
334
703
|
try {
|
|
@@ -348,6 +717,32 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
348
717
|
return reply.code(400).send({ error: message(err) });
|
|
349
718
|
}
|
|
350
719
|
});
|
|
720
|
+
// Retire, not delete. The record survives so past decisions citing it stay explicable; it just
|
|
721
|
+
// stops being enforced (FINDING-038 — a mis-authored policy otherwise denied forever).
|
|
722
|
+
app.delete(`${V}/governance/policies/:id`, async (req, reply) => {
|
|
723
|
+
const { id } = req.params;
|
|
724
|
+
try {
|
|
725
|
+
const existed = await adapter.retirePolicy(id);
|
|
726
|
+
if (!existed)
|
|
727
|
+
return reply.code(404).send({ error: `policy '${id}' not found` });
|
|
728
|
+
return { status: 'retired', id };
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
return reply.code(400).send({ error: message(err) });
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
app.delete(`${V}/governance/rules/:id`, async (req, reply) => {
|
|
735
|
+
const { id } = req.params;
|
|
736
|
+
try {
|
|
737
|
+
const existed = await adapter.retireConformanceRule(id);
|
|
738
|
+
if (!existed)
|
|
739
|
+
return reply.code(404).send({ error: `conformance rule '${id}' not found` });
|
|
740
|
+
return { status: 'retired', id };
|
|
741
|
+
}
|
|
742
|
+
catch (err) {
|
|
743
|
+
return reply.code(400).send({ error: message(err) });
|
|
744
|
+
}
|
|
745
|
+
});
|
|
351
746
|
app.get(`${V}/governance/rules/preview`, async (req, reply) => {
|
|
352
747
|
const q = req.query;
|
|
353
748
|
try {
|
|
@@ -368,7 +763,9 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
368
763
|
const workflow = adapter.getWorkflow(id);
|
|
369
764
|
if (!workflow)
|
|
370
765
|
return reply.code(404).send({ error: `workflow '${id}' not found` });
|
|
371
|
-
|
|
766
|
+
// humanGates: the phases that will PAUSE for a person even under humanConfirm:none (core#208).
|
|
767
|
+
// Surfaced so an operator can see a workflow's gates BEFORE launching it (FINDING-023).
|
|
768
|
+
return { workflow, humanGates: humanGatePhaseIds(workflow) };
|
|
372
769
|
});
|
|
373
770
|
// Register (or replace) a user-authored workflow definition.
|
|
374
771
|
// Validates, persists to ~/.wicked/workflows/<id>.json, hot-registers in the
|
|
@@ -435,16 +832,31 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
435
832
|
const repo = repos.find((r) => r.id === id);
|
|
436
833
|
if (!repo)
|
|
437
834
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
438
|
-
const graphPath =
|
|
439
|
-
const dbPath =
|
|
835
|
+
const graphPath = requirementsGraph(repo);
|
|
836
|
+
const dbPath = codeGraphDb(repo);
|
|
440
837
|
// Coverage from the live estate store — computed by wicked-core governance layer.
|
|
441
838
|
let coverage = null;
|
|
442
839
|
if (existsSync(dbPath)) {
|
|
443
840
|
try {
|
|
444
|
-
const { stdout } = await
|
|
841
|
+
const { stdout } = await execCapped(process.env['WICKED_CORE_EXE'] ?? 'wicked-core', ['coverage', '--db', dbPath, '--json'], { timeout: 20_000, cwd: repo.root_path });
|
|
445
842
|
coverage = JSON.parse(stdout);
|
|
446
843
|
}
|
|
447
|
-
catch {
|
|
844
|
+
catch (err) {
|
|
845
|
+
// The bare `catch {}` here claimed "store not yet indexed" for EVERY failure — a comment
|
|
846
|
+
// asserting a diagnosis the code never checked. An output overflow, a timeout and a missing
|
|
847
|
+
// binary all became a silent null, so the panel showed "no coverage" for reasons that are
|
|
848
|
+
// not the same problem and do not have the same fix (FINDING-016, and FINDING-050's shape:
|
|
849
|
+
// distinct causes collapsed into one outcome).
|
|
850
|
+
//
|
|
851
|
+
// Coverage stays optional — this endpoint must not 500 because the store is not indexed yet,
|
|
852
|
+
// which is a legitimate and common state. But a cause that is NOT that gets said out loud.
|
|
853
|
+
if (err instanceof ExecOutputTooLarge) {
|
|
854
|
+
req.log.warn({ err: err.message }, 'coverage output exceeded the buffer cap');
|
|
855
|
+
}
|
|
856
|
+
else if (!(err instanceof SyntaxError)) {
|
|
857
|
+
req.log.debug({ err: message(err) }, 'coverage unavailable');
|
|
858
|
+
}
|
|
859
|
+
}
|
|
448
860
|
}
|
|
449
861
|
try {
|
|
450
862
|
const content = await fsp.readFile(graphPath, 'utf8');
|
|
@@ -468,7 +880,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
468
880
|
domain: z.string().optional(),
|
|
469
881
|
offset: z.coerce.number().int().min(0).default(0),
|
|
470
882
|
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
471
|
-
});
|
|
883
|
+
}).strict();
|
|
472
884
|
app.get(`${V}/repos/:id/requirements`, async (req, reply) => {
|
|
473
885
|
const { id } = req.params;
|
|
474
886
|
const repos = await adapter.listRepos();
|
|
@@ -477,9 +889,9 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
477
889
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
478
890
|
const parsed = ReqQuerySchema.safeParse(req.query);
|
|
479
891
|
if (!parsed.success) {
|
|
480
|
-
return reply.code(400).send(
|
|
892
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid query'));
|
|
481
893
|
}
|
|
482
|
-
const page = await listRequirements(repo
|
|
894
|
+
const page = await listRequirements(repo, parsed.data);
|
|
483
895
|
if (page === null) {
|
|
484
896
|
return reply.code(404).send({ error: 'requirements_graph.json not generated for this repo yet' });
|
|
485
897
|
}
|
|
@@ -498,7 +910,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
498
910
|
catch {
|
|
499
911
|
return reply.code(400).send({ error: 'Malformed requirement key encoding' });
|
|
500
912
|
}
|
|
501
|
-
const detail = await getRequirement(repo
|
|
913
|
+
const detail = await getRequirement(repo, decoded);
|
|
502
914
|
if (detail === null)
|
|
503
915
|
return reply.code(404).send({ error: 'Requirement not found' });
|
|
504
916
|
return { requirement: detail };
|
|
@@ -510,6 +922,11 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
510
922
|
status: z.enum(['active', 'deprecated', 'review']).optional(),
|
|
511
923
|
risk: z.boolean().optional(),
|
|
512
924
|
})
|
|
925
|
+
.strict()
|
|
926
|
+
// `.strict()` BEFORE `.refine()`: the refine runs on the parsed object, so with stripping still
|
|
927
|
+
// in effect `{"title":"x","note":"y"}` would pass the non-empty check having silently discarded
|
|
928
|
+
// the edit the caller cared about. Every field here is optional, which is exactly the shape
|
|
929
|
+
// FINDING-031 makes dangerous.
|
|
513
930
|
.refine((b) => Object.keys(b).length > 0, { message: 'empty patch' });
|
|
514
931
|
app.patch(`${V}/repos/:id/requirements/:key`, async (req, reply) => {
|
|
515
932
|
const { id, key } = req.params;
|
|
@@ -519,7 +936,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
519
936
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
520
937
|
const parsed = ReqPatchSchema.safeParse(req.body);
|
|
521
938
|
if (!parsed.success) {
|
|
522
|
-
return reply.code(400).send(
|
|
939
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid patch'));
|
|
523
940
|
}
|
|
524
941
|
let decodedKey;
|
|
525
942
|
try {
|
|
@@ -528,7 +945,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
528
945
|
catch {
|
|
529
946
|
return reply.code(400).send({ error: 'Malformed requirement key encoding' });
|
|
530
947
|
}
|
|
531
|
-
const detail = await patchRequirement(repo
|
|
948
|
+
const detail = await patchRequirement(repo, decodedKey, parsed.data);
|
|
532
949
|
if (detail === null)
|
|
533
950
|
return reply.code(404).send({ error: 'Requirement not found' });
|
|
534
951
|
return { requirement: detail };
|
|
@@ -539,7 +956,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
539
956
|
const repo = repos.find((r) => r.id === id);
|
|
540
957
|
if (!repo)
|
|
541
958
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
542
|
-
const dbPath =
|
|
959
|
+
const dbPath = codeGraphDb(repo);
|
|
543
960
|
if (!existsSync(dbPath)) {
|
|
544
961
|
return reply.send({ graph: null });
|
|
545
962
|
}
|
|
@@ -556,7 +973,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
556
973
|
if (q.focus !== undefined && q.focus.trim() !== '') {
|
|
557
974
|
args.push('--focus', q.focus.trim());
|
|
558
975
|
}
|
|
559
|
-
const { stdout } = await
|
|
976
|
+
const { stdout } = await execCapped('wicked-estate', args, {
|
|
560
977
|
timeout: 30_000,
|
|
561
978
|
cwd: repo.root_path,
|
|
562
979
|
});
|
|
@@ -588,12 +1005,12 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
588
1005
|
const repo = repos.find((r) => r.id === id);
|
|
589
1006
|
if (!repo)
|
|
590
1007
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
591
|
-
const dbPath =
|
|
1008
|
+
const dbPath = codeGraphDb(repo);
|
|
592
1009
|
if (!existsSync(dbPath)) {
|
|
593
1010
|
return reply.code(404).send({ error: 'Code graph not built for this repo yet' });
|
|
594
1011
|
}
|
|
595
1012
|
try {
|
|
596
|
-
const { stdout } = await
|
|
1013
|
+
const { stdout } = await execCapped('wicked-estate', ['blast-radius', q.name.trim(), '--db', dbPath, '--json'], { timeout: 30_000, cwd: repo.root_path });
|
|
597
1014
|
return reply.send(JSON.parse(stdout));
|
|
598
1015
|
}
|
|
599
1016
|
catch (err) {
|
|
@@ -607,7 +1024,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
607
1024
|
if (!repo)
|
|
608
1025
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
609
1026
|
try {
|
|
610
|
-
const { stdout } = await
|
|
1027
|
+
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
1028
|
const commits = stdout.trim().split('\n').filter(Boolean).map((line) => {
|
|
612
1029
|
const parts = line.split('\x1f');
|
|
613
1030
|
return {
|
|
@@ -639,7 +1056,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
639
1056
|
if (!repo)
|
|
640
1057
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
641
1058
|
try {
|
|
642
|
-
const { stdout } = await
|
|
1059
|
+
const { stdout } = await execCapped('git', ['shortlog', '-sne', '-n', '--no-merges', 'HEAD'], { timeout: 10_000, cwd: repo.root_path });
|
|
643
1060
|
// Output: " 42\tFull Name <email@example.com>"
|
|
644
1061
|
const contributors = stdout.trim().split('\n').filter(Boolean).slice(0, 10).map((line) => {
|
|
645
1062
|
const m = line.match(/^\s*(\d+)\s+(.+?)\s+<([^>]+)>/);
|
|
@@ -671,7 +1088,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
671
1088
|
app.post(`${V}/terminals`, async (req, reply) => {
|
|
672
1089
|
const parsed = OpenTerminalSchema.safeParse(req.body);
|
|
673
1090
|
if (!parsed.success) {
|
|
674
|
-
return reply.code(400).send(
|
|
1091
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
675
1092
|
}
|
|
676
1093
|
const b = parsed.data;
|
|
677
1094
|
try {
|
|
@@ -688,7 +1105,7 @@ export function registerRoutes(app, adapter, gateCache) {
|
|
|
688
1105
|
const { id } = req.params;
|
|
689
1106
|
const parsed = ResizeTerminalSchema.safeParse(req.body);
|
|
690
1107
|
if (!parsed.success) {
|
|
691
|
-
return reply.code(400).send(
|
|
1108
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
692
1109
|
}
|
|
693
1110
|
try {
|
|
694
1111
|
const status = await adapter.resizeTerminal(id, parsed.data.cols, parsed.data.rows);
|