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.
- package/dist/api/audit.d.ts +57 -0
- package/dist/api/audit.d.ts.map +1 -0
- package/dist/api/audit.js +115 -0
- package/dist/api/audit.js.map +1 -0
- package/dist/api/auth.d.ts +192 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +515 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/gate-cache.d.ts +8 -0
- package/dist/api/gate-cache.d.ts.map +1 -1
- package/dist/api/gate-cache.js +10 -0
- package/dist/api/gate-cache.js.map +1 -1
- package/dist/api/open-path.d.ts +18 -0
- package/dist/api/open-path.d.ts.map +1 -0
- package/dist/api/open-path.js +79 -0
- package/dist/api/open-path.js.map +1 -0
- package/dist/api/routes.d.ts +112 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +435 -16
- package/dist/api/routes.js.map +1 -1
- package/dist/api/seat-health.d.ts +55 -0
- package/dist/api/seat-health.d.ts.map +1 -0
- package/dist/api/seat-health.js +273 -0
- package/dist/api/seat-health.js.map +1 -0
- package/dist/api/seat-signin.d.ts +27 -0
- package/dist/api/seat-signin.d.ts.map +1 -0
- package/dist/api/seat-signin.js +143 -0
- package/dist/api/seat-signin.js.map +1 -0
- package/dist/api/server.d.ts +111 -3
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +236 -11
- package/dist/api/server.js.map +1 -1
- package/dist/cli/index.js +97 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/mcp.d.ts +14 -0
- package/dist/cli/mcp.d.ts.map +1 -0
- package/dist/cli/mcp.js +119 -0
- package/dist/cli/mcp.js.map +1 -0
- package/dist/core/adapter.d.ts +64 -1
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +162 -4
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/types.d.ts +23 -361
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +11 -4
- package/dist/core/types.js.map +1 -1
- package/dist/interactive/bridge-pool.d.ts +71 -0
- package/dist/interactive/bridge-pool.d.ts.map +1 -0
- package/dist/interactive/bridge-pool.js +187 -0
- package/dist/interactive/bridge-pool.js.map +1 -0
- package/dist/interactive/bridge-root.d.ts +36 -0
- package/dist/interactive/bridge-root.d.ts.map +1 -0
- package/dist/interactive/bridge-root.js +48 -0
- package/dist/interactive/bridge-root.js.map +1 -0
- package/dist/interactive/draft-events.d.ts +144 -0
- package/dist/interactive/draft-events.d.ts.map +1 -0
- package/dist/interactive/draft-events.js +490 -0
- package/dist/interactive/draft-events.js.map +1 -0
- package/dist/interactive/edit-events.d.ts +172 -0
- package/dist/interactive/edit-events.d.ts.map +1 -0
- package/dist/interactive/edit-events.js +530 -0
- package/dist/interactive/edit-events.js.map +1 -0
- package/dist/interactive/ledger.d.ts +39 -0
- package/dist/interactive/ledger.d.ts.map +1 -0
- package/dist/interactive/ledger.js +93 -0
- package/dist/interactive/ledger.js.map +1 -0
- package/dist/interactive/proxy-routes.d.ts +39 -0
- package/dist/interactive/proxy-routes.d.ts.map +1 -0
- package/dist/interactive/proxy-routes.js +189 -0
- package/dist/interactive/proxy-routes.js.map +1 -0
- package/dist/interactive/ws-relay.d.ts +85 -0
- package/dist/interactive/ws-relay.d.ts.map +1 -0
- package/dist/interactive/ws-relay.js +191 -0
- package/dist/interactive/ws-relay.js.map +1 -0
- package/dist/projects/activity.d.ts +29 -0
- package/dist/projects/activity.d.ts.map +1 -0
- package/dist/projects/activity.js +172 -0
- package/dist/projects/activity.js.map +1 -0
- package/dist/projects/charter.d.ts +28 -0
- package/dist/projects/charter.d.ts.map +1 -0
- package/dist/projects/charter.js +53 -0
- package/dist/projects/charter.js.map +1 -0
- package/dist/projects/events.d.ts +55 -0
- package/dist/projects/events.d.ts.map +1 -0
- package/dist/projects/events.js +141 -0
- package/dist/projects/events.js.map +1 -0
- package/dist/projects/membership-index.d.ts +25 -0
- package/dist/projects/membership-index.d.ts.map +1 -0
- package/dist/projects/membership-index.js +46 -0
- package/dist/projects/membership-index.js.map +1 -0
- package/dist/projects/routes.d.ts +90 -0
- package/dist/projects/routes.d.ts.map +1 -0
- package/dist/projects/routes.js +388 -0
- package/dist/projects/routes.js.map +1 -0
- package/dist/projects/settings.d.ts +32 -0
- package/dist/projects/settings.d.ts.map +1 -0
- package/dist/projects/settings.js +64 -0
- package/dist/projects/settings.js.map +1 -0
- package/dist/qe/acceptance.d.ts +137 -0
- package/dist/qe/acceptance.d.ts.map +1 -0
- package/dist/qe/acceptance.js +249 -0
- package/dist/qe/acceptance.js.map +1 -0
- package/dist/qe/gate-events.d.ts +111 -0
- package/dist/qe/gate-events.d.ts.map +1 -0
- package/dist/qe/gate-events.js +168 -0
- package/dist/qe/gate-events.js.map +1 -0
- package/dist/qe/ledger.d.ts +100 -0
- package/dist/qe/ledger.d.ts.map +1 -0
- package/dist/qe/ledger.js +154 -0
- package/dist/qe/ledger.js.map +1 -0
- package/dist/studio/assets/index-CCwXa1cn.js +428 -0
- package/dist/studio/assets/index-HWxo0h41.css +32 -0
- package/dist/studio/index.html +2 -2
- package/package.json +10 -4
- package/dist/studio/assets/index-DaaUU8Ep.css +0 -32
- package/dist/studio/assets/index-Fu5DRC00.js +0 -423
package/dist/api/routes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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
|
-
|
|
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
|