wicked-crew 0.4.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/api-prefix.d.ts +13 -0
- package/dist/api/api-prefix.d.ts.map +1 -0
- package/dist/api/api-prefix.js +13 -0
- package/dist/api/api-prefix.js.map +1 -0
- package/dist/api/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/evidence.d.ts +11 -0
- package/dist/api/evidence.d.ts.map +1 -1
- package/dist/api/evidence.js +8 -3
- package/dist/api/evidence.js.map +1 -1
- 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 +113 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +516 -27
- 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/api/unit-output.d.ts +62 -0
- package/dist/api/unit-output.d.ts.map +1 -0
- package/dist/api/unit-output.js +108 -0
- package/dist/api/unit-output.js.map +1 -0
- package/dist/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 +106 -1
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +293 -12
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/bridge-path.d.ts +17 -6
- package/dist/core/bridge-path.d.ts.map +1 -1
- package/dist/core/bridge-path.js +17 -6
- package/dist/core/bridge-path.js.map +1 -1
- package/dist/core/types.d.ts +23 -352
- 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-Ci_R6ARr.js +0 -423
- package/dist/studio/assets/index-Dio_c1Q3.css +0 -32
package/dist/api/routes.js
CHANGED
|
@@ -3,13 +3,31 @@ 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
|
-
import { ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError } from '../core/adapter.js';
|
|
8
|
+
import { ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError, humanGatePhaseIds } from '../core/adapter.js';
|
|
9
9
|
import { codeGraphDb, requirementsGraph } from '../core/repoPaths.js';
|
|
10
|
-
import {
|
|
10
|
+
import { QeGateCache } from '../qe/gate-events.js';
|
|
11
|
+
import { buildAcceptanceView, resolveRunWorkflow } from '../qe/acceptance.js';
|
|
12
|
+
import { buildEvidenceBundle, coreUnitId, evidenceFilename } from './evidence.js';
|
|
13
|
+
import { outputUnavailableReason, resolveUnit, unitKeysFor } from './unit-output.js';
|
|
11
14
|
import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
|
|
12
|
-
|
|
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';
|
|
26
|
+
// Re-exported so existing `import { API_PREFIX } from './routes.js'` callers keep working; the
|
|
27
|
+
// value lives in the leaf module api-prefix.ts to keep unit-output.ts out of this file's cycle.
|
|
28
|
+
export { API_PREFIX } from './api-prefix.js';
|
|
29
|
+
import { API_PREFIX } from './api-prefix.js';
|
|
30
|
+
const V = API_PREFIX;
|
|
13
31
|
// Daemon version reported by /health — read from package.json so it never drifts
|
|
14
32
|
// from the shipped version across releases. Resolves the package root from the
|
|
15
33
|
// compiled module location (dist/api/routes.js → ../../package.json) and works the
|
|
@@ -88,7 +106,10 @@ const RegisterRepoSchema = z
|
|
|
88
106
|
* schema does not accept — so `.strict()` is what turns that trip into a 400 instead of an
|
|
89
107
|
* unworkflowed run reported as `201`.
|
|
90
108
|
*/
|
|
91
|
-
|
|
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({
|
|
92
113
|
problem: z.string().min(1),
|
|
93
114
|
sessionId: z.string().min(1).optional(),
|
|
94
115
|
clisJson: z.string().min(1).optional(),
|
|
@@ -96,8 +117,11 @@ const LaunchSchema = z.object({
|
|
|
96
117
|
humanConfirm: z.string().min(1).optional(),
|
|
97
118
|
repoRef: z.string().min(1).optional(),
|
|
98
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(),
|
|
99
123
|
}).strict();
|
|
100
|
-
const GateSchema = z.object({
|
|
124
|
+
export const GateSchema = z.object({
|
|
101
125
|
approve: z.boolean(),
|
|
102
126
|
amend: z.string().optional(),
|
|
103
127
|
}).strict();
|
|
@@ -106,7 +130,7 @@ const InjectSchema = z.object({
|
|
|
106
130
|
/** `"all"` broadcasts to every active worker; any other value is a CLI key. */
|
|
107
131
|
target: z.string().min(1).default('all'),
|
|
108
132
|
}).strict();
|
|
109
|
-
const OpenTerminalSchema = z.object({
|
|
133
|
+
export const OpenTerminalSchema = z.object({
|
|
110
134
|
cwd: z.string().min(1),
|
|
111
135
|
cmd: z.array(z.string().min(1)).min(1).optional(),
|
|
112
136
|
cols: z.number().int().positive(),
|
|
@@ -119,16 +143,81 @@ const ResizeTerminalSchema = z.object({
|
|
|
119
143
|
cols: z.number().int().positive(),
|
|
120
144
|
rows: z.number().int().positive(),
|
|
121
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();
|
|
122
151
|
/**
|
|
123
152
|
* The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
|
|
124
153
|
* core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
|
|
125
154
|
*/
|
|
126
|
-
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;
|
|
127
183
|
// Liveness — also proves the actor + event pump are up.
|
|
128
184
|
app.get(`${V}/health`, async () => {
|
|
129
185
|
const ping = await adapter.ping();
|
|
130
186
|
return { status: 'ok', version: PKG_VERSION, ping };
|
|
131
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
|
+
});
|
|
132
221
|
// Report the actually-bound port/host (honours --port / CREW_PORT / port 0).
|
|
133
222
|
app.get(`${V}/config`, async () => {
|
|
134
223
|
const addr = app.server.address();
|
|
@@ -136,8 +225,77 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
136
225
|
const host = typeof addr === 'object' && addr ? addr.address : '127.0.0.1';
|
|
137
226
|
return { port, host };
|
|
138
227
|
});
|
|
139
|
-
// The council seats for the launch form (static production roster)
|
|
140
|
-
|
|
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
|
+
});
|
|
141
299
|
// Registered repos → target-repo picker.
|
|
142
300
|
app.get(`${V}/repos`, async () => ({ repos: await adapter.listRepos() }));
|
|
143
301
|
app.post(`${V}/repos`, async (req, reply) => {
|
|
@@ -210,23 +368,116 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
210
368
|
input.repoRef = b.repoRef;
|
|
211
369
|
if (b.workflow !== undefined)
|
|
212
370
|
input.workflow = b.workflow;
|
|
371
|
+
if (b.projectId !== undefined)
|
|
372
|
+
input.projectId = b.projectId;
|
|
213
373
|
try {
|
|
214
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
|
+
}
|
|
215
396
|
return reply.code(201).send({ runId });
|
|
216
397
|
}
|
|
217
398
|
catch (err) {
|
|
218
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
|
+
}
|
|
219
408
|
const busy = /busy|in flight|already/i.test(msg);
|
|
220
409
|
return reply.code(busy ? 409 : 400).send({ error: msg });
|
|
221
410
|
}
|
|
222
411
|
});
|
|
223
412
|
// Run list (replaces GET /sessions). Actionable-first; reconciles the gate and elicitation caches
|
|
224
413
|
// so that terminal-run entries are pruned even when their terminal CoreEvent was missed.
|
|
225
|
-
app.get(`${V}/runs`, async () => {
|
|
414
|
+
app.get(`${V}/runs`, async (req) => {
|
|
226
415
|
const views = await adapter.sessionsDetail();
|
|
227
416
|
gateCache.reconcile(views);
|
|
228
417
|
elicitationCache.reconcile(views);
|
|
229
|
-
|
|
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 };
|
|
230
481
|
});
|
|
231
482
|
// One run's detail.
|
|
232
483
|
app.get(`${V}/runs/:id`, async (req, reply) => {
|
|
@@ -237,9 +488,6 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
237
488
|
return reply.code(404).send({ error: 'Run not found' });
|
|
238
489
|
return { run };
|
|
239
490
|
});
|
|
240
|
-
// A unit's captured transcript. unitKey is the suffix after `<run>:` — `u<ord>` for free-text
|
|
241
|
-
// runs, `<phase_id>` for workflow runs (e.g. "survey", "coverage"). Strip any accidental
|
|
242
|
-
// `<id>:` prefix so both `survey` and `run-1:survey` resolve to the same key.
|
|
243
491
|
// ── Chat sessions (crew#165): warm ACP seat pool + group fan-out (core#134) ──
|
|
244
492
|
// A chat is NOT a run: no council, no gates, no units. Seats warm on open;
|
|
245
493
|
// messages fan out to warm seats; replies stream on /ws as chatDelta/chatReply.
|
|
@@ -247,6 +495,8 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
247
495
|
chatId: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional(),
|
|
248
496
|
clis: z.array(z.string().min(1)).min(1).max(8).optional(),
|
|
249
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(),
|
|
250
500
|
}).strict();
|
|
251
501
|
app.post(`${V}/chats`, async (req, reply) => {
|
|
252
502
|
const parsed = ChatOpenSchema.safeParse(req.body ?? {});
|
|
@@ -263,12 +513,48 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
263
513
|
return reply.code(404).send({ error: `Repo ${b.repoRef} not found` });
|
|
264
514
|
cwd = repo.root_path;
|
|
265
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
|
+
}
|
|
266
536
|
const clis = b.clis ??
|
|
267
537
|
CoreAdapter.roster()
|
|
268
538
|
.map((s) => s.key)
|
|
269
539
|
.filter((k) => typeof k === 'string');
|
|
270
540
|
try {
|
|
271
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
|
+
}
|
|
272
558
|
return reply.code(201).send({ chatId, seats });
|
|
273
559
|
}
|
|
274
560
|
catch (err) {
|
|
@@ -333,11 +619,33 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
333
619
|
return reply.code(400).send({ error: message(err) });
|
|
334
620
|
}
|
|
335
621
|
});
|
|
622
|
+
// A unit's captured transcript. `unitKey` accepts the fully-qualified id (`run-1:survey`),
|
|
623
|
+
// the id suffix (`survey`, `u3`), or the ordinal (`u3`, `3`) — see `resolveUnit`.
|
|
624
|
+
//
|
|
625
|
+
// A `null` body is never bare here. An unknown run or an unknown key is a 404 that names the
|
|
626
|
+
// keys this run does have; a unit that exists but has no stored transcript answers 200 with
|
|
627
|
+
// `outputUnavailable` saying WHICH cause applies — denied, not finished, or a store that
|
|
628
|
+
// disagrees with itself. It previously answered `200 {"output": null}` to all four, so the
|
|
629
|
+
// failed unit an operator opens during triage was the one that told them nothing (FINDING-006).
|
|
336
630
|
app.get(`${V}/runs/:id/units/:unitKey/output`, async (req, reply) => {
|
|
337
631
|
const { id, unitKey } = req.params;
|
|
338
|
-
const
|
|
339
|
-
const
|
|
340
|
-
|
|
632
|
+
const views = await adapter.sessionsDetail();
|
|
633
|
+
const run = views.find((v) => v.session.id === id);
|
|
634
|
+
if (!run)
|
|
635
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
636
|
+
const unit = resolveUnit(run, unitKey);
|
|
637
|
+
if (!unit) {
|
|
638
|
+
return reply.code(404).send({
|
|
639
|
+
error: `Unit '${unitKey}' not found in run ${id}`,
|
|
640
|
+
units: unitKeysFor(run),
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
// Keyed off the unit RECORD (the same derivation the evidence bundle uses), not off the
|
|
644
|
+
// caller's path segment — the two disagreeing is what made a resolvable unit unreadable.
|
|
645
|
+
const output = await adapter.workOutput(coreUnitId(id, unit));
|
|
646
|
+
if (output !== null)
|
|
647
|
+
return reply.send({ output });
|
|
648
|
+
return reply.send({ output: null, outputUnavailable: outputUnavailableReason(unit) });
|
|
341
649
|
});
|
|
342
650
|
// The whole run as one auditable JSON attachment: the run, its units (each with
|
|
343
651
|
// the captured transcript), and the decision trail read back from core's durable
|
|
@@ -353,6 +661,44 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
353
661
|
.header('Content-Disposition', `attachment; filename="${evidenceFilename(id)}"`)
|
|
354
662
|
.send(bundle);
|
|
355
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
|
+
});
|
|
356
702
|
// The steering gate (§11.1). approve+amend = approve-with-steer; approve:false = reject (cancels).
|
|
357
703
|
app.post(`${V}/runs/:id/gate`, async (req, reply) => {
|
|
358
704
|
const { id } = req.params;
|
|
@@ -371,6 +717,17 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
371
717
|
}
|
|
372
718
|
try {
|
|
373
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
|
+
});
|
|
374
731
|
return reply.send({ status });
|
|
375
732
|
}
|
|
376
733
|
catch (err) {
|
|
@@ -386,6 +743,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
386
743
|
return reply.code(404).send({ error: 'Run not found' });
|
|
387
744
|
try {
|
|
388
745
|
const status = await adapter.cancelRun(id);
|
|
746
|
+
audit.record('run.cancelled', actorOf(req), { runId: id, detail: { status } });
|
|
389
747
|
return reply.send({ status });
|
|
390
748
|
}
|
|
391
749
|
catch (err) {
|
|
@@ -401,9 +759,14 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
401
759
|
if (!run)
|
|
402
760
|
return reply.code(404).send({ error: 'Run not found' });
|
|
403
761
|
try {
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
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
|
+
});
|
|
407
770
|
return reply.send({ status });
|
|
408
771
|
}
|
|
409
772
|
catch (err) {
|
|
@@ -424,6 +787,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
424
787
|
return reply.code(404).send({ error: 'Run not found' });
|
|
425
788
|
try {
|
|
426
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 } });
|
|
427
791
|
return reply.send({ status: 'ok' });
|
|
428
792
|
}
|
|
429
793
|
catch (err) {
|
|
@@ -457,6 +821,28 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
457
821
|
if (run.session.status !== 'awaiting_human') {
|
|
458
822
|
return reply.code(404).send({ error: 'No open gate for this run' });
|
|
459
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
|
+
}
|
|
460
846
|
const events = await adapter.runEvents(id);
|
|
461
847
|
if (events === null) {
|
|
462
848
|
// Now — and only now — 503 is the honest answer: this run really is holding for a human, and
|
|
@@ -491,6 +877,25 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
491
877
|
const ids = await adapter.sessions();
|
|
492
878
|
if (!ids.includes(id))
|
|
493
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
|
+
}
|
|
494
899
|
return reply.code(404).send({ error: 'No pending elicitation for this run' });
|
|
495
900
|
});
|
|
496
901
|
const ElicitationRespondSchema = z
|
|
@@ -564,6 +969,10 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
564
969
|
return reply.code(code).send({ error: message(err) });
|
|
565
970
|
}
|
|
566
971
|
// 7. Done.
|
|
972
|
+
audit.record('elicitation.resolved', actorOf(req), {
|
|
973
|
+
runId: id,
|
|
974
|
+
detail: { elicitationId: taken.entry.elicitationId, action: body.action },
|
|
975
|
+
});
|
|
567
976
|
return { status: 'resolved' };
|
|
568
977
|
});
|
|
569
978
|
// The durable history of one run (FINDING-057).
|
|
@@ -626,14 +1035,60 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
626
1035
|
const claims = await adapter.listConformanceClaims();
|
|
627
1036
|
return { claims };
|
|
628
1037
|
});
|
|
629
|
-
|
|
1038
|
+
// Normalize a `?repo=` query: Fastify parses a repeated `?repo=a&repo=b` into a `string[]` (take
|
|
1039
|
+
// the first), and the value is TRIMMED so it's both validated and PASSED trimmed — a `?repo=%20x%20`
|
|
1040
|
+
// must not pass the guard then miss the registry lookup on whitespace (Copilot #227).
|
|
1041
|
+
const repoParam = (req) => {
|
|
1042
|
+
const raw = req.query?.repo;
|
|
1043
|
+
const first = Array.isArray(raw) ? raw[0] : raw;
|
|
1044
|
+
const trimmed = first?.trim();
|
|
1045
|
+
return trimmed ? trimmed : undefined;
|
|
1046
|
+
};
|
|
1047
|
+
// An unknown repo is the CALLER's error (404); a binding/parse failure is OURS (500). Don't collapse
|
|
1048
|
+
// them (Copilot #227). Core spells the not-found case `no registered repo '<ref>'`.
|
|
1049
|
+
const repoReadStatus = (msg) => (/no registered repo/i.test(msg) ? 404 : 500);
|
|
1050
|
+
app.get(`${V}/governance/coverage`, async (req, reply) => {
|
|
1051
|
+
// FINDING-009: with `?repo=<ref>`, report coverage over THAT repo's own code graph. The bare
|
|
1052
|
+
// endpoint reads the daemon store and reports a vacuous `coverage: 1.0` that names no repo — the
|
|
1053
|
+
// real gate lives per-repo. An unknown repo is an ERROR in core (never a silent vacuous report).
|
|
1054
|
+
const repo = repoParam(req);
|
|
1055
|
+
if (repo) {
|
|
1056
|
+
try {
|
|
1057
|
+
const report = await adapter.getCoverageReportForRepo(repo);
|
|
1058
|
+
return { report };
|
|
1059
|
+
}
|
|
1060
|
+
catch (err) {
|
|
1061
|
+
const msg = message(err);
|
|
1062
|
+
return reply.code(repoReadStatus(msg)).send({ error: msg });
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
630
1065
|
const report = await adapter.getCoverageReport();
|
|
631
1066
|
return { report };
|
|
632
1067
|
});
|
|
1068
|
+
app.get(`${V}/governance/graph`, async (req, reply) => {
|
|
1069
|
+
// #122: node-count-by-kind summary of a repo's OWN code graph. Repo-scoped ONLY — there is no
|
|
1070
|
+
// meaningful daemon-wide graph view (the daemon store holds run/governance nodes, not a repo's
|
|
1071
|
+
// code graph), so a missing `?repo=` is a 400, an unknown repo a 404, and an engine/binding
|
|
1072
|
+
// failure a 500 (not masked as 404).
|
|
1073
|
+
const repo = repoParam(req);
|
|
1074
|
+
if (!repo) {
|
|
1075
|
+
return reply.code(400).send({ error: 'governance/graph requires a ?repo=<ref>' });
|
|
1076
|
+
}
|
|
1077
|
+
try {
|
|
1078
|
+
const kinds = await adapter.getGraphKindsForRepo(repo);
|
|
1079
|
+
return { kinds };
|
|
1080
|
+
}
|
|
1081
|
+
catch (err) {
|
|
1082
|
+
const msg = message(err);
|
|
1083
|
+
return reply.code(repoReadStatus(msg)).send({ error: msg });
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
633
1086
|
// ── Governance writes (crew#42) ────────────────────────────────────────────
|
|
634
1087
|
app.post(`${V}/governance/policies`, async (req, reply) => {
|
|
635
1088
|
try {
|
|
636
|
-
|
|
1089
|
+
const policy = req.body;
|
|
1090
|
+
await adapter.upsertPolicy(policy);
|
|
1091
|
+
audit.record('governance.policy.upserted', actorOf(req), { detail: { id: policy?.id } });
|
|
637
1092
|
return { status: 'ok' };
|
|
638
1093
|
}
|
|
639
1094
|
catch (err) {
|
|
@@ -642,7 +1097,9 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
642
1097
|
});
|
|
643
1098
|
app.post(`${V}/governance/rules`, async (req, reply) => {
|
|
644
1099
|
try {
|
|
645
|
-
|
|
1100
|
+
const rule = req.body;
|
|
1101
|
+
await adapter.upsertConformanceRule(rule);
|
|
1102
|
+
audit.record('governance.rule.upserted', actorOf(req), { detail: { id: rule?.id } });
|
|
646
1103
|
return { status: 'ok' };
|
|
647
1104
|
}
|
|
648
1105
|
catch (err) {
|
|
@@ -657,6 +1114,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
657
1114
|
const existed = await adapter.retirePolicy(id);
|
|
658
1115
|
if (!existed)
|
|
659
1116
|
return reply.code(404).send({ error: `policy '${id}' not found` });
|
|
1117
|
+
audit.record('governance.policy.retired', actorOf(req), { detail: { id } });
|
|
660
1118
|
return { status: 'retired', id };
|
|
661
1119
|
}
|
|
662
1120
|
catch (err) {
|
|
@@ -669,6 +1127,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
669
1127
|
const existed = await adapter.retireConformanceRule(id);
|
|
670
1128
|
if (!existed)
|
|
671
1129
|
return reply.code(404).send({ error: `conformance rule '${id}' not found` });
|
|
1130
|
+
audit.record('governance.rule.retired', actorOf(req), { detail: { id } });
|
|
672
1131
|
return { status: 'retired', id };
|
|
673
1132
|
}
|
|
674
1133
|
catch (err) {
|
|
@@ -695,7 +1154,9 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
695
1154
|
const workflow = adapter.getWorkflow(id);
|
|
696
1155
|
if (!workflow)
|
|
697
1156
|
return reply.code(404).send({ error: `workflow '${id}' not found` });
|
|
698
|
-
|
|
1157
|
+
// humanGates: the phases that will PAUSE for a person even under humanConfirm:none (core#208).
|
|
1158
|
+
// Surfaced so an operator can see a workflow's gates BEFORE launching it (FINDING-023).
|
|
1159
|
+
return { workflow, humanGates: humanGatePhaseIds(workflow) };
|
|
699
1160
|
});
|
|
700
1161
|
// Register (or replace) a user-authored workflow definition.
|
|
701
1162
|
// Validates, persists to ~/.wicked/workflows/<id>.json, hot-registers in the
|
|
@@ -710,6 +1171,7 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
710
1171
|
}
|
|
711
1172
|
try {
|
|
712
1173
|
const id = await adapter.registerWorkflow(body);
|
|
1174
|
+
audit.record('workflow.registered', actorOf(req), { detail: { id } });
|
|
713
1175
|
return reply.code(201).send({ id, status: 'registered' });
|
|
714
1176
|
}
|
|
715
1177
|
catch (err) {
|
|
@@ -1071,14 +1533,41 @@ export function registerRoutes(app, adapter, gateCache, elicitationCache) {
|
|
|
1071
1533
|
return reply.code(400).send({ error: 'graphNodeLimit must be an integer between 20 and 500' });
|
|
1072
1534
|
}
|
|
1073
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
|
+
}
|
|
1074
1544
|
// Only allow known keys through.
|
|
1075
|
-
const allowed = [
|
|
1545
|
+
const allowed = [
|
|
1546
|
+
'graphNodeLimit',
|
|
1547
|
+
'worker_config_root',
|
|
1548
|
+
];
|
|
1076
1549
|
const safe = {};
|
|
1077
1550
|
for (const key of allowed) {
|
|
1078
1551
|
if (key in patch)
|
|
1079
1552
|
safe[key] = patch[key];
|
|
1080
1553
|
}
|
|
1081
|
-
|
|
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),
|
|
1082
1571
|
});
|
|
1083
1572
|
}
|
|
1084
1573
|
//# sourceMappingURL=routes.js.map
|