wicked-crew 0.5.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 +70 -0
- package/dist/api/audit.d.ts.map +1 -0
- package/dist/api/audit.js +131 -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/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 +34 -0
- package/dist/api/open-path.d.ts.map +1 -0
- package/dist/api/open-path.js +101 -0
- package/dist/api/open-path.js.map +1 -0
- 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 +164 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +769 -21
- 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/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 +190 -3
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +365 -11
- 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 +171 -12
- 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 +87 -10
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +347 -28
- 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 +92 -352
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +14 -4
- package/dist/core/types.js.map +1 -1
- package/dist/interactive/bridge-pool.d.ts +99 -0
- package/dist/interactive/bridge-pool.d.ts.map +1 -0
- package/dist/interactive/bridge-pool.js +244 -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/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 +224 -0
- package/dist/interactive/draft-events.d.ts.map +1 -0
- package/dist/interactive/draft-events.js +793 -0
- package/dist/interactive/draft-events.js.map +1 -0
- package/dist/interactive/edit-events.d.ts +194 -0
- package/dist/interactive/edit-events.d.ts.map +1 -0
- package/dist/interactive/edit-events.js +601 -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/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/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/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/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 +97 -0
- package/dist/projects/routes.d.ts.map +1 -0
- package/dist/projects/routes.js +510 -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-8p8uwCxG.js +530 -0
- package/dist/studio/assets/index-D6S9zUtO.css +32 -0
- package/dist/studio/index.html +5 -3
- 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,30 @@ 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, 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';
|
|
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 { 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';
|
|
20
|
+
import { registerProjectRoutes } from '../projects/routes.js';
|
|
21
|
+
import { ProjectSettingsStore } from '../projects/settings.js';
|
|
22
|
+
import { boundOrigin, InteractiveBridgePool } from '../interactive/bridge-pool.js';
|
|
23
|
+
import { registerInteractiveProxy } from '../interactive/proxy-routes.js';
|
|
24
|
+
import { MembershipIndex } from '../projects/membership-index.js';
|
|
25
|
+
import { MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.js';
|
|
26
|
+
import { AuditLog } from './audit.js';
|
|
27
|
+
import { RetryIndex } from './retry-index.js';
|
|
28
|
+
import { GuidanceIndex } from './guidance-index.js';
|
|
29
|
+
import { LOCAL_ACTOR } from './auth.js';
|
|
13
30
|
// Re-exported so existing `import { API_PREFIX } from './routes.js'` callers keep working; the
|
|
14
31
|
// value lives in the leaf module api-prefix.ts to keep unit-output.ts out of this file's cycle.
|
|
15
32
|
export { API_PREFIX } from './api-prefix.js';
|
|
@@ -93,7 +110,10 @@ const RegisterRepoSchema = z
|
|
|
93
110
|
* schema does not accept — so `.strict()` is what turns that trip into a 400 instead of an
|
|
94
111
|
* unworkflowed run reported as `201`.
|
|
95
112
|
*/
|
|
96
|
-
|
|
113
|
+
// The request-body schemas below are exported so `tests/wire-contract.test.ts` can prove, at
|
|
114
|
+
// compile time, that every body the published contract (`wicked-crew-api-types`) lets a client
|
|
115
|
+
// send is a body these schemas accept — the request-direction half of the drift guard (task #84).
|
|
116
|
+
export const LaunchSchema = z.object({
|
|
97
117
|
problem: z.string().min(1),
|
|
98
118
|
sessionId: z.string().min(1).optional(),
|
|
99
119
|
clisJson: z.string().min(1).optional(),
|
|
@@ -101,17 +121,39 @@ const LaunchSchema = z.object({
|
|
|
101
121
|
humanConfirm: z.string().min(1).optional(),
|
|
102
122
|
repoRef: z.string().min(1).optional(),
|
|
103
123
|
workflow: z.string().min(1).optional(),
|
|
104
|
-
|
|
105
|
-
|
|
124
|
+
/** DES-PROJECT-001 §2.2 — file the run into a project; membership attaches atomically with
|
|
125
|
+
* the launch record. Unknown/archived ⇒ the launch fails (never a silent unfiled run). */
|
|
126
|
+
projectId: z.string().min(1).optional(),
|
|
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
|
+
});
|
|
139
|
+
export const GateSchema = z.object({
|
|
106
140
|
approve: z.boolean(),
|
|
107
141
|
amend: z.string().optional(),
|
|
108
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;
|
|
109
151
|
const InjectSchema = z.object({
|
|
110
152
|
message: z.string().min(1),
|
|
111
153
|
/** `"all"` broadcasts to every active worker; any other value is a CLI key. */
|
|
112
154
|
target: z.string().min(1).default('all'),
|
|
113
155
|
}).strict();
|
|
114
|
-
const OpenTerminalSchema = z.object({
|
|
156
|
+
export const OpenTerminalSchema = z.object({
|
|
115
157
|
cwd: z.string().min(1),
|
|
116
158
|
cmd: z.array(z.string().min(1)).min(1).optional(),
|
|
117
159
|
cols: z.number().int().positive(),
|
|
@@ -124,16 +166,119 @@ const ResizeTerminalSchema = z.object({
|
|
|
124
166
|
cols: z.number().int().positive(),
|
|
125
167
|
rows: z.number().int().positive(),
|
|
126
168
|
}).strict();
|
|
169
|
+
/** `POST /open` (crew#273) — the studio Files tab's "open with the OS default app" body. */
|
|
170
|
+
export const OpenPathSchema = z.object({
|
|
171
|
+
path: z.string().min(1),
|
|
172
|
+
runId: z.string().min(1).optional(),
|
|
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;
|
|
127
194
|
/**
|
|
128
195
|
* The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
|
|
129
196
|
* core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
|
|
130
197
|
*/
|
|
131
|
-
export function registerRoutes(app, adapter, gateCache, elicitationCache
|
|
198
|
+
export function registerRoutes(app, adapter, gateCache, elicitationCache,
|
|
199
|
+
// Defaulted so a caller that never arms the bus seam (tests drive this
|
|
200
|
+
// function directly) gets the same behavior as an unarmed daemon: an empty
|
|
201
|
+
// cache, `busEvent: null`, and the lazy ledger read doing all the work.
|
|
202
|
+
qeGateEvents = new QeGateCache(),
|
|
203
|
+
// Defaulted for the same reason: tests that drive this function directly get
|
|
204
|
+
// the projects surface with no bus (events skipped) and a fresh index.
|
|
205
|
+
projects = { bus: null, index: new MembershipIndex(), log: () => undefined },
|
|
206
|
+
// Defaulted likewise — to a NOOP trail, deliberately: a directly-driven
|
|
207
|
+
// route set (unit tests) must never write the operator's real
|
|
208
|
+
// ~/.wicked-crew/audit.log or leave appends pending after close. The real
|
|
209
|
+
// trail always arrives from `createServer`.
|
|
210
|
+
security = { audit: AuditLog.noop(), authMode: 'off' },
|
|
211
|
+
// Defaulted likewise: a directly-driven route set gets a fresh (all-active) health map and the
|
|
212
|
+
// real OS opener — tests inject both through this seam.
|
|
213
|
+
runtime = {}) {
|
|
214
|
+
const { audit } = security;
|
|
215
|
+
const seatHealth = runtime.seatHealth ?? new SeatHealthTracker();
|
|
216
|
+
const openWithOs = runtime.openWithOs ?? openWithSystemDefault;
|
|
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
|
+
};
|
|
236
|
+
// Resolved ONCE and shared by the project routes (which read/write `interactiveRoot`) and the
|
|
237
|
+
// interactive proxy (which resolves a root from it) — two stores would let a PATCH land in one
|
|
238
|
+
// and the proxy keep reading the other.
|
|
239
|
+
const projectSettings = projects.settings ?? new ProjectSettingsStore();
|
|
240
|
+
// `req.actor` is pinned by the auth hooks `createServer` installs; a caller
|
|
241
|
+
// driving this function directly (tests) has no hooks, so downstream code
|
|
242
|
+
// still gets the ONE actor shape via this accessor.
|
|
243
|
+
const actorOf = (req) => req.actor ?? LOCAL_ACTOR;
|
|
132
244
|
// Liveness — also proves the actor + event pump are up.
|
|
133
245
|
app.get(`${V}/health`, async () => {
|
|
134
246
|
const ping = await adapter.ping();
|
|
135
247
|
return { status: 'ok', version: PKG_VERSION, ping };
|
|
136
248
|
});
|
|
249
|
+
// Who am I talking to the daemon as? (task #88). In local mode this is
|
|
250
|
+
// always the implicit full-trust local actor; in required mode it names the
|
|
251
|
+
// token's actor — the cheap probe a skin uses to decide what to render.
|
|
252
|
+
app.get(`${V}/whoami`, async (req) => ({
|
|
253
|
+
actor: actorOf(req),
|
|
254
|
+
authMode: security.authMode,
|
|
255
|
+
}));
|
|
256
|
+
// The actor audit trail (task #88): who launched/steered/governed what.
|
|
257
|
+
// Read-only (observer trust); newest first; `?runId=` / `?action=` / `?limit=`.
|
|
258
|
+
app.get(`${V}/audit`, async (req, reply) => {
|
|
259
|
+
const q = req.query;
|
|
260
|
+
const first = (v) => (Array.isArray(v) ? v[0] : v)?.trim() || undefined;
|
|
261
|
+
const runId = first(q.runId);
|
|
262
|
+
const action = first(q.action);
|
|
263
|
+
const limitRaw = first(q.limit);
|
|
264
|
+
// Reject partial-numeric strings like "10abc" — parseInt accepts those,
|
|
265
|
+
// Number() is strict and returns NaN for them (Copilot, #250).
|
|
266
|
+
const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
|
|
267
|
+
if (limitRaw !== undefined && (!Number.isFinite(limit) || limit < 1 || !Number.isInteger(limit))) {
|
|
268
|
+
return reply.code(400).send({ error: '`limit` must be a positive integer' });
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const entries = await audit.read({
|
|
272
|
+
...(runId !== undefined ? { runId } : {}),
|
|
273
|
+
...(action !== undefined ? { action } : {}),
|
|
274
|
+
...(limit !== undefined ? { limit } : {}),
|
|
275
|
+
});
|
|
276
|
+
return { entries };
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
return reply.code(500).send({ error: message(err) });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
137
282
|
// Report the actually-bound port/host (honours --port / CREW_PORT / port 0).
|
|
138
283
|
app.get(`${V}/config`, async () => {
|
|
139
284
|
const addr = app.server.address();
|
|
@@ -141,8 +286,209 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
141
286
|
const host = typeof addr === 'object' && addr ? addr.address : '127.0.0.1';
|
|
142
287
|
return { port, host };
|
|
143
288
|
});
|
|
144
|
-
// The council seats for the launch form (static production roster)
|
|
145
|
-
|
|
289
|
+
// The council seats for the launch form (static production roster), each carrying its RUNTIME
|
|
290
|
+
// health (crew#274). The roster is declarative — every configured seat is listed — and `health`
|
|
291
|
+
// is what the platform has observed: default active with no message; inactive + the error
|
|
292
|
+
// excerpt after a seat-level failure, until an ok output or the recovery probe flips it back.
|
|
293
|
+
// Existing fields ride through verbatim (the seat still round-trips into `clisJson` on launch)
|
|
294
|
+
// — the spread is deliberately NOT a field whitelist, which is what lets the engine's
|
|
295
|
+
// `login_invocation` (seat sign-in, wicked-core PR#278) pass through untouched. Each seat also
|
|
296
|
+
// gains `signed_in`: the cheap file/env presence heuristic, computed against the LIVE
|
|
297
|
+
// `WICKED_WORKER_HOME` env — the same value the engine reads at the next worker spawn, kept
|
|
298
|
+
// current by `applyWorkerConfigRoot` at boot and on every settings change.
|
|
299
|
+
app.get(`${V}/roster`, async () => {
|
|
300
|
+
const workerRoot = process.env['WICKED_WORKER_HOME'];
|
|
301
|
+
return {
|
|
302
|
+
roster: CoreAdapter.roster().map((seat) => ({
|
|
303
|
+
...seat,
|
|
304
|
+
health: seatHealth.healthFor(String(seat.key)),
|
|
305
|
+
signed_in: signedIn(String(seat.key), workerRoot === '' ? undefined : workerRoot),
|
|
306
|
+
})),
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
// Open a file/folder with the OS default application (crew#273) — the studio Files tab's
|
|
310
|
+
// click-to-open. The open MUST happen daemon-side (the SPA cannot spawn a process), which is
|
|
311
|
+
// why the path is validated first: absolute, and inside one of the caller-visible roots — the
|
|
312
|
+
// run's workdir + extra write roots (when `runId` is given) or a registered repo root. The
|
|
313
|
+
// run's QE evidence/decisions dirs (`.wicked-qe`/`.wicked-testing`) live under the repo root,
|
|
314
|
+
// so the repo-root rule covers them. Never an arbitrary path.
|
|
315
|
+
app.post(`${V}/open`, async (req, reply) => {
|
|
316
|
+
const parsed = OpenPathSchema.safeParse(req.body);
|
|
317
|
+
if (!parsed.success) {
|
|
318
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid open request'));
|
|
319
|
+
}
|
|
320
|
+
const { path: rawPath, runId } = parsed.data;
|
|
321
|
+
if (!isAbsolute(rawPath)) {
|
|
322
|
+
return reply.code(400).send({ error: '`path` must be an absolute path' });
|
|
323
|
+
}
|
|
324
|
+
const target = resolve(rawPath);
|
|
325
|
+
let roots;
|
|
326
|
+
try {
|
|
327
|
+
let session;
|
|
328
|
+
if (runId !== undefined) {
|
|
329
|
+
const views = await adapter.sessionsDetail();
|
|
330
|
+
const view = views.find((v) => v.session.id === runId);
|
|
331
|
+
if (view === undefined) {
|
|
332
|
+
return reply.code(404).send({ error: `unknown run: ${runId}` });
|
|
333
|
+
}
|
|
334
|
+
session = view.session;
|
|
335
|
+
}
|
|
336
|
+
roots = allowedRootsFor(session, await adapter.listRepos());
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
return reply.code(500).send({ error: message(err) });
|
|
340
|
+
}
|
|
341
|
+
if (!roots.some((root) => isInsideRoot(root, target))) {
|
|
342
|
+
return reply.code(403).send({
|
|
343
|
+
error: "path is outside every allowed root (the run's workdir/write roots and the registered repos)",
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
await openWithOs(target);
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
return reply.code(502).send({ error: `could not open ${target}: ${message(err)}` });
|
|
351
|
+
}
|
|
352
|
+
return { status: 'opened' };
|
|
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
|
+
});
|
|
146
492
|
// Registered repos → target-repo picker.
|
|
147
493
|
app.get(`${V}/repos`, async () => ({ repos: await adapter.listRepos() }));
|
|
148
494
|
app.post(`${V}/repos`, async (req, reply) => {
|
|
@@ -215,23 +561,148 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
215
561
|
input.repoRef = b.repoRef;
|
|
216
562
|
if (b.workflow !== undefined)
|
|
217
563
|
input.workflow = b.workflow;
|
|
564
|
+
if (b.projectId !== undefined) {
|
|
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
|
+
}
|
|
218
592
|
try {
|
|
219
593
|
const runId = await adapter.launchRun(input);
|
|
594
|
+
// Who launched it — the engine's LaunchOptions carries no actor field
|
|
595
|
+
// (checked, wicked-core-ts 0.6.0), so the crew-side trail is the system
|
|
596
|
+
// of record for run provenance (task #88).
|
|
597
|
+
audit.record('run.launched', actorOf(req), {
|
|
598
|
+
runId,
|
|
599
|
+
detail: {
|
|
600
|
+
...(b.workflow !== undefined ? { workflow: b.workflow } : {}),
|
|
601
|
+
...(b.repoRef !== undefined ? { repoRef: b.repoRef } : {}),
|
|
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 } : {}),
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
if (b.retryOf !== undefined)
|
|
610
|
+
retryIndex.set(runId, b.retryOf);
|
|
611
|
+
if (b.projectId !== undefined) {
|
|
612
|
+
// The engine attached the crew.run membership ATOMICALLY with the launch record
|
|
613
|
+
// (DES-PROJECT-001 §2.2) — this is the post-commit half: tag future /ws frames and
|
|
614
|
+
// emit the membership event (auto-attach at launch is an attach, §4).
|
|
615
|
+
projects.index.set(runId, b.projectId);
|
|
616
|
+
projects.bus?.emit(MEMBERSHIP_ATTACHED,
|
|
617
|
+
// The AUTHENTICATED actor id, not a caller-supplied string — locked
|
|
618
|
+
// decision #6 replaces spoofable actor strings on the event surface.
|
|
619
|
+
{ project_id: b.projectId, member: { kind: 'crew.run', ref: runId }, actor: actorOf(req).id }, membershipAttachedKey(b.projectId, 'crew.run', runId, Date.now()));
|
|
620
|
+
}
|
|
220
621
|
return reply.code(201).send({ runId });
|
|
221
622
|
}
|
|
222
623
|
catch (err) {
|
|
223
624
|
const msg = message(err);
|
|
625
|
+
// An unknown/archived project is a state conflict on a real resource, not a malformed
|
|
626
|
+
// request: 404/409 per the projects error mapping; anything else keeps the launch 400/409.
|
|
627
|
+
if (b.projectId !== undefined && /project.*not registered/i.test(msg)) {
|
|
628
|
+
return reply.code(404).send({ error: msg });
|
|
629
|
+
}
|
|
630
|
+
if (b.projectId !== undefined && /archived|'default'|synthesized/i.test(msg)) {
|
|
631
|
+
return reply.code(409).send({ error: msg });
|
|
632
|
+
}
|
|
224
633
|
const busy = /busy|in flight|already/i.test(msg);
|
|
225
634
|
return reply.code(busy ? 409 : 400).send({ error: msg });
|
|
226
635
|
}
|
|
227
636
|
});
|
|
228
637
|
// Run list (replaces GET /sessions). Actionable-first; reconciles the gate and elicitation caches
|
|
229
638
|
// so that terminal-run entries are pruned even when their terminal CoreEvent was missed.
|
|
230
|
-
app.get(`${V}/runs`, async () => {
|
|
639
|
+
app.get(`${V}/runs`, async (req) => {
|
|
231
640
|
const views = await adapter.sessionsDetail();
|
|
232
641
|
gateCache.reconcile(views);
|
|
233
642
|
elicitationCache.reconcile(views);
|
|
234
|
-
|
|
643
|
+
// Archived runs are WRITTEN OFF (crew#265): excluded from the default view so finished
|
|
644
|
+
// history doesn't drown live signal, returned in full with `?include=archived`. The caches
|
|
645
|
+
// above reconcile over the COMPLETE set either way — a gate on an archived run must still
|
|
646
|
+
// resolve, not leak.
|
|
647
|
+
// Fastify parses a REPEATED query param as string[] — normalize so `?include=archived`
|
|
648
|
+
// and `?include=archived&include=archived` behave identically (Copilot).
|
|
649
|
+
const { include } = req.query;
|
|
650
|
+
const includeArchived = (Array.isArray(include) ? include : [include]).includes('archived');
|
|
651
|
+
const visible = includeArchived
|
|
652
|
+
? views
|
|
653
|
+
: views.filter((v) => v.session.archived_at == null);
|
|
654
|
+
return { runs: sortActionableFirst(visible).map(decorateRun) };
|
|
655
|
+
});
|
|
656
|
+
// ── Run archival (crew#265) — write-off, not delete ────────────────────────
|
|
657
|
+
const ArchiveSchema = z.object({
|
|
658
|
+
archived: z.boolean(),
|
|
659
|
+
note: z.string().max(500).optional(),
|
|
660
|
+
}).strict();
|
|
661
|
+
app.post(`${V}/runs/:id/archive`, async (req, reply) => {
|
|
662
|
+
const { id } = req.params;
|
|
663
|
+
const parsed = ArchiveSchema.safeParse(req.body ?? {});
|
|
664
|
+
if (!parsed.success) {
|
|
665
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
const found = await adapter.archiveRun(id, parsed.data.archived, parsed.data.note);
|
|
669
|
+
if (!found)
|
|
670
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
671
|
+
return { runId: id, archived: parsed.data.archived };
|
|
672
|
+
}
|
|
673
|
+
catch (e) {
|
|
674
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
675
|
+
// The engine names a NON-terminal status ("run X is Executing — only a terminal run…"):
|
|
676
|
+
// a state conflict on a real resource, not a bad request (write-off must never hide
|
|
677
|
+
// live work). Anything else — including the old-addon guard — is a real 500.
|
|
678
|
+
if (/only a terminal run/i.test(msg))
|
|
679
|
+
return reply.code(409).send({ error: msg });
|
|
680
|
+
return reply.code(500).send({ error: msg });
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
// Bulk write-off for campaign backlogs — explicit ids only, never implicit age selection.
|
|
684
|
+
const BulkArchiveSchema = z.object({
|
|
685
|
+
ids: z.array(z.string().min(1)).min(1).max(200),
|
|
686
|
+
note: z.string().max(500).optional(),
|
|
687
|
+
}).strict();
|
|
688
|
+
app.post(`${V}/runs/archive`, async (req, reply) => {
|
|
689
|
+
const parsed = BulkArchiveSchema.safeParse(req.body ?? {});
|
|
690
|
+
if (!parsed.success) {
|
|
691
|
+
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
692
|
+
}
|
|
693
|
+
// Per-id outcomes rather than all-or-nothing: a batch of 45 with one live run in it
|
|
694
|
+
// should archive 44 and NAME the refusal, not roll back the write-off.
|
|
695
|
+
const results = [];
|
|
696
|
+
for (const id of parsed.data.ids) {
|
|
697
|
+
try {
|
|
698
|
+
const found = await adapter.archiveRun(id, true, parsed.data.note);
|
|
699
|
+
results.push(found ? { id, ok: true } : { id, ok: false, error: 'not found' });
|
|
700
|
+
}
|
|
701
|
+
catch (e) {
|
|
702
|
+
results.push({ id, ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return { results, archived: results.filter((r) => r.ok).length };
|
|
235
706
|
});
|
|
236
707
|
// One run's detail.
|
|
237
708
|
app.get(`${V}/runs/:id`, async (req, reply) => {
|
|
@@ -240,7 +711,39 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
240
711
|
const run = views.find((v) => v.session.id === id);
|
|
241
712
|
if (!run)
|
|
242
713
|
return reply.code(404).send({ error: 'Run not found' });
|
|
243
|
-
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 };
|
|
244
747
|
});
|
|
245
748
|
// ── Chat sessions (crew#165): warm ACP seat pool + group fan-out (core#134) ──
|
|
246
749
|
// A chat is NOT a run: no council, no gates, no units. Seats warm on open;
|
|
@@ -249,6 +752,8 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
249
752
|
chatId: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional(),
|
|
250
753
|
clis: z.array(z.string().min(1)).min(1).max(8).optional(),
|
|
251
754
|
repoRef: z.string().optional(),
|
|
755
|
+
/** DES-PROJECT-001 §2.2 — file the chat into a project (`crew.chat` membership). */
|
|
756
|
+
projectId: z.string().min(1).optional(),
|
|
252
757
|
}).strict();
|
|
253
758
|
app.post(`${V}/chats`, async (req, reply) => {
|
|
254
759
|
const parsed = ChatOpenSchema.safeParse(req.body ?? {});
|
|
@@ -265,12 +770,48 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
265
770
|
return reply.code(404).send({ error: `Repo ${b.repoRef} not found` });
|
|
266
771
|
cwd = repo.root_path;
|
|
267
772
|
}
|
|
773
|
+
// Validate the project BEFORE opening seats: a chat has no launch record for the engine to
|
|
774
|
+
// attach against atomically (chats are an in-memory seat pool), so the route validates
|
|
775
|
+
// up-front and attaches right after open — the one non-atomic attach, documented in the ADR
|
|
776
|
+
// changelog. Fail here and no seats were warmed for a filing that could never happen.
|
|
777
|
+
if (b.projectId !== undefined) {
|
|
778
|
+
try {
|
|
779
|
+
const project = await adapter.projectGet(b.projectId);
|
|
780
|
+
if (project === null) {
|
|
781
|
+
return reply.code(404).send({ error: `Project ${b.projectId} not found` });
|
|
782
|
+
}
|
|
783
|
+
if (project.status === 'archived') {
|
|
784
|
+
return reply
|
|
785
|
+
.code(409)
|
|
786
|
+
.send({ error: `project ${b.projectId} is archived and blocks new attachments` });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
catch (err) {
|
|
790
|
+
return reply.code(501).send({ error: message(err) });
|
|
791
|
+
}
|
|
792
|
+
}
|
|
268
793
|
const clis = b.clis ??
|
|
269
794
|
CoreAdapter.roster()
|
|
270
795
|
.map((s) => s.key)
|
|
271
796
|
.filter((k) => typeof k === 'string');
|
|
272
797
|
try {
|
|
273
798
|
const seats = await adapter.chatOpen(chatId, clis, cwd);
|
|
799
|
+
if (b.projectId !== undefined) {
|
|
800
|
+
try {
|
|
801
|
+
const { member, created } = await adapter.projectMemberAttach(b.projectId, 'crew.chat', chatId);
|
|
802
|
+
if (created) {
|
|
803
|
+
projects.index.set(chatId, b.projectId);
|
|
804
|
+
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));
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
catch (err) {
|
|
808
|
+
// The chat is open and usable; the filing failed. Say so instead of failing the open —
|
|
809
|
+
// the caller can re-attach via POST /projects/:id/members.
|
|
810
|
+
return reply
|
|
811
|
+
.code(201)
|
|
812
|
+
.send({ chatId, seats, projectAttachError: message(err) });
|
|
813
|
+
}
|
|
814
|
+
}
|
|
274
815
|
return reply.code(201).send({ chatId, seats });
|
|
275
816
|
}
|
|
276
817
|
catch (err) {
|
|
@@ -377,6 +918,44 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
377
918
|
.header('Content-Disposition', `attachment; filename="${evidenceFilename(id)}"`)
|
|
378
919
|
.send(bundle);
|
|
379
920
|
});
|
|
921
|
+
// ── Acceptance gate read (Phase 6a) ─────────────────────────────────────────
|
|
922
|
+
// The QE evidence ledger's verdict + manifest for THIS run's repo, plus the
|
|
923
|
+
// gate's deny-dominates resolution of the workflow's acceptance requirement.
|
|
924
|
+
// Sits beside `/runs/:id/evidence` deliberately: evidence is what the RUN
|
|
925
|
+
// recorded about itself; acceptance is what the QE pipeline recorded about
|
|
926
|
+
// the repo the run worked on — two different systems of record, two routes.
|
|
927
|
+
//
|
|
928
|
+
// Always a 200 for a known run: "no ledger", "no verdict" and "FAIL" are
|
|
929
|
+
// real answers about the gate (each a deny with its own reason), not errors
|
|
930
|
+
// in the request. Only an unknown run 404s. `?qeRun=<id>` pins the read to
|
|
931
|
+
// one QE run's newest verdict instead of the repo's newest overall.
|
|
932
|
+
app.get(`${V}/runs/:id/acceptance`, async (req, reply) => {
|
|
933
|
+
const { id } = req.params;
|
|
934
|
+
const q = req.query;
|
|
935
|
+
const qeRunRaw = Array.isArray(q.qeRun) ? q.qeRun[0] : q.qeRun;
|
|
936
|
+
const qeRunId = qeRunRaw?.trim();
|
|
937
|
+
const views = await adapter.sessionsDetail();
|
|
938
|
+
const run = views.find((v) => v.session.id === id);
|
|
939
|
+
if (!run)
|
|
940
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
941
|
+
// `sessionsDetail()` patches workflow_id back to the definition name for
|
|
942
|
+
// BUILT-INS; runs of user-registered workflows still carry the instance id,
|
|
943
|
+
// so resolve by phase sequence over the full registry. A free-text run
|
|
944
|
+
// resolves to no workflow, which reads as "declares no requirement".
|
|
945
|
+
const workflow = resolveRunWorkflow(run, adapter.listWorkflows());
|
|
946
|
+
let repo = null;
|
|
947
|
+
if (run.session.repo_ref !== null) {
|
|
948
|
+
const repos = await adapter.listRepos();
|
|
949
|
+
repo = repos.find((r) => r.id === run.session.repo_ref) ?? null;
|
|
950
|
+
}
|
|
951
|
+
return buildAcceptanceView({
|
|
952
|
+
runId: id,
|
|
953
|
+
repo,
|
|
954
|
+
workflow,
|
|
955
|
+
gateEvents: qeGateEvents,
|
|
956
|
+
...(qeRunId !== undefined && qeRunId !== '' ? { qeRunId } : {}),
|
|
957
|
+
});
|
|
958
|
+
});
|
|
380
959
|
// The steering gate (§11.1). approve+amend = approve-with-steer; approve:false = reject (cancels).
|
|
381
960
|
app.post(`${V}/runs/:id/gate`, async (req, reply) => {
|
|
382
961
|
const { id } = req.params;
|
|
@@ -395,6 +974,17 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
395
974
|
}
|
|
396
975
|
try {
|
|
397
976
|
const status = await adapter.confirmGate(id, parsed.data.approve, parsed.data.amend);
|
|
977
|
+
// WHO approved/rejected — the gate-decision audit (task #88). The engine
|
|
978
|
+
// records THAT the gate resolved (interaction_requests / gateDecided);
|
|
979
|
+
// only this HTTP layer knows the authenticated principal behind it.
|
|
980
|
+
audit.record('gate.decided', actorOf(req), {
|
|
981
|
+
runId: id,
|
|
982
|
+
detail: {
|
|
983
|
+
approve: parsed.data.approve,
|
|
984
|
+
...(parsed.data.amend !== undefined ? { amend: parsed.data.amend } : {}),
|
|
985
|
+
status,
|
|
986
|
+
},
|
|
987
|
+
});
|
|
398
988
|
return reply.send({ status });
|
|
399
989
|
}
|
|
400
990
|
catch (err) {
|
|
@@ -410,6 +1000,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
410
1000
|
return reply.code(404).send({ error: 'Run not found' });
|
|
411
1001
|
try {
|
|
412
1002
|
const status = await adapter.cancelRun(id);
|
|
1003
|
+
audit.record('run.cancelled', actorOf(req), { runId: id, detail: { status } });
|
|
413
1004
|
return reply.send({ status });
|
|
414
1005
|
}
|
|
415
1006
|
catch (err) {
|
|
@@ -425,9 +1016,14 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
425
1016
|
if (!run)
|
|
426
1017
|
return reply.code(404).send({ error: 'Run not found' });
|
|
427
1018
|
try {
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
1019
|
+
const gated = run.session.status === 'awaiting_human';
|
|
1020
|
+
const status = gated ? await adapter.confirmGate(id, true) : await adapter.resumeRun(id);
|
|
1021
|
+
// A resume of a gated run IS a gate approval — audit it as one, so the
|
|
1022
|
+
// "who approved" trail has no side door (task #88).
|
|
1023
|
+
audit.record(gated ? 'gate.decided' : 'run.resumed', actorOf(req), {
|
|
1024
|
+
runId: id,
|
|
1025
|
+
detail: gated ? { approve: true, via: 'resume', status } : { status },
|
|
1026
|
+
});
|
|
431
1027
|
return reply.send({ status });
|
|
432
1028
|
}
|
|
433
1029
|
catch (err) {
|
|
@@ -448,6 +1044,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
448
1044
|
return reply.code(404).send({ error: 'Run not found' });
|
|
449
1045
|
try {
|
|
450
1046
|
await adapter.injectWorkerMessage(id, parsed.data.message, parsed.data.target);
|
|
1047
|
+
audit.record('run.injected', actorOf(req), { runId: id, detail: { target: parsed.data.target } });
|
|
451
1048
|
return reply.send({ status: 'ok' });
|
|
452
1049
|
}
|
|
453
1050
|
catch (err) {
|
|
@@ -481,6 +1078,28 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
481
1078
|
if (run.session.status !== 'awaiting_human') {
|
|
482
1079
|
return reply.code(404).send({ error: 'No open gate for this run' });
|
|
483
1080
|
}
|
|
1081
|
+
// DURABLE TRUTH FIRST (DES-PROJECT-001 §5.3): the engine persists the open prompt in
|
|
1082
|
+
// `interaction_requests`, written in the same transaction as the `awaiting_human` pause —
|
|
1083
|
+
// so a daemon restart reads it back directly instead of replaying the event log. The cache
|
|
1084
|
+
// adopts the row (latency layer over durable truth — its comments finally true). The replay
|
|
1085
|
+
// below stays as the FALLBACK, not just for engines predating the binding: a run parked
|
|
1086
|
+
// BEFORE the engine grew the table is awaiting_human with no row, and answering 404 there
|
|
1087
|
+
// would re-open FINDING-051 for exactly the runs mid-upgrade. Row → serve it; no row →
|
|
1088
|
+
// fall through and let the log speak.
|
|
1089
|
+
const durableRows = typeof adapter.interactionRequests === 'function'
|
|
1090
|
+
? await adapter.interactionRequests(id, 'open')
|
|
1091
|
+
: null;
|
|
1092
|
+
const durableGate = durableRows?.find((r) => r.kind === 'gate');
|
|
1093
|
+
if (durableGate !== undefined) {
|
|
1094
|
+
const entry = {
|
|
1095
|
+
ord: durableGate.ord ?? 0,
|
|
1096
|
+
prompt: durableGate.prompt,
|
|
1097
|
+
lifecycle: 'open',
|
|
1098
|
+
receivedAt: new Date(durableGate.created_at).toISOString(),
|
|
1099
|
+
};
|
|
1100
|
+
gateCache.adopt(id, entry);
|
|
1101
|
+
return { runId: id, ...entry };
|
|
1102
|
+
}
|
|
484
1103
|
const events = await adapter.runEvents(id);
|
|
485
1104
|
if (events === null) {
|
|
486
1105
|
// Now — and only now — 503 is the honest answer: this run really is holding for a human, and
|
|
@@ -515,6 +1134,25 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
515
1134
|
const ids = await adapter.sessions();
|
|
516
1135
|
if (!ids.includes(id))
|
|
517
1136
|
return reply.code(404).send({ error: 'Run not found' });
|
|
1137
|
+
// Durable probe (DES-PROJECT-001 §5.3): `interaction_requests` reserves kind `elicitation`.
|
|
1138
|
+
// The engine writes no elicitation rows yet (its elicitation surface is future work — the
|
|
1139
|
+
// resolve path still answers 501), so this read is empty today; it exists so the cache is
|
|
1140
|
+
// STRUCTURALLY a latency layer, and the day the engine writes the rows, restart survival
|
|
1141
|
+
// holds here exactly as it does for gates, with no route change. Guarded like `runEvents`:
|
|
1142
|
+
// a partial-stub adapter (tests) or a pre-0.6.0 addon simply has no durable half.
|
|
1143
|
+
const durable = typeof adapter.interactionRequests === 'function'
|
|
1144
|
+
? await adapter.interactionRequests(id, 'open')
|
|
1145
|
+
: null;
|
|
1146
|
+
const pending = durable?.find((r) => r.kind === 'elicitation');
|
|
1147
|
+
if (pending !== undefined) {
|
|
1148
|
+
return {
|
|
1149
|
+
runId: id,
|
|
1150
|
+
elicitationId: pending.id,
|
|
1151
|
+
message: pending.prompt,
|
|
1152
|
+
options: null,
|
|
1153
|
+
receivedAt: new Date(pending.created_at).toISOString(),
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
518
1156
|
return reply.code(404).send({ error: 'No pending elicitation for this run' });
|
|
519
1157
|
});
|
|
520
1158
|
const ElicitationRespondSchema = z
|
|
@@ -588,6 +1226,10 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
588
1226
|
return reply.code(code).send({ error: message(err) });
|
|
589
1227
|
}
|
|
590
1228
|
// 7. Done.
|
|
1229
|
+
audit.record('elicitation.resolved', actorOf(req), {
|
|
1230
|
+
runId: id,
|
|
1231
|
+
detail: { elicitationId: taken.entry.elicitationId, action: body.action },
|
|
1232
|
+
});
|
|
591
1233
|
return { status: 'resolved' };
|
|
592
1234
|
});
|
|
593
1235
|
// The durable history of one run (FINDING-057).
|
|
@@ -701,7 +1343,9 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
701
1343
|
// ── Governance writes (crew#42) ────────────────────────────────────────────
|
|
702
1344
|
app.post(`${V}/governance/policies`, async (req, reply) => {
|
|
703
1345
|
try {
|
|
704
|
-
|
|
1346
|
+
const policy = req.body;
|
|
1347
|
+
await adapter.upsertPolicy(policy);
|
|
1348
|
+
audit.record('governance.policy.upserted', actorOf(req), { detail: { id: policy?.id } });
|
|
705
1349
|
return { status: 'ok' };
|
|
706
1350
|
}
|
|
707
1351
|
catch (err) {
|
|
@@ -710,7 +1354,9 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
710
1354
|
});
|
|
711
1355
|
app.post(`${V}/governance/rules`, async (req, reply) => {
|
|
712
1356
|
try {
|
|
713
|
-
|
|
1357
|
+
const rule = req.body;
|
|
1358
|
+
await adapter.upsertConformanceRule(rule);
|
|
1359
|
+
audit.record('governance.rule.upserted', actorOf(req), { detail: { id: rule?.id } });
|
|
714
1360
|
return { status: 'ok' };
|
|
715
1361
|
}
|
|
716
1362
|
catch (err) {
|
|
@@ -725,6 +1371,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
725
1371
|
const existed = await adapter.retirePolicy(id);
|
|
726
1372
|
if (!existed)
|
|
727
1373
|
return reply.code(404).send({ error: `policy '${id}' not found` });
|
|
1374
|
+
audit.record('governance.policy.retired', actorOf(req), { detail: { id } });
|
|
728
1375
|
return { status: 'retired', id };
|
|
729
1376
|
}
|
|
730
1377
|
catch (err) {
|
|
@@ -737,6 +1384,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
737
1384
|
const existed = await adapter.retireConformanceRule(id);
|
|
738
1385
|
if (!existed)
|
|
739
1386
|
return reply.code(404).send({ error: `conformance rule '${id}' not found` });
|
|
1387
|
+
audit.record('governance.rule.retired', actorOf(req), { detail: { id } });
|
|
740
1388
|
return { status: 'retired', id };
|
|
741
1389
|
}
|
|
742
1390
|
catch (err) {
|
|
@@ -780,6 +1428,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
780
1428
|
}
|
|
781
1429
|
try {
|
|
782
1430
|
const id = await adapter.registerWorkflow(body);
|
|
1431
|
+
audit.record('workflow.registered', actorOf(req), { detail: { id } });
|
|
783
1432
|
return reply.code(201).send({ id, status: 'registered' });
|
|
784
1433
|
}
|
|
785
1434
|
catch (err) {
|
|
@@ -1129,26 +1778,125 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
1129
1778
|
}
|
|
1130
1779
|
});
|
|
1131
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.
|
|
1132
1784
|
app.get(`${V}/settings`, async () => ({ settings: await adapter.getSettings() }));
|
|
1133
1785
|
app.put(`${V}/settings`, async (req, reply) => {
|
|
1134
1786
|
const patch = req.body;
|
|
1135
1787
|
if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
|
|
1136
1788
|
return reply.code(400).send({ error: 'body must be a JSON object' });
|
|
1137
1789
|
}
|
|
1138
|
-
if ('graphNodeLimit'
|
|
1790
|
+
if (Object.hasOwn(patch, 'graphNodeLimit')) {
|
|
1139
1791
|
const limit = patch.graphNodeLimit;
|
|
1140
1792
|
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 20 || limit > 500) {
|
|
1141
1793
|
return reply.code(400).send({ error: 'graphNodeLimit must be an integer between 20 and 500' });
|
|
1142
1794
|
}
|
|
1143
1795
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1796
|
+
if (Object.hasOwn(patch, 'worker_config_root')) {
|
|
1797
|
+
const root = patch.worker_config_root;
|
|
1798
|
+
if (typeof root !== 'string' || (root !== '' && !isAbsolute(root))) {
|
|
1799
|
+
return reply.code(400).send({
|
|
1800
|
+
error: 'worker_config_root must be an absolute path, or "" for the default (~/.wicked-worker)',
|
|
1801
|
+
});
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
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.
|
|
1846
|
+
const allowed = [
|
|
1847
|
+
'graphNodeLimit',
|
|
1848
|
+
'worker_config_root',
|
|
1849
|
+
'workerStallMinutes',
|
|
1850
|
+
];
|
|
1146
1851
|
const safe = {};
|
|
1147
1852
|
for (const key of allowed) {
|
|
1148
|
-
|
|
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))
|
|
1149
1861
|
safe[key] = patch[key];
|
|
1150
1862
|
}
|
|
1151
|
-
|
|
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));
|
|
1870
|
+
const settings = await adapter.updateSettings(safe);
|
|
1871
|
+
// Re-apply the worker-config root to this process's env (seat sign-in). The engine reads
|
|
1872
|
+
// WICKED_WORKER_HOME per worker spawn — never cached — so this alone makes the change live
|
|
1873
|
+
// at the next spawn: no daemon restart, no engine restart.
|
|
1874
|
+
applyWorkerConfigRoot(settings.worker_config_root);
|
|
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
|
+
});
|
|
1880
|
+
return { settings };
|
|
1881
|
+
});
|
|
1882
|
+
// ── Projects (DES-PROJECT-001) — the 9-route experience-plane surface ────────
|
|
1883
|
+
registerProjectRoutes(app, adapter, { ...projects, settings: projectSettings }, security);
|
|
1884
|
+
// ── The wicked-interactive bridge, reverse-proxied (DES-MERGE-001 §5.3/§7.2) ──
|
|
1885
|
+
// Mounted BESIDE the routes above and under the same `${V}` prefix, so it inherits one
|
|
1886
|
+
// origin, one auth hook, and one CORS posture — the whole point of slice 1.
|
|
1887
|
+
registerInteractiveProxy(app, adapter, {
|
|
1888
|
+
settings: projectSettings,
|
|
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
|
+
}),
|
|
1899
|
+
log: (m) => app.log.warn(m),
|
|
1152
1900
|
});
|
|
1153
1901
|
}
|
|
1154
1902
|
//# sourceMappingURL=routes.js.map
|