wicked-crew 0.7.10 → 0.7.12
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/delivery-index.d.ts +21 -0
- package/dist/api/delivery-index.d.ts.map +1 -1
- package/dist/api/delivery-index.js +35 -0
- package/dist/api/delivery-index.js.map +1 -1
- package/dist/api/group-index.d.ts +59 -0
- package/dist/api/group-index.d.ts.map +1 -0
- package/dist/api/group-index.js +100 -0
- package/dist/api/group-index.js.map +1 -0
- package/dist/api/post-hoc-deliver.d.ts +27 -0
- package/dist/api/post-hoc-deliver.d.ts.map +1 -1
- package/dist/api/post-hoc-deliver.js +54 -0
- package/dist/api/post-hoc-deliver.js.map +1 -1
- package/dist/api/retry-index.d.ts +6 -0
- package/dist/api/retry-index.d.ts.map +1 -1
- package/dist/api/retry-index.js +13 -6
- package/dist/api/retry-index.js.map +1 -1
- package/dist/api/routes.d.ts +55 -2
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +188 -17
- package/dist/api/routes.js.map +1 -1
- package/dist/api/seat-health.d.ts +11 -0
- package/dist/api/seat-health.d.ts.map +1 -1
- package/dist/api/seat-health.js +23 -0
- package/dist/api/seat-health.js.map +1 -1
- package/dist/api/server.d.ts.map +1 -1
- package/dist/api/server.js +19 -5
- package/dist/api/server.js.map +1 -1
- package/dist/api/testing.d.ts.map +1 -1
- package/dist/api/testing.js +11 -27
- package/dist/api/testing.js.map +1 -1
- package/dist/campaigns/rollup.d.ts +39 -0
- package/dist/campaigns/rollup.d.ts.map +1 -0
- package/dist/campaigns/rollup.js +84 -0
- package/dist/campaigns/rollup.js.map +1 -0
- package/dist/campaigns/routes.d.ts +10 -3
- package/dist/campaigns/routes.d.ts.map +1 -1
- package/dist/campaigns/routes.js +27 -5
- package/dist/campaigns/routes.js.map +1 -1
- package/dist/cli/index.js +13 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/core/adapter.d.ts +0 -12
- package/dist/core/adapter.d.ts.map +1 -1
- package/dist/core/adapter.js +0 -14
- package/dist/core/adapter.js.map +1 -1
- package/dist/core/daemon-signal-log.d.ts +44 -0
- package/dist/core/daemon-signal-log.d.ts.map +1 -0
- package/dist/core/daemon-signal-log.js +57 -0
- package/dist/core/daemon-signal-log.js.map +1 -0
- package/dist/core/deliver.d.ts +20 -3
- package/dist/core/deliver.d.ts.map +1 -1
- package/dist/core/deliver.js +82 -9
- package/dist/core/deliver.js.map +1 -1
- package/dist/studio/assets/{index-gtE6scX0.js → index-DuU8RZg9.js} +95 -95
- package/dist/studio/index.html +1 -1
- package/dist/studio/testid-inventory.json +47 -17
- package/endpoint-manifest.json +4 -3
- package/package.json +3 -3
- package/dist/campaigns/worktrees.d.ts +0 -39
- package/dist/campaigns/worktrees.d.ts.map +0 -1
- package/dist/campaigns/worktrees.js +0 -95
- package/dist/campaigns/worktrees.js.map +0 -1
package/dist/api/routes.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readFileSync, existsSync } from 'node:fs';
|
|
|
5
5
|
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
|
-
import { ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError, SteeringUnsupportedError, humanGatePhaseIds } from '../core/adapter.js';
|
|
8
|
+
import { CampaignsUnsupportedError, ChatUnsupportedError, CoreAdapter, ElicitationUnsupportedError, SteeringUnsupportedError, humanGatePhaseIds } from '../core/adapter.js';
|
|
9
9
|
import { codeGraphDb, requirementsGraph } from '../core/repoPaths.js';
|
|
10
10
|
import { QeGateCache } from '../qe/gate-events.js';
|
|
11
11
|
import { buildAcceptanceView, resolveRunWorkflow } from '../qe/acceptance.js';
|
|
@@ -32,9 +32,10 @@ import { MEMBERSHIP_ATTACHED, membershipAttachedKey } from '../projects/events.j
|
|
|
32
32
|
import { AuditLog } from './audit.js';
|
|
33
33
|
import { AcpFoldCache, EngineVersionCache, eventsDirOf, installedPackageVersion, listStoreFiles, readStudioBundleVersion, } from './diagnostics.js';
|
|
34
34
|
import { RetryIndex } from './retry-index.js';
|
|
35
|
+
import { GroupIndex } from './group-index.js';
|
|
35
36
|
import { GuidanceIndex } from './guidance-index.js';
|
|
36
|
-
import { DeliveryIndex, deliveryStateWithVacuity, gitRunBranchIsEmpty, gitWorktreeIsClean, prUrlFrom, } from './delivery-index.js';
|
|
37
|
-
import { runDeliverScript } from './post-hoc-deliver.js';
|
|
37
|
+
import { DeliveryIndex, deliveryStateWithVacuity, gitRunBranchIsEmpty, gitWorktreeIsClean, isDeliverConflictStranded, prUrlFrom, } from './delivery-index.js';
|
|
38
|
+
import { gitReprovisionWorktree, runDeliverScript, } from './post-hoc-deliver.js';
|
|
38
39
|
import { LOCAL_ACTOR } from './auth.js';
|
|
39
40
|
// Re-exported so existing `import { API_PREFIX } from './routes.js'` callers keep working; the
|
|
40
41
|
// value lives in the leaf module api-prefix.ts to keep unit-output.ts out of this file's cycle.
|
|
@@ -151,9 +152,22 @@ export const LaunchSchema = z.object({
|
|
|
151
152
|
* (the route checks the store and 400s with a named error otherwise); persisted via the
|
|
152
153
|
* `run.launched` audit entry + retry index and echoed as `AgentSession.retry_of`. */
|
|
153
154
|
retryOf: z.string().min(1).optional(),
|
|
155
|
+
/** wicked-studio#27 (api-types 0.19.0) — attach this AD-HOC run to an EXISTING campaign's
|
|
156
|
+
* surface. Validated against the campaign store before anything launches (unknown ⇒ 404,
|
|
157
|
+
* engine without campaign bindings ⇒ 501 — never a silently dropped attach); persisted via
|
|
158
|
+
* the `run.launched` audit entry + group index, echoed as `AgentSession.campaign_id`, and
|
|
159
|
+
* served under `Campaign.attached_runs`. Provenance only: the run is NOT a DAG node. */
|
|
160
|
+
campaignId: z.string().min(1).optional(),
|
|
161
|
+
/** wicked-studio#27 (api-types 0.19.0) — ad-hoc label group, created on first use: runs
|
|
162
|
+
* sharing a label form one `RunGroup` on `GET /campaigns`. Persisted/echoed like
|
|
163
|
+
* `campaignId` (as `AgentSession.group_label`). */
|
|
164
|
+
groupLabel: z.string().min(1).max(200).optional(),
|
|
154
165
|
}).strict().refine((b) => b.deliver !== 'pr' || b.workflow !== undefined, {
|
|
155
166
|
message: 'deliver: "pr" requires a workflow — a free-text run has no def to append the deliver phase to',
|
|
156
167
|
path: ['deliver'],
|
|
168
|
+
}).refine((b) => b.campaignId === undefined || b.groupLabel === undefined, {
|
|
169
|
+
message: 'campaignId and groupLabel are mutually exclusive — a run files onto ONE grouping surface (an existing campaign, or a label group)',
|
|
170
|
+
path: ['groupLabel'],
|
|
157
171
|
});
|
|
158
172
|
export const GateSchema = z.object({
|
|
159
173
|
approve: z.boolean(),
|
|
@@ -235,6 +249,7 @@ runtime = {}) {
|
|
|
235
249
|
const openWithOs = runtime.openWithOs ?? openWithSystemDefault;
|
|
236
250
|
const signedIn = runtime.signedIn ?? signedInHeuristic;
|
|
237
251
|
const retryIndex = runtime.retryIndex ?? new RetryIndex();
|
|
252
|
+
const groupIndex = runtime.groupIndex ?? new GroupIndex();
|
|
238
253
|
const guidanceIndex = runtime.guidanceIndex ?? new GuidanceIndex();
|
|
239
254
|
const deliveryIndex = runtime.deliveryIndex ?? new DeliveryIndex();
|
|
240
255
|
const worktreeExists = runtime.worktreeExists ?? ((p) => existsSync(p));
|
|
@@ -248,6 +263,9 @@ runtime = {}) {
|
|
|
248
263
|
}),
|
|
249
264
|
};
|
|
250
265
|
const deliverExec = runtime.deliverExec ?? runDeliverScript;
|
|
266
|
+
const reprovisionWorktree = runtime.reprovisionWorktree ?? gitReprovisionWorktree;
|
|
267
|
+
/** Repo root for a repo ref, from the registry — shared by the reprovision path below. */
|
|
268
|
+
const repoRootOf = async (repoRef) => (await adapter.listRepos()).find((r) => r.id === repoRef)?.root_path;
|
|
251
269
|
// The run-DTO joins (DES-UX-001 §8.2/§8.3, DES-UX-002 §7.2): `project_id` from the membership
|
|
252
270
|
// record — `null` = genuinely unfiled, so the field is ALWAYS present on served runs —
|
|
253
271
|
// `retry_of` from the lineage index and `guidance` from the guidance index, each set only
|
|
@@ -260,15 +278,59 @@ runtime = {}) {
|
|
|
260
278
|
// "done" with nothing produced must be LOUD on the wire, never silently green.
|
|
261
279
|
// Applied at DTO assembly on exactly the two endpoints that serve the run DTO
|
|
262
280
|
// (GET /runs + GET /runs/:id); the internal sessionsDetail() consumers are untouched.
|
|
281
|
+
// crew#418 A: a `failed` run whose ONLY failure was the deliver phase's LIFT collision (a
|
|
282
|
+
// rebase conflict the changelog union merge could not clear, or a non-fast-forward push) is
|
|
283
|
+
// reinterpreted on the wire as `completed` + `delivery: 'stranded'` — recoverable, not a hard
|
|
284
|
+
// failure. The engine's durable record stays `failed` (audit trail); this is a wire derivation,
|
|
285
|
+
// exactly like `delivery` itself, applied at the SAME three terminal-status decision points so
|
|
286
|
+
// the run reads consistently across GET /runs(/:id), resume, and POST /runs/:id/deliver. The
|
|
287
|
+
// deliver unit stays `rejected` with its marker-bearing `denial_reason`, so WHY it stranded is
|
|
288
|
+
// still on the wire.
|
|
289
|
+
//
|
|
290
|
+
// Returns whether this run IS such a strand — the caller uses it for a BRANCH-based `'stranded'`
|
|
291
|
+
// read: the engine reaps a failed-deliver run's worktree once the deliver phase has committed
|
|
292
|
+
// the work (a clean tree), so the work lives on the `wicked/<id>` branch, not the worktree — the
|
|
293
|
+
// worktree-stat derivation would read `'none'` (and flicker as the async reap lands). The
|
|
294
|
+
// lift-conflict marker is proof the branch carries the work (the deliver script verified it was
|
|
295
|
+
// ahead of the default branch before it ever attempted the rebase), so the read is a STABLE
|
|
296
|
+
// `'stranded'` regardless of whether the worktree survived. Idempotent: a second call sees the
|
|
297
|
+
// already-flipped `completed` and returns false, so the flip never re-fires.
|
|
298
|
+
const normalizeStranded = (view) => {
|
|
299
|
+
const conflictStrand = isDeliverConflictStranded(view); // true only while status === 'failed'
|
|
300
|
+
if (conflictStrand)
|
|
301
|
+
view.session.status = 'completed';
|
|
302
|
+
return conflictStrand;
|
|
303
|
+
};
|
|
304
|
+
/** The run's delivery state, honest by construction (crew#393/#311/#418): a recorded PR wins
|
|
305
|
+
* (`'delivered'`); else a lift-conflict strand reads `'stranded'` from the branch; else the
|
|
306
|
+
* worktree-stat derivation (stranded / vacuous / none). */
|
|
307
|
+
const resolveDelivery = async (view, conflictStrand) => {
|
|
308
|
+
const url = deliveryIndex.urlFor(view.session.id);
|
|
309
|
+
if (url !== undefined)
|
|
310
|
+
return { delivery: 'delivered', deliverUrl: url };
|
|
311
|
+
if (conflictStrand)
|
|
312
|
+
return { delivery: 'stranded' };
|
|
313
|
+
return deliveryStateWithVacuity(view.session, undefined, vacuityProbes);
|
|
314
|
+
};
|
|
263
315
|
const decorateRun = async (view) => {
|
|
316
|
+
const conflictStrand = normalizeStranded(view);
|
|
264
317
|
view.session.project_id = projects.index.projectOf(view.session.id) ?? null;
|
|
265
318
|
const retryOf = retryIndex.retryOfFor(view.session.id);
|
|
266
319
|
if (retryOf !== undefined)
|
|
267
320
|
view.session.retry_of = retryOf;
|
|
321
|
+
// wicked-studio#27 (api-types 0.19.0): the launch-time group attach, from the same durable
|
|
322
|
+
// trail record as lineage. ABSENT when ungrouped — never null.
|
|
323
|
+
const attach = groupIndex.attachOf(view.session.id);
|
|
324
|
+
if (attach !== undefined) {
|
|
325
|
+
if ('campaignId' in attach)
|
|
326
|
+
view.session.campaign_id = attach.campaignId;
|
|
327
|
+
else
|
|
328
|
+
view.session.group_label = attach.label;
|
|
329
|
+
}
|
|
268
330
|
const guidance = guidanceIndex.guidanceFor(view.session.id);
|
|
269
331
|
if (guidance !== undefined)
|
|
270
332
|
view.session.guidance = guidance;
|
|
271
|
-
const state = await
|
|
333
|
+
const state = await resolveDelivery(view, conflictStrand);
|
|
272
334
|
view.session.delivery = state.delivery;
|
|
273
335
|
if (state.deliverUrl !== undefined)
|
|
274
336
|
view.session.deliverUrl = state.deliverUrl;
|
|
@@ -628,9 +690,11 @@ runtime = {}) {
|
|
|
628
690
|
manifest: {
|
|
629
691
|
requestType: 'LaunchRunBody',
|
|
630
692
|
responseType: '{ runId: string }',
|
|
631
|
-
// 404/409: unknown project /
|
|
632
|
-
// catch below); 400: zod
|
|
633
|
-
|
|
693
|
+
// 404/409: unknown project / unknown campaignId (wicked-studio#27) /
|
|
694
|
+
// archived-or-synthesized project + busy engine (see the catch below); 400: zod
|
|
695
|
+
// reject or a retryOf naming no existing run; 501: campaignId attach on an engine
|
|
696
|
+
// addon without the campaign bindings ("upgrade the engine").
|
|
697
|
+
statusCodes: [201, 400, 404, 409, 501],
|
|
634
698
|
},
|
|
635
699
|
},
|
|
636
700
|
}, async (req, reply) => {
|
|
@@ -716,6 +780,26 @@ runtime = {}) {
|
|
|
716
780
|
});
|
|
717
781
|
}
|
|
718
782
|
}
|
|
783
|
+
// Ad-hoc campaign attach (wicked-studio#27): `campaignId` must name an EXISTING campaign,
|
|
784
|
+
// checked BEFORE anything launches — an unknown id is a 404 with nothing committed, never a
|
|
785
|
+
// run silently filed onto a surface that does not exist. An engine addon without the
|
|
786
|
+
// campaign bindings is a 501 ("upgrade the engine"), the campaigns-surface doctrine. A
|
|
787
|
+
// `groupLabel` needs no check: a label group is created by its first use.
|
|
788
|
+
if (b.campaignId !== undefined) {
|
|
789
|
+
try {
|
|
790
|
+
if ((await adapter.campaignDetail(b.campaignId)) === null) {
|
|
791
|
+
return reply.code(404).send({
|
|
792
|
+
error: `campaignId names an unknown campaign: ${b.campaignId} — an ad-hoc run can only attach to an existing campaign (launch one via POST /campaigns, or use groupLabel for a create-on-first-use group)`,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
catch (err) {
|
|
797
|
+
if (err instanceof CampaignsUnsupportedError) {
|
|
798
|
+
return reply.code(501).send({ error: message(err) });
|
|
799
|
+
}
|
|
800
|
+
throw err;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
719
803
|
try {
|
|
720
804
|
const runId = await adapter.launchRun(input);
|
|
721
805
|
// Who launched it — the engine's LaunchOptions carries no actor field
|
|
@@ -734,10 +818,18 @@ runtime = {}) {
|
|
|
734
818
|
// CREW-UX-3: the trail is the durable record of lineage — the retry index (and a
|
|
735
819
|
// restarted daemon's hydrate) reads it back from exactly this entry.
|
|
736
820
|
...(b.retryOf !== undefined ? { retryOf: b.retryOf } : {}),
|
|
821
|
+
// wicked-studio#27: the trail is likewise the durable record of the group attach —
|
|
822
|
+
// the group index (and a restarted daemon's hydrate) reads it back from here.
|
|
823
|
+
...(b.campaignId !== undefined ? { campaignId: b.campaignId } : {}),
|
|
824
|
+
...(b.groupLabel !== undefined ? { groupLabel: b.groupLabel } : {}),
|
|
737
825
|
},
|
|
738
826
|
});
|
|
739
827
|
if (b.retryOf !== undefined)
|
|
740
828
|
retryIndex.set(runId, b.retryOf);
|
|
829
|
+
if (b.campaignId !== undefined)
|
|
830
|
+
groupIndex.set(runId, { campaignId: b.campaignId });
|
|
831
|
+
else if (b.groupLabel !== undefined)
|
|
832
|
+
groupIndex.set(runId, { label: b.groupLabel });
|
|
741
833
|
if (b.projectId !== undefined) {
|
|
742
834
|
// The engine attached the crew.run membership ATOMICALLY with the launch record
|
|
743
835
|
// (DES-PROJECT-001 §2.2) — this is the post-commit half: tag future /ws frames and
|
|
@@ -852,6 +944,30 @@ runtime = {}) {
|
|
|
852
944
|
// existing worktree. Idempotent: a delivered run answers its recorded URL, never a second PR.
|
|
853
945
|
// Failure is a loud 4xx/5xx carrying the script's own words — never a silent 200.
|
|
854
946
|
const deliverInFlight = new Set();
|
|
947
|
+
// Per-REPO serialization for the worktree-admin-sensitive region (reprovision → deliver →
|
|
948
|
+
// cleanup). deliverInFlight is per-RUN and stops the same run double-delivering, but two
|
|
949
|
+
// DIFFERENT stranded runs in the same repo would both run `git worktree prune/add/remove`
|
|
950
|
+
// against one `.git/worktrees` admin state and race. Chaining a promise per repo ref serializes
|
|
951
|
+
// them; different repos still run in parallel. The tail entry is pruned so the map cannot grow.
|
|
952
|
+
const repoDeliverChain = new Map();
|
|
953
|
+
const withRepoDeliverLock = async (repoRef, fn) => {
|
|
954
|
+
const prior = repoDeliverChain.get(repoRef) ?? Promise.resolve();
|
|
955
|
+
let release;
|
|
956
|
+
const mine = new Promise((r) => {
|
|
957
|
+
release = r;
|
|
958
|
+
});
|
|
959
|
+
repoDeliverChain.set(repoRef, mine); // WE are now the tail
|
|
960
|
+
await prior.catch(() => undefined); // wait our turn; a prior failure never blocks ours
|
|
961
|
+
try {
|
|
962
|
+
return await fn();
|
|
963
|
+
}
|
|
964
|
+
finally {
|
|
965
|
+
release(); // unblock whoever chained after us
|
|
966
|
+
// If nothing chained after us, drop the entry so idle repos leave no trace.
|
|
967
|
+
if (repoDeliverChain.get(repoRef) === mine)
|
|
968
|
+
repoDeliverChain.delete(repoRef);
|
|
969
|
+
}
|
|
970
|
+
};
|
|
855
971
|
/** The last words of a failed deliver script — enough to name the refusal, bounded so a full
|
|
856
972
|
* git transcript never becomes an error body. */
|
|
857
973
|
const deliverErrorTail = (output) => {
|
|
@@ -880,6 +996,10 @@ runtime = {}) {
|
|
|
880
996
|
const run = views.find((v) => v.session.id === id);
|
|
881
997
|
if (!run)
|
|
882
998
|
return reply.code(404).send({ error: 'Run not found' });
|
|
999
|
+
// crew#418 A: a run stranded by a deliver-phase lift collision reads `failed` from the
|
|
1000
|
+
// engine but IS liftable post-hoc — normalize its status to `completed` so this route
|
|
1001
|
+
// accepts it, exactly as the run wire and the resume route see it.
|
|
1002
|
+
const conflictStrand = normalizeStranded(run);
|
|
883
1003
|
const s = run.session;
|
|
884
1004
|
if (s.status !== 'completed') {
|
|
885
1005
|
return reply.code(409).send({
|
|
@@ -891,22 +1011,55 @@ runtime = {}) {
|
|
|
891
1011
|
error: `run ${id} is not repo-scoped — there is no worktree to lift into a PR`,
|
|
892
1012
|
});
|
|
893
1013
|
}
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1014
|
+
// Capture the ref + workdir while the null-check narrowing is fresh — an intervening await
|
|
1015
|
+
// below invalidates property narrowing, so re-reading `s.repo_ref`/`s.workdir` later widens
|
|
1016
|
+
// back to `| null`.
|
|
1017
|
+
const repoRef = s.repo_ref;
|
|
1018
|
+
const initialWorkdir = s.workdir;
|
|
899
1019
|
// 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.
|
|
1020
|
+
// spawns could race gh into two PRs — the exact double-open idempotency forbids. The guard
|
|
1021
|
+
// wraps the reprovision too, so a reaped run's `wicked/<id>` branch is only ever checked out
|
|
1022
|
+
// by one throwaway worktree at a time.
|
|
901
1023
|
if (deliverInFlight.has(id)) {
|
|
902
1024
|
return reply.code(409).send({
|
|
903
1025
|
error: `a delivery for run ${id} is already in progress — wait for it to finish`,
|
|
904
1026
|
});
|
|
905
1027
|
}
|
|
906
1028
|
deliverInFlight.add(id);
|
|
1029
|
+
// The worktree the script runs in. Usually the run's own; but the engine REAPS a
|
|
1030
|
+
// failed-deliver run's worktree once its work is committed (crew#418) — the work then lives
|
|
1031
|
+
// on the `wicked/<id>` branch. For such a strand, stand a throwaway worktree back up from
|
|
1032
|
+
// that branch and lift THAT; the branch (the record) is untouched. `cleanupWorktree` tears
|
|
1033
|
+
// the throwaway down after the lift.
|
|
907
1034
|
let result;
|
|
1035
|
+
let worktreeGone = false;
|
|
908
1036
|
try {
|
|
909
|
-
|
|
1037
|
+
// The worktree admin (reprovision), the deliver spawn, AND the throwaway teardown all run
|
|
1038
|
+
// under the per-repo lock, so a second stranded run in this repo cannot touch
|
|
1039
|
+
// `.git/worktrees` until this one has stood its worktree up, lifted, and torn it back
|
|
1040
|
+
// down. `cw` is a LOCAL torn down in the lock body's own finally — nothing captured-mutated
|
|
1041
|
+
// crosses the closure boundary.
|
|
1042
|
+
await withRepoDeliverLock(repoRef, async () => {
|
|
1043
|
+
let workdir = initialWorkdir;
|
|
1044
|
+
let cw = null;
|
|
1045
|
+
try {
|
|
1046
|
+
if (!worktreeExists(workdir)) {
|
|
1047
|
+
const root = conflictStrand ? await repoRootOf(repoRef) : undefined;
|
|
1048
|
+
const reprov = root !== undefined ? await reprovisionWorktree(root, id) : null;
|
|
1049
|
+
if (reprov === null) {
|
|
1050
|
+
worktreeGone = true;
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
workdir = reprov.workdir;
|
|
1054
|
+
cw = reprov.cleanup;
|
|
1055
|
+
}
|
|
1056
|
+
result = await deliverExec(workdir, s.problem ?? undefined);
|
|
1057
|
+
}
|
|
1058
|
+
finally {
|
|
1059
|
+
if (cw !== null)
|
|
1060
|
+
await cw(); // tear the throwaway down whether the lift succeeded or threw
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
910
1063
|
}
|
|
911
1064
|
catch (err) {
|
|
912
1065
|
return reply.code(500).send({ error: `deliver script could not run: ${message(err)}` });
|
|
@@ -914,6 +1067,15 @@ runtime = {}) {
|
|
|
914
1067
|
finally {
|
|
915
1068
|
deliverInFlight.delete(id);
|
|
916
1069
|
}
|
|
1070
|
+
if (worktreeGone) {
|
|
1071
|
+
return reply.code(409).send({
|
|
1072
|
+
error: `run ${id}'s worktree is gone (${s.workdir}) — nothing left to deliver`,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
if (result === undefined) {
|
|
1076
|
+
// Unreachable: the lock body assigns `result` on every path that is not `worktreeGone`.
|
|
1077
|
+
return reply.code(500).send({ error: `deliver for run ${id} produced no result` });
|
|
1078
|
+
}
|
|
917
1079
|
if (result.spawnFailure === true) {
|
|
918
1080
|
// The script never reached its own verdict (spawn failure, timeout kill) — an infra
|
|
919
1081
|
// fault, not a refusal: 500 so the caller knows a retry is reasonable.
|
|
@@ -1288,6 +1450,10 @@ runtime = {}) {
|
|
|
1288
1450
|
const run = views.find((v) => v.session.id === id);
|
|
1289
1451
|
if (!run)
|
|
1290
1452
|
return reply.code(404).send({ error: 'Run not found' });
|
|
1453
|
+
// crew#418 A: a run stranded by a deliver-phase lift collision reads `failed` from the engine
|
|
1454
|
+
// but its work is complete and liftable — normalize to `completed` so the terminal branch
|
|
1455
|
+
// below refuses resume with the DELIVER recovery (POST /runs/:id/deliver), not a re-entry.
|
|
1456
|
+
const conflictStrand = normalizeStranded(run);
|
|
1291
1457
|
// A TERMINAL run is refused LOUDLY, with the actual recovery named (crew#311 defect 2).
|
|
1292
1458
|
// The engine's `resume_run` no-ops on completed/cancelled and answers the status token, so
|
|
1293
1459
|
// this route used to reply 200 {"status":"cancelled"} — on the exact runs an operator was
|
|
@@ -1297,14 +1463,14 @@ runtime = {}) {
|
|
|
1297
1463
|
// The 409 body carries the machine-readable pointer (`ResumeRefusal` in api-types).
|
|
1298
1464
|
const terminal = run.session.status;
|
|
1299
1465
|
if (terminal === 'completed' || terminal === 'cancelled') {
|
|
1300
|
-
const state = await
|
|
1466
|
+
const state = await resolveDelivery(run, conflictStrand);
|
|
1301
1467
|
const recovery = terminal === 'completed' && state.delivery === 'stranded'
|
|
1302
1468
|
? 'deliver'
|
|
1303
1469
|
: 'retry';
|
|
1304
1470
|
const why = terminal === 'cancelled'
|
|
1305
1471
|
? `run ${id} is cancelled — a terminal run cannot be resumed; relaunch the work as a new run with POST /runs {"retryOf":"${id}"}`
|
|
1306
1472
|
: state.delivery === 'stranded'
|
|
1307
|
-
? `run ${id} is already completed — nothing to resume; its unlifted work is
|
|
1473
|
+
? `run ${id} is already completed — nothing to resume; its unlifted work is on the wicked/${id} branch: deliver it with POST /runs/${id}/deliver`
|
|
1308
1474
|
: state.delivery === 'vacuous'
|
|
1309
1475
|
? `run ${id} is already completed, but VACUOUSLY — its units produced no work to resume or deliver; relaunch with POST /runs {"retryOf":"${id}"}`
|
|
1310
1476
|
: `run ${id} is already completed — nothing to resume; relaunch the work as a new run with POST /runs {"retryOf":"${id}"}`;
|
|
@@ -2380,11 +2546,16 @@ runtime = {}) {
|
|
|
2380
2546
|
registerProjectRoutes(app, adapter, { ...projects, settings: projectSettings }, security);
|
|
2381
2547
|
// ── Campaigns (crew#342 + TH-9) — the engine's durable Run-DAG scheduler over REST ──────────
|
|
2382
2548
|
// Progress streams as campaign* CoreEvents on the existing allowlist-free /ws relay; these
|
|
2383
|
-
// routes are launch/resume/cancel + the reads a scoreboard builds from.
|
|
2549
|
+
// routes are launch/resume/cancel + the reads a scoreboard builds from. The GET routes join
|
|
2550
|
+
// per-member delivery (wicked-studio#27) with the SAME machinery the run DTOs use — one
|
|
2551
|
+
// sessionsDetail() per request, the shared TTL-memoized vacuity probes, never a per-node fetch.
|
|
2384
2552
|
registerCampaignRoutes(app, adapter, {
|
|
2385
2553
|
audit,
|
|
2386
2554
|
actorOf,
|
|
2387
2555
|
roster: () => CoreAdapter.roster(),
|
|
2556
|
+
groupIndex,
|
|
2557
|
+
deliveryUrlFor: (runId) => deliveryIndex.urlFor(runId),
|
|
2558
|
+
vacuity: vacuityProbes,
|
|
2388
2559
|
});
|
|
2389
2560
|
// ── Governance wiki management (wiki-mgmt) — scoreboard + honest empty-state meta ──────────
|
|
2390
2561
|
// Browse (`GET /governance/rules` + facets) and retire (`DELETE /governance/rules/:id`) stay
|