wicked-crew 0.7.8 → 0.7.10
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 +9 -1
- package/dist/api/audit.d.ts.map +1 -1
- package/dist/api/audit.js +11 -3
- package/dist/api/audit.js.map +1 -1
- package/dist/api/delivery-index.d.ts +94 -7
- package/dist/api/delivery-index.d.ts.map +1 -1
- package/dist/api/delivery-index.js +182 -4
- package/dist/api/delivery-index.js.map +1 -1
- package/dist/api/elicitation-cache.d.ts +10 -0
- package/dist/api/elicitation-cache.d.ts.map +1 -1
- package/dist/api/elicitation-cache.js +18 -0
- package/dist/api/elicitation-cache.js.map +1 -1
- package/dist/api/governance-steering.d.ts +8 -0
- package/dist/api/governance-steering.d.ts.map +1 -1
- package/dist/api/governance-steering.js +36 -12
- package/dist/api/governance-steering.js.map +1 -1
- package/dist/api/post-hoc-deliver.d.ts +38 -0
- package/dist/api/post-hoc-deliver.d.ts.map +1 -0
- package/dist/api/post-hoc-deliver.js +57 -0
- package/dist/api/post-hoc-deliver.js.map +1 -0
- package/dist/api/routes.d.ts +34 -7
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +314 -35
- package/dist/api/routes.js.map +1 -1
- package/dist/api/seat-signin.d.ts +18 -2
- package/dist/api/seat-signin.d.ts.map +1 -1
- package/dist/api/seat-signin.js +21 -2
- package/dist/api/seat-signin.js.map +1 -1
- package/dist/api/server.d.ts +22 -7
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +131 -22
- package/dist/api/server.js.map +1 -1
- package/dist/api/stall-watchdog.d.ts +46 -21
- package/dist/api/stall-watchdog.d.ts.map +1 -1
- package/dist/api/stall-watchdog.js +193 -19
- package/dist/api/stall-watchdog.js.map +1 -1
- package/dist/api/steering-landing.d.ts +130 -0
- package/dist/api/steering-landing.d.ts.map +1 -0
- package/dist/api/steering-landing.js +423 -0
- package/dist/api/steering-landing.js.map +1 -0
- package/dist/api/testing.d.ts +3 -0
- package/dist/api/testing.d.ts.map +1 -1
- package/dist/api/testing.js +146 -18
- package/dist/api/testing.js.map +1 -1
- package/dist/api/unit-output.d.ts.map +1 -1
- package/dist/api/unit-output.js +7 -1
- package/dist/api/unit-output.js.map +1 -1
- package/dist/campaigns/plan.d.ts +46 -0
- package/dist/campaigns/plan.d.ts.map +1 -1
- package/dist/campaigns/plan.js +80 -0
- package/dist/campaigns/plan.js.map +1 -1
- package/dist/campaigns/routes.d.ts +2 -2
- package/dist/campaigns/worktrees.d.ts +39 -0
- package/dist/campaigns/worktrees.d.ts.map +1 -0
- package/dist/campaigns/worktrees.js +95 -0
- package/dist/campaigns/worktrees.js.map +1 -0
- package/dist/cli/index.js +46 -13
- package/dist/cli/index.js.map +1 -1
- package/dist/core/adapter.d.ts +65 -13
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +148 -31
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/bridge-reaper.d.ts +54 -9
- package/dist/core/bridge-reaper.d.ts.map +1 -1
- package/dist/core/bridge-reaper.js +116 -11
- package/dist/core/bridge-reaper.js.map +1 -1
- package/dist/core/types.d.ts +25 -12
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +14 -0
- package/dist/core/types.js.map +1 -1
- package/dist/interactive/chat-events.d.ts.map +1 -1
- package/dist/interactive/chat-events.js +5 -2
- package/dist/interactive/chat-events.js.map +1 -1
- package/dist/interactive/demo-events.d.ts.map +1 -1
- package/dist/interactive/demo-events.js +5 -2
- package/dist/interactive/demo-events.js.map +1 -1
- package/dist/interactive/doc-delete-routes.d.ts +69 -0
- package/dist/interactive/doc-delete-routes.d.ts.map +1 -0
- package/dist/interactive/doc-delete-routes.js +240 -0
- package/dist/interactive/doc-delete-routes.js.map +1 -0
- package/dist/interactive/doc-ledger-sweep.d.ts +53 -0
- package/dist/interactive/doc-ledger-sweep.d.ts.map +1 -0
- package/dist/interactive/doc-ledger-sweep.js +45 -0
- package/dist/interactive/doc-ledger-sweep.js.map +1 -0
- package/dist/interactive/draft-events.d.ts.map +1 -1
- package/dist/interactive/draft-events.js +5 -2
- package/dist/interactive/draft-events.js.map +1 -1
- package/dist/interactive/edit-events.d.ts.map +1 -1
- package/dist/interactive/edit-events.js +5 -2
- package/dist/interactive/edit-events.js.map +1 -1
- package/dist/interactive/ledger.d.ts +20 -0
- package/dist/interactive/ledger.d.ts.map +1 -1
- package/dist/interactive/ledger.js +32 -0
- package/dist/interactive/ledger.js.map +1 -1
- package/dist/interactive/ws-relay.d.ts +10 -0
- package/dist/interactive/ws-relay.d.ts.map +1 -1
- package/dist/interactive/ws-relay.js +16 -0
- package/dist/interactive/ws-relay.js.map +1 -1
- package/dist/projects/graph-paths.d.ts +7 -22
- package/dist/projects/graph-paths.d.ts.map +1 -1
- package/dist/projects/graph-paths.js +10 -37
- package/dist/projects/graph-paths.js.map +1 -1
- package/dist/projects/settings.d.ts +32 -2
- package/dist/projects/settings.d.ts.map +1 -1
- package/dist/projects/settings.js +48 -6
- package/dist/projects/settings.js.map +1 -1
- package/dist/projects/state-home.d.ts +42 -0
- package/dist/projects/state-home.d.ts.map +1 -0
- package/dist/projects/state-home.js +55 -0
- package/dist/projects/state-home.js.map +1 -0
- package/dist/qe/acceptance.d.ts +9 -0
- package/dist/qe/acceptance.d.ts.map +1 -1
- package/dist/qe/acceptance.js +21 -1
- package/dist/qe/acceptance.js.map +1 -1
- package/dist/studio/assets/{index-BdQQv2hz.js → index-gtE6scX0.js} +98 -98
- package/dist/studio/index.html +1 -1
- package/dist/studio/testid-inventory.json +70 -4
- package/endpoint-manifest.json +28 -2
- package/package.json +4 -4
package/dist/api/routes.js
CHANGED
|
@@ -21,17 +21,20 @@ import { registerProjectRoutes } from '../projects/routes.js';
|
|
|
21
21
|
import { registerCampaignRoutes } from '../campaigns/routes.js';
|
|
22
22
|
import { registerGovernanceWikiRoutes } from './governance-wiki.js';
|
|
23
23
|
import { DEFAULT_STEERING_TYPE, STEERING_TYPE_VALUES, STEERING_TYPES, registerGovernanceSteeringRoutes, } from './governance-steering.js';
|
|
24
|
+
import { isSteeringAuthorRun, landSteeringProposal } from './steering-landing.js';
|
|
24
25
|
import { registerTestingRoutes } from './testing.js';
|
|
25
26
|
import { ProjectSettingsStore } from '../projects/settings.js';
|
|
26
27
|
import { boundOrigin, InteractiveBridgePool } from '../interactive/bridge-pool.js';
|
|
27
28
|
import { registerInteractiveProxy } from '../interactive/proxy-routes.js';
|
|
29
|
+
import { registerInteractiveDocDelete } from '../interactive/doc-delete-routes.js';
|
|
28
30
|
import { MembershipIndex } from '../projects/membership-index.js';
|
|
29
31
|
import { MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.js';
|
|
30
32
|
import { AuditLog } from './audit.js';
|
|
31
33
|
import { AcpFoldCache, EngineVersionCache, eventsDirOf, installedPackageVersion, listStoreFiles, readStudioBundleVersion, } from './diagnostics.js';
|
|
32
34
|
import { RetryIndex } from './retry-index.js';
|
|
33
35
|
import { GuidanceIndex } from './guidance-index.js';
|
|
34
|
-
import { DeliveryIndex } from './delivery-index.js';
|
|
36
|
+
import { DeliveryIndex, deliveryStateWithVacuity, gitRunBranchIsEmpty, gitWorktreeIsClean, prUrlFrom, } from './delivery-index.js';
|
|
37
|
+
import { runDeliverScript } from './post-hoc-deliver.js';
|
|
35
38
|
import { LOCAL_ACTOR } from './auth.js';
|
|
36
39
|
// Re-exported so existing `import { API_PREFIX } from './routes.js'` callers keep working; the
|
|
37
40
|
// value lives in the leaf module api-prefix.ts to keep unit-output.ts out of this file's cycle.
|
|
@@ -137,14 +140,18 @@ export const LaunchSchema = z.object({
|
|
|
137
140
|
* the launch record. Unknown/archived ⇒ the launch fails (never a silent unfiled run). */
|
|
138
141
|
projectId: z.string().min(1).optional(),
|
|
139
142
|
/** crew#293 — `"pr"` appends the hardened deliver Tool phase (push run branch + `gh pr create`)
|
|
140
|
-
* to a PER-RUN copy of the selected workflow
|
|
141
|
-
* below, so the 400 happens at parse time, not after the adapter is consulted).
|
|
142
|
-
|
|
143
|
+
* to a PER-RUN copy of the selected workflow; requires `workflow` (enforced by the refine
|
|
144
|
+
* below, so the 400 happens at parse time, not after the adapter is consulted). crew#393 —
|
|
145
|
+
* `"none"` explicitly declines delivery (legal with or without a workflow); OMITTED means the
|
|
146
|
+
* daemon decides: repo-scoped + a CODE-WORK workflow (a def with an `executes_code` phase)
|
|
147
|
+
* defaults to `"pr"` (flippable via the `deliverDefault` setting), everything else to
|
|
148
|
+
* `"none"` — see the resolution below. */
|
|
149
|
+
deliver: z.enum(['pr', 'none']).optional(),
|
|
143
150
|
/** DES-UX-001 §8.3 (CREW-UX-3) — the run this launch retries. Must name an EXISTING run id
|
|
144
151
|
* (the route checks the store and 400s with a named error otherwise); persisted via the
|
|
145
152
|
* `run.launched` audit entry + retry index and echoed as `AgentSession.retry_of`. */
|
|
146
153
|
retryOf: z.string().min(1).optional(),
|
|
147
|
-
}).strict().refine((b) => b.deliver
|
|
154
|
+
}).strict().refine((b) => b.deliver !== 'pr' || b.workflow !== undefined, {
|
|
148
155
|
message: 'deliver: "pr" requires a workflow — a free-text run has no def to append the deliver phase to',
|
|
149
156
|
path: ['deliver'],
|
|
150
157
|
});
|
|
@@ -230,14 +237,30 @@ runtime = {}) {
|
|
|
230
237
|
const retryIndex = runtime.retryIndex ?? new RetryIndex();
|
|
231
238
|
const guidanceIndex = runtime.guidanceIndex ?? new GuidanceIndex();
|
|
232
239
|
const deliveryIndex = runtime.deliveryIndex ?? new DeliveryIndex();
|
|
240
|
+
const worktreeExists = runtime.worktreeExists ?? ((p) => existsSync(p));
|
|
241
|
+
const vacuityProbes = {
|
|
242
|
+
worktreeExists,
|
|
243
|
+
worktreeIsClean: runtime.worktreeIsClean ?? gitWorktreeIsClean(),
|
|
244
|
+
runBranchIsEmpty: runtime.runBranchIsEmpty ??
|
|
245
|
+
gitRunBranchIsEmpty(async (repoRef) => {
|
|
246
|
+
const repos = await adapter.listRepos();
|
|
247
|
+
return repos.find((r) => r.id === repoRef)?.root_path;
|
|
248
|
+
}),
|
|
249
|
+
};
|
|
250
|
+
const deliverExec = runtime.deliverExec ?? runDeliverScript;
|
|
233
251
|
// The run-DTO joins (DES-UX-001 §8.2/§8.3, DES-UX-002 §7.2): `project_id` from the membership
|
|
234
252
|
// record — `null` = genuinely unfiled, so the field is ALWAYS present on served runs —
|
|
235
|
-
// `retry_of` from the lineage index
|
|
236
|
-
//
|
|
237
|
-
//
|
|
253
|
+
// `retry_of` from the lineage index and `guidance` from the guidance index, each set only
|
|
254
|
+
// when known (absent, never null, spells "not a retry" / "no note"). `delivery` (crew#393,
|
|
255
|
+
// api-types 0.18.0) is DERIVED on every served run — delivered from the index (CREW-UX-8's
|
|
256
|
+
// durable record), stranded from the run record + a worktree stat, none otherwise — so a
|
|
257
|
+
// completed code run whose work is sitting unlifted in its worktree is VISIBLE on the wire,
|
|
258
|
+
// legacy records included. The would-be-stranded runs are further split (crew#311): a
|
|
259
|
+
// completed run whose worktree carries NO contribution at all reads `'vacuous'` — units all
|
|
260
|
+
// "done" with nothing produced must be LOUD on the wire, never silently green.
|
|
238
261
|
// Applied at DTO assembly on exactly the two endpoints that serve the run DTO
|
|
239
262
|
// (GET /runs + GET /runs/:id); the internal sessionsDetail() consumers are untouched.
|
|
240
|
-
const decorateRun = (view) => {
|
|
263
|
+
const decorateRun = async (view) => {
|
|
241
264
|
view.session.project_id = projects.index.projectOf(view.session.id) ?? null;
|
|
242
265
|
const retryOf = retryIndex.retryOfFor(view.session.id);
|
|
243
266
|
if (retryOf !== undefined)
|
|
@@ -245,9 +268,10 @@ runtime = {}) {
|
|
|
245
268
|
const guidance = guidanceIndex.guidanceFor(view.session.id);
|
|
246
269
|
if (guidance !== undefined)
|
|
247
270
|
view.session.guidance = guidance;
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
271
|
+
const state = await deliveryStateWithVacuity(view.session, deliveryIndex.urlFor(view.session.id), vacuityProbes);
|
|
272
|
+
view.session.delivery = state.delivery;
|
|
273
|
+
if (state.deliverUrl !== undefined)
|
|
274
|
+
view.session.deliverUrl = state.deliverUrl;
|
|
251
275
|
return view;
|
|
252
276
|
};
|
|
253
277
|
// Resolved ONCE and shared by the project routes (which read/write `interactiveRoot`) and the
|
|
@@ -643,8 +667,44 @@ runtime = {}) {
|
|
|
643
667
|
input.projectGraph = decision.binding;
|
|
644
668
|
req.log.info({ runId: input.sessionId, projectId: b.projectId, repoRef: b.repoRef ?? null }, `run ${input.sessionId}: ${decision.reason}`);
|
|
645
669
|
}
|
|
646
|
-
|
|
647
|
-
|
|
670
|
+
// The delivery contract (crew#393): a completed code run must end with a reviewable
|
|
671
|
+
// deliverable, or an explicit, recorded decision not to. So the deliver option is resolved
|
|
672
|
+
// HERE, at the boundary, for every launch:
|
|
673
|
+
// - explicit 'pr' / 'none' wins (the operator decided);
|
|
674
|
+
// - omitted + repo-scoped + a CODE-WORK workflow (the def carries at least one
|
|
675
|
+
// `executes_code` phase — feature/bug/migration, not chat/onboarding/recon) ⇒ the
|
|
676
|
+
// daemon's `deliverDefault` setting ('pr' unless the operator flipped it) — the default
|
|
677
|
+
// that keeps run 83052f0b's work from stranding invisibly again. The code-work guard is
|
|
678
|
+
// the issue's own scope ("default deliver:'pr' for CODE-WORK launches"): a read-only
|
|
679
|
+
// workflow leaves a clean worktree, and the deliver script FAILS a clean worktree loudly
|
|
680
|
+
// ("nothing to deliver", crew#317) — defaulting it on would flip every repo-scoped chat
|
|
681
|
+
// from completed to failed;
|
|
682
|
+
// - omitted otherwise ⇒ 'none': a repo-less run has no worktree to lift, and a free-text
|
|
683
|
+
// run has no def to append the deliver phase to (the adapter REFUSES deliver:'pr'
|
|
684
|
+
// without a workflow rather than silently dropping it, so defaulting it on would turn
|
|
685
|
+
// a legal launch into a 400).
|
|
686
|
+
// The adapter input spells 'none' as an omitted field; the audit entry below records the
|
|
687
|
+
// RESOLVED value either way, plus whether it was defaulted.
|
|
688
|
+
let deliver;
|
|
689
|
+
let deliverDefaulted = false;
|
|
690
|
+
if (b.deliver !== undefined) {
|
|
691
|
+
deliver = b.deliver;
|
|
692
|
+
}
|
|
693
|
+
else if (b.repoRef !== undefined && b.workflow !== undefined) {
|
|
694
|
+
// Unknown def ⇒ no default (the launch fails at workflow resolution with its own error —
|
|
695
|
+
// defaulting 'pr' onto it would swap that for a misleading deliver-flavored one).
|
|
696
|
+
const def = adapter.getWorkflow(b.workflow);
|
|
697
|
+
const codeWork = def !== null && def.phases.some((p) => p.executes_code === true);
|
|
698
|
+
deliver =
|
|
699
|
+
codeWork && (await adapter.getSettings()).deliverDefault !== 'none' ? 'pr' : 'none';
|
|
700
|
+
deliverDefaulted = true;
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
deliver = 'none';
|
|
704
|
+
deliverDefaulted = true;
|
|
705
|
+
}
|
|
706
|
+
if (deliver === 'pr')
|
|
707
|
+
input.deliver = 'pr';
|
|
648
708
|
// Retry lineage (DES-UX-001 §8.3): `retryOf` must name an EXISTING run — recording lineage
|
|
649
709
|
// to a run that never existed would be provenance pointing at nothing, so the launch fails
|
|
650
710
|
// loudly (400, before anything is committed) rather than filing a dangling edge.
|
|
@@ -667,7 +727,10 @@ runtime = {}) {
|
|
|
667
727
|
...(b.workflow !== undefined ? { workflow: b.workflow } : {}),
|
|
668
728
|
...(b.repoRef !== undefined ? { repoRef: b.repoRef } : {}),
|
|
669
729
|
...(b.projectId !== undefined ? { projectId: b.projectId } : {}),
|
|
670
|
-
|
|
730
|
+
// crew#393: the RESOLVED delivery decision, not just the caller's field — so the
|
|
731
|
+
// trail says what the run will actually do, and whether the daemon decided it.
|
|
732
|
+
deliver,
|
|
733
|
+
...(deliverDefaulted ? { deliverDefaulted: true } : {}),
|
|
671
734
|
// CREW-UX-3: the trail is the durable record of lineage — the retry index (and a
|
|
672
735
|
// restarted daemon's hydrate) reads it back from exactly this entry.
|
|
673
736
|
...(b.retryOf !== undefined ? { retryOf: b.retryOf } : {}),
|
|
@@ -718,7 +781,7 @@ runtime = {}) {
|
|
|
718
781
|
const visible = includeArchived
|
|
719
782
|
? views
|
|
720
783
|
: views.filter((v) => v.session.archived_at == null);
|
|
721
|
-
return { runs: sortActionableFirst(visible).map(decorateRun) };
|
|
784
|
+
return { runs: await Promise.all(sortActionableFirst(visible).map(decorateRun)) };
|
|
722
785
|
});
|
|
723
786
|
// ── Run archival (crew#265) — write-off, not delete ────────────────────────
|
|
724
787
|
const ArchiveSchema = z.object({
|
|
@@ -778,7 +841,107 @@ runtime = {}) {
|
|
|
778
841
|
const run = views.find((v) => v.session.id === id);
|
|
779
842
|
if (!run)
|
|
780
843
|
return reply.code(404).send({ error: 'Run not found' });
|
|
781
|
-
return { run: decorateRun(run) };
|
|
844
|
+
return { run: await decorateRun(run) };
|
|
845
|
+
});
|
|
846
|
+
// ── Post-hoc delivery (crew#393) — lift a stranded run's worktree into a PR ──
|
|
847
|
+
// The recovery path for the run 83052f0b class: a COMPLETED repo-scoped run whose reviewable
|
|
848
|
+
// work was never lifted (`delivery: 'stranded'` on the wire — including runs recorded long
|
|
849
|
+
// before this route existed). Runs the SAME hardened script as the deliver phase (#293/#317:
|
|
850
|
+
// commit, refuse the default branch, rebase — a conflict aborts LOUDLY with nothing pushed —
|
|
851
|
+
// never force, push, `gh pr create`, success re-derived from a real PR URL) against the run's
|
|
852
|
+
// existing worktree. Idempotent: a delivered run answers its recorded URL, never a second PR.
|
|
853
|
+
// Failure is a loud 4xx/5xx carrying the script's own words — never a silent 200.
|
|
854
|
+
const deliverInFlight = new Set();
|
|
855
|
+
/** The last words of a failed deliver script — enough to name the refusal, bounded so a full
|
|
856
|
+
* git transcript never becomes an error body. */
|
|
857
|
+
const deliverErrorTail = (output) => {
|
|
858
|
+
const trimmed = output.trim();
|
|
859
|
+
return trimmed.length <= 2000 ? trimmed : `…${trimmed.slice(-2000)}`;
|
|
860
|
+
};
|
|
861
|
+
app.post(`${V}/runs/:id/deliver`, {
|
|
862
|
+
config: {
|
|
863
|
+
manifest: {
|
|
864
|
+
responseType: 'DeliverRunResult',
|
|
865
|
+
// 404: unknown run. 409: not completed / repo-less / worktree gone / already in
|
|
866
|
+
// flight / the script's own loud refusal (conflict, nothing to deliver, gh failure).
|
|
867
|
+
// 500: exit 0 with no verifiable PR URL, or the spawn itself failed.
|
|
868
|
+
statusCodes: [200, 404, 409, 500],
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
}, async (req, reply) => {
|
|
872
|
+
const { id } = req.params;
|
|
873
|
+
// Idempotency first: a recorded delivery answers the SAME URL — `gh pr create` is never
|
|
874
|
+
// re-run on a delivered run, so a double-click (or a retry after a slow response) cannot
|
|
875
|
+
// double-open. The index only ever holds real runs, so this needs no store round-trip.
|
|
876
|
+
const existing = deliveryIndex.urlFor(id);
|
|
877
|
+
if (existing !== undefined)
|
|
878
|
+
return { prUrl: existing };
|
|
879
|
+
const views = await adapter.sessionsDetail();
|
|
880
|
+
const run = views.find((v) => v.session.id === id);
|
|
881
|
+
if (!run)
|
|
882
|
+
return reply.code(404).send({ error: 'Run not found' });
|
|
883
|
+
const s = run.session;
|
|
884
|
+
if (s.status !== 'completed') {
|
|
885
|
+
return reply.code(409).send({
|
|
886
|
+
error: `run ${id} is ${s.status} — only a completed run can be delivered post-hoc`,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
if (s.repo_ref == null || typeof s.workdir !== 'string' || s.workdir === '') {
|
|
890
|
+
return reply.code(409).send({
|
|
891
|
+
error: `run ${id} is not repo-scoped — there is no worktree to lift into a PR`,
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
if (!worktreeExists(s.workdir)) {
|
|
895
|
+
return reply.code(409).send({
|
|
896
|
+
error: `run ${id}'s worktree is gone (${s.workdir}) — nothing left to deliver`,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
// One delivery per run at a time: the script pushes and opens a PR, so two concurrent
|
|
900
|
+
// spawns could race gh into two PRs — the exact double-open idempotency forbids.
|
|
901
|
+
if (deliverInFlight.has(id)) {
|
|
902
|
+
return reply.code(409).send({
|
|
903
|
+
error: `a delivery for run ${id} is already in progress — wait for it to finish`,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
deliverInFlight.add(id);
|
|
907
|
+
let result;
|
|
908
|
+
try {
|
|
909
|
+
result = await deliverExec(s.workdir, s.problem);
|
|
910
|
+
}
|
|
911
|
+
catch (err) {
|
|
912
|
+
return reply.code(500).send({ error: `deliver script could not run: ${message(err)}` });
|
|
913
|
+
}
|
|
914
|
+
finally {
|
|
915
|
+
deliverInFlight.delete(id);
|
|
916
|
+
}
|
|
917
|
+
if (result.spawnFailure === true) {
|
|
918
|
+
// The script never reached its own verdict (spawn failure, timeout kill) — an infra
|
|
919
|
+
// fault, not a refusal: 500 so the caller knows a retry is reasonable.
|
|
920
|
+
return reply.code(500).send({
|
|
921
|
+
error: `deliver script could not run to completion: ${deliverErrorTail(result.output)}`,
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
if (result.status !== 0) {
|
|
925
|
+
// The script's own words (crew#317's rule: never silent, never masked) — it names
|
|
926
|
+
// exactly what it refused (rebase conflict, nothing to deliver, gh's error) and
|
|
927
|
+
// guarantees nothing was pushed on the refusing paths.
|
|
928
|
+
return reply.code(409).send({
|
|
929
|
+
error: `deliver failed (exit ${result.status}): ${deliverErrorTail(result.output)}`,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
const url = prUrlFrom(result.output);
|
|
933
|
+
if (url === null) {
|
|
934
|
+
// Exit 0 with no URL should be unreachable (the script re-derives its own success),
|
|
935
|
+
// but a delivery nothing can be pointed at is never recorded (crew#317).
|
|
936
|
+
return reply.code(500).send({
|
|
937
|
+
error: 'deliver script exited 0 but produced no PR URL — refusing to record a delivery nothing can be pointed at',
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
// The durable record first, then the read-side index — the same write order as the
|
|
941
|
+
// deliver-phase resolution in server.ts, so the index can only LAG a crash, never lead it.
|
|
942
|
+
audit.record('run.delivered', actorOf(req), { runId: id, detail: { url, via: 'post-hoc' } });
|
|
943
|
+
deliveryIndex.set(id, url);
|
|
944
|
+
return { prUrl: url };
|
|
782
945
|
});
|
|
783
946
|
// Durable pre-gate guidance (DES-UX-002 §7.2 — spec'd there as CREW-UX-4, implemented as
|
|
784
947
|
// CREW-UX-7 because crew#308 already spent that id; see guidance-index.ts). Upserts the ONE
|
|
@@ -1042,7 +1205,7 @@ runtime = {}) {
|
|
|
1042
1205
|
config: {
|
|
1043
1206
|
manifest: {
|
|
1044
1207
|
requestType: 'GateDecision',
|
|
1045
|
-
responseType: '{ status: SessionStatus }',
|
|
1208
|
+
responseType: '{ status: SessionStatus; landing?: SteeringLandingResult }',
|
|
1046
1209
|
// 409 twice over: a run not awaiting a human gate, and an engine refusal at confirm.
|
|
1047
1210
|
statusCodes: [200, 400, 404, 409],
|
|
1048
1211
|
},
|
|
@@ -1062,6 +1225,15 @@ runtime = {}) {
|
|
|
1062
1225
|
.code(409)
|
|
1063
1226
|
.send({ error: `Run is not awaiting a human gate (status: ${run.session.status})` });
|
|
1064
1227
|
}
|
|
1228
|
+
// The steering-author landing (crew#388): decided — and the gate prompt captured — BEFORE
|
|
1229
|
+
// the confirm, because a terminal-phase approve prunes the gate cache and moves the run out
|
|
1230
|
+
// of `awaiting_human`. The landing itself runs AFTER a successful approve only.
|
|
1231
|
+
// (`listWorkflows` is presence-guarded for partial-stub adapters — a registry-less adapter
|
|
1232
|
+
// cannot be hosting a steering-author run, and the legacy gate path must not 500 over it.)
|
|
1233
|
+
const steeringPropose = parsed.data.approve &&
|
|
1234
|
+
typeof adapter.listWorkflows === 'function' &&
|
|
1235
|
+
isSteeringAuthorRun(run, adapter.listWorkflows());
|
|
1236
|
+
const gatePrompt = steeringPropose ? gateCache.get(id)?.prompt : undefined;
|
|
1065
1237
|
try {
|
|
1066
1238
|
const status = await adapter.confirmGate(id, parsed.data.approve, parsed.data.amend);
|
|
1067
1239
|
// WHO approved/rejected — the gate-decision audit (task #88). The engine
|
|
@@ -1075,7 +1247,18 @@ runtime = {}) {
|
|
|
1075
1247
|
status,
|
|
1076
1248
|
},
|
|
1077
1249
|
});
|
|
1078
|
-
|
|
1250
|
+
// APPROVE of the steering-author propose gate = the doctrine's landing moment: the
|
|
1251
|
+
// approved proposal is written to the governance store with `provenance.source: "chat"`,
|
|
1252
|
+
// audited per rule, idempotent on replay, and LOUD on failure — the response carries the
|
|
1253
|
+
// outcome either way, so the studio can show "landed PAT-101" or the explicit error
|
|
1254
|
+
// instead of the silent no-op crew#388 recorded. An approve WITH an amend note still
|
|
1255
|
+
// lands the proposal UNCHANGED: the amend steers the RUN's continuation, not the rule
|
|
1256
|
+
// text (an operator who wants different rules rejects and re-authors). A reject lands
|
|
1257
|
+
// nothing — the run cancels and the proposal stays an artifact.
|
|
1258
|
+
const landing = steeringPropose
|
|
1259
|
+
? await landSteeringProposal({ adapter, audit, actor: actorOf(req) }, run, gatePrompt)
|
|
1260
|
+
: undefined;
|
|
1261
|
+
return reply.send({ status, ...(landing !== undefined ? { landing } : {}) });
|
|
1079
1262
|
}
|
|
1080
1263
|
catch (err) {
|
|
1081
1264
|
return reply.code(409).send({ error: message(err) });
|
|
@@ -1105,8 +1288,37 @@ runtime = {}) {
|
|
|
1105
1288
|
const run = views.find((v) => v.session.id === id);
|
|
1106
1289
|
if (!run)
|
|
1107
1290
|
return reply.code(404).send({ error: 'Run not found' });
|
|
1291
|
+
// A TERMINAL run is refused LOUDLY, with the actual recovery named (crew#311 defect 2).
|
|
1292
|
+
// The engine's `resume_run` no-ops on completed/cancelled and answers the status token, so
|
|
1293
|
+
// this route used to reply 200 {"status":"cancelled"} — on the exact runs an operator was
|
|
1294
|
+
// trying to rescue, the recovery affordance read as "resume destroyed my run". A cancelled
|
|
1295
|
+
// run has NO in-run recovery (the path is a retry launch, `POST /runs {retryOf}`); a
|
|
1296
|
+
// completed run has nothing to resume — but a stranded one's work IS liftable post-hoc.
|
|
1297
|
+
// The 409 body carries the machine-readable pointer (`ResumeRefusal` in api-types).
|
|
1298
|
+
const terminal = run.session.status;
|
|
1299
|
+
if (terminal === 'completed' || terminal === 'cancelled') {
|
|
1300
|
+
const state = await deliveryStateWithVacuity(run.session, deliveryIndex.urlFor(id), vacuityProbes);
|
|
1301
|
+
const recovery = terminal === 'completed' && state.delivery === 'stranded'
|
|
1302
|
+
? 'deliver'
|
|
1303
|
+
: 'retry';
|
|
1304
|
+
const why = terminal === 'cancelled'
|
|
1305
|
+
? `run ${id} is cancelled — a terminal run cannot be resumed; relaunch the work as a new run with POST /runs {"retryOf":"${id}"}`
|
|
1306
|
+
: state.delivery === 'stranded'
|
|
1307
|
+
? `run ${id} is already completed — nothing to resume; its unlifted work is in the worktree: deliver it with POST /runs/${id}/deliver`
|
|
1308
|
+
: state.delivery === 'vacuous'
|
|
1309
|
+
? `run ${id} is already completed, but VACUOUSLY — its units produced no work to resume or deliver; relaunch with POST /runs {"retryOf":"${id}"}`
|
|
1310
|
+
: `run ${id} is already completed — nothing to resume; relaunch the work as a new run with POST /runs {"retryOf":"${id}"}`;
|
|
1311
|
+
return reply.code(409).send({ error: why, recovery });
|
|
1312
|
+
}
|
|
1108
1313
|
try {
|
|
1109
1314
|
const gated = run.session.status === 'awaiting_human';
|
|
1315
|
+
// A gated resume IS a gate approval, so it lands a steering-author proposal exactly like
|
|
1316
|
+
// POST /runs/:id/gate would — the "no side door" rule (task #88) holds for the landing
|
|
1317
|
+
// write too (crew#388). Prompt captured pre-confirm; see the gate route.
|
|
1318
|
+
const steeringPropose = gated &&
|
|
1319
|
+
typeof adapter.listWorkflows === 'function' &&
|
|
1320
|
+
isSteeringAuthorRun(run, adapter.listWorkflows());
|
|
1321
|
+
const gatePrompt = steeringPropose ? gateCache.get(id)?.prompt : undefined;
|
|
1110
1322
|
const status = gated ? await adapter.confirmGate(id, true) : await adapter.resumeRun(id);
|
|
1111
1323
|
// A resume of a gated run IS a gate approval — audit it as one, so the
|
|
1112
1324
|
// "who approved" trail has no side door (task #88).
|
|
@@ -1114,7 +1326,10 @@ runtime = {}) {
|
|
|
1114
1326
|
runId: id,
|
|
1115
1327
|
detail: gated ? { approve: true, via: 'resume', status } : { status },
|
|
1116
1328
|
});
|
|
1117
|
-
|
|
1329
|
+
const landing = steeringPropose
|
|
1330
|
+
? await landSteeringProposal({ adapter, audit, actor: actorOf(req) }, run, gatePrompt)
|
|
1331
|
+
: undefined;
|
|
1332
|
+
return reply.send({ status, ...(landing !== undefined ? { landing } : {}) });
|
|
1118
1333
|
}
|
|
1119
1334
|
catch (err) {
|
|
1120
1335
|
return reply.code(409).send({ error: message(err) });
|
|
@@ -1225,11 +1440,13 @@ runtime = {}) {
|
|
|
1225
1440
|
if (!ids.includes(id))
|
|
1226
1441
|
return reply.code(404).send({ error: 'Run not found' });
|
|
1227
1442
|
// Durable probe (DES-PROJECT-001 §5.3): `interaction_requests` reserves kind `elicitation`.
|
|
1228
|
-
// The engine writes no elicitation rows yet
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1231
|
-
//
|
|
1232
|
-
//
|
|
1443
|
+
// The engine writes no elicitation rows yet — the LIVE elicitation wire is complete
|
|
1444
|
+
// (create → cache → resolve, crew#357/#358), but the durable half of the PROMPT is still
|
|
1445
|
+
// engine-side future work (wicked-core interaction.rs: "gate today; elicitation reserved") —
|
|
1446
|
+
// so this read is empty today; it exists so the cache is STRUCTURALLY a latency layer, and
|
|
1447
|
+
// the day the engine writes the rows, restart survival holds here exactly as it does for
|
|
1448
|
+
// gates, with no route change. Guarded like `runEvents`: a partial-stub adapter (tests) or
|
|
1449
|
+
// a pre-0.6.0 addon simply has no durable half.
|
|
1233
1450
|
const durable = typeof adapter.interactionRequests === 'function'
|
|
1234
1451
|
? await adapter.interactionRequests(id, 'open')
|
|
1235
1452
|
: null;
|
|
@@ -2048,6 +2265,45 @@ runtime = {}) {
|
|
|
2048
2265
|
.send({ error: 'workerStallMinutes must be an integer between 1 and 1440' });
|
|
2049
2266
|
}
|
|
2050
2267
|
}
|
|
2268
|
+
// The escalation ladder's knobs (crew#341). This trio lets the PLATFORM touch runs, so a
|
|
2269
|
+
// typo must be a 400, never a silently-dropped key that leaves the operator believing they
|
|
2270
|
+
// armed (or disarmed) automatic recovery. `workerStallEscalateMinutes: 0` is the explicit
|
|
2271
|
+
// OFF spelling — and the shipped default is off (absent).
|
|
2272
|
+
if (Object.hasOwn(patch, 'workerStallEscalateMinutes')) {
|
|
2273
|
+
const mins = patch.workerStallEscalateMinutes;
|
|
2274
|
+
if (typeof mins !== 'number' || !Number.isInteger(mins) || mins < 0 || mins > 1440) {
|
|
2275
|
+
return reply.code(400).send({
|
|
2276
|
+
error: 'workerStallEscalateMinutes must be an integer between 0 (escalation off) and 1440',
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
if (Object.hasOwn(patch, 'workerStallEscalateAction')) {
|
|
2281
|
+
const a = patch.workerStallEscalateAction;
|
|
2282
|
+
if (a !== 'reassign' && a !== 'notify') {
|
|
2283
|
+
return reply
|
|
2284
|
+
.code(400)
|
|
2285
|
+
.send({ error: "workerStallEscalateAction must be 'reassign' or 'notify'" });
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
if (Object.hasOwn(patch, 'workerStallMaxEscalations')) {
|
|
2289
|
+
const n = patch.workerStallMaxEscalations;
|
|
2290
|
+
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > 10) {
|
|
2291
|
+
return reply
|
|
2292
|
+
.code(400)
|
|
2293
|
+
.send({ error: 'workerStallMaxEscalations must be an integer between 1 and 10' });
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
// deliverDefault (crew#393): the repo-scoped launch delivery default. Two values only —
|
|
2297
|
+
// this knob decides whether completed code runs open PRs, so a typo must be a 400, never
|
|
2298
|
+
// a silently-dropped key that leaves the operator believing they flipped it.
|
|
2299
|
+
if (Object.hasOwn(patch, 'deliverDefault')) {
|
|
2300
|
+
const d = patch.deliverDefault;
|
|
2301
|
+
if (d !== 'pr' && d !== 'none') {
|
|
2302
|
+
return reply
|
|
2303
|
+
.code(400)
|
|
2304
|
+
.send({ error: "deliverDefault must be 'pr' or 'none'" });
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2051
2307
|
// Skin-owned keys (crew#323): allowed through, but VALIDATED rather than trusted. The
|
|
2052
2308
|
// daemon does not read these values, so the only two things it can check are the two that
|
|
2053
2309
|
// can hurt it — a value it cannot persist, and a value big enough to bloat settings.json.
|
|
@@ -2084,6 +2340,10 @@ runtime = {}) {
|
|
|
2084
2340
|
'graphNodeLimit',
|
|
2085
2341
|
'worker_config_root',
|
|
2086
2342
|
'workerStallMinutes',
|
|
2343
|
+
'workerStallEscalateMinutes',
|
|
2344
|
+
'workerStallEscalateAction',
|
|
2345
|
+
'workerStallMaxEscalations',
|
|
2346
|
+
'deliverDefault',
|
|
2087
2347
|
];
|
|
2088
2348
|
const safe = {};
|
|
2089
2349
|
for (const key of allowed) {
|
|
@@ -2153,18 +2413,37 @@ runtime = {}) {
|
|
|
2153
2413
|
// ── The wicked-interactive bridge, reverse-proxied (DES-MERGE-001 §5.3/§7.2) ──
|
|
2154
2414
|
// Mounted BESIDE the routes above and under the same `${V}` prefix, so it inherits one
|
|
2155
2415
|
// origin, one auth hook, and one CORS posture — the whole point of slice 1.
|
|
2416
|
+
// ONE pool, shared with the governed doc-delete route below: two pools over one root would
|
|
2417
|
+
// race each other into starting duplicate bridges.
|
|
2418
|
+
const interactiveBridges = runtime.interactiveBridges ??
|
|
2419
|
+
new InteractiveBridgePool({
|
|
2420
|
+
log: (m) => app.log.warn(m),
|
|
2421
|
+
debug: (m) => app.log.debug(m),
|
|
2422
|
+
// #298: the daemon's own origin, read LAZILY off the bound server — the pool is built
|
|
2423
|
+
// before `listen`, but only consulted while serving a request, i.e. once bound. The
|
|
2424
|
+
// pool POSTs it to the bridge's /api/studio-origin on start/adopt so the bridge's
|
|
2425
|
+
// `GET /` redirects into studio.
|
|
2426
|
+
studioOrigin: () => boundOrigin(app.server.address()),
|
|
2427
|
+
});
|
|
2156
2428
|
registerInteractiveProxy(app, adapter, {
|
|
2157
2429
|
settings: projectSettings,
|
|
2158
|
-
pool:
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2430
|
+
pool: interactiveBridges,
|
|
2431
|
+
log: (m) => app.log.warn(m),
|
|
2432
|
+
});
|
|
2433
|
+
// ── Governed doc delete (crew#338) — the one door that changes BOTH stores ──
|
|
2434
|
+
// `DELETE /projects/:id/interactive/docs/:doc` retires the doc on the bridge AND drops crew's
|
|
2435
|
+
// handoff-ledger rows for it (the draft leg keys by document id, so a stale row claims the
|
|
2436
|
+
// name forever — studio#119's ghost). One static segment more specific than the proxy's
|
|
2437
|
+
// wildcard, so the proxy stays pure transport for everything else.
|
|
2438
|
+
registerInteractiveDocDelete(app, adapter, {
|
|
2439
|
+
settings: projectSettings,
|
|
2440
|
+
pool: interactiveBridges,
|
|
2441
|
+
audit,
|
|
2442
|
+
actorOf,
|
|
2443
|
+
// The inert default mirrors AuditLog.noop(): a directly-driven route set must never sweep
|
|
2444
|
+
// the operator's real ~/.wicked-crew ledgers. The real sweep always arrives from
|
|
2445
|
+
// `createServer`.
|
|
2446
|
+
dropDocLedgerRows: runtime.dropDocLedgerRows ?? (() => ({ ok: true, removed_keys: [] })),
|
|
2168
2447
|
log: (m) => app.log.warn(m),
|
|
2169
2448
|
});
|
|
2170
2449
|
}
|