wicked-crew 0.5.0 → 0.6.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.
Files changed (116) hide show
  1. package/dist/api/audit.d.ts +57 -0
  2. package/dist/api/audit.d.ts.map +1 -0
  3. package/dist/api/audit.js +115 -0
  4. package/dist/api/audit.js.map +1 -0
  5. package/dist/api/auth.d.ts +192 -0
  6. package/dist/api/auth.d.ts.map +1 -0
  7. package/dist/api/auth.js +515 -0
  8. package/dist/api/auth.js.map +1 -0
  9. package/dist/api/gate-cache.d.ts +8 -0
  10. package/dist/api/gate-cache.d.ts.map +1 -1
  11. package/dist/api/gate-cache.js +10 -0
  12. package/dist/api/gate-cache.js.map +1 -1
  13. package/dist/api/open-path.d.ts +18 -0
  14. package/dist/api/open-path.d.ts.map +1 -0
  15. package/dist/api/open-path.js +79 -0
  16. package/dist/api/open-path.js.map +1 -0
  17. package/dist/api/routes.d.ts +112 -1
  18. package/dist/api/routes.d.ts.map +1 -1
  19. package/dist/api/routes.js +435 -16
  20. package/dist/api/routes.js.map +1 -1
  21. package/dist/api/seat-health.d.ts +55 -0
  22. package/dist/api/seat-health.d.ts.map +1 -0
  23. package/dist/api/seat-health.js +273 -0
  24. package/dist/api/seat-health.js.map +1 -0
  25. package/dist/api/seat-signin.d.ts +27 -0
  26. package/dist/api/seat-signin.d.ts.map +1 -0
  27. package/dist/api/seat-signin.js +143 -0
  28. package/dist/api/seat-signin.js.map +1 -0
  29. package/dist/api/server.d.ts +111 -3
  30. package/dist/api/server.d.ts.map +1 -1
  31. package/dist/api/server.js +236 -11
  32. package/dist/api/server.js.map +1 -1
  33. package/dist/cli/index.js +97 -3
  34. package/dist/cli/index.js.map +1 -1
  35. package/dist/cli/mcp.d.ts +14 -0
  36. package/dist/cli/mcp.d.ts.map +1 -0
  37. package/dist/cli/mcp.js +119 -0
  38. package/dist/cli/mcp.js.map +1 -0
  39. package/dist/core/adapter.d.ts +64 -1
  40. package/dist/core/adapter.d.ts.map +1 -1
  41. package/dist/core/adapter.js +162 -4
  42. package/dist/core/adapter.js.map +1 -1
  43. package/dist/core/types.d.ts +23 -361
  44. package/dist/core/types.d.ts.map +1 -1
  45. package/dist/core/types.js +11 -4
  46. package/dist/core/types.js.map +1 -1
  47. package/dist/interactive/bridge-pool.d.ts +71 -0
  48. package/dist/interactive/bridge-pool.d.ts.map +1 -0
  49. package/dist/interactive/bridge-pool.js +187 -0
  50. package/dist/interactive/bridge-pool.js.map +1 -0
  51. package/dist/interactive/bridge-root.d.ts +36 -0
  52. package/dist/interactive/bridge-root.d.ts.map +1 -0
  53. package/dist/interactive/bridge-root.js +48 -0
  54. package/dist/interactive/bridge-root.js.map +1 -0
  55. package/dist/interactive/draft-events.d.ts +144 -0
  56. package/dist/interactive/draft-events.d.ts.map +1 -0
  57. package/dist/interactive/draft-events.js +490 -0
  58. package/dist/interactive/draft-events.js.map +1 -0
  59. package/dist/interactive/edit-events.d.ts +172 -0
  60. package/dist/interactive/edit-events.d.ts.map +1 -0
  61. package/dist/interactive/edit-events.js +530 -0
  62. package/dist/interactive/edit-events.js.map +1 -0
  63. package/dist/interactive/ledger.d.ts +39 -0
  64. package/dist/interactive/ledger.d.ts.map +1 -0
  65. package/dist/interactive/ledger.js +93 -0
  66. package/dist/interactive/ledger.js.map +1 -0
  67. package/dist/interactive/proxy-routes.d.ts +39 -0
  68. package/dist/interactive/proxy-routes.d.ts.map +1 -0
  69. package/dist/interactive/proxy-routes.js +189 -0
  70. package/dist/interactive/proxy-routes.js.map +1 -0
  71. package/dist/interactive/ws-relay.d.ts +85 -0
  72. package/dist/interactive/ws-relay.d.ts.map +1 -0
  73. package/dist/interactive/ws-relay.js +191 -0
  74. package/dist/interactive/ws-relay.js.map +1 -0
  75. package/dist/projects/activity.d.ts +29 -0
  76. package/dist/projects/activity.d.ts.map +1 -0
  77. package/dist/projects/activity.js +172 -0
  78. package/dist/projects/activity.js.map +1 -0
  79. package/dist/projects/charter.d.ts +28 -0
  80. package/dist/projects/charter.d.ts.map +1 -0
  81. package/dist/projects/charter.js +53 -0
  82. package/dist/projects/charter.js.map +1 -0
  83. package/dist/projects/events.d.ts +55 -0
  84. package/dist/projects/events.d.ts.map +1 -0
  85. package/dist/projects/events.js +141 -0
  86. package/dist/projects/events.js.map +1 -0
  87. package/dist/projects/membership-index.d.ts +25 -0
  88. package/dist/projects/membership-index.d.ts.map +1 -0
  89. package/dist/projects/membership-index.js +46 -0
  90. package/dist/projects/membership-index.js.map +1 -0
  91. package/dist/projects/routes.d.ts +90 -0
  92. package/dist/projects/routes.d.ts.map +1 -0
  93. package/dist/projects/routes.js +388 -0
  94. package/dist/projects/routes.js.map +1 -0
  95. package/dist/projects/settings.d.ts +32 -0
  96. package/dist/projects/settings.d.ts.map +1 -0
  97. package/dist/projects/settings.js +64 -0
  98. package/dist/projects/settings.js.map +1 -0
  99. package/dist/qe/acceptance.d.ts +137 -0
  100. package/dist/qe/acceptance.d.ts.map +1 -0
  101. package/dist/qe/acceptance.js +249 -0
  102. package/dist/qe/acceptance.js.map +1 -0
  103. package/dist/qe/gate-events.d.ts +111 -0
  104. package/dist/qe/gate-events.d.ts.map +1 -0
  105. package/dist/qe/gate-events.js +168 -0
  106. package/dist/qe/gate-events.js.map +1 -0
  107. package/dist/qe/ledger.d.ts +100 -0
  108. package/dist/qe/ledger.d.ts.map +1 -0
  109. package/dist/qe/ledger.js +154 -0
  110. package/dist/qe/ledger.js.map +1 -0
  111. package/dist/studio/assets/index-CCwXa1cn.js +428 -0
  112. package/dist/studio/assets/index-HWxo0h41.css +32 -0
  113. package/dist/studio/index.html +2 -2
  114. package/package.json +10 -4
  115. package/dist/studio/assets/index-DaaUU8Ep.css +0 -32
  116. package/dist/studio/assets/index-Fu5DRC00.js +0 -423
@@ -3,13 +3,26 @@ 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 { join } from 'node:path';
6
+ import { isAbsolute, join, 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';
10
+ import { QeGateCache } from '../qe/gate-events.js';
11
+ import { buildAcceptanceView, resolveRunWorkflow } from '../qe/acceptance.js';
10
12
  import { buildEvidenceBundle, coreUnitId, evidenceFilename } from './evidence.js';
11
13
  import { outputUnavailableReason, resolveUnit, unitKeysFor } from './unit-output.js';
12
14
  import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
15
+ import { SeatHealthTracker } from './seat-health.js';
16
+ import { applyWorkerConfigRoot, signedInHeuristic } from './seat-signin.js';
17
+ import { isInsideRoot, openWithSystemDefault } from './open-path.js';
18
+ import { registerProjectRoutes } from '../projects/routes.js';
19
+ import { ProjectSettingsStore } from '../projects/settings.js';
20
+ import { InteractiveBridgePool } from '../interactive/bridge-pool.js';
21
+ import { registerInteractiveProxy } from '../interactive/proxy-routes.js';
22
+ import { MembershipIndex } from '../projects/membership-index.js';
23
+ import { MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.js';
24
+ import { AuditLog } from './audit.js';
25
+ import { LOCAL_ACTOR } from './auth.js';
13
26
  // Re-exported so existing `import { API_PREFIX } from './routes.js'` callers keep working; the
14
27
  // value lives in the leaf module api-prefix.ts to keep unit-output.ts out of this file's cycle.
15
28
  export { API_PREFIX } from './api-prefix.js';
@@ -93,7 +106,10 @@ const RegisterRepoSchema = z
93
106
  * schema does not accept — so `.strict()` is what turns that trip into a 400 instead of an
94
107
  * unworkflowed run reported as `201`.
95
108
  */
96
- const LaunchSchema = z.object({
109
+ // The request-body schemas below are exported so `tests/wire-contract.test.ts` can prove, at
110
+ // compile time, that every body the published contract (`wicked-crew-api-types`) lets a client
111
+ // send is a body these schemas accept — the request-direction half of the drift guard (task #84).
112
+ export const LaunchSchema = z.object({
97
113
  problem: z.string().min(1),
98
114
  sessionId: z.string().min(1).optional(),
99
115
  clisJson: z.string().min(1).optional(),
@@ -101,8 +117,11 @@ const LaunchSchema = z.object({
101
117
  humanConfirm: z.string().min(1).optional(),
102
118
  repoRef: z.string().min(1).optional(),
103
119
  workflow: z.string().min(1).optional(),
120
+ /** DES-PROJECT-001 §2.2 — file the run into a project; membership attaches atomically with
121
+ * the launch record. Unknown/archived ⇒ the launch fails (never a silent unfiled run). */
122
+ projectId: z.string().min(1).optional(),
104
123
  }).strict();
105
- const GateSchema = z.object({
124
+ export const GateSchema = z.object({
106
125
  approve: z.boolean(),
107
126
  amend: z.string().optional(),
108
127
  }).strict();
@@ -111,7 +130,7 @@ const InjectSchema = z.object({
111
130
  /** `"all"` broadcasts to every active worker; any other value is a CLI key. */
112
131
  target: z.string().min(1).default('all'),
113
132
  }).strict();
114
- const OpenTerminalSchema = z.object({
133
+ export const OpenTerminalSchema = z.object({
115
134
  cwd: z.string().min(1),
116
135
  cmd: z.array(z.string().min(1)).min(1).optional(),
117
136
  cols: z.number().int().positive(),
@@ -124,16 +143,81 @@ const ResizeTerminalSchema = z.object({
124
143
  cols: z.number().int().positive(),
125
144
  rows: z.number().int().positive(),
126
145
  }).strict();
146
+ /** `POST /open` (crew#273) — the studio Files tab's "open with the OS default app" body. */
147
+ export const OpenPathSchema = z.object({
148
+ path: z.string().min(1),
149
+ runId: z.string().min(1).optional(),
150
+ }).strict();
127
151
  /**
128
152
  * The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
129
153
  * core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
130
154
  */
131
- export function registerRoutes(app, adapter, gateCache, elicitationCache) {
155
+ export function registerRoutes(app, adapter, gateCache, elicitationCache,
156
+ // Defaulted so a caller that never arms the bus seam (tests drive this
157
+ // function directly) gets the same behavior as an unarmed daemon: an empty
158
+ // cache, `busEvent: null`, and the lazy ledger read doing all the work.
159
+ qeGateEvents = new QeGateCache(),
160
+ // Defaulted for the same reason: tests that drive this function directly get
161
+ // the projects surface with no bus (events skipped) and a fresh index.
162
+ projects = { bus: null, index: new MembershipIndex(), log: () => undefined },
163
+ // Defaulted likewise — to a NOOP trail, deliberately: a directly-driven
164
+ // route set (unit tests) must never write the operator's real
165
+ // ~/.wicked-crew/audit.log or leave appends pending after close. The real
166
+ // trail always arrives from `createServer`.
167
+ security = { audit: AuditLog.noop(), authMode: 'off' },
168
+ // Defaulted likewise: a directly-driven route set gets a fresh (all-active) health map and the
169
+ // real OS opener — tests inject both through this seam.
170
+ runtime = {}) {
171
+ const { audit } = security;
172
+ const seatHealth = runtime.seatHealth ?? new SeatHealthTracker();
173
+ const openWithOs = runtime.openWithOs ?? openWithSystemDefault;
174
+ const signedIn = runtime.signedIn ?? signedInHeuristic;
175
+ // Resolved ONCE and shared by the project routes (which read/write `interactiveRoot`) and the
176
+ // interactive proxy (which resolves a root from it) — two stores would let a PATCH land in one
177
+ // and the proxy keep reading the other.
178
+ const projectSettings = projects.settings ?? new ProjectSettingsStore();
179
+ // `req.actor` is pinned by the auth hooks `createServer` installs; a caller
180
+ // driving this function directly (tests) has no hooks, so downstream code
181
+ // still gets the ONE actor shape via this accessor.
182
+ const actorOf = (req) => req.actor ?? LOCAL_ACTOR;
132
183
  // Liveness — also proves the actor + event pump are up.
133
184
  app.get(`${V}/health`, async () => {
134
185
  const ping = await adapter.ping();
135
186
  return { status: 'ok', version: PKG_VERSION, ping };
136
187
  });
188
+ // Who am I talking to the daemon as? (task #88). In local mode this is
189
+ // always the implicit full-trust local actor; in required mode it names the
190
+ // token's actor — the cheap probe a skin uses to decide what to render.
191
+ app.get(`${V}/whoami`, async (req) => ({
192
+ actor: actorOf(req),
193
+ authMode: security.authMode,
194
+ }));
195
+ // The actor audit trail (task #88): who launched/steered/governed what.
196
+ // Read-only (observer trust); newest first; `?runId=` / `?action=` / `?limit=`.
197
+ app.get(`${V}/audit`, async (req, reply) => {
198
+ const q = req.query;
199
+ const first = (v) => (Array.isArray(v) ? v[0] : v)?.trim() || undefined;
200
+ const runId = first(q.runId);
201
+ const action = first(q.action);
202
+ const limitRaw = first(q.limit);
203
+ // Reject partial-numeric strings like "10abc" — parseInt accepts those,
204
+ // Number() is strict and returns NaN for them (Copilot, #250).
205
+ const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
206
+ if (limitRaw !== undefined && (!Number.isFinite(limit) || limit < 1 || !Number.isInteger(limit))) {
207
+ return reply.code(400).send({ error: '`limit` must be a positive integer' });
208
+ }
209
+ try {
210
+ const entries = await audit.read({
211
+ ...(runId !== undefined ? { runId } : {}),
212
+ ...(action !== undefined ? { action } : {}),
213
+ ...(limit !== undefined ? { limit } : {}),
214
+ });
215
+ return { entries };
216
+ }
217
+ catch (err) {
218
+ return reply.code(500).send({ error: message(err) });
219
+ }
220
+ });
137
221
  // Report the actually-bound port/host (honours --port / CREW_PORT / port 0).
138
222
  app.get(`${V}/config`, async () => {
139
223
  const addr = app.server.address();
@@ -141,8 +225,77 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
141
225
  const host = typeof addr === 'object' && addr ? addr.address : '127.0.0.1';
142
226
  return { port, host };
143
227
  });
144
- // The council seats for the launch form (static production roster).
145
- app.get(`${V}/roster`, async () => ({ roster: CoreAdapter.roster() }));
228
+ // The council seats for the launch form (static production roster), each carrying its RUNTIME
229
+ // health (crew#274). The roster is declarative — every configured seat is listed — and `health`
230
+ // is what the platform has observed: default active with no message; inactive + the error
231
+ // excerpt after a seat-level failure, until an ok output or the recovery probe flips it back.
232
+ // Existing fields ride through verbatim (the seat still round-trips into `clisJson` on launch)
233
+ // — the spread is deliberately NOT a field whitelist, which is what lets the engine's
234
+ // `login_invocation` (seat sign-in, wicked-core PR#278) pass through untouched. Each seat also
235
+ // gains `signed_in`: the cheap file/env presence heuristic, computed against the LIVE
236
+ // `WICKED_WORKER_HOME` env — the same value the engine reads at the next worker spawn, kept
237
+ // current by `applyWorkerConfigRoot` at boot and on every settings change.
238
+ app.get(`${V}/roster`, async () => {
239
+ const workerRoot = process.env['WICKED_WORKER_HOME'];
240
+ return {
241
+ roster: CoreAdapter.roster().map((seat) => ({
242
+ ...seat,
243
+ health: seatHealth.healthFor(String(seat.key)),
244
+ signed_in: signedIn(String(seat.key), workerRoot === '' ? undefined : workerRoot),
245
+ })),
246
+ };
247
+ });
248
+ // Open a file/folder with the OS default application (crew#273) — the studio Files tab's
249
+ // click-to-open. The open MUST happen daemon-side (the SPA cannot spawn a process), which is
250
+ // why the path is validated first: absolute, and inside one of the caller-visible roots — the
251
+ // run's workdir + extra write roots (when `runId` is given) or a registered repo root. The
252
+ // run's QE evidence/decisions dirs (`.wicked-qe`/`.wicked-testing`) live under the repo root,
253
+ // so the repo-root rule covers them. Never an arbitrary path.
254
+ app.post(`${V}/open`, async (req, reply) => {
255
+ const parsed = OpenPathSchema.safeParse(req.body);
256
+ if (!parsed.success) {
257
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid open request'));
258
+ }
259
+ const { path: rawPath, runId } = parsed.data;
260
+ if (!isAbsolute(rawPath)) {
261
+ return reply.code(400).send({ error: '`path` must be an absolute path' });
262
+ }
263
+ const target = resolve(rawPath);
264
+ const roots = [];
265
+ try {
266
+ if (runId !== undefined) {
267
+ const views = await adapter.sessionsDetail();
268
+ const view = views.find((v) => v.session.id === runId);
269
+ if (view === undefined) {
270
+ return reply.code(404).send({ error: `unknown run: ${runId}` });
271
+ }
272
+ const workdir = view.session.workdir;
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
+ }
279
+ }
280
+ for (const repo of await adapter.listRepos())
281
+ roots.push(repo.root_path);
282
+ }
283
+ catch (err) {
284
+ return reply.code(500).send({ error: message(err) });
285
+ }
286
+ if (!roots.some((root) => isInsideRoot(root, target))) {
287
+ return reply.code(403).send({
288
+ error: "path is outside every allowed root (the run's workdir/write roots and the registered repos)",
289
+ });
290
+ }
291
+ try {
292
+ await openWithOs(target);
293
+ }
294
+ catch (err) {
295
+ return reply.code(502).send({ error: `could not open ${target}: ${message(err)}` });
296
+ }
297
+ return { status: 'opened' };
298
+ });
146
299
  // Registered repos → target-repo picker.
147
300
  app.get(`${V}/repos`, async () => ({ repos: await adapter.listRepos() }));
148
301
  app.post(`${V}/repos`, async (req, reply) => {
@@ -215,23 +368,116 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
215
368
  input.repoRef = b.repoRef;
216
369
  if (b.workflow !== undefined)
217
370
  input.workflow = b.workflow;
371
+ if (b.projectId !== undefined)
372
+ input.projectId = b.projectId;
218
373
  try {
219
374
  const runId = await adapter.launchRun(input);
375
+ // Who launched it — the engine's LaunchOptions carries no actor field
376
+ // (checked, wicked-core-ts 0.6.0), so the crew-side trail is the system
377
+ // of record for run provenance (task #88).
378
+ audit.record('run.launched', actorOf(req), {
379
+ runId,
380
+ detail: {
381
+ ...(b.workflow !== undefined ? { workflow: b.workflow } : {}),
382
+ ...(b.repoRef !== undefined ? { repoRef: b.repoRef } : {}),
383
+ ...(b.projectId !== undefined ? { projectId: b.projectId } : {}),
384
+ },
385
+ });
386
+ if (b.projectId !== undefined) {
387
+ // The engine attached the crew.run membership ATOMICALLY with the launch record
388
+ // (DES-PROJECT-001 §2.2) — this is the post-commit half: tag future /ws frames and
389
+ // emit the membership event (auto-attach at launch is an attach, §4).
390
+ projects.index.set(runId, b.projectId);
391
+ projects.bus?.emit(MEMBERSHIP_ATTACHED,
392
+ // The AUTHENTICATED actor id, not a caller-supplied string — locked
393
+ // decision #6 replaces spoofable actor strings on the event surface.
394
+ { project_id: b.projectId, member: { kind: 'crew.run', ref: runId }, actor: actorOf(req).id }, membershipAttachedKey(b.projectId, 'crew.run', runId, Date.now()));
395
+ }
220
396
  return reply.code(201).send({ runId });
221
397
  }
222
398
  catch (err) {
223
399
  const msg = message(err);
400
+ // An unknown/archived project is a state conflict on a real resource, not a malformed
401
+ // request: 404/409 per the projects error mapping; anything else keeps the launch 400/409.
402
+ if (b.projectId !== undefined && /project.*not registered/i.test(msg)) {
403
+ return reply.code(404).send({ error: msg });
404
+ }
405
+ if (b.projectId !== undefined && /archived|'default'|synthesized/i.test(msg)) {
406
+ return reply.code(409).send({ error: msg });
407
+ }
224
408
  const busy = /busy|in flight|already/i.test(msg);
225
409
  return reply.code(busy ? 409 : 400).send({ error: msg });
226
410
  }
227
411
  });
228
412
  // Run list (replaces GET /sessions). Actionable-first; reconciles the gate and elicitation caches
229
413
  // so that terminal-run entries are pruned even when their terminal CoreEvent was missed.
230
- app.get(`${V}/runs`, async () => {
414
+ app.get(`${V}/runs`, async (req) => {
231
415
  const views = await adapter.sessionsDetail();
232
416
  gateCache.reconcile(views);
233
417
  elicitationCache.reconcile(views);
234
- return { runs: sortActionableFirst(views) };
418
+ // Archived runs are WRITTEN OFF (crew#265): excluded from the default view so finished
419
+ // history doesn't drown live signal, returned in full with `?include=archived`. The caches
420
+ // above reconcile over the COMPLETE set either way — a gate on an archived run must still
421
+ // resolve, not leak.
422
+ // Fastify parses a REPEATED query param as string[] — normalize so `?include=archived`
423
+ // and `?include=archived&include=archived` behave identically (Copilot).
424
+ const { include } = req.query;
425
+ const includeArchived = (Array.isArray(include) ? include : [include]).includes('archived');
426
+ const visible = includeArchived
427
+ ? views
428
+ : views.filter((v) => v.session.archived_at == null);
429
+ return { runs: sortActionableFirst(visible) };
430
+ });
431
+ // ── Run archival (crew#265) — write-off, not delete ────────────────────────
432
+ const ArchiveSchema = z.object({
433
+ archived: z.boolean(),
434
+ note: z.string().max(500).optional(),
435
+ }).strict();
436
+ app.post(`${V}/runs/:id/archive`, async (req, reply) => {
437
+ const { id } = req.params;
438
+ const parsed = ArchiveSchema.safeParse(req.body ?? {});
439
+ if (!parsed.success) {
440
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
441
+ }
442
+ try {
443
+ const found = await adapter.archiveRun(id, parsed.data.archived, parsed.data.note);
444
+ if (!found)
445
+ return reply.code(404).send({ error: 'Run not found' });
446
+ return { runId: id, archived: parsed.data.archived };
447
+ }
448
+ catch (e) {
449
+ const msg = e instanceof Error ? e.message : String(e);
450
+ // The engine names a NON-terminal status ("run X is Executing — only a terminal run…"):
451
+ // a state conflict on a real resource, not a bad request (write-off must never hide
452
+ // live work). Anything else — including the old-addon guard — is a real 500.
453
+ if (/only a terminal run/i.test(msg))
454
+ return reply.code(409).send({ error: msg });
455
+ return reply.code(500).send({ error: msg });
456
+ }
457
+ });
458
+ // Bulk write-off for campaign backlogs — explicit ids only, never implicit age selection.
459
+ const BulkArchiveSchema = z.object({
460
+ ids: z.array(z.string().min(1)).min(1).max(200),
461
+ note: z.string().max(500).optional(),
462
+ }).strict();
463
+ app.post(`${V}/runs/archive`, async (req, reply) => {
464
+ const parsed = BulkArchiveSchema.safeParse(req.body ?? {});
465
+ if (!parsed.success) {
466
+ return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
467
+ }
468
+ // Per-id outcomes rather than all-or-nothing: a batch of 45 with one live run in it
469
+ // should archive 44 and NAME the refusal, not roll back the write-off.
470
+ const results = [];
471
+ for (const id of parsed.data.ids) {
472
+ try {
473
+ const found = await adapter.archiveRun(id, true, parsed.data.note);
474
+ results.push(found ? { id, ok: true } : { id, ok: false, error: 'not found' });
475
+ }
476
+ catch (e) {
477
+ results.push({ id, ok: false, error: e instanceof Error ? e.message : String(e) });
478
+ }
479
+ }
480
+ return { results, archived: results.filter((r) => r.ok).length };
235
481
  });
236
482
  // One run's detail.
237
483
  app.get(`${V}/runs/:id`, async (req, reply) => {
@@ -249,6 +495,8 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
249
495
  chatId: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional(),
250
496
  clis: z.array(z.string().min(1)).min(1).max(8).optional(),
251
497
  repoRef: z.string().optional(),
498
+ /** DES-PROJECT-001 §2.2 — file the chat into a project (`crew.chat` membership). */
499
+ projectId: z.string().min(1).optional(),
252
500
  }).strict();
253
501
  app.post(`${V}/chats`, async (req, reply) => {
254
502
  const parsed = ChatOpenSchema.safeParse(req.body ?? {});
@@ -265,12 +513,48 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
265
513
  return reply.code(404).send({ error: `Repo ${b.repoRef} not found` });
266
514
  cwd = repo.root_path;
267
515
  }
516
+ // Validate the project BEFORE opening seats: a chat has no launch record for the engine to
517
+ // attach against atomically (chats are an in-memory seat pool), so the route validates
518
+ // up-front and attaches right after open — the one non-atomic attach, documented in the ADR
519
+ // changelog. Fail here and no seats were warmed for a filing that could never happen.
520
+ if (b.projectId !== undefined) {
521
+ try {
522
+ const project = await adapter.projectGet(b.projectId);
523
+ if (project === null) {
524
+ return reply.code(404).send({ error: `Project ${b.projectId} not found` });
525
+ }
526
+ if (project.status === 'archived') {
527
+ return reply
528
+ .code(409)
529
+ .send({ error: `project ${b.projectId} is archived and blocks new attachments` });
530
+ }
531
+ }
532
+ catch (err) {
533
+ return reply.code(501).send({ error: message(err) });
534
+ }
535
+ }
268
536
  const clis = b.clis ??
269
537
  CoreAdapter.roster()
270
538
  .map((s) => s.key)
271
539
  .filter((k) => typeof k === 'string');
272
540
  try {
273
541
  const seats = await adapter.chatOpen(chatId, clis, cwd);
542
+ if (b.projectId !== undefined) {
543
+ try {
544
+ const { member, created } = await adapter.projectMemberAttach(b.projectId, 'crew.chat', chatId);
545
+ if (created) {
546
+ projects.index.set(chatId, b.projectId);
547
+ projects.bus?.emit(MEMBERSHIP_ATTACHED, { project_id: b.projectId, member: { kind: 'crew.chat', ref: chatId }, actor: actorOf(req).id }, membershipAttachedKey(b.projectId, 'crew.chat', chatId, member.attached_at));
548
+ }
549
+ }
550
+ catch (err) {
551
+ // The chat is open and usable; the filing failed. Say so instead of failing the open —
552
+ // the caller can re-attach via POST /projects/:id/members.
553
+ return reply
554
+ .code(201)
555
+ .send({ chatId, seats, projectAttachError: message(err) });
556
+ }
557
+ }
274
558
  return reply.code(201).send({ chatId, seats });
275
559
  }
276
560
  catch (err) {
@@ -377,6 +661,44 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
377
661
  .header('Content-Disposition', `attachment; filename="${evidenceFilename(id)}"`)
378
662
  .send(bundle);
379
663
  });
664
+ // ── Acceptance gate read (Phase 6a) ─────────────────────────────────────────
665
+ // The QE evidence ledger's verdict + manifest for THIS run's repo, plus the
666
+ // gate's deny-dominates resolution of the workflow's acceptance requirement.
667
+ // Sits beside `/runs/:id/evidence` deliberately: evidence is what the RUN
668
+ // recorded about itself; acceptance is what the QE pipeline recorded about
669
+ // the repo the run worked on — two different systems of record, two routes.
670
+ //
671
+ // Always a 200 for a known run: "no ledger", "no verdict" and "FAIL" are
672
+ // real answers about the gate (each a deny with its own reason), not errors
673
+ // in the request. Only an unknown run 404s. `?qeRun=<id>` pins the read to
674
+ // one QE run's newest verdict instead of the repo's newest overall.
675
+ app.get(`${V}/runs/:id/acceptance`, async (req, reply) => {
676
+ const { id } = req.params;
677
+ const q = req.query;
678
+ const qeRunRaw = Array.isArray(q.qeRun) ? q.qeRun[0] : q.qeRun;
679
+ const qeRunId = qeRunRaw?.trim();
680
+ const views = await adapter.sessionsDetail();
681
+ const run = views.find((v) => v.session.id === id);
682
+ if (!run)
683
+ return reply.code(404).send({ error: 'Run not found' });
684
+ // `sessionsDetail()` patches workflow_id back to the definition name for
685
+ // BUILT-INS; runs of user-registered workflows still carry the instance id,
686
+ // so resolve by phase sequence over the full registry. A free-text run
687
+ // resolves to no workflow, which reads as "declares no requirement".
688
+ const workflow = resolveRunWorkflow(run, adapter.listWorkflows());
689
+ let repo = null;
690
+ if (run.session.repo_ref !== null) {
691
+ const repos = await adapter.listRepos();
692
+ repo = repos.find((r) => r.id === run.session.repo_ref) ?? null;
693
+ }
694
+ return buildAcceptanceView({
695
+ runId: id,
696
+ repo,
697
+ workflow,
698
+ gateEvents: qeGateEvents,
699
+ ...(qeRunId !== undefined && qeRunId !== '' ? { qeRunId } : {}),
700
+ });
701
+ });
380
702
  // The steering gate (§11.1). approve+amend = approve-with-steer; approve:false = reject (cancels).
381
703
  app.post(`${V}/runs/:id/gate`, async (req, reply) => {
382
704
  const { id } = req.params;
@@ -395,6 +717,17 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
395
717
  }
396
718
  try {
397
719
  const status = await adapter.confirmGate(id, parsed.data.approve, parsed.data.amend);
720
+ // WHO approved/rejected — the gate-decision audit (task #88). The engine
721
+ // records THAT the gate resolved (interaction_requests / gateDecided);
722
+ // only this HTTP layer knows the authenticated principal behind it.
723
+ audit.record('gate.decided', actorOf(req), {
724
+ runId: id,
725
+ detail: {
726
+ approve: parsed.data.approve,
727
+ ...(parsed.data.amend !== undefined ? { amend: parsed.data.amend } : {}),
728
+ status,
729
+ },
730
+ });
398
731
  return reply.send({ status });
399
732
  }
400
733
  catch (err) {
@@ -410,6 +743,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
410
743
  return reply.code(404).send({ error: 'Run not found' });
411
744
  try {
412
745
  const status = await adapter.cancelRun(id);
746
+ audit.record('run.cancelled', actorOf(req), { runId: id, detail: { status } });
413
747
  return reply.send({ status });
414
748
  }
415
749
  catch (err) {
@@ -425,9 +759,14 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
425
759
  if (!run)
426
760
  return reply.code(404).send({ error: 'Run not found' });
427
761
  try {
428
- const status = run.session.status === 'awaiting_human'
429
- ? await adapter.confirmGate(id, true)
430
- : await adapter.resumeRun(id);
762
+ const gated = run.session.status === 'awaiting_human';
763
+ const status = gated ? await adapter.confirmGate(id, true) : await adapter.resumeRun(id);
764
+ // A resume of a gated run IS a gate approval — audit it as one, so the
765
+ // "who approved" trail has no side door (task #88).
766
+ audit.record(gated ? 'gate.decided' : 'run.resumed', actorOf(req), {
767
+ runId: id,
768
+ detail: gated ? { approve: true, via: 'resume', status } : { status },
769
+ });
431
770
  return reply.send({ status });
432
771
  }
433
772
  catch (err) {
@@ -448,6 +787,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
448
787
  return reply.code(404).send({ error: 'Run not found' });
449
788
  try {
450
789
  await adapter.injectWorkerMessage(id, parsed.data.message, parsed.data.target);
790
+ audit.record('run.injected', actorOf(req), { runId: id, detail: { target: parsed.data.target } });
451
791
  return reply.send({ status: 'ok' });
452
792
  }
453
793
  catch (err) {
@@ -481,6 +821,28 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
481
821
  if (run.session.status !== 'awaiting_human') {
482
822
  return reply.code(404).send({ error: 'No open gate for this run' });
483
823
  }
824
+ // DURABLE TRUTH FIRST (DES-PROJECT-001 §5.3): the engine persists the open prompt in
825
+ // `interaction_requests`, written in the same transaction as the `awaiting_human` pause —
826
+ // so a daemon restart reads it back directly instead of replaying the event log. The cache
827
+ // adopts the row (latency layer over durable truth — its comments finally true). The replay
828
+ // below stays as the FALLBACK, not just for engines predating the binding: a run parked
829
+ // BEFORE the engine grew the table is awaiting_human with no row, and answering 404 there
830
+ // would re-open FINDING-051 for exactly the runs mid-upgrade. Row → serve it; no row →
831
+ // fall through and let the log speak.
832
+ const durableRows = typeof adapter.interactionRequests === 'function'
833
+ ? await adapter.interactionRequests(id, 'open')
834
+ : null;
835
+ const durableGate = durableRows?.find((r) => r.kind === 'gate');
836
+ if (durableGate !== undefined) {
837
+ const entry = {
838
+ ord: durableGate.ord ?? 0,
839
+ prompt: durableGate.prompt,
840
+ lifecycle: 'open',
841
+ receivedAt: new Date(durableGate.created_at).toISOString(),
842
+ };
843
+ gateCache.adopt(id, entry);
844
+ return { runId: id, ...entry };
845
+ }
484
846
  const events = await adapter.runEvents(id);
485
847
  if (events === null) {
486
848
  // Now — and only now — 503 is the honest answer: this run really is holding for a human, and
@@ -515,6 +877,25 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
515
877
  const ids = await adapter.sessions();
516
878
  if (!ids.includes(id))
517
879
  return reply.code(404).send({ error: 'Run not found' });
880
+ // Durable probe (DES-PROJECT-001 §5.3): `interaction_requests` reserves kind `elicitation`.
881
+ // The engine writes no elicitation rows yet (its elicitation surface is future work — the
882
+ // resolve path still answers 501), so this read is empty today; it exists so the cache is
883
+ // STRUCTURALLY a latency layer, and the day the engine writes the rows, restart survival
884
+ // holds here exactly as it does for gates, with no route change. Guarded like `runEvents`:
885
+ // a partial-stub adapter (tests) or a pre-0.6.0 addon simply has no durable half.
886
+ const durable = typeof adapter.interactionRequests === 'function'
887
+ ? await adapter.interactionRequests(id, 'open')
888
+ : null;
889
+ const pending = durable?.find((r) => r.kind === 'elicitation');
890
+ if (pending !== undefined) {
891
+ return {
892
+ runId: id,
893
+ elicitationId: pending.id,
894
+ message: pending.prompt,
895
+ options: null,
896
+ receivedAt: new Date(pending.created_at).toISOString(),
897
+ };
898
+ }
518
899
  return reply.code(404).send({ error: 'No pending elicitation for this run' });
519
900
  });
520
901
  const ElicitationRespondSchema = z
@@ -588,6 +969,10 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
588
969
  return reply.code(code).send({ error: message(err) });
589
970
  }
590
971
  // 7. Done.
972
+ audit.record('elicitation.resolved', actorOf(req), {
973
+ runId: id,
974
+ detail: { elicitationId: taken.entry.elicitationId, action: body.action },
975
+ });
591
976
  return { status: 'resolved' };
592
977
  });
593
978
  // The durable history of one run (FINDING-057).
@@ -701,7 +1086,9 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
701
1086
  // ── Governance writes (crew#42) ────────────────────────────────────────────
702
1087
  app.post(`${V}/governance/policies`, async (req, reply) => {
703
1088
  try {
704
- await adapter.upsertPolicy(req.body);
1089
+ const policy = req.body;
1090
+ await adapter.upsertPolicy(policy);
1091
+ audit.record('governance.policy.upserted', actorOf(req), { detail: { id: policy?.id } });
705
1092
  return { status: 'ok' };
706
1093
  }
707
1094
  catch (err) {
@@ -710,7 +1097,9 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
710
1097
  });
711
1098
  app.post(`${V}/governance/rules`, async (req, reply) => {
712
1099
  try {
713
- await adapter.upsertConformanceRule(req.body);
1100
+ const rule = req.body;
1101
+ await adapter.upsertConformanceRule(rule);
1102
+ audit.record('governance.rule.upserted', actorOf(req), { detail: { id: rule?.id } });
714
1103
  return { status: 'ok' };
715
1104
  }
716
1105
  catch (err) {
@@ -725,6 +1114,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
725
1114
  const existed = await adapter.retirePolicy(id);
726
1115
  if (!existed)
727
1116
  return reply.code(404).send({ error: `policy '${id}' not found` });
1117
+ audit.record('governance.policy.retired', actorOf(req), { detail: { id } });
728
1118
  return { status: 'retired', id };
729
1119
  }
730
1120
  catch (err) {
@@ -737,6 +1127,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
737
1127
  const existed = await adapter.retireConformanceRule(id);
738
1128
  if (!existed)
739
1129
  return reply.code(404).send({ error: `conformance rule '${id}' not found` });
1130
+ audit.record('governance.rule.retired', actorOf(req), { detail: { id } });
740
1131
  return { status: 'retired', id };
741
1132
  }
742
1133
  catch (err) {
@@ -780,6 +1171,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
780
1171
  }
781
1172
  try {
782
1173
  const id = await adapter.registerWorkflow(body);
1174
+ audit.record('workflow.registered', actorOf(req), { detail: { id } });
783
1175
  return reply.code(201).send({ id, status: 'registered' });
784
1176
  }
785
1177
  catch (err) {
@@ -1141,14 +1533,41 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
1141
1533
  return reply.code(400).send({ error: 'graphNodeLimit must be an integer between 20 and 500' });
1142
1534
  }
1143
1535
  }
1536
+ if ('worker_config_root' in patch) {
1537
+ const root = patch.worker_config_root;
1538
+ if (typeof root !== 'string' || (root !== '' && !isAbsolute(root))) {
1539
+ return reply.code(400).send({
1540
+ error: 'worker_config_root must be an absolute path, or "" for the default (~/.wicked-worker)',
1541
+ });
1542
+ }
1543
+ }
1144
1544
  // Only allow known keys through.
1145
- const allowed = ['graphNodeLimit'];
1545
+ const allowed = [
1546
+ 'graphNodeLimit',
1547
+ 'worker_config_root',
1548
+ ];
1146
1549
  const safe = {};
1147
1550
  for (const key of allowed) {
1148
1551
  if (key in patch)
1149
1552
  safe[key] = patch[key];
1150
1553
  }
1151
- return { settings: await adapter.updateSettings(safe) };
1554
+ const settings = await adapter.updateSettings(safe);
1555
+ // Re-apply the worker-config root to this process's env (seat sign-in). The engine reads
1556
+ // WICKED_WORKER_HOME per worker spawn — never cached — so this alone makes the change live
1557
+ // at the next spawn: no daemon restart, no engine restart.
1558
+ applyWorkerConfigRoot(settings.worker_config_root);
1559
+ audit.record('settings.updated', actorOf(req), { detail: { changed: Object.keys(safe) } });
1560
+ return { settings };
1561
+ });
1562
+ // ── Projects (DES-PROJECT-001) — the 9-route experience-plane surface ────────
1563
+ registerProjectRoutes(app, adapter, { ...projects, settings: projectSettings }, security);
1564
+ // ── The wicked-interactive bridge, reverse-proxied (DES-MERGE-001 §5.3/§7.2) ──
1565
+ // Mounted BESIDE the routes above and under the same `${V}` prefix, so it inherits one
1566
+ // origin, one auth hook, and one CORS posture — the whole point of slice 1.
1567
+ registerInteractiveProxy(app, adapter, {
1568
+ settings: projectSettings,
1569
+ pool: runtime.interactiveBridges ?? new InteractiveBridgePool({ log: (m) => app.log.warn(m) }),
1570
+ log: (m) => app.log.warn(m),
1152
1571
  });
1153
1572
  }
1154
1573
  //# sourceMappingURL=routes.js.map