wicked-crew 0.6.0 → 0.7.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/audit.d.ts +13 -0
- package/dist/api/audit.d.ts.map +1 -1
- package/dist/api/audit.js +18 -2
- package/dist/api/audit.js.map +1 -1
- package/dist/api/guidance-index.d.ts +39 -0
- package/dist/api/guidance-index.d.ts.map +1 -0
- package/dist/api/guidance-index.js +67 -0
- package/dist/api/guidance-index.js.map +1 -0
- package/dist/api/open-path.d.ts +16 -0
- package/dist/api/open-path.d.ts.map +1 -1
- package/dist/api/open-path.js +22 -0
- package/dist/api/open-path.js.map +1 -1
- package/dist/api/retry-index.d.ts +30 -0
- package/dist/api/retry-index.d.ts.map +1 -0
- package/dist/api/retry-index.js +45 -0
- package/dist/api/retry-index.js.map +1 -0
- package/dist/api/routes.d.ts +53 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +352 -23
- package/dist/api/routes.js.map +1 -1
- package/dist/api/run-files.d.ts +63 -0
- package/dist/api/run-files.d.ts.map +1 -0
- package/dist/api/run-files.js +271 -0
- package/dist/api/run-files.js.map +1 -0
- package/dist/api/server.d.ts +79 -0
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +135 -6
- package/dist/api/server.js.map +1 -1
- package/dist/api/stall-watchdog.d.ts +62 -0
- package/dist/api/stall-watchdog.d.ts.map +1 -0
- package/dist/api/stall-watchdog.js +138 -0
- package/dist/api/stall-watchdog.js.map +1 -0
- package/dist/cli/index.js +78 -13
- package/dist/cli/index.js.map +1 -1
- package/dist/core/adapter.d.ts +24 -10
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +191 -30
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/bridge-reaper.d.ts +134 -0
- package/dist/core/bridge-reaper.d.ts.map +1 -0
- package/dist/core/bridge-reaper.js +286 -0
- package/dist/core/bridge-reaper.js.map +1 -0
- package/dist/core/deliver.d.ts +118 -0
- package/dist/core/deliver.d.ts.map +1 -0
- package/dist/core/deliver.js +241 -0
- package/dist/core/deliver.js.map +1 -0
- package/dist/core/deliverable-floor.d.ts +103 -0
- package/dist/core/deliverable-floor.d.ts.map +1 -0
- package/dist/core/deliverable-floor.js +173 -0
- package/dist/core/deliverable-floor.js.map +1 -0
- package/dist/core/exec.d.ts +2 -0
- package/dist/core/exec.d.ts.map +1 -1
- package/dist/core/exec.js.map +1 -1
- package/dist/core/types.d.ts +79 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +3 -0
- package/dist/core/types.js.map +1 -1
- package/dist/interactive/bridge-pool.d.ts +28 -0
- package/dist/interactive/bridge-pool.d.ts.map +1 -1
- package/dist/interactive/bridge-pool.js +67 -10
- package/dist/interactive/bridge-pool.js.map +1 -1
- package/dist/interactive/chat-events.d.ts +207 -0
- package/dist/interactive/chat-events.d.ts.map +1 -0
- package/dist/interactive/chat-events.js +769 -0
- package/dist/interactive/chat-events.js.map +1 -0
- package/dist/interactive/demo-events.d.ts +283 -0
- package/dist/interactive/demo-events.d.ts.map +1 -0
- package/dist/interactive/demo-events.js +889 -0
- package/dist/interactive/demo-events.js.map +1 -0
- package/dist/interactive/draft-events.d.ts +87 -7
- package/dist/interactive/draft-events.d.ts.map +1 -1
- package/dist/interactive/draft-events.js +352 -49
- package/dist/interactive/draft-events.js.map +1 -1
- package/dist/interactive/edit-events.d.ts +22 -0
- package/dist/interactive/edit-events.d.ts.map +1 -1
- package/dist/interactive/edit-events.js +73 -2
- package/dist/interactive/edit-events.js.map +1 -1
- package/dist/interactive/repo-snapshot.d.ts +100 -0
- package/dist/interactive/repo-snapshot.d.ts.map +1 -0
- package/dist/interactive/repo-snapshot.js +289 -0
- package/dist/interactive/repo-snapshot.js.map +1 -0
- package/dist/projects/graph-paths.d.ts +92 -0
- package/dist/projects/graph-paths.d.ts.map +1 -0
- package/dist/projects/graph-paths.js +130 -0
- package/dist/projects/graph-paths.js.map +1 -0
- package/dist/projects/graph.d.ts +179 -0
- package/dist/projects/graph.d.ts.map +1 -0
- package/dist/projects/graph.js +775 -0
- package/dist/projects/graph.js.map +1 -0
- package/dist/projects/routes.d.ts +7 -0
- package/dist/projects/routes.d.ts.map +1 -1
- package/dist/projects/routes.js +122 -0
- package/dist/projects/routes.js.map +1 -1
- package/dist/studio/assets/index-8p8uwCxG.js +530 -0
- package/dist/studio/assets/index-D6S9zUtO.css +32 -0
- package/dist/studio/index.html +5 -3
- package/package.json +3 -3
- package/dist/studio/assets/index-CCwXa1cn.js +0 -428
- package/dist/studio/assets/index-HWxo0h41.css +0 -32
package/dist/api/routes.js
CHANGED
|
@@ -3,7 +3,7 @@ import { listRequirements, getRequirement, patchRequirement } from './requiremen
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { readFileSync, existsSync } from 'node:fs';
|
|
5
5
|
import { promises as fsp } from 'node:fs';
|
|
6
|
-
import { isAbsolute, join, resolve } from 'node:path';
|
|
6
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError, humanGatePhaseIds } from '../core/adapter.js';
|
|
9
9
|
import { codeGraphDb, requirementsGraph } from '../core/repoPaths.js';
|
|
@@ -14,14 +14,18 @@ import { outputUnavailableReason, resolveUnit, unitKeysFor } from './unit-output
|
|
|
14
14
|
import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
|
|
15
15
|
import { SeatHealthTracker } from './seat-health.js';
|
|
16
16
|
import { applyWorkerConfigRoot, signedInHeuristic } from './seat-signin.js';
|
|
17
|
-
import { isInsideRoot, openWithSystemDefault } from './open-path.js';
|
|
17
|
+
import { allowedRootsFor, isInsideRoot, openWithSystemDefault } from './open-path.js';
|
|
18
|
+
import { InvalidDiffBaseError, NotARegularFileError, UnresolvableDiffBaseError, readFileCapped, worktreeDiff, } from './run-files.js';
|
|
19
|
+
import { resolveProjectGraphBinding } from '../projects/graph.js';
|
|
18
20
|
import { registerProjectRoutes } from '../projects/routes.js';
|
|
19
21
|
import { ProjectSettingsStore } from '../projects/settings.js';
|
|
20
|
-
import { InteractiveBridgePool } from '../interactive/bridge-pool.js';
|
|
22
|
+
import { boundOrigin, InteractiveBridgePool } from '../interactive/bridge-pool.js';
|
|
21
23
|
import { registerInteractiveProxy } from '../interactive/proxy-routes.js';
|
|
22
24
|
import { MembershipIndex } from '../projects/membership-index.js';
|
|
23
25
|
import { MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.js';
|
|
24
26
|
import { AuditLog } from './audit.js';
|
|
27
|
+
import { RetryIndex } from './retry-index.js';
|
|
28
|
+
import { GuidanceIndex } from './guidance-index.js';
|
|
25
29
|
import { LOCAL_ACTOR } from './auth.js';
|
|
26
30
|
// Re-exported so existing `import { API_PREFIX } from './routes.js'` callers keep working; the
|
|
27
31
|
// value lives in the leaf module api-prefix.ts to keep unit-output.ts out of this file's cycle.
|
|
@@ -120,11 +124,30 @@ export const LaunchSchema = z.object({
|
|
|
120
124
|
/** DES-PROJECT-001 §2.2 — file the run into a project; membership attaches atomically with
|
|
121
125
|
* the launch record. Unknown/archived ⇒ the launch fails (never a silent unfiled run). */
|
|
122
126
|
projectId: z.string().min(1).optional(),
|
|
123
|
-
|
|
127
|
+
/** crew#293 — `"pr"` appends the hardened deliver Tool phase (push run branch + `gh pr create`)
|
|
128
|
+
* to a PER-RUN copy of the selected workflow. Requires `workflow` (enforced by the refine
|
|
129
|
+
* below, so the 400 happens at parse time, not after the adapter is consulted). */
|
|
130
|
+
deliver: z.literal('pr').optional(),
|
|
131
|
+
/** DES-UX-001 §8.3 (CREW-UX-3) — the run this launch retries. Must name an EXISTING run id
|
|
132
|
+
* (the route checks the store and 400s with a named error otherwise); persisted via the
|
|
133
|
+
* `run.launched` audit entry + retry index and echoed as `AgentSession.retry_of`. */
|
|
134
|
+
retryOf: z.string().min(1).optional(),
|
|
135
|
+
}).strict().refine((b) => b.deliver === undefined || b.workflow !== undefined, {
|
|
136
|
+
message: 'deliver: "pr" requires a workflow — a free-text run has no def to append the deliver phase to',
|
|
137
|
+
path: ['deliver'],
|
|
138
|
+
});
|
|
124
139
|
export const GateSchema = z.object({
|
|
125
140
|
approve: z.boolean(),
|
|
126
141
|
amend: z.string().optional(),
|
|
127
142
|
}).strict();
|
|
143
|
+
/** `PUT /runs/:id/guidance` (DES-UX-002 §7.2, CREW-UX-7) — the durable pre-gate note body.
|
|
144
|
+
* The empty string is a legal body: it CLEARS the note. The byte cap is checked in the route
|
|
145
|
+
* (not zod's char-counting `max`) so the 400 names the actual limit. */
|
|
146
|
+
export const GuidanceSchema = z.object({
|
|
147
|
+
text: z.string(),
|
|
148
|
+
}).strict();
|
|
149
|
+
/** ~8KB — a guidance note is operator prose, not a document store. */
|
|
150
|
+
const GUIDANCE_MAX_BYTES = 8192;
|
|
128
151
|
const InjectSchema = z.object({
|
|
129
152
|
message: z.string().min(1),
|
|
130
153
|
/** `"all"` broadcasts to every active worker; any other value is a CLI key. */
|
|
@@ -148,6 +171,26 @@ export const OpenPathSchema = z.object({
|
|
|
148
171
|
path: z.string().min(1),
|
|
149
172
|
runId: z.string().min(1).optional(),
|
|
150
173
|
}).strict();
|
|
174
|
+
/**
|
|
175
|
+
* A SKIN-OWNED settings key (crew#323): one lowercase segment under the `studio.` namespace,
|
|
176
|
+
* e.g. `studio.appearance`, `studio.notifications`. The daemon never interprets these values —
|
|
177
|
+
* the settings store is shared with the skin, and the namespace is what makes that legible.
|
|
178
|
+
*/
|
|
179
|
+
const STUDIO_SETTINGS_KEY = /^studio\.[a-z][a-z0-9-]*$/;
|
|
180
|
+
/**
|
|
181
|
+
* Per-key ceiling on a `studio.*` value, as the UTF-8 byte length of its JSON form.
|
|
182
|
+
*
|
|
183
|
+
* 512KB, not "a few KB": `studio.appearance` carries the operator's logo as a data URI, and
|
|
184
|
+
* base64 costs ~33%, so this admits a ~380KB image — a real logo, not a favicon — while a
|
|
185
|
+
* preferences blob like `studio.notifications` spends a few dozen bytes of it. One generous
|
|
186
|
+
* cap covers both because the daemon cannot tell them apart; what it stops is settings.json
|
|
187
|
+
* quietly becoming an asset store. It sits at half of Fastify's 1MiB default body limit, so a
|
|
188
|
+
* realistic multi-key patch (one blob near the cap plus small preference blobs) is still parsed
|
|
189
|
+
* before it is judged — but note the two limits COMPOSE: a body whose keys total more than 1MiB
|
|
190
|
+
* is refused by Fastify with a 413 that this route never sees, so the per-key 400 below is the
|
|
191
|
+
* ceiling on ONE key, not on the patch.
|
|
192
|
+
*/
|
|
193
|
+
const STUDIO_SETTINGS_MAX_BYTES = 512 * 1024;
|
|
151
194
|
/**
|
|
152
195
|
* The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
|
|
153
196
|
* core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
|
|
@@ -172,6 +215,24 @@ runtime = {}) {
|
|
|
172
215
|
const seatHealth = runtime.seatHealth ?? new SeatHealthTracker();
|
|
173
216
|
const openWithOs = runtime.openWithOs ?? openWithSystemDefault;
|
|
174
217
|
const signedIn = runtime.signedIn ?? signedInHeuristic;
|
|
218
|
+
const retryIndex = runtime.retryIndex ?? new RetryIndex();
|
|
219
|
+
const guidanceIndex = runtime.guidanceIndex ?? new GuidanceIndex();
|
|
220
|
+
// The run-DTO joins (DES-UX-001 §8.2/§8.3, DES-UX-002 §7.2): `project_id` from the membership
|
|
221
|
+
// record — `null` = genuinely unfiled, so the field is ALWAYS present on served runs —
|
|
222
|
+
// `retry_of` from the lineage index, and `guidance` from the guidance index, each set only
|
|
223
|
+
// when known (absent, never null, spells "not a retry" / "no note").
|
|
224
|
+
// Applied at DTO assembly on exactly the two endpoints that serve the run DTO
|
|
225
|
+
// (GET /runs + GET /runs/:id); the internal sessionsDetail() consumers are untouched.
|
|
226
|
+
const decorateRun = (view) => {
|
|
227
|
+
view.session.project_id = projects.index.projectOf(view.session.id) ?? null;
|
|
228
|
+
const retryOf = retryIndex.retryOfFor(view.session.id);
|
|
229
|
+
if (retryOf !== undefined)
|
|
230
|
+
view.session.retry_of = retryOf;
|
|
231
|
+
const guidance = guidanceIndex.guidanceFor(view.session.id);
|
|
232
|
+
if (guidance !== undefined)
|
|
233
|
+
view.session.guidance = guidance;
|
|
234
|
+
return view;
|
|
235
|
+
};
|
|
175
236
|
// Resolved ONCE and shared by the project routes (which read/write `interactiveRoot`) and the
|
|
176
237
|
// interactive proxy (which resolves a root from it) — two stores would let a PATCH land in one
|
|
177
238
|
// and the proxy keep reading the other.
|
|
@@ -261,24 +322,18 @@ runtime = {}) {
|
|
|
261
322
|
return reply.code(400).send({ error: '`path` must be an absolute path' });
|
|
262
323
|
}
|
|
263
324
|
const target = resolve(rawPath);
|
|
264
|
-
|
|
325
|
+
let roots;
|
|
265
326
|
try {
|
|
327
|
+
let session;
|
|
266
328
|
if (runId !== undefined) {
|
|
267
329
|
const views = await adapter.sessionsDetail();
|
|
268
330
|
const view = views.find((v) => v.session.id === runId);
|
|
269
331
|
if (view === undefined) {
|
|
270
332
|
return reply.code(404).send({ error: `unknown run: ${runId}` });
|
|
271
333
|
}
|
|
272
|
-
|
|
273
|
-
if (typeof workdir === 'string' && workdir.length > 0)
|
|
274
|
-
roots.push(workdir);
|
|
275
|
-
for (const r of view.session.extra_write_roots ?? []) {
|
|
276
|
-
if (typeof r === 'string' && r.length > 0)
|
|
277
|
-
roots.push(r);
|
|
278
|
-
}
|
|
334
|
+
session = view.session;
|
|
279
335
|
}
|
|
280
|
-
|
|
281
|
-
roots.push(repo.root_path);
|
|
336
|
+
roots = allowedRootsFor(session, await adapter.listRepos());
|
|
282
337
|
}
|
|
283
338
|
catch (err) {
|
|
284
339
|
return reply.code(500).send({ error: message(err) });
|
|
@@ -296,6 +351,144 @@ runtime = {}) {
|
|
|
296
351
|
}
|
|
297
352
|
return { status: 'opened' };
|
|
298
353
|
});
|
|
354
|
+
// ── Run file & diff reads (DES-FEEDBACK-002 CREW-1) ────────────────────────
|
|
355
|
+
// The studio's in-app viewer (P0-3). Both routes are GET-only assembly of reviewed machinery:
|
|
356
|
+
// the SAME containment `POST /open` runs (`allowedRootsFor` + fail-closed `isInsideRoot`) over
|
|
357
|
+
// the SAME root set (run workdir + extra write roots + registered repo roots), capped payloads,
|
|
358
|
+
// and `execCapped` git with argv arrays. Threat delta over /open is strictly smaller: these only
|
|
359
|
+
// return bytes the daemon can already read inside the same containment — no OS opener, no write.
|
|
360
|
+
/** Resolve `:id` → the run's session, and the contained target from `?path=` when present.
|
|
361
|
+
* Shared by both routes so their validation ladders (404 unknown run → 400 non-absolute →
|
|
362
|
+
* 403 outside every root) cannot drift. Returns `null` after replying. */
|
|
363
|
+
const resolveRunPath = async (reply, id, rawPath) => {
|
|
364
|
+
let session;
|
|
365
|
+
let roots;
|
|
366
|
+
try {
|
|
367
|
+
const views = await adapter.sessionsDetail();
|
|
368
|
+
const view = views.find((v) => v.session.id === id);
|
|
369
|
+
if (view === undefined) {
|
|
370
|
+
await reply.code(404).send({ error: `unknown run: ${id}` });
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
session = view.session;
|
|
374
|
+
if (rawPath === undefined)
|
|
375
|
+
return { session };
|
|
376
|
+
roots = allowedRootsFor(session, await adapter.listRepos());
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
await reply.code(500).send({ error: message(err) });
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
if (!isAbsolute(rawPath)) {
|
|
383
|
+
await reply.code(400).send({ error: '`path` must be an absolute path' });
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
const target = resolve(rawPath);
|
|
387
|
+
if (!roots.some((root) => isInsideRoot(root, target))) {
|
|
388
|
+
await reply.code(403).send({
|
|
389
|
+
error: "path is outside every allowed root (the run's workdir/write roots and the registered repos)",
|
|
390
|
+
});
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
return { session, target };
|
|
394
|
+
};
|
|
395
|
+
// Fastify parses a repeated param as string[] (Copilot, #250/#266). These are FILE-READ
|
|
396
|
+
// routes: `?path=a&path=b` is rejected outright (400) rather than silently reading as
|
|
397
|
+
// either (Copilot, #305).
|
|
398
|
+
const REPEATED_PATH = Symbol('repeated path param');
|
|
399
|
+
const singlePathQ = (v) => Array.isArray(v) ? REPEATED_PATH : v?.trim() || undefined;
|
|
400
|
+
// File content from the run's contained roots: 512 KB cap (`truncated: true` past it, first
|
|
401
|
+
// 512 KB served), NUL-in-first-8KB binary sniff (`binary: true`, `content: ""`). Read-only by
|
|
402
|
+
// construction (`fs` read); no directory listing — the studio already has the file list.
|
|
403
|
+
app.get(`${V}/runs/:id/files`, async (req, reply) => {
|
|
404
|
+
const { id } = req.params;
|
|
405
|
+
const rawPath = singlePathQ(req.query.path);
|
|
406
|
+
if (rawPath === REPEATED_PATH) {
|
|
407
|
+
return reply.code(400).send({ error: '`path` may be given at most once' });
|
|
408
|
+
}
|
|
409
|
+
if (rawPath === undefined) {
|
|
410
|
+
return reply.code(400).send({ error: '`path` query parameter is required' });
|
|
411
|
+
}
|
|
412
|
+
const resolved = await resolveRunPath(reply, id, rawPath);
|
|
413
|
+
if (resolved === null)
|
|
414
|
+
return reply;
|
|
415
|
+
const target = resolved.target;
|
|
416
|
+
try {
|
|
417
|
+
const read = await readFileCapped(target);
|
|
418
|
+
return { path: target, ...read };
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
if (err.code === 'ENOENT') {
|
|
422
|
+
return reply.code(404).send({ error: `no such file: ${target}` });
|
|
423
|
+
}
|
|
424
|
+
if (err instanceof NotARegularFileError) {
|
|
425
|
+
return reply.code(400).send({ error: `\`path\` is not a regular file: ${target}` });
|
|
426
|
+
}
|
|
427
|
+
return reply.code(500).send({ error: message(err) });
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
// The run's worktree diff against HEAD — or, with `?base=` (CREW-UX-1, DES-UX-001 §8.1),
|
|
431
|
+
// against the run branch's fork point (`base=merge-base`) or a plain in-repo ref, so committed
|
|
432
|
+
// run work is visible. Staged + unstaged; untracked appended as all-addition `--no-index`
|
|
433
|
+
// hunks; whole-tree or `?path=` narrowed. 1 MB output cap. `diff: ""` is a real answer (clean
|
|
434
|
+
// tree), not an error. 409 — not 404 — when the run has no workdir or the workdir has been
|
|
435
|
+
// reaped: the RUN exists; what is gone is the thing to diff against. `base` is a baseline,
|
|
436
|
+
// NEVER a command surface: anything that is not the merge-base literal or a plain resolvable
|
|
437
|
+
// ref (flags, paths, ranges, separators) is a named 400 before any git process sees it.
|
|
438
|
+
app.get(`${V}/runs/:id/diff`, async (req, reply) => {
|
|
439
|
+
const { id } = req.params;
|
|
440
|
+
const q = req.query;
|
|
441
|
+
const rawPath = singlePathQ(q.path);
|
|
442
|
+
if (rawPath === REPEATED_PATH) {
|
|
443
|
+
return reply.code(400).send({ error: '`path` may be given at most once' });
|
|
444
|
+
}
|
|
445
|
+
const rawBase = singlePathQ(q.base);
|
|
446
|
+
if (rawBase === REPEATED_PATH) {
|
|
447
|
+
return reply.code(400).send({ error: '`base` may be given at most once' });
|
|
448
|
+
}
|
|
449
|
+
const resolved = await resolveRunPath(reply, id, rawPath);
|
|
450
|
+
if (resolved === null)
|
|
451
|
+
return reply;
|
|
452
|
+
const workdir = resolved.session.workdir;
|
|
453
|
+
if (typeof workdir !== 'string' || workdir.length === 0) {
|
|
454
|
+
return reply.code(409).send({ error: `run ${id} has no workdir — nothing to diff` });
|
|
455
|
+
}
|
|
456
|
+
if (!existsSync(workdir)) {
|
|
457
|
+
return reply.code(409).send({ error: `run ${id}'s workdir no longer exists: ${workdir}` });
|
|
458
|
+
}
|
|
459
|
+
// Narrowing is WORKTREE-scoped: a contained-but-outside-the-worktree path (extra write
|
|
460
|
+
// root / repo root) is a valid FILE read but has no meaning as a diff pathspec — rejected
|
|
461
|
+
// explicitly here rather than handing git a `../`-prefixed pathspec and surfacing its
|
|
462
|
+
// "outside repository" error as a 500 (Copilot, #305).
|
|
463
|
+
if (resolved.target !== undefined && !isInsideRoot(workdir, resolved.target)) {
|
|
464
|
+
return reply.code(400).send({
|
|
465
|
+
error: `\`path\` must be inside the run's worktree to diff: ${workdir}`,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
const rel = resolved.target === undefined ? undefined : relative(workdir, resolved.target);
|
|
469
|
+
try {
|
|
470
|
+
return await worktreeDiff(workdir, rel, rawBase);
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
// Named 400s (§8.1): malformed base (not a plain ref) and well-formed-but-unresolvable
|
|
474
|
+
// base are both client errors, each with its error name in the body — never a git 500.
|
|
475
|
+
if (err instanceof InvalidDiffBaseError || err instanceof UnresolvableDiffBaseError) {
|
|
476
|
+
return reply.code(400).send({ error: `${err.name}: ${message(err)}` });
|
|
477
|
+
}
|
|
478
|
+
if (err.code === 'ENOENT') {
|
|
479
|
+
return reply.code(500).send({ error: 'git executable not found on server' });
|
|
480
|
+
}
|
|
481
|
+
// execCapped throws (no partial output attached) past its 64 MiB daemon-wide buffer —
|
|
482
|
+
// beyond graceful truncation, so the answer is an explicit, actionable refusal
|
|
483
|
+
// rather than a generic 500 (Copilot, #305).
|
|
484
|
+
if (err instanceof ExecOutputTooLarge) {
|
|
485
|
+
return reply.code(507).send({
|
|
486
|
+
error: "diff output exceeds the server's execution buffer — narrow the request with ?path=",
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
return reply.code(500).send({ error: message(err) });
|
|
490
|
+
}
|
|
491
|
+
});
|
|
299
492
|
// Registered repos → target-repo picker.
|
|
300
493
|
app.get(`${V}/repos`, async () => ({ repos: await adapter.listRepos() }));
|
|
301
494
|
app.post(`${V}/repos`, async (req, reply) => {
|
|
@@ -368,8 +561,34 @@ runtime = {}) {
|
|
|
368
561
|
input.repoRef = b.repoRef;
|
|
369
562
|
if (b.workflow !== undefined)
|
|
370
563
|
input.workflow = b.workflow;
|
|
371
|
-
if (b.projectId !== undefined)
|
|
564
|
+
if (b.projectId !== undefined) {
|
|
372
565
|
input.projectId = b.projectId;
|
|
566
|
+
// A project is a CONTEXT (crew#326): a run filed into one should see the project's whole
|
|
567
|
+
// co-located graph, not just its own repo's. Resolved — never REFRESHED — at launch: a
|
|
568
|
+
// launch that silently indexed N repos would block this response for as long as the slowest
|
|
569
|
+
// of them takes, so a missing or stale graph degrades to the repo graph and says why.
|
|
570
|
+
//
|
|
571
|
+
// The decision is recorded either way. "This run sees the project" and "this run sees one
|
|
572
|
+
// repo, because X" are both facts about what the run could observe, and the second is the one
|
|
573
|
+
// an operator needs when a worker reports that a sibling repo does not exist.
|
|
574
|
+
const decision = await resolveProjectGraphBinding(adapter, b.projectId, b.repoRef);
|
|
575
|
+
if (decision.binding !== null)
|
|
576
|
+
input.projectGraph = decision.binding;
|
|
577
|
+
req.log.info({ runId: input.sessionId, projectId: b.projectId, repoRef: b.repoRef ?? null }, `run ${input.sessionId}: ${decision.reason}`);
|
|
578
|
+
}
|
|
579
|
+
if (b.deliver !== undefined)
|
|
580
|
+
input.deliver = b.deliver;
|
|
581
|
+
// Retry lineage (DES-UX-001 §8.3): `retryOf` must name an EXISTING run — recording lineage
|
|
582
|
+
// to a run that never existed would be provenance pointing at nothing, so the launch fails
|
|
583
|
+
// loudly (400, before anything is committed) rather than filing a dangling edge.
|
|
584
|
+
if (b.retryOf !== undefined) {
|
|
585
|
+
const known = await adapter.sessions();
|
|
586
|
+
if (!known.includes(b.retryOf)) {
|
|
587
|
+
return reply.code(400).send({
|
|
588
|
+
error: `retryOf names an unknown run: ${b.retryOf} — lineage must point at an existing run id`,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
373
592
|
try {
|
|
374
593
|
const runId = await adapter.launchRun(input);
|
|
375
594
|
// Who launched it — the engine's LaunchOptions carries no actor field
|
|
@@ -381,8 +600,14 @@ runtime = {}) {
|
|
|
381
600
|
...(b.workflow !== undefined ? { workflow: b.workflow } : {}),
|
|
382
601
|
...(b.repoRef !== undefined ? { repoRef: b.repoRef } : {}),
|
|
383
602
|
...(b.projectId !== undefined ? { projectId: b.projectId } : {}),
|
|
603
|
+
...(b.deliver !== undefined ? { deliver: b.deliver } : {}),
|
|
604
|
+
// CREW-UX-3: the trail is the durable record of lineage — the retry index (and a
|
|
605
|
+
// restarted daemon's hydrate) reads it back from exactly this entry.
|
|
606
|
+
...(b.retryOf !== undefined ? { retryOf: b.retryOf } : {}),
|
|
384
607
|
},
|
|
385
608
|
});
|
|
609
|
+
if (b.retryOf !== undefined)
|
|
610
|
+
retryIndex.set(runId, b.retryOf);
|
|
386
611
|
if (b.projectId !== undefined) {
|
|
387
612
|
// The engine attached the crew.run membership ATOMICALLY with the launch record
|
|
388
613
|
// (DES-PROJECT-001 §2.2) — this is the post-commit half: tag future /ws frames and
|
|
@@ -426,7 +651,7 @@ runtime = {}) {
|
|
|
426
651
|
const visible = includeArchived
|
|
427
652
|
? views
|
|
428
653
|
: views.filter((v) => v.session.archived_at == null);
|
|
429
|
-
return { runs: sortActionableFirst(visible) };
|
|
654
|
+
return { runs: sortActionableFirst(visible).map(decorateRun) };
|
|
430
655
|
});
|
|
431
656
|
// ── Run archival (crew#265) — write-off, not delete ────────────────────────
|
|
432
657
|
const ArchiveSchema = z.object({
|
|
@@ -486,7 +711,39 @@ runtime = {}) {
|
|
|
486
711
|
const run = views.find((v) => v.session.id === id);
|
|
487
712
|
if (!run)
|
|
488
713
|
return reply.code(404).send({ error: 'Run not found' });
|
|
489
|
-
return { run };
|
|
714
|
+
return { run: decorateRun(run) };
|
|
715
|
+
});
|
|
716
|
+
// Durable pre-gate guidance (DES-UX-002 §7.2 — spec'd there as CREW-UX-4, implemented as
|
|
717
|
+
// CREW-UX-7 because crew#308 already spent that id; see guidance-index.ts). Upserts the ONE
|
|
718
|
+
// operator note on the run; the empty string clears it. The durable record is the
|
|
719
|
+
// `guidance.set` audit entry (actor + full text); the index is the read-side layer the run
|
|
720
|
+
// DTOs echo it from.
|
|
721
|
+
//
|
|
722
|
+
// GOVERNANCE ISOLATION (deliberate): the governance gate does NOT read this field — the
|
|
723
|
+
// engine's `LaunchOptions` never sees it, and no gate evaluation consults it. It is
|
|
724
|
+
// operator-visible context only; the amend text at gate decision (`POST /runs/:id/gate`)
|
|
725
|
+
// stays the ONE injection point. The studio pre-populates its steer textarea from this note,
|
|
726
|
+
// and injection still happens only through the governed amend.
|
|
727
|
+
app.put(`${V}/runs/:id/guidance`, async (req, reply) => {
|
|
728
|
+
const { id } = req.params;
|
|
729
|
+
const parsed = GuidanceSchema.safeParse(req.body);
|
|
730
|
+
if (!parsed.success) {
|
|
731
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
732
|
+
}
|
|
733
|
+
const { text } = parsed.data;
|
|
734
|
+
if (Buffer.byteLength(text, 'utf8') > GUIDANCE_MAX_BYTES) {
|
|
735
|
+
return reply.code(400).send({
|
|
736
|
+
error: `guidance exceeds the ${GUIDANCE_MAX_BYTES}-byte cap — a note this size belongs in the problem statement or a linked doc`,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
const known = await adapter.sessions();
|
|
740
|
+
if (!known.includes(id))
|
|
741
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
742
|
+
// The trail is the durable record (CREW-UX-3 posture): a restarted daemon's
|
|
743
|
+
// GuidanceIndex.hydrate reads the note back from exactly this entry.
|
|
744
|
+
audit.record('guidance.set', actorOf(req), { runId: id, detail: { text } });
|
|
745
|
+
guidanceIndex.set(id, text);
|
|
746
|
+
return { runId: id, guidance: text };
|
|
490
747
|
});
|
|
491
748
|
// ── Chat sessions (crew#165): warm ACP seat pool + group fan-out (core#134) ──
|
|
492
749
|
// A chat is NOT a run: no council, no gates, no units. Seats warm on open;
|
|
@@ -1521,19 +1778,22 @@ runtime = {}) {
|
|
|
1521
1778
|
}
|
|
1522
1779
|
});
|
|
1523
1780
|
// ── System settings ──────────────────────────────────────────────────────────
|
|
1781
|
+
// The store is SHARED with the skin (crew#323): beside the engine's own keys it round-trips
|
|
1782
|
+
// the studio's `studio.*` preference blobs verbatim — see `CrewSystemSettings`'s index
|
|
1783
|
+
// signature in core/types.ts, which states that rather than leaving it to a client comment.
|
|
1524
1784
|
app.get(`${V}/settings`, async () => ({ settings: await adapter.getSettings() }));
|
|
1525
1785
|
app.put(`${V}/settings`, async (req, reply) => {
|
|
1526
1786
|
const patch = req.body;
|
|
1527
1787
|
if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
|
|
1528
1788
|
return reply.code(400).send({ error: 'body must be a JSON object' });
|
|
1529
1789
|
}
|
|
1530
|
-
if ('graphNodeLimit'
|
|
1790
|
+
if (Object.hasOwn(patch, 'graphNodeLimit')) {
|
|
1531
1791
|
const limit = patch.graphNodeLimit;
|
|
1532
1792
|
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 20 || limit > 500) {
|
|
1533
1793
|
return reply.code(400).send({ error: 'graphNodeLimit must be an integer between 20 and 500' });
|
|
1534
1794
|
}
|
|
1535
1795
|
}
|
|
1536
|
-
if ('worker_config_root'
|
|
1796
|
+
if (Object.hasOwn(patch, 'worker_config_root')) {
|
|
1537
1797
|
const root = patch.worker_config_root;
|
|
1538
1798
|
if (typeof root !== 'string' || (root !== '' && !isAbsolute(root))) {
|
|
1539
1799
|
return reply.code(400).send({
|
|
@@ -1541,22 +1801,82 @@ runtime = {}) {
|
|
|
1541
1801
|
});
|
|
1542
1802
|
}
|
|
1543
1803
|
}
|
|
1544
|
-
//
|
|
1804
|
+
// workerStallMinutes (crew#287): the stall watchdog's silence threshold. Bounded to a day —
|
|
1805
|
+
// a huge value is "off in practice", which should be a deliberate choice, not a typo.
|
|
1806
|
+
if (Object.hasOwn(patch, 'workerStallMinutes')) {
|
|
1807
|
+
const mins = patch.workerStallMinutes;
|
|
1808
|
+
if (typeof mins !== 'number' || !Number.isInteger(mins) || mins < 1 || mins > 1440) {
|
|
1809
|
+
return reply
|
|
1810
|
+
.code(400)
|
|
1811
|
+
.send({ error: 'workerStallMinutes must be an integer between 1 and 1440' });
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
// Skin-owned keys (crew#323): allowed through, but VALIDATED rather than trusted. The
|
|
1815
|
+
// daemon does not read these values, so the only two things it can check are the two that
|
|
1816
|
+
// can hurt it — a value it cannot persist, and a value big enough to bloat settings.json.
|
|
1817
|
+
// Both answer 400 naming the key: silence is exactly what made #323 invisible for a whole
|
|
1818
|
+
// campaign of appearance work.
|
|
1819
|
+
const studioKeys = Object.keys(patch).filter((k) => STUDIO_SETTINGS_KEY.test(k));
|
|
1820
|
+
for (const key of studioKeys) {
|
|
1821
|
+
const value = patch[key];
|
|
1822
|
+
// `undefined` from a throwing/circular value AND from a value JSON.stringify simply
|
|
1823
|
+
// drops (a function, a symbol) — both are unpersistable, both are refused.
|
|
1824
|
+
let encoded;
|
|
1825
|
+
try {
|
|
1826
|
+
encoded = JSON.stringify(value);
|
|
1827
|
+
}
|
|
1828
|
+
catch {
|
|
1829
|
+
encoded = undefined;
|
|
1830
|
+
}
|
|
1831
|
+
if (encoded === undefined) {
|
|
1832
|
+
return reply.code(400).send({ error: `${key} must be a JSON-serializable value` });
|
|
1833
|
+
}
|
|
1834
|
+
const bytes = Buffer.byteLength(encoded, 'utf8');
|
|
1835
|
+
if (bytes > STUDIO_SETTINGS_MAX_BYTES) {
|
|
1836
|
+
return reply.code(400).send({
|
|
1837
|
+
error: `${key} is ${bytes} bytes of JSON, over the ${STUDIO_SETTINGS_MAX_BYTES}-byte per-key cap on studio.* settings`,
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
// Only known engine keys and validated `studio.*` keys are persisted. A key that is
|
|
1842
|
+
// NEITHER is dropped, not refused: request bodies are forward-additive too (DES-STUDIO-001
|
|
1843
|
+
// §5.1), so an older daemon meeting a newer client's engine key must not fail the whole
|
|
1844
|
+
// patch and take the caller's other keys down with it. The cost is that a typo goes
|
|
1845
|
+
// unnoticed on the wire — so the dropped keys are NAMED in the audit entry below.
|
|
1545
1846
|
const allowed = [
|
|
1546
1847
|
'graphNodeLimit',
|
|
1547
1848
|
'worker_config_root',
|
|
1849
|
+
'workerStallMinutes',
|
|
1548
1850
|
];
|
|
1549
1851
|
const safe = {};
|
|
1550
1852
|
for (const key of allowed) {
|
|
1551
|
-
|
|
1853
|
+
// `Object.hasOwn`, NOT `key in patch` (Copilot on #324): `in` walks the prototype chain, so
|
|
1854
|
+
// a body whose prototype carries an engine key would be persisted from a value the caller
|
|
1855
|
+
// never sent. Not reachable through the default JSON parser — `JSON.parse` yields a plain
|
|
1856
|
+
// object and Fastify refuses `__proto__` — but this route already accepts custom
|
|
1857
|
+
// content-type parsers, which can produce non-plain objects. Own properties only, and the
|
|
1858
|
+
// same spelling the `ignored` filter below uses, so the two can never disagree about what
|
|
1859
|
+
// "present" means.
|
|
1860
|
+
if (Object.hasOwn(patch, key))
|
|
1552
1861
|
safe[key] = patch[key];
|
|
1553
1862
|
}
|
|
1863
|
+
for (const key of studioKeys) {
|
|
1864
|
+
safe[key] = patch[key];
|
|
1865
|
+
}
|
|
1866
|
+
// `Object.hasOwn`, NOT `k in safe`: `in` walks the prototype chain, so a dropped key named
|
|
1867
|
+
// `toString` / `valueOf` / `constructor` would test as "kept" and vanish from `ignored` —
|
|
1868
|
+
// the exact silent drop this route exists to end.
|
|
1869
|
+
const ignored = Object.keys(patch).filter((k) => !Object.hasOwn(safe, k));
|
|
1554
1870
|
const settings = await adapter.updateSettings(safe);
|
|
1555
1871
|
// Re-apply the worker-config root to this process's env (seat sign-in). The engine reads
|
|
1556
1872
|
// WICKED_WORKER_HOME per worker spawn — never cached — so this alone makes the change live
|
|
1557
1873
|
// at the next spawn: no daemon restart, no engine restart.
|
|
1558
1874
|
applyWorkerConfigRoot(settings.worker_config_root);
|
|
1559
|
-
|
|
1875
|
+
// `changed` names every persisted key, engine and `studio.*` alike; `ignored` (present only
|
|
1876
|
+
// when there is one) is where a dropped unknown key stops being invisible.
|
|
1877
|
+
audit.record('settings.updated', actorOf(req), {
|
|
1878
|
+
detail: { changed: Object.keys(safe), ...(ignored.length > 0 ? { ignored } : {}) },
|
|
1879
|
+
});
|
|
1560
1880
|
return { settings };
|
|
1561
1881
|
});
|
|
1562
1882
|
// ── Projects (DES-PROJECT-001) — the 9-route experience-plane surface ────────
|
|
@@ -1566,7 +1886,16 @@ runtime = {}) {
|
|
|
1566
1886
|
// origin, one auth hook, and one CORS posture — the whole point of slice 1.
|
|
1567
1887
|
registerInteractiveProxy(app, adapter, {
|
|
1568
1888
|
settings: projectSettings,
|
|
1569
|
-
pool: runtime.interactiveBridges ??
|
|
1889
|
+
pool: runtime.interactiveBridges ??
|
|
1890
|
+
new InteractiveBridgePool({
|
|
1891
|
+
log: (m) => app.log.warn(m),
|
|
1892
|
+
debug: (m) => app.log.debug(m),
|
|
1893
|
+
// #298: the daemon's own origin, read LAZILY off the bound server — the pool is built
|
|
1894
|
+
// before `listen`, but only consulted while serving a request, i.e. once bound. The
|
|
1895
|
+
// pool POSTs it to the bridge's /api/studio-origin on start/adopt so the bridge's
|
|
1896
|
+
// `GET /` redirects into studio.
|
|
1897
|
+
studioOrigin: () => boundOrigin(app.server.address()),
|
|
1898
|
+
}),
|
|
1570
1899
|
log: (m) => app.log.warn(m),
|
|
1571
1900
|
});
|
|
1572
1901
|
}
|