wicked-crew 0.7.28 → 0.7.29
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/README.md +9 -1
- package/dist/api/chat-scope.d.ts +210 -0
- package/dist/api/chat-scope.d.ts.map +1 -0
- package/dist/api/chat-scope.js +724 -0
- package/dist/api/chat-scope.js.map +1 -0
- package/dist/api/diagnostics.d.ts +30 -2
- package/dist/api/diagnostics.d.ts.map +1 -1
- package/dist/api/diagnostics.js +73 -5
- package/dist/api/diagnostics.js.map +1 -1
- package/dist/api/governance-health.d.ts +163 -0
- package/dist/api/governance-health.d.ts.map +1 -0
- package/dist/api/governance-health.js +348 -0
- package/dist/api/governance-health.js.map +1 -0
- package/dist/api/open-path.d.ts.map +1 -1
- package/dist/api/open-path.js +2 -1
- package/dist/api/open-path.js.map +1 -1
- package/dist/api/post-hoc-deliver.d.ts +9 -5
- package/dist/api/post-hoc-deliver.d.ts.map +1 -1
- package/dist/api/post-hoc-deliver.js +21 -7
- package/dist/api/post-hoc-deliver.js.map +1 -1
- package/dist/api/routes.d.ts +48 -12
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +309 -48
- package/dist/api/routes.js.map +1 -1
- package/dist/api/seat-health.d.ts.map +1 -1
- package/dist/api/seat-health.js +25 -5
- package/dist/api/seat-health.js.map +1 -1
- package/dist/api/seat-signin.d.ts.map +1 -1
- package/dist/api/seat-signin.js +49 -23
- package/dist/api/seat-signin.js.map +1 -1
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +20 -0
- package/dist/api/server.js.map +1 -1
- package/dist/campaigns/routes.d.ts +4 -4
- package/dist/cli/governance.d.ts +131 -0
- package/dist/cli/governance.d.ts.map +1 -0
- package/dist/cli/governance.js +354 -0
- package/dist/cli/governance.js.map +1 -0
- package/dist/cli/index.js +69 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/core/adapter.d.ts +89 -1
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +123 -7
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/bridge-reaper.d.ts.map +1 -1
- package/dist/core/bridge-reaper.js +4 -1
- package/dist/core/bridge-reaper.js.map +1 -1
- package/dist/core/deliver-text.d.ts +184 -0
- package/dist/core/deliver-text.d.ts.map +1 -0
- package/dist/core/deliver-text.js +434 -0
- package/dist/core/deliver-text.js.map +1 -0
- package/dist/core/deliver-triage.d.ts +90 -0
- package/dist/core/deliver-triage.d.ts.map +1 -0
- package/dist/core/deliver-triage.js +106 -0
- package/dist/core/deliver-triage.js.map +1 -0
- package/dist/core/deliver.d.ts +103 -9
- package/dist/core/deliver.d.ts.map +1 -1
- package/dist/core/deliver.js +244 -46
- package/dist/core/deliver.js.map +1 -1
- package/dist/core/estate-mcp-client.d.ts.map +1 -1
- package/dist/core/estate-mcp-client.js +7 -2
- package/dist/core/estate-mcp-client.js.map +1 -1
- package/dist/core/exec.d.ts.map +1 -1
- package/dist/core/exec.js +11 -1
- package/dist/core/exec.js.map +1 -1
- package/dist/core/governance-store.d.ts +197 -0
- package/dist/core/governance-store.d.ts.map +1 -0
- package/dist/core/governance-store.js +283 -0
- package/dist/core/governance-store.js.map +1 -0
- package/dist/core/repoPaths.d.ts +36 -5
- package/dist/core/repoPaths.d.ts.map +1 -1
- package/dist/core/repoPaths.js +46 -4
- package/dist/core/repoPaths.js.map +1 -1
- package/dist/interactive/bridge-pool.d.ts.map +1 -1
- package/dist/interactive/bridge-pool.js +9 -4
- package/dist/interactive/bridge-pool.js.map +1 -1
- package/dist/projects/graph-paths.d.ts +4 -2
- package/dist/projects/graph-paths.d.ts.map +1 -1
- package/dist/projects/graph-paths.js +4 -2
- package/dist/projects/graph-paths.js.map +1 -1
- package/dist/projects/graph.d.ts +5 -4
- package/dist/projects/graph.d.ts.map +1 -1
- package/dist/projects/graph.js +22 -5
- package/dist/projects/graph.js.map +1 -1
- package/dist/projects/routes.d.ts.map +1 -1
- package/dist/projects/routes.js +18 -9
- package/dist/projects/routes.js.map +1 -1
- package/dist/skills/plugin-source.d.ts.map +1 -1
- package/dist/skills/plugin-source.js +3 -0
- package/dist/skills/plugin-source.js.map +1 -1
- package/dist/studio/assets/index-CeJTutxP.js +540 -0
- package/dist/studio/index.html +1 -1
- package/dist/studio/testid-inventory.json +212 -6
- package/endpoint-manifest.json +60 -12
- package/package.json +3 -3
- package/dist/studio/assets/index-CdmCMh2f.js +0 -539
package/dist/api/routes.js
CHANGED
|
@@ -6,7 +6,7 @@ import { promises as fsp } from 'node:fs';
|
|
|
6
6
|
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { CampaignsUnsupportedError, ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError, SteeringUnsupportedError, humanGatePhaseIds } from '../core/adapter.js';
|
|
9
|
-
import { codeGraphDb, requirementsGraph } from '../core/repoPaths.js';
|
|
9
|
+
import { codeGraphDb, codeGraphErrorStatus, requirementsGraph } from '../core/repoPaths.js';
|
|
10
10
|
import { resolveCursorUnit } from '../core/cursor.js';
|
|
11
11
|
import { detectRefusal } from './gate-cache.js';
|
|
12
12
|
import { QeGateCache } from '../qe/gate-events.js';
|
|
@@ -17,6 +17,7 @@ import { execCapped, ExecOutputTooLarge } from '../core/exec.js';
|
|
|
17
17
|
import { callEstateTool, EstateMcpError } from '../core/estate-mcp-client.js';
|
|
18
18
|
import { SeatHealthTracker } from './seat-health.js';
|
|
19
19
|
import { applyWorkerConfigRoot, signedInHeuristic } from './seat-signin.js';
|
|
20
|
+
import { ChatScopeIndex, chatScopeDeps, prepareChatScratch, removeChatScratch, resolveChatScope, } from './chat-scope.js';
|
|
20
21
|
import { allowedRootsFor, isInsideRoot, openWithSystemDefault } from './open-path.js';
|
|
21
22
|
import { InvalidDiffBaseError, NotARegularFileError, UnresolvableDiffBaseError, readFileCapped, worktreeDiff, } from './run-files.js';
|
|
22
23
|
import { resolveProjectGraphBinding } from '../projects/graph.js';
|
|
@@ -30,6 +31,7 @@ import { registerSkillsRoutes } from './skills.js';
|
|
|
30
31
|
import { disabledSkillsHealth } from '../skills/runtime.js';
|
|
31
32
|
import { ProjectSettingsStore } from '../projects/settings.js';
|
|
32
33
|
import { boundOrigin, InteractiveBridgePool } from '../interactive/bridge-pool.js';
|
|
34
|
+
import { composeDeliverText, factsFromRun, framedDeliverText, runUrlFor } from '../core/deliver-text.js';
|
|
33
35
|
import { registerInteractiveProxy } from '../interactive/proxy-routes.js';
|
|
34
36
|
import { registerInteractiveDocDelete } from '../interactive/doc-delete-routes.js';
|
|
35
37
|
import { registerInteractiveDocList } from '../interactive/doc-list-routes.js';
|
|
@@ -37,6 +39,8 @@ import { MembershipIndex } from '../projects/membership-index.js';
|
|
|
37
39
|
import { MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.js';
|
|
38
40
|
import { AuditLog } from './audit.js';
|
|
39
41
|
import { AcpFoldCache, EngineVersionCache, eventsDirOf, installedPackageVersion, listStoreFiles, readStudioBundleVersion, } from './diagnostics.js';
|
|
42
|
+
import { GovernanceDiagnostics } from './governance-health.js';
|
|
43
|
+
import { legacyHomeOutboxPath } from '../core/governance-store.js';
|
|
40
44
|
import { RetryIndex } from './retry-index.js';
|
|
41
45
|
import { GroupIndex } from './group-index.js';
|
|
42
46
|
import { RunTimingIndex, recordRunLaunched } from './run-timing-index.js';
|
|
@@ -69,6 +73,25 @@ const STATUS_ORDER = {
|
|
|
69
73
|
function sortActionableFirst(views) {
|
|
70
74
|
return [...views].sort((a, b) => (STATUS_ORDER[a.session.status] ?? 9) - (STATUS_ORDER[b.session.status] ?? 9));
|
|
71
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* `codeGraphDb(repo)` for a repo surface, or `null` after answering **503** — a CURRENT engine that
|
|
78
|
+
* resolved no repo-graph root (`code_graph_root_unresolvable`, wicked-core#406) is a daemon-
|
|
79
|
+
* environment fault, not a bad request and not Fastify's generic 500. The mapping is the shared
|
|
80
|
+
* `codeGraphErrorStatus`; anything it does not classify rethrows unchanged (the stale-addon error
|
|
81
|
+
* keeps its pre-existing shape here).
|
|
82
|
+
*/
|
|
83
|
+
function codeGraphDbOr503(repo, reply) {
|
|
84
|
+
try {
|
|
85
|
+
return codeGraphDb(repo);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
const status = codeGraphErrorStatus(err);
|
|
89
|
+
if (status === null)
|
|
90
|
+
throw err;
|
|
91
|
+
void reply.code(status).send({ error: message(err) });
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
72
95
|
function message(err) {
|
|
73
96
|
return err instanceof Error ? err.message : String(err);
|
|
74
97
|
}
|
|
@@ -411,6 +434,21 @@ const STUDIO_SETTINGS_MAX_BYTES = 512 * 1024;
|
|
|
411
434
|
* The daemon REST surface. Every endpoint is a thin wrapper over one adapter /
|
|
412
435
|
* core-ts call (DES-STUDIO-001 §2). `session`/`phase` nouns are now `run`/`unit`.
|
|
413
436
|
*/
|
|
437
|
+
/**
|
|
438
|
+
* `POST /chats` body (crew#165; `projectId` DES-PROJECT-001 §2.2; `repoRefs` + scope semantics
|
|
439
|
+
* crew#502). Exported for the wire-contract drift guard (`tests/wire-contract.test.ts`).
|
|
440
|
+
*/
|
|
441
|
+
export const ChatOpenSchema = z.object({
|
|
442
|
+
chatId: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional(),
|
|
443
|
+
clis: z.array(z.string().min(1)).min(1).max(8).optional(),
|
|
444
|
+
/** One repo in scope — the legacy spelling, merged into `repoRefs` (crew#502). */
|
|
445
|
+
repoRef: z.string().min(1).optional(),
|
|
446
|
+
/** The repos in scope, by registry id or name (crew#502). */
|
|
447
|
+
repoRefs: z.array(z.string().min(1)).min(1).max(32).optional(),
|
|
448
|
+
/** File the chat into a project (`crew.chat` membership) — and, with no `repoRefs`, scope it to
|
|
449
|
+
* every registered `crew.repo` member of that project (crew#502). */
|
|
450
|
+
projectId: z.string().min(1).optional(),
|
|
451
|
+
}).strict();
|
|
414
452
|
export function registerRoutes(app, adapter, gateCache, elicitationCache,
|
|
415
453
|
// Defaulted so a caller that never arms the bus seam (tests drive this
|
|
416
454
|
// function directly) gets the same behavior as an unarmed daemon: an empty
|
|
@@ -435,6 +473,7 @@ runtime = {}) {
|
|
|
435
473
|
const groupIndex = runtime.groupIndex ?? new GroupIndex();
|
|
436
474
|
const runTimingIndex = runtime.runTimingIndex ?? new RunTimingIndex();
|
|
437
475
|
const guidanceIndex = runtime.guidanceIndex ?? new GuidanceIndex();
|
|
476
|
+
const chatScopes = runtime.chatScopes ?? new ChatScopeIndex();
|
|
438
477
|
const deliveryIndex = runtime.deliveryIndex ?? new DeliveryIndex();
|
|
439
478
|
const worktreeExists = runtime.worktreeExists ?? ((p) => existsSync(p));
|
|
440
479
|
const vacuityProbes = {
|
|
@@ -457,6 +496,12 @@ runtime = {}) {
|
|
|
457
496
|
isDelivered: (runId) => deliveryIndex.urlFor(runId) !== undefined,
|
|
458
497
|
});
|
|
459
498
|
const deliverExec = runtime.deliverExec ?? runDeliverScript;
|
|
499
|
+
// crew#524: the deliver phase's script asks THIS daemon for the run-derived PR text, and the PR
|
|
500
|
+
// body links the run here — so the adapter learns the bound origin lazily (the server has not
|
|
501
|
+
// listened yet when routes register). Guarded: directly-driven route sets hand in fakes.
|
|
502
|
+
if (typeof adapter.setDeliverApiOrigin === 'function') {
|
|
503
|
+
adapter.setDeliverApiOrigin(() => boundOrigin(app.server.address()));
|
|
504
|
+
}
|
|
460
505
|
const reprovisionWorktree = runtime.reprovisionWorktree ?? gitReprovisionWorktree;
|
|
461
506
|
// The estate MCP client behind `/proposals*` AND `/memory*` (DES-MEM-FACETED-001) — one seam. The
|
|
462
507
|
// default is the real spawn-per-call `wicked-estate-mcp` client; route tests inject a stub so no
|
|
@@ -572,16 +617,22 @@ runtime = {}) {
|
|
|
572
617
|
// can never turn this GET into an event-log re-reader or a `--version` spawner per request.
|
|
573
618
|
const acpFoldCache = new AcpFoldCache();
|
|
574
619
|
const engineVersionCache = new EngineVersionCache();
|
|
620
|
+
// The governance block (crew#495): the store the engine's emit seam writes to (as the adapter
|
|
621
|
+
// exported it — `null` on a boot that resolved none), its record count through the engine
|
|
622
|
+
// binding when the addon has one, and the dead-letter outbox folded on change. Built at boot so
|
|
623
|
+
// the record baseline is the boot baseline.
|
|
624
|
+
const governanceDiagnostics = new GovernanceDiagnostics(adapter.governanceStore ?? null, CoreAdapter.eventStoreCounter(), runtime.governanceLegacyOutboxPath === undefined ? legacyHomeOutboxPath() : runtime.governanceLegacyOutboxPath);
|
|
575
625
|
app.get(`${V}/diagnostics`, { config: { manifest: { responseType: 'DiagnosticsResponse', statusCodes: [200] } } }, async () => {
|
|
576
626
|
// `dbPath` is a readonly field of the real adapter; a stub-driven route set may lack it,
|
|
577
627
|
// and diagnostics over an unknown store honestly reports no stores and no ACP record.
|
|
578
628
|
const dbPath = typeof adapter.dbPath === 'string' && adapter.dbPath !== '' ? adapter.dbPath : null;
|
|
579
|
-
const [stores, byCli, engineBinaries] = await Promise.all([
|
|
629
|
+
const [stores, byCli, engineBinaries, governance] = await Promise.all([
|
|
580
630
|
dbPath !== null ? listStoreFiles(dbPath) : Promise.resolve([]),
|
|
581
631
|
dbPath !== null
|
|
582
632
|
? acpFoldCache.get(eventsDirOf(dbPath))
|
|
583
633
|
: Promise.resolve({}),
|
|
584
634
|
engineVersionCache.get(),
|
|
635
|
+
governanceDiagnostics.health(),
|
|
585
636
|
]);
|
|
586
637
|
const uptimeMs = Math.round(process.uptime() * 1000);
|
|
587
638
|
const addr = app.server.address();
|
|
@@ -601,6 +652,9 @@ runtime = {}) {
|
|
|
601
652
|
// config-error, with the `skills.*` findings the ladder produced — the operator's one
|
|
602
653
|
// read-only answer to "why do launches refuse the snapshot".
|
|
603
654
|
skills: runtime.skills?.health() ?? disabledSkillsHealth(),
|
|
655
|
+
// Is the governance evidence LANDING (crew#495): the store, the records on it, the dead
|
|
656
|
+
// letters — with a `governance.deadletter` finding the moment the outbox holds one.
|
|
657
|
+
governance,
|
|
604
658
|
};
|
|
605
659
|
});
|
|
606
660
|
// Who am I talking to the daemon as? (task #88). In local mode this is
|
|
@@ -1181,6 +1235,28 @@ runtime = {}) {
|
|
|
1181
1235
|
return reply.code(404).send({ error: 'Run not found' });
|
|
1182
1236
|
return { run: decorateRun(run) };
|
|
1183
1237
|
});
|
|
1238
|
+
/** The full workflow registry, or `[]` on a directly-driven route set whose fake adapter has none. */
|
|
1239
|
+
const listWorkflowsSafe = () => typeof adapter.listWorkflows === 'function' ? adapter.listWorkflows() : [];
|
|
1240
|
+
// ── Deliver text (crew#524 / F-3R2-014) — the PR title + body a run's delivery carries ──
|
|
1241
|
+
// `gh pr create --fill` gave wicked-studio#249 a mid-word title and an EMPTY body. The deliver
|
|
1242
|
+
// script now asks this route for the text composed from the PERSISTED RUN RECORD: the intent,
|
|
1243
|
+
// `Fixes #N`, the run link, every phase with its seat and gate outcome, the repo checks with
|
|
1244
|
+
// their exit codes, the evaluator verdict (`core/deliver-text.ts`). text/plain, FRAMED as line 1
|
|
1245
|
+
// title, line 2 blank, then the body — the same shape the script's embedded fallback has, so the
|
|
1246
|
+
// script parses exactly one thing. Read-only; the run's `deliver` unit is still running when it
|
|
1247
|
+
// asks, and is listed as `this PR`.
|
|
1248
|
+
app.get(`${V}/runs/:id/deliver-text`, { config: { manifest: { statusCodes: [200, 404] } } }, async (req, reply) => {
|
|
1249
|
+
const { id } = req.params;
|
|
1250
|
+
const views = await adapter.sessionsDetail();
|
|
1251
|
+
const run = views.find((v) => v.session.id === id);
|
|
1252
|
+
if (!run)
|
|
1253
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
1254
|
+
const origin = boundOrigin(app.server.address());
|
|
1255
|
+
// The workflow DEFINITION (a user-registered workflow's view carries the engine instance id).
|
|
1256
|
+
const def = resolveRunWorkflow(run, listWorkflowsSafe());
|
|
1257
|
+
const text = composeDeliverText(factsFromRun(decorateRun(run), runUrlFor(origin, id), { workflowId: def?.id ?? null }));
|
|
1258
|
+
return reply.type('text/plain; charset=utf-8').send(framedDeliverText(text));
|
|
1259
|
+
});
|
|
1184
1260
|
// ── Post-hoc delivery (crew#393) — lift a stranded run's worktree into a PR ──
|
|
1185
1261
|
// The recovery path for the run 83052f0b class: a COMPLETED repo-scoped run whose reviewable
|
|
1186
1262
|
// work was never lifted (`delivery: 'stranded'` on the wire — including runs recorded long
|
|
@@ -1299,7 +1375,15 @@ runtime = {}) {
|
|
|
1299
1375
|
workdir = reprov.workdir;
|
|
1300
1376
|
cw = reprov.cleanup;
|
|
1301
1377
|
}
|
|
1302
|
-
|
|
1378
|
+
// crew#524: the post-hoc lift composes its PR text from the run record it already
|
|
1379
|
+
// holds (the fallback), and names this daemon so the script can re-ask at delivery.
|
|
1380
|
+
const origin = boundOrigin(app.server.address());
|
|
1381
|
+
const def = resolveRunWorkflow(run, listWorkflowsSafe());
|
|
1382
|
+
result = await deliverExec(workdir, s.problem ?? undefined, {
|
|
1383
|
+
runId: id,
|
|
1384
|
+
apiOrigin: origin,
|
|
1385
|
+
facts: factsFromRun(run, runUrlFor(origin, id), { workflowId: def?.id ?? null }),
|
|
1386
|
+
});
|
|
1303
1387
|
}
|
|
1304
1388
|
finally {
|
|
1305
1389
|
if (cw !== null)
|
|
@@ -1394,28 +1478,27 @@ runtime = {}) {
|
|
|
1394
1478
|
// ── Chat sessions (crew#165): warm ACP seat pool + group fan-out (core#134) ──
|
|
1395
1479
|
// A chat is NOT a run: no council, no gates, no units. Seats warm on open;
|
|
1396
1480
|
// messages fan out to warm seats; replies stream on /ws as chatDelta/chatReply.
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1481
|
+
//
|
|
1482
|
+
// A chat is SCOPED (crew#502, F-067): its seats run in a private scratch root — never a repo,
|
|
1483
|
+
// never the daemon's cwd — with the scoped repos as read roots and, where a graph binds, the
|
|
1484
|
+
// READ-ONLY estate MCP over it (the grounding governed runs get). The scope is resolved and
|
|
1485
|
+
// validated BEFORE any seat warms, stated to the seats in the scratch root's AGENTS.md /
|
|
1486
|
+
// CLAUDE.md, and returned as `scope` (see `chat-scope.ts`).
|
|
1487
|
+
app.post(`${V}/chats`, {
|
|
1488
|
+
config: {
|
|
1489
|
+
manifest: {
|
|
1490
|
+
requestType: 'ChatOpenBody',
|
|
1491
|
+
responseType: 'ChatOpenResponse',
|
|
1492
|
+
statusCodes: [201, 400, 404, 409, 500, 501],
|
|
1493
|
+
},
|
|
1494
|
+
},
|
|
1495
|
+
}, async (req, reply) => {
|
|
1405
1496
|
const parsed = ChatOpenSchema.safeParse(req.body ?? {});
|
|
1406
1497
|
if (!parsed.success) {
|
|
1407
1498
|
return reply.code(400).send(invalidBody(parsed.error, 'Invalid request body'));
|
|
1408
1499
|
}
|
|
1409
1500
|
const b = parsed.data;
|
|
1410
1501
|
const chatId = b.chatId ?? randomUUID();
|
|
1411
|
-
let cwd;
|
|
1412
|
-
if (b.repoRef !== undefined) {
|
|
1413
|
-
const repos = await adapter.listRepos();
|
|
1414
|
-
const repo = repos.find((r) => r.id === b.repoRef);
|
|
1415
|
-
if (!repo)
|
|
1416
|
-
return reply.code(404).send({ error: `Repo ${b.repoRef} not found` });
|
|
1417
|
-
cwd = repo.root_path;
|
|
1418
|
-
}
|
|
1419
1502
|
// Validate the project BEFORE opening seats: a chat has no launch record for the engine to
|
|
1420
1503
|
// attach against atomically (chats are an in-memory seat pool), so the route validates
|
|
1421
1504
|
// up-front and attaches right after open — the one non-atomic attach, documented in the ADR
|
|
@@ -1436,32 +1519,169 @@ runtime = {}) {
|
|
|
1436
1519
|
return reply.code(501).send({ error: message(err) });
|
|
1437
1520
|
}
|
|
1438
1521
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1522
|
+
// A chat id this daemon already holds a scope for is LIVE: re-preparing its scratch root and
|
|
1523
|
+
// re-opening it would overwrite the statement its seats are reading and (on a changed scope)
|
|
1524
|
+
// evict them mid-conversation (Copilot, #518). Close it first, or let the daemon mint the id.
|
|
1525
|
+
const token = chatScopes.reserve(chatId);
|
|
1526
|
+
if (token === null) {
|
|
1527
|
+
const state = chatScopes.stateOf(chatId);
|
|
1528
|
+
return reply.code(409).send({
|
|
1529
|
+
error: state === 'closing'
|
|
1530
|
+
? `chat ${chatId} is closing; wait for its chatClosed (a few seconds at most) before reusing the id, or omit chatId to mint a fresh one`
|
|
1531
|
+
: `chat ${chatId} is already open on this daemon; DELETE /chats/${chatId} first, or omit chatId to mint a fresh one`,
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
// Everything below either ends in `chatScopes.set(chatId, …, token)` or releases the reservation.
|
|
1443
1535
|
try {
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1536
|
+
// The scope (crew#502): explicit repos (`repoRefs`, the legacy `repoRef` merged in) or the
|
|
1537
|
+
// project's members — each ref checked against the registry, EVERY missing one named — else
|
|
1538
|
+
// none. Resolved before any seat warms: a 404 here warmed nothing.
|
|
1539
|
+
const resolution = await resolveChatScope({
|
|
1540
|
+
chatId,
|
|
1541
|
+
...(b.projectId !== undefined ? { projectId: b.projectId } : {}),
|
|
1542
|
+
repoRefs: [...(b.repoRef !== undefined ? [b.repoRef] : []), ...(b.repoRefs ?? [])],
|
|
1543
|
+
}, { ...chatScopeDeps(adapter), scratchBase: chatScopes.base, log: (m) => req.log.warn(m) });
|
|
1544
|
+
if (!resolution.ok) {
|
|
1545
|
+
chatScopes.release(chatId, token);
|
|
1546
|
+
return reply.code(resolution.status).send({
|
|
1547
|
+
error: resolution.error,
|
|
1548
|
+
...(resolution.missing !== undefined ? { missing: resolution.missing } : {}),
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
const { scope, engine } = resolution;
|
|
1552
|
+
try {
|
|
1553
|
+
// Cleans up only what it created itself on failure (Copilot, #518).
|
|
1554
|
+
prepareChatScratch(chatId, scope);
|
|
1555
|
+
}
|
|
1556
|
+
catch (err) {
|
|
1557
|
+
chatScopes.release(chatId, token);
|
|
1558
|
+
return reply
|
|
1559
|
+
.code(500)
|
|
1560
|
+
.send({ error: `cannot prepare the chat's scratch root ${scope.cwd}: ${message(err)}` });
|
|
1561
|
+
}
|
|
1562
|
+
// The default seats of a SCOPED chat are pre-filtered by admissibility (independent review,
|
|
1563
|
+
// W5): the engine admits a seat to a scoped chat only when its ACP adapter asks permissions
|
|
1564
|
+
// (`acp_input_governance`) or its record arms the kernel write floor (`os_sandbox`) — with the
|
|
1565
|
+
// default roster that is claude and opencode — so a default project-scoped open does not show
|
|
1566
|
+
// four refused seats. Explicit `clis` are passed through as asked; the engine refuses per seat
|
|
1567
|
+
// with the reason.
|
|
1568
|
+
const scoped = scope.kind !== 'none';
|
|
1569
|
+
const roster = CoreAdapter.roster();
|
|
1570
|
+
const clis = b.clis ??
|
|
1571
|
+
roster
|
|
1572
|
+
.filter((s) => !scoped || s.acp?.acp_input_governance === true || s.acp?.os_sandbox === true)
|
|
1573
|
+
.map((s) => s.key)
|
|
1574
|
+
.filter((k) => typeof k === 'string');
|
|
1575
|
+
if (clis.length === 0) {
|
|
1576
|
+
chatScopes.release(chatId, token);
|
|
1577
|
+
removeChatScratch(scope.cwd, chatScopes.base);
|
|
1578
|
+
return reply.code(409).send({
|
|
1579
|
+
error: 'no seat in the roster can be held to a scoped chat (an ACP adapter admitted to input ' +
|
|
1580
|
+
'governance, or `os_sandbox = true` on its [cli.acp] record); open the chat unscoped, or ' +
|
|
1581
|
+
'name seats with `clis`',
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
try {
|
|
1585
|
+
const seats = await adapter.chatOpen(chatId, clis, engine.cwd, {
|
|
1586
|
+
codeGraphDb: engine.codeGraphDb,
|
|
1587
|
+
readRoots: engine.readRoots,
|
|
1588
|
+
});
|
|
1589
|
+
// Nothing warmed (independent review, W1): the engine holds no pool row and has dropped the
|
|
1590
|
+
// scope itself (no `chatClosed` will come) — report the PER-SEAT reasons, never an
|
|
1591
|
+
// engine-version guess; the root goes, the id is free again.
|
|
1592
|
+
if (seats.every((s) => !s.ok)) {
|
|
1593
|
+
chatScopes.release(chatId, token);
|
|
1594
|
+
removeChatScratch(scope.cwd, chatScopes.base);
|
|
1595
|
+
return reply.code(409).send({
|
|
1596
|
+
error: `chat ${chatId}: no seat warmed (${seats.length} failed) — see seats`,
|
|
1597
|
+
seats,
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
// Honest scope (Copilot, #518): a SCOPED chat is a promise — the roots are read-only, the
|
|
1601
|
+
// graph is attached, the seats see nothing else — and only an engine that CONFIRMS it
|
|
1602
|
+
// recorded the scope (its `chatList` row carries the scope fields) can keep it. An engine
|
|
1603
|
+
// predating chat scope (wicked-core#410) dropped `scopeJson` and runs the seats unbounded in
|
|
1604
|
+
// the scratch root; an engine that cannot be asked has confirmed nothing. Neither may hold a
|
|
1605
|
+
// scoped chat: it is closed again, its root removed, and the caller gets a 501 naming the
|
|
1606
|
+
// remedy — never a chat whose statement promises what its seats do not enforce. An UNSCOPED
|
|
1607
|
+
// chat (`kind: 'none'`) promises nothing beyond its scratch root and proceeds.
|
|
1608
|
+
const applied = await adapter.chatScopeApplied(chatId);
|
|
1609
|
+
if (applied !== true && scoped) {
|
|
1610
|
+
await adapter.chatClose(chatId).catch(() => undefined);
|
|
1611
|
+
// The engine's `chatClosed` for this id is still on its way: park, do not release
|
|
1612
|
+
// (Copilot, #518), so a reuse cannot have its root removed by the late event.
|
|
1613
|
+
chatScopes.abortToClosing(chatId, token);
|
|
1614
|
+
removeChatScratch(scope.cwd, chatScopes.base);
|
|
1615
|
+
if (applied === false) {
|
|
1616
|
+
// A row WITHOUT the scope fields: the engine predates chat scope (wicked-core#410).
|
|
1617
|
+
return reply.code(501).send({
|
|
1618
|
+
error: 'the installed wicked-core-ts predates chat scope (wicked-core#410): it cannot ground ' +
|
|
1619
|
+
'a scoped chat or hold its read roots read-only — upgrade the engine, or open the ' +
|
|
1620
|
+
'chat without projectId/repoRefs.',
|
|
1621
|
+
seats,
|
|
1622
|
+
});
|
|
1451
1623
|
}
|
|
1624
|
+
// NO row although a seat reported warm: nothing is actually held for this chat.
|
|
1625
|
+
return reply.code(409).send({
|
|
1626
|
+
error: `chat ${chatId}: a seat reported warm but the engine holds no row for the chat — ` +
|
|
1627
|
+
'nothing warmed; open it again (see seats)',
|
|
1628
|
+
seats,
|
|
1629
|
+
});
|
|
1452
1630
|
}
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1631
|
+
// File the chat into its project WHILE the id is still reserved (Copilot, #518): publishing
|
|
1632
|
+
// first would let a concurrent DELETE / engine `chatClosed` land during this await and leave
|
|
1633
|
+
// a 201 with a stale scope and a membership attached to a closed chat.
|
|
1634
|
+
let projectAttachError;
|
|
1635
|
+
let attachedMemberId;
|
|
1636
|
+
if (b.projectId !== undefined) {
|
|
1637
|
+
try {
|
|
1638
|
+
const { member, created } = await adapter.projectMemberAttach(b.projectId, 'crew.chat', chatId);
|
|
1639
|
+
if (created)
|
|
1640
|
+
attachedMemberId = member.id;
|
|
1641
|
+
if (created) {
|
|
1642
|
+
projects.index.set(chatId, b.projectId);
|
|
1643
|
+
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));
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
catch (err) {
|
|
1647
|
+
// The chat is open and usable; the filing failed. Said on the 201 rather than failing
|
|
1648
|
+
// the open — the caller can re-attach via POST /projects/:id/members.
|
|
1649
|
+
projectAttachError = message(err);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
if (!chatScopes.set(chatId, scope, token)) {
|
|
1653
|
+
// The reservation was cancelled while the open was in flight — an engine `chatClosed` or a
|
|
1654
|
+
// `DELETE` for this id (Copilot, #518). Nothing was recorded; tear the chat down (engine
|
|
1655
|
+
// session, scratch root, and the filing just made) instead of returning a stale 201.
|
|
1656
|
+
await adapter.chatClose(chatId).catch(() => undefined);
|
|
1657
|
+
removeChatScratch(scope.cwd, chatScopes.base);
|
|
1658
|
+
if (b.projectId !== undefined && attachedMemberId !== undefined) {
|
|
1659
|
+
await adapter.projectMemberDetach(b.projectId, attachedMemberId).catch(() => false);
|
|
1660
|
+
projects.index.delete(chatId);
|
|
1661
|
+
}
|
|
1662
|
+
return reply.code(409).send({
|
|
1663
|
+
error: `chat ${chatId} was closed while it was being opened; open it again`,
|
|
1664
|
+
});
|
|
1459
1665
|
}
|
|
1666
|
+
return reply.code(201).send({
|
|
1667
|
+
chatId,
|
|
1668
|
+
seats,
|
|
1669
|
+
scope,
|
|
1670
|
+
...(projectAttachError !== undefined ? { projectAttachError } : {}),
|
|
1671
|
+
});
|
|
1672
|
+
}
|
|
1673
|
+
catch (err) {
|
|
1674
|
+
// Nothing warmed: the scratch root prepared above must not linger — removed under the SAME
|
|
1675
|
+
// base the resolver created it in (Copilot, #518), fail-closed like every removal.
|
|
1676
|
+
chatScopes.release(chatId, token);
|
|
1677
|
+
removeChatScratch(scope.cwd, chatScopes.base);
|
|
1678
|
+
return reply.code(400).send({ error: message(err) });
|
|
1460
1679
|
}
|
|
1461
|
-
return reply.code(201).send({ chatId, seats });
|
|
1462
1680
|
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1681
|
+
finally {
|
|
1682
|
+
// A reservation that never became a scope (any early return above) must not pin the id —
|
|
1683
|
+
// token-guarded, so a reservation that is no longer ours is left alone.
|
|
1684
|
+
chatScopes.release(chatId, token);
|
|
1465
1685
|
}
|
|
1466
1686
|
});
|
|
1467
1687
|
const ChatMessageSchema = z.object({
|
|
@@ -1488,7 +1708,7 @@ runtime = {}) {
|
|
|
1488
1708
|
// lived in the tab that abandoned it, and an operator could not reclaim one without restarting
|
|
1489
1709
|
// the daemon. Registered BEFORE `/chats/:id` is irrelevant to fastify (it routes on the literal
|
|
1490
1710
|
// segment first), but the order reads the way the routes nest.
|
|
1491
|
-
app.get(`${V}/chats`, async (_req, reply) => {
|
|
1711
|
+
app.get(`${V}/chats`, { config: { manifest: { responseType: 'ChatListResponse', statusCodes: [200, 400, 501] } } }, async (_req, reply) => {
|
|
1492
1712
|
try {
|
|
1493
1713
|
return { chats: await adapter.chatList() };
|
|
1494
1714
|
}
|
|
@@ -1503,10 +1723,11 @@ runtime = {}) {
|
|
|
1503
1723
|
.send({ error: message(err) });
|
|
1504
1724
|
}
|
|
1505
1725
|
});
|
|
1506
|
-
app.get(`${V}/chats/:id`, async (req, reply) => {
|
|
1726
|
+
app.get(`${V}/chats/:id`, { config: { manifest: { responseType: 'ChatDetailResponse', statusCodes: [200, 400] } } }, async (req, reply) => {
|
|
1507
1727
|
const { id } = req.params;
|
|
1508
1728
|
try {
|
|
1509
|
-
|
|
1729
|
+
// The scope recorded at open (crew#502) — `null` for a chat this daemon did not open.
|
|
1730
|
+
return { chatId: id, seats: await adapter.chatSeats(id), scope: chatScopes.get(id) ?? null };
|
|
1510
1731
|
}
|
|
1511
1732
|
catch (err) {
|
|
1512
1733
|
return reply.code(400).send({ error: message(err) });
|
|
@@ -1514,6 +1735,10 @@ runtime = {}) {
|
|
|
1514
1735
|
});
|
|
1515
1736
|
app.delete(`${V}/chats/:id`, async (req, reply) => {
|
|
1516
1737
|
const { id } = req.params;
|
|
1738
|
+
// The scratch root goes with the chat at once, whether or not the engine still knew it
|
|
1739
|
+
// (crew#502); the id stays parked until the engine's `chatClosed` is observed, so a delayed
|
|
1740
|
+
// close can never land on a chat that reused it (Copilot, #518).
|
|
1741
|
+
chatScopes.beginClose(id);
|
|
1517
1742
|
try {
|
|
1518
1743
|
await adapter.chatClose(id);
|
|
1519
1744
|
return { ok: true };
|
|
@@ -2411,7 +2636,9 @@ runtime = {}) {
|
|
|
2411
2636
|
if (!repo)
|
|
2412
2637
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
2413
2638
|
const graphPath = requirementsGraph(repo);
|
|
2414
|
-
const dbPath =
|
|
2639
|
+
const dbPath = codeGraphDbOr503(repo, reply);
|
|
2640
|
+
if (dbPath === null)
|
|
2641
|
+
return reply;
|
|
2415
2642
|
// Coverage from the live estate store — computed by wicked-core governance layer.
|
|
2416
2643
|
let coverage = null;
|
|
2417
2644
|
if (existsSync(dbPath)) {
|
|
@@ -2469,7 +2696,17 @@ runtime = {}) {
|
|
|
2469
2696
|
if (!parsed.success) {
|
|
2470
2697
|
return reply.code(400).send(invalidBody(parsed.error, 'Invalid query'));
|
|
2471
2698
|
}
|
|
2472
|
-
|
|
2699
|
+
let page;
|
|
2700
|
+
try {
|
|
2701
|
+
page = await listRequirements(repo, parsed.data);
|
|
2702
|
+
}
|
|
2703
|
+
catch (err) {
|
|
2704
|
+
// wicked-core#406: a CURRENT engine with no repo-graph root is a 503, not a 500.
|
|
2705
|
+
const status = codeGraphErrorStatus(err);
|
|
2706
|
+
if (status === null)
|
|
2707
|
+
throw err;
|
|
2708
|
+
return reply.code(status).send({ error: message(err) });
|
|
2709
|
+
}
|
|
2473
2710
|
if (page === null) {
|
|
2474
2711
|
return reply.code(404).send({ error: 'requirements_graph.json not generated for this repo yet' });
|
|
2475
2712
|
}
|
|
@@ -2494,7 +2731,17 @@ runtime = {}) {
|
|
|
2494
2731
|
catch {
|
|
2495
2732
|
return reply.code(400).send({ error: 'Malformed requirement key encoding' });
|
|
2496
2733
|
}
|
|
2497
|
-
|
|
2734
|
+
let detail;
|
|
2735
|
+
try {
|
|
2736
|
+
detail = await getRequirement(repo, decoded);
|
|
2737
|
+
}
|
|
2738
|
+
catch (err) {
|
|
2739
|
+
// wicked-core#406: a CURRENT engine with no repo-graph root is a 503, not a 500.
|
|
2740
|
+
const status = codeGraphErrorStatus(err);
|
|
2741
|
+
if (status === null)
|
|
2742
|
+
throw err;
|
|
2743
|
+
return reply.code(status).send({ error: message(err) });
|
|
2744
|
+
}
|
|
2498
2745
|
if (detail === null)
|
|
2499
2746
|
return reply.code(404).send({ error: 'Requirement not found' });
|
|
2500
2747
|
return { requirement: detail };
|
|
@@ -2529,7 +2776,17 @@ runtime = {}) {
|
|
|
2529
2776
|
catch {
|
|
2530
2777
|
return reply.code(400).send({ error: 'Malformed requirement key encoding' });
|
|
2531
2778
|
}
|
|
2532
|
-
|
|
2779
|
+
let detail;
|
|
2780
|
+
try {
|
|
2781
|
+
detail = await patchRequirement(repo, decodedKey, parsed.data);
|
|
2782
|
+
}
|
|
2783
|
+
catch (err) {
|
|
2784
|
+
// wicked-core#406: a CURRENT engine with no repo-graph root is a 503, not a 500.
|
|
2785
|
+
const status = codeGraphErrorStatus(err);
|
|
2786
|
+
if (status === null)
|
|
2787
|
+
throw err;
|
|
2788
|
+
return reply.code(status).send({ error: message(err) });
|
|
2789
|
+
}
|
|
2533
2790
|
if (detail === null)
|
|
2534
2791
|
return reply.code(404).send({ error: 'Requirement not found' });
|
|
2535
2792
|
return { requirement: detail };
|
|
@@ -2540,7 +2797,9 @@ runtime = {}) {
|
|
|
2540
2797
|
const repo = repos.find((r) => r.id === id);
|
|
2541
2798
|
if (!repo)
|
|
2542
2799
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
2543
|
-
const dbPath =
|
|
2800
|
+
const dbPath = codeGraphDbOr503(repo, reply);
|
|
2801
|
+
if (dbPath === null)
|
|
2802
|
+
return reply;
|
|
2544
2803
|
if (!existsSync(dbPath)) {
|
|
2545
2804
|
return reply.send({ graph: null });
|
|
2546
2805
|
}
|
|
@@ -2591,7 +2850,9 @@ runtime = {}) {
|
|
|
2591
2850
|
const repo = repos.find((r) => r.id === id);
|
|
2592
2851
|
if (!repo)
|
|
2593
2852
|
return reply.code(404).send({ error: `Repo ${id} not found` });
|
|
2594
|
-
const dbPath =
|
|
2853
|
+
const dbPath = codeGraphDbOr503(repo, reply);
|
|
2854
|
+
if (dbPath === null)
|
|
2855
|
+
return reply;
|
|
2595
2856
|
if (!existsSync(dbPath)) {
|
|
2596
2857
|
return reply.code(404).send({ error: 'Code graph not built for this repo yet' });
|
|
2597
2858
|
}
|