@cat-factory/orchestration 0.298.1 → 0.300.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/container/execution-service.d.ts.map +1 -1
  2. package/dist/container/execution-service.js +5 -0
  3. package/dist/container/execution-service.js.map +1 -1
  4. package/dist/modules/bootstrap/BootstrapService.d.ts +17 -8
  5. package/dist/modules/bootstrap/BootstrapService.d.ts.map +1 -1
  6. package/dist/modules/bootstrap/BootstrapService.js +114 -44
  7. package/dist/modules/bootstrap/BootstrapService.js.map +1 -1
  8. package/dist/modules/bootstrap/MonorepoBootstrapController.d.ts +0 -2
  9. package/dist/modules/bootstrap/MonorepoBootstrapController.d.ts.map +1 -1
  10. package/dist/modules/bootstrap/MonorepoBootstrapController.js +1 -5
  11. package/dist/modules/bootstrap/MonorepoBootstrapController.js.map +1 -1
  12. package/dist/modules/bootstrap/bootstrapDelivery.d.ts +20 -0
  13. package/dist/modules/bootstrap/bootstrapDelivery.d.ts.map +1 -0
  14. package/dist/modules/bootstrap/bootstrapDelivery.js +83 -0
  15. package/dist/modules/bootstrap/bootstrapDelivery.js.map +1 -0
  16. package/dist/modules/execution/BugFishingController.d.ts +277 -0
  17. package/dist/modules/execution/BugFishingController.d.ts.map +1 -0
  18. package/dist/modules/execution/BugFishingController.js +644 -0
  19. package/dist/modules/execution/BugFishingController.js.map +1 -0
  20. package/dist/modules/execution/DeployFixController.d.ts.map +1 -1
  21. package/dist/modules/execution/DeployFixController.js +13 -3
  22. package/dist/modules/execution/DeployFixController.js.map +1 -1
  23. package/dist/modules/execution/EnvironmentInvestigationController.d.ts.map +1 -1
  24. package/dist/modules/execution/EnvironmentInvestigationController.js +42 -9
  25. package/dist/modules/execution/EnvironmentInvestigationController.js.map +1 -1
  26. package/dist/modules/execution/ExecutionService.d.ts +9 -0
  27. package/dist/modules/execution/ExecutionService.d.ts.map +1 -1
  28. package/dist/modules/execution/ExecutionService.js +37 -11
  29. package/dist/modules/execution/ExecutionService.js.map +1 -1
  30. package/dist/modules/execution/ExecutionServiceDependencies.d.ts +16 -1
  31. package/dist/modules/execution/ExecutionServiceDependencies.d.ts.map +1 -1
  32. package/dist/modules/execution/PollCompletionController.d.ts +3 -0
  33. package/dist/modules/execution/PollCompletionController.d.ts.map +1 -1
  34. package/dist/modules/execution/PollCompletionController.js +16 -0
  35. package/dist/modules/execution/PollCompletionController.js.map +1 -1
  36. package/dist/modules/execution/RunDispatcher.d.ts +1 -0
  37. package/dist/modules/execution/RunDispatcher.d.ts.map +1 -1
  38. package/dist/modules/execution/RunDispatcher.js +6 -1
  39. package/dist/modules/execution/RunDispatcher.js.map +1 -1
  40. package/dist/modules/execution/RunDispatcherDependencies.d.ts +2 -0
  41. package/dist/modules/execution/RunDispatcherDependencies.d.ts.map +1 -1
  42. package/dist/modules/execution/RunDispatcherDependencies.js.map +1 -1
  43. package/dist/modules/execution/StepGraph.d.ts.map +1 -1
  44. package/dist/modules/execution/StepGraph.js +10 -6
  45. package/dist/modules/execution/StepGraph.js.map +1 -1
  46. package/dist/modules/execution/bugFishing.logic.d.ts +129 -0
  47. package/dist/modules/execution/bugFishing.logic.d.ts.map +1 -0
  48. package/dist/modules/execution/bugFishing.logic.js +284 -0
  49. package/dist/modules/execution/bugFishing.logic.js.map +1 -0
  50. package/dist/modules/execution/deployer.logic.d.ts +41 -0
  51. package/dist/modules/execution/deployer.logic.d.ts.map +1 -1
  52. package/dist/modules/execution/deployer.logic.js +86 -0
  53. package/dist/modules/execution/deployer.logic.js.map +1 -1
  54. package/dist/modules/execution/dispatcher-registries.d.ts +4 -2
  55. package/dist/modules/execution/dispatcher-registries.d.ts.map +1 -1
  56. package/dist/modules/execution/dispatcher-registries.js +69 -1
  57. package/dist/modules/execution/dispatcher-registries.js.map +1 -1
  58. package/dist/modules/execution/gate-window-controllers.d.ts +22 -1
  59. package/dist/modules/execution/gate-window-controllers.d.ts.map +1 -1
  60. package/dist/modules/execution/gate-window-controllers.js +29 -0
  61. package/dist/modules/execution/gate-window-controllers.js.map +1 -1
  62. package/dist/modules/execution/prReport.environments.d.ts +2 -1
  63. package/dist/modules/execution/prReport.environments.d.ts.map +1 -1
  64. package/dist/modules/execution/prReport.environments.js +265 -12
  65. package/dist/modules/execution/prReport.environments.js.map +1 -1
  66. package/dist/modules/execution/run-decision-surfaces.d.ts +16 -3
  67. package/dist/modules/execution/run-decision-surfaces.d.ts.map +1 -1
  68. package/dist/modules/execution/run-decision-surfaces.js +26 -2
  69. package/dist/modules/execution/run-decision-surfaces.js.map +1 -1
  70. package/dist/modules/recurring/RecurringPipelineService.d.ts.map +1 -1
  71. package/dist/modules/recurring/RecurringPipelineService.js +1 -0
  72. package/dist/modules/recurring/RecurringPipelineService.js.map +1 -1
  73. package/dist/modules/settings/workspaceSettingsMerge.d.ts.map +1 -1
  74. package/dist/modules/settings/workspaceSettingsMerge.js +6 -0
  75. package/dist/modules/settings/workspaceSettingsMerge.js.map +1 -1
  76. package/package.json +11 -11
@@ -0,0 +1,644 @@
1
+ import { BUGFIX_PIPELINE_ID, ConflictError, NotFoundError, ValidationError, getErrorMessage, noopLogger, runBestEffort, } from '@cat-factory/kernel';
2
+ import { BUG_FISHER_KIND } from '@cat-factory/agents';
3
+ import { bugFishingSpawnIsClaimable, claimBugFishingSpawn, coerceBugFishingFindings, dismissBugFishingFinding, failBugFishingPhase, planBugFishingPhases, recordBugFishingPhase, settleBugFishingSpawn, untriagedBugFishingFindings, } from './bugFishing.logic.js';
4
+ /** The step kind the bug-fishing phase loop runs on (the read-only expedition agent). */
5
+ export const BUG_FISHING_STEP_KIND = BUG_FISHER_KIND;
6
+ /**
7
+ * What a phase records when its pass came back with no readable findings report.
8
+ *
9
+ * Named as its own cause rather than folded into the generic "did not complete": a crashed
10
+ * container and a container that ran fine and answered unusably need different fixes, and only
11
+ * the second one points at the kind's prompt or its structured-output schema.
12
+ */
13
+ const UNREADABLE_PASS_REASON = 'The pass returned no readable findings report, so nothing it may have found could be recorded.';
14
+ /**
15
+ * Drives the BUG-FISHING EXPEDITION: the read-only `bug-fisher` agent's phase loop, the human
16
+ * triage that follows it, and the bug-fix tasks that triage spawns.
17
+ *
18
+ * The shape is the Ralph loop's (one step, dispatched repeatedly) joined to the PR review's
19
+ * (park, then let a human act on findings). {@link recordPhaseResult} runs as the completion
20
+ * interceptor's body: it stamps the finished pass's findings onto `step.bugFishing` and either
21
+ * RE-ARMS the same step for the next angle or, once every angle has settled, parks the run for
22
+ * triage. {@link address} spawns a bug-fix task per marked finding, and {@link resolve} finishes
23
+ * the parked expedition.
24
+ *
25
+ * Two properties are worth stating because they are what the design is FOR:
26
+ *
27
+ * - **Marking does not wait for the expedition.** {@link address} accepts findings while later
28
+ * angles are still fishing. That is the whole reason the angles run as separate dispatches
29
+ * rather than as one prompt: a human who reads the concurrency pass at minute six should be
30
+ * able to start its fix then, not after the requirements pass finishes at minute forty.
31
+ * - **A failed angle costs only that angle.** The passes share nothing but the checkout, so
32
+ * {@link recordPhaseFailure} settles the one that died with its reason on the record and moves
33
+ * to the next, rather than failing a run that has already caught something.
34
+ *
35
+ * All expedition state rides the run's `bug-fisher` step (`step.bugFishing`) — no side table —
36
+ * so it is runtime-symmetric by construction, exactly like `prReview` / `forkDecision`.
37
+ */
38
+ export class BugFishingController {
39
+ deps;
40
+ log;
41
+ constructor(deps) {
42
+ this.deps = deps;
43
+ this.log = deps.logger ?? noopLogger;
44
+ }
45
+ /**
46
+ * The expedition state a `bug-fisher` step should carry on THIS dispatch, seeded on first
47
+ * entry from the task's angle selection and the workspace's fix-pipeline default.
48
+ *
49
+ * Called by the step handler before dispatching, and idempotent: a step that already carries
50
+ * an expedition (every dispatch after the first, and every durable replay of the first) keeps
51
+ * it, because re-planning would discard the phases already fished.
52
+ *
53
+ * ALWAYS plans at least one angle, because {@link planBugFishingPhases} falls back to the whole
54
+ * catalog: an empty or entirely unrecognised selection means "fish everything", which is the
55
+ * point of an expedition, so narrowing it is the deliberate act. There is deliberately no
56
+ * "planned nothing" outcome for the handler to pass through — one existed, could not be
57
+ * reached, and would have dispatched a full container run for a step it had just marked
58
+ * skipped.
59
+ */
60
+ async ensurePlanned(workspaceId, step, block) {
61
+ if (step.bugFishing)
62
+ return step.bugFishing;
63
+ const { phases, unknown } = planBugFishingPhases(block.taskTypeFields?.fishingPhaseIds);
64
+ if (unknown.length > 0) {
65
+ // Named, never silently dropped: the person picked angles this build no longer ships, and
66
+ // an expedition that quietly fished fewer than they asked for reads as one that found
67
+ // nothing under those angles.
68
+ this.log.warn('bugFishing.unknownAnglesDropped', {
69
+ workspaceId,
70
+ blockId: block.id,
71
+ unknown,
72
+ });
73
+ }
74
+ return {
75
+ status: 'fishing',
76
+ phases,
77
+ currentPhaseIndex: 0,
78
+ findings: [],
79
+ model: null,
80
+ defaultFixPipelineId: await this.resolveDefaultFixPipelineId(workspaceId),
81
+ };
82
+ }
83
+ /**
84
+ * Record a COMPLETED pass and decide the flow. Runs as the completion interceptor's body
85
+ * (short-circuiting `recordStepResult` when it parks or loops).
86
+ *
87
+ * With angles left: stamp the findings, re-arm the SAME step and return `continue`, so the
88
+ * durable driver re-enters and dispatches the next angle (the dispatch epoch gives it a job id
89
+ * of its own — see `dispatchEpochFor`). With the last angle settled: park on a durable
90
+ * decision-wait and raise the triage card. An expedition that caught nothing at all still
91
+ * parks: "we looked from eight angles and found nothing" is the answer a human asked for, and
92
+ * advancing silently past it would present that as no answer.
93
+ *
94
+ * Idempotent against a durable replay: a step whose expedition is no longer `fishing` has
95
+ * already been recorded, so it falls through to whatever its status says.
96
+ */
97
+ async recordPhaseResult(workspaceId, instance, step, output, model, block) {
98
+ const state = step.bugFishing;
99
+ if (!state)
100
+ return null;
101
+ if (state.status !== 'fishing') {
102
+ // Already settled by an earlier pass of this interceptor (a durable retry / replay). Keep a
103
+ // parked expedition parked rather than re-recording, which would re-mint finding ids a
104
+ // human may be mid-triage on.
105
+ if (state.status === 'awaiting_triage') {
106
+ return this.deps.stateMachine.parkStepOnDecision(workspaceId, instance, step);
107
+ }
108
+ return null;
109
+ }
110
+ const phaseIndex = state.currentPhaseIndex ?? 0;
111
+ const phase = (state.phases ?? [])[phaseIndex];
112
+ if (!phase) {
113
+ // The cursor is past the plan: nothing left to record. Park so the catch is triaged rather
114
+ // than advancing past findings nobody has seen.
115
+ step.bugFishing = { ...state, status: 'awaiting_triage' };
116
+ return this.deps.stateMachine.parkStepOnDecision(workspaceId, instance, step);
117
+ }
118
+ if (!output) {
119
+ // The pass returned nothing this build can read: no `result.custom` at all, or a blob the
120
+ // kind's structured-output schema rejected. Settling that as a COMPLETED phase with an
121
+ // empty summary would put it on the record as "this angle was fished and found nothing",
122
+ // which is the one thing it demonstrably does not say — and the expedition's whole product
123
+ // is a human's reading of which angles came back empty. So it takes the failure path, the
124
+ // same one a crashed container job takes, and the angle is named as unfished.
125
+ //
126
+ // The model is stamped first: WHICH model answered unusably is exactly what a reader of
127
+ // this failure needs, and the failure path has no dispatch result to take it from.
128
+ step.bugFishing = { ...state, model: model ?? state.model ?? null };
129
+ return this.recordPhaseFailure(workspaceId, instance, step, UNREADABLE_PASS_REASON, block);
130
+ }
131
+ const { findings, dropped } = coerceBugFishingFindings(output, phase.id, () => this.deps.idGenerator.next('bff'));
132
+ const next = recordBugFishingPhase(state, phaseIndex, {
133
+ summary: output.summary ?? null,
134
+ findings,
135
+ dropped,
136
+ at: this.deps.clock.now(),
137
+ });
138
+ step.bugFishing = { ...next, model: model ?? next.model ?? null };
139
+ // The pass's own findings are already on the step; the raw structured blob would render a
140
+ // second, unstamped copy in the generic viewer.
141
+ step.custom = undefined;
142
+ if (step.bugFishing.status === 'fishing')
143
+ return this.rearmForNextPhase(workspaceId, instance, step);
144
+ await this.raiseTriageReady(workspaceId, instance, block, step.bugFishing);
145
+ return this.deps.stateMachine.parkStepOnDecision(workspaceId, instance, step);
146
+ }
147
+ /**
148
+ * Settle a pass whose container job FAILED, then carry on with the next angle.
149
+ *
150
+ * Run from the driver's failed-job path (the analogue of the PR-review challenge failure
151
+ * branch). The angles are independent by construction, so one crashing must not cost the
152
+ * expedition the passes that already landed nor the ones still to come; the failure is NAMED
153
+ * on the phase, because a phase that silently reported nothing is indistinguishable from a
154
+ * phase that honestly found nothing. Returns null when the step carries no live expedition, so
155
+ * an ordinary failure still fails the run.
156
+ */
157
+ async recordPhaseFailure(workspaceId, instance, step, reason, block) {
158
+ const state = step.bugFishing;
159
+ if (!state || state.status !== 'fishing')
160
+ return null;
161
+ const next = failBugFishingPhase(state, state.currentPhaseIndex ?? 0, reason, this.deps.clock.now());
162
+ step.bugFishing = next;
163
+ step.custom = undefined;
164
+ if (next.status === 'fishing')
165
+ return this.rearmForNextPhase(workspaceId, instance, step);
166
+ await this.raiseTriageReady(workspaceId, instance, block, next);
167
+ return this.deps.stateMachine.parkStepOnDecision(workspaceId, instance, step);
168
+ }
169
+ /**
170
+ * Re-arm the `bug-fisher` step for the NEXT angle and let the driver re-enter.
171
+ *
172
+ * `resetStepForRerun` clears the job handle (which is what makes the driver dispatch again on
173
+ * re-entry) while deliberately leaving `bugFishing` alone — the state is the accumulated catch,
174
+ * and clearing it would throw away every pass before this one. The container is reclaimed
175
+ * first so the next angle boots a fresh context: a container-reusing transport would otherwise
176
+ * re-attach to the finished job and replay its result, which reads as an angle that found
177
+ * exactly what the previous one did.
178
+ */
179
+ async rearmForNextPhase(workspaceId, instance, step) {
180
+ await this.deps.stateMachine.stopRunContainer(workspaceId, instance);
181
+ this.deps.stepGraph.resetStepForRerun(step);
182
+ this.deps.stepGraph.startStep(step);
183
+ await this.deps.stateMachine.persistAndEmit(workspaceId, instance);
184
+ return { kind: 'continue' };
185
+ }
186
+ /**
187
+ * MARK findings to be addressed: spawn a bug-fix task per finding, linked to the expedition.
188
+ *
189
+ * Accepted while the expedition is still fishing later angles as well as once it has parked —
190
+ * see the class doc for why that is the point rather than a convenience.
191
+ *
192
+ * Order of operations per finding, and why: the spawn record is CLAIMED under the run's
193
+ * compare-and-swap first, with the task id it is about to create, and only then is the block
194
+ * inserted and its run started; the claim is settled to `spawned` or `failed` afterwards.
195
+ * Creating first and recording after was the first cut and it is not safe: two people marking
196
+ * the same finding (or one person and a retried request) both read a snapshot with no spawn on
197
+ * it, and both file the same bug and start a run for it. The claim is what makes exactly one
198
+ * of them win, and it is why a spawn record must be read through its STATUS rather than by
199
+ * being present: `pending` means a task is being made, not that one exists.
200
+ *
201
+ * The pipeline is resolved ONCE for the whole batch and validated against the workspace's
202
+ * catalog before anything is created: a caller naming a pipeline that does not exist gets a
203
+ * refusal naming it, rather than N tasks pinned to a pipeline nothing can run.
204
+ */
205
+ async address(workspaceId, executionId, input, initiatedBy) {
206
+ const instance = await this.deps.executionRepository.get(workspaceId, executionId);
207
+ if (!instance)
208
+ throw new NotFoundError('Run', executionId);
209
+ const step = this.activeExpeditionStep(instance);
210
+ const state = step?.bugFishing;
211
+ if (!step || !state) {
212
+ throw new ConflictError('This run has no bug-fishing expedition to triage.', 'no_expedition');
213
+ }
214
+ const requested = new Set(input.findingIds);
215
+ const targets = (state.findings ?? []).filter((f) => requested.has(f.id));
216
+ const missing = [...requested].filter((id) => !targets.some((f) => f.id === id));
217
+ if (missing.length > 0) {
218
+ throw new ValidationError('Some findings are no longer part of this expedition.', {
219
+ reason: 'unknown_finding',
220
+ findingIds: missing,
221
+ });
222
+ }
223
+ // A read-time refusal, so a caller marking a batch is told which findings are already taken
224
+ // before anything at all is created. It is NOT the safety property — the per-finding claim
225
+ // below is, and it re-checks the same rule against the winning snapshot.
226
+ const already = targets.filter((f) => !bugFishingSpawnIsClaimable(f.spawn, this.deps.clock.now()));
227
+ if (already.length > 0) {
228
+ throw new ConflictError('Some of those findings already have a fix task.', 'already_addressed', { findingIds: already.map((f) => f.id) });
229
+ }
230
+ const pipelineId = await this.resolveSpawnPipelineId(workspaceId, input.pipelineId, state);
231
+ const host = await this.resolveSpawnHost(workspaceId, instance.blockId);
232
+ let latest = state;
233
+ for (const finding of targets) {
234
+ latest = await this.spawnForFinding(workspaceId, executionId, {
235
+ expeditionBlockId: instance.blockId,
236
+ host,
237
+ finding,
238
+ pipelineId,
239
+ initiatedBy,
240
+ });
241
+ }
242
+ const emitted = await this.deps.executionRepository.get(workspaceId, executionId);
243
+ if (emitted)
244
+ await this.deps.stateMachine.emitInstance(workspaceId, emitted);
245
+ return latest;
246
+ }
247
+ /**
248
+ * Claim ONE finding, create its fix task behind the claim, and settle the claim either way.
249
+ *
250
+ * The three writes are separate on purpose. The CLAIM is the concurrency boundary: it lands
251
+ * under CAS, and the caller learns whether it won by re-reading the finding and comparing the
252
+ * task id it minted — the initiative loop's spawn recognises its own the same way, and for the
253
+ * same reason (the loser's transform is a no-op, so a lost race creates nothing rather than
254
+ * duplicating the winner's work).
255
+ *
256
+ * The SETTLE writes cannot be skipped on either branch. On success the record becomes terminal,
257
+ * which is what stops the finding being marked twice. On failure it records the cause and
258
+ * becomes claimable again, which is what stops a finding whose fix task was never created from
259
+ * reading as one that is being worked on. The failure then PROPAGATES: marking is the caller's
260
+ * own request, so answering 200 with a finding nobody is fixing would report work the platform
261
+ * did not do.
262
+ */
263
+ async spawnForFinding(workspaceId, executionId, input) {
264
+ const { finding, pipelineId, initiatedBy } = input;
265
+ const taskId = this.deps.idGenerator.next('blk');
266
+ const claim = {
267
+ status: 'pending',
268
+ taskId,
269
+ executionId: null,
270
+ pipelineId,
271
+ requestedBy: initiatedBy,
272
+ requestedAt: this.deps.clock.now(),
273
+ };
274
+ const claimed = await this.mutateExpedition(workspaceId, executionId, (live) => claimBugFishingSpawn(live, finding.id, claim, this.deps.clock.now()));
275
+ if (claimed.findings?.find((f) => f.id === finding.id)?.spawn?.taskId !== taskId) {
276
+ // Somebody else's claim is on it. Refuse rather than spawn a second task for one finding:
277
+ // the caller asked for a fix to be filed and one is being filed, but not by this request,
278
+ // and saying so is the only answer that does not either duplicate the bug or claim credit
279
+ // for work this call did not do.
280
+ throw new ConflictError('Some of those findings already have a fix task.', 'already_addressed', { findingIds: [finding.id] });
281
+ }
282
+ try {
283
+ const spawnedRunId = await this.spawnFixTask(workspaceId, {
284
+ expeditionBlockId: input.expeditionBlockId,
285
+ host: input.host,
286
+ finding,
287
+ taskId,
288
+ pipelineId,
289
+ initiatedBy,
290
+ });
291
+ return await this.mutateExpedition(workspaceId, executionId, (live) => settleBugFishingSpawn(live, finding.id, taskId, {
292
+ status: 'spawned',
293
+ executionId: spawnedRunId,
294
+ }));
295
+ }
296
+ catch (error) {
297
+ // Release the claim before rethrowing, so the finding is markable again. Best-effort
298
+ // because the caller is about to be told the spawn failed either way, and a release that
299
+ // itself fails must not replace that cause with its own: the claim then expires on its TTL.
300
+ await runBestEffort(this.log, 'bugFishing.releaseSpawnClaim', () => this.mutateExpedition(workspaceId, executionId, (live) => settleBugFishingSpawn(live, finding.id, taskId, {
301
+ status: 'failed',
302
+ failureReason: getErrorMessage(error),
303
+ })), { workspaceId, executionId, findingId: finding.id, taskId });
304
+ throw error;
305
+ }
306
+ }
307
+ /**
308
+ * Apply a pure reduction to the run's live expedition state under CAS, and answer the result.
309
+ *
310
+ * REFUSES rather than returning when the winning snapshot carries no expedition. That case was
311
+ * a silent `return` and it is the worst shape available here: on the settle path the task and
312
+ * its run already exist, so dropping the write leaves a finding that reads as untouched beside
313
+ * a fix task nothing on the expedition points at, and the caller is told it all worked.
314
+ */
315
+ async mutateExpedition(workspaceId, executionId, reduce) {
316
+ let next;
317
+ await this.deps.stateMachine.mutateInstance(workspaceId, executionId, (inst) => {
318
+ const live = this.activeExpeditionStep(inst);
319
+ if (!live?.bugFishing) {
320
+ throw new ConflictError('This run has no bug-fishing expedition to triage.', 'no_expedition');
321
+ }
322
+ live.bugFishing = reduce(live.bugFishing);
323
+ next = live.bugFishing;
324
+ });
325
+ return next;
326
+ }
327
+ /**
328
+ * Dismiss a finding: it stays on the expedition's record, struck through, and can no longer be
329
+ * marked. A curation action, not a resolution — the run stays exactly where it is, whether it
330
+ * is still fishing or already parked.
331
+ *
332
+ * REFUSES an id this expedition does not carry, and one whose fix task exists or is being
333
+ * created, with the same two reasons {@link address} uses for the same two cases. Answering 200
334
+ * to either was a filter that quietly matched nothing: the SPA then struck the row through on
335
+ * its optimistic echo and the next snapshot silently put it back, which reads as a dismissal
336
+ * the platform undid rather than as one it never accepted.
337
+ */
338
+ async dismissFinding(workspaceId, executionId, findingId) {
339
+ let state;
340
+ const instance = await this.deps.stateMachine.mutateInstance(workspaceId, executionId, (inst) => {
341
+ const step = this.activeExpeditionStep(inst);
342
+ if (!step?.bugFishing) {
343
+ throw new ConflictError('This run has no bug-fishing expedition to triage.', 'no_expedition');
344
+ }
345
+ const finding = (step.bugFishing.findings ?? []).find((f) => f.id === findingId);
346
+ if (!finding) {
347
+ throw new ValidationError('That finding is no longer part of this expedition.', {
348
+ reason: 'unknown_finding',
349
+ findingIds: [findingId],
350
+ });
351
+ }
352
+ if (!finding.dismissed && !bugFishingSpawnIsClaimable(finding.spawn, this.deps.clock.now()))
353
+ throw new ConflictError('That finding already has a fix task.', 'already_addressed', {
354
+ findingIds: [findingId],
355
+ });
356
+ step.bugFishing = dismissBugFishingFinding(step.bugFishing, findingId, this.deps.clock.now());
357
+ state = step.bugFishing;
358
+ });
359
+ await this.deps.stateMachine.emitInstance(workspaceId, instance);
360
+ return state;
361
+ }
362
+ /**
363
+ * Finish a parked expedition: mark it `done` and advance the run past the step.
364
+ *
365
+ * Mirrors the review gate's resolved-gate advance — the pure in-memory advance runs inside the
366
+ * CAS, its side effects (settle / emit) once after, on the winning snapshot. Findings left
367
+ * untriaged are left as they are: they stay on the run's record, and the expedition's own
368
+ * state is the honest account of what was decided and what was not.
369
+ */
370
+ async resolve(workspaceId, executionId) {
371
+ let stepIndex = -1;
372
+ let state;
373
+ const instance = await this.deps.stateMachine.mutateInstance(workspaceId, executionId, (inst) => {
374
+ const index = inst.steps.findIndex((s) => s.agentKind === BUG_FISHING_STEP_KIND &&
375
+ s.state === 'waiting_decision' &&
376
+ s.approval?.status === 'pending' &&
377
+ s.bugFishing?.status === 'awaiting_triage');
378
+ const step = index === -1 ? undefined : inst.steps[index];
379
+ if (!step?.approval || !step.bugFishing) {
380
+ throw new ConflictError('The run is no longer awaiting a bug-fishing triage.', 'not_awaiting_triage');
381
+ }
382
+ stepIndex = index;
383
+ step.bugFishing = { ...step.bugFishing, status: 'done' };
384
+ step.approval.status = 'approved';
385
+ this.deps.stateMachine.advanceRunPastGate(inst, index);
386
+ state = step.bugFishing;
387
+ });
388
+ await this.deps.stateMachine.clearWaitingNotification(workspaceId, instance);
389
+ if (stepIndex !== -1) {
390
+ await this.deps.stateMachine.settleAdvancedGate(workspaceId, instance, stepIndex);
391
+ }
392
+ return state;
393
+ }
394
+ /** The active expedition state for a run's GET, or null when no step carries one. */
395
+ async getActive(workspaceId, executionId) {
396
+ const instance = await this.deps.executionRepository.get(workspaceId, executionId);
397
+ if (!instance)
398
+ return null;
399
+ return this.activeExpeditionStep(instance)?.bugFishing ?? null;
400
+ }
401
+ /**
402
+ * The run's "active" expedition step: prefer the step the run is currently on, else the latest
403
+ * `bug-fisher` step carrying expedition state. Mirrors `PrReviewController.activePrReviewStep`.
404
+ */
405
+ activeExpeditionStep(instance) {
406
+ const current = instance.steps[instance.currentStep];
407
+ if (current?.agentKind === BUG_FISHING_STEP_KIND && current.bugFishing)
408
+ return current;
409
+ for (let i = instance.steps.length - 1; i >= 0; i--) {
410
+ const s = instance.steps[i];
411
+ if (s.agentKind === BUG_FISHING_STEP_KIND && s.bugFishing)
412
+ return s;
413
+ }
414
+ return undefined;
415
+ }
416
+ // ---- Spawning the fix tasks ---------------------------------------------
417
+ /**
418
+ * The pipeline a marked finding's task runs: the caller's override, else the expedition's
419
+ * recorded default, else the workspace setting as it stands now, else the built-in bug-fix
420
+ * preset — and in every case validated against the workspace's own catalog.
421
+ *
422
+ * Validated rather than trusted because both the override and the workspace setting are bare
423
+ * ids a human typed or picked, and a workspace can delete the pipeline it named. Spawning onto
424
+ * a missing pipeline would create tasks that cannot start, so the refusal NAMES the pipeline
425
+ * instead: which one is missing is the whole content of the fix.
426
+ */
427
+ async resolveSpawnPipelineId(workspaceId, override, state) {
428
+ const wanted = override?.trim() ||
429
+ state.defaultFixPipelineId ||
430
+ (await this.resolveDefaultFixPipelineId(workspaceId));
431
+ const pipeline = await this.deps.pipelineRepository.get(workspaceId, wanted);
432
+ if (!pipeline) {
433
+ throw new ValidationError(`Pipeline "${wanted}" no longer exists — pick another for the spawned fix tasks.`, { reason: 'pipeline_not_found', pipelineId: wanted });
434
+ }
435
+ return pipeline.id;
436
+ }
437
+ /**
438
+ * The workspace's configured fix pipeline, else the built-in bug-fix preset.
439
+ *
440
+ * A settings read that THROWS is deliberately NOT swallowed into the built-in default: an
441
+ * unreachable settings store and a workspace that configured nothing are opposite facts, and
442
+ * silently spawning onto the platform preset would put a team's bug work on a pipeline they
443
+ * moved off. An UNWIRED store is the third case and genuinely does mean "no configured
444
+ * default", so that one answers the built-in.
445
+ */
446
+ async resolveDefaultFixPipelineId(workspaceId) {
447
+ if (!this.deps.workspaceSettingsRepository)
448
+ return BUGFIX_PIPELINE_ID;
449
+ const settings = await this.deps.workspaceSettingsRepository.get(workspaceId);
450
+ return settings?.bugFishingFixPipelineId?.trim() || BUGFIX_PIPELINE_ID;
451
+ }
452
+ /**
453
+ * The frame a spawned fix task lives under, its service row, and the standards that frame's
454
+ * service holds every task under it to.
455
+ *
456
+ * The expedition's OWN parent frame, so the fix runs against the same repository the
457
+ * expedition fished. A task whose frame cannot be resolved has nothing to host the work and no
458
+ * repo to fix, so this refuses rather than dropping the task somewhere plausible.
459
+ *
460
+ * The frame is also where the service's standing standards live (`serviceFragmentIds`), which
461
+ * is why it is carried rather than reduced to an id: a spawned task has to be handed them
462
+ * EXPLICITLY, since a task-level run folds only its own `fragmentIds` and never re-unions the
463
+ * service's (kernel's `applicableFragmentIds`).
464
+ */
465
+ async resolveSpawnHost(workspaceId, expeditionBlockId) {
466
+ const anchor = await this.deps.blockRepository.get(workspaceId, expeditionBlockId);
467
+ const frame = anchor?.parentId
468
+ ? await this.deps.blockRepository.get(workspaceId, anchor.parentId)
469
+ : null;
470
+ if (!frame) {
471
+ throw new ConflictError('This expedition is not under a service, so there is nowhere to put a fix task.', 'no_host_frame');
472
+ }
473
+ const serviceId = this.deps.serviceRepository
474
+ ? ((await this.deps.serviceRepository.getByFrameBlock(frame.id))?.id ?? null)
475
+ : null;
476
+ return { frame, serviceId };
477
+ }
478
+ /**
479
+ * The best-practice fragments a spawned fix task is created with.
480
+ *
481
+ * Resolved through the SAME seam the create form goes through (`BoardService.addTask`'s
482
+ * `fragmentIdsFor`), against the same inputs a person filing this bug by hand under this
483
+ * service would supply: the service's standing standards, the `bug` type's own defaults, and
484
+ * whichever standing-context entries hold for the fields the finding filled in. Anything less
485
+ * would make a spawned fix quietly weaker than the identical bug filed by hand, and the
486
+ * difference would only ever show up in the fix's output.
487
+ *
488
+ * Unwired (a facade that passes no defaults seam) means no fragments, which is the same answer
489
+ * that seam gives a deployment with no fragment pool at all.
490
+ */
491
+ async resolveSpawnFragmentIds(frame, taskTypeFields) {
492
+ if (!this.deps.taskTypeDefaults)
493
+ return [];
494
+ return this.deps.taskTypeDefaults.fragmentIdsFor({
495
+ taskType: 'bug',
496
+ ...(frame.serviceFragmentIds ? { serviceFragmentIds: frame.serviceFragmentIds } : {}),
497
+ ...(taskTypeFields ? { fields: taskTypeFields } : {}),
498
+ });
499
+ }
500
+ /**
501
+ * Create ONE bug-fix task for a finding and start its run, answering the run's id (or null when
502
+ * the start entry point reported none).
503
+ *
504
+ * The task is a `bug`-typed block carrying the finding as its description and its evidence as
505
+ * the reproduction field, so the bug-fix pipeline's investigator starts from what the
506
+ * expedition actually found rather than from a title. On a start failure the block is rolled
507
+ * back, exactly as the initiative loop does: a task on the board with no run and no record on
508
+ * either side is the one outcome nothing later reconciles.
509
+ *
510
+ * The block id is passed IN rather than minted here, because the claim that authorised this
511
+ * call already carries it — see {@link spawnForFinding}.
512
+ *
513
+ * A failure PROPAGATES rather than being swallowed into "this finding stays untriaged". The
514
+ * marking is the caller's own request, not a best-effort background pass, and the failures that
515
+ * reach here are exactly the ones they can act on: a pipeline that refuses a one-off task
516
+ * start, a per-service task limit, a board write that was denied. Answering 200 with an
517
+ * unchanged finding would report the request as done and leave somebody waiting for a fix task
518
+ * that is never going to appear. Findings spawned EARLIER in the batch are already persisted,
519
+ * so the error names what is left rather than undoing what worked.
520
+ */
521
+ async spawnFixTask(workspaceId, input) {
522
+ const { finding, taskId } = input;
523
+ const { frame, serviceId } = input.host;
524
+ const taskTypeFields = {
525
+ severity: finding.severity,
526
+ ...(finding.failureScenario ? { stepsToReproduce: finding.failureScenario } : {}),
527
+ };
528
+ const fragmentIds = await this.resolveSpawnFragmentIds(frame, taskTypeFields);
529
+ const block = {
530
+ id: taskId,
531
+ title: finding.title || 'Bug-fishing finding',
532
+ // Inherit the host frame's behavioural repo type, like `BoardService.addTask`.
533
+ type: frame.type,
534
+ description: renderFindingBrief(finding),
535
+ position: { x: 24, y: 96 },
536
+ status: 'planned',
537
+ progress: 0,
538
+ dependsOn: [],
539
+ executionId: null,
540
+ level: 'task',
541
+ parentId: frame.id,
542
+ expeditionId: input.expeditionBlockId,
543
+ taskType: 'bug',
544
+ taskTypeFields,
545
+ pipelineId: input.pipelineId,
546
+ // The person who MARKED the finding is this task's creator, for the same reason the create
547
+ // form's signed-in user is: it is what the "notify the task creator" audience resolves
548
+ // through, so without it every notification this fix run raises reaches nobody. Null with
549
+ // auth disabled, exactly as `BoardService.addTask` leaves it.
550
+ ...(input.initiatedBy != null ? { createdBy: input.initiatedBy } : {}),
551
+ ...(fragmentIds.length ? { fragmentIds } : {}),
552
+ };
553
+ await this.deps.blockRepository.insert(workspaceId, block, serviceId);
554
+ let executionId = null;
555
+ try {
556
+ const started = await this.deps.start(workspaceId, taskId, input.pipelineId, {
557
+ initiatedBy: input.initiatedBy,
558
+ });
559
+ executionId = readExecutionId(started);
560
+ }
561
+ catch (error) {
562
+ await runBestEffort(this.log, 'bugFishing.rollbackSpawnedBlock', () => this.deps.blockRepository.deleteMany(workspaceId, [taskId]), { workspaceId, blockId: taskId, findingId: finding.id });
563
+ this.log.warn('bugFishing.spawnStartFailed', {
564
+ workspaceId,
565
+ findingId: finding.id,
566
+ pipelineId: input.pipelineId,
567
+ error: getErrorMessage(error),
568
+ });
569
+ throw error;
570
+ }
571
+ // Announced only once the run has started, for the reason the initiative loop's spawn states:
572
+ // past the start there is nothing left to roll back, so a failing fan-out must not delete a
573
+ // block whose run is live. Best-effort; the board reconciles a missed push on its next
574
+ // snapshot.
575
+ await runBestEffort(this.log, 'bugFishing.announceSpawnedBlock', () => this.deps.events?.boardChanged(workspaceId, { reason: 'block-added', block }) ??
576
+ Promise.resolve(), { workspaceId, blockId: taskId, findingId: finding.id });
577
+ return executionId;
578
+ }
579
+ /** Raise the "finish triaging the catch" inbox card when the expedition parks. */
580
+ async raiseTriageReady(workspaceId, instance, block, state) {
581
+ if (!this.deps.notificationService || !block)
582
+ return;
583
+ const untriaged = untriagedBugFishingFindings(state, this.deps.clock.now()).length;
584
+ const phases = (state.phases ?? []).length;
585
+ await this.deps.notificationService.raise(workspaceId, {
586
+ type: 'bug_fishing_triage',
587
+ blockId: block.id,
588
+ executionId: instance.id,
589
+ title: untriaged === 0
590
+ ? `"${block.title}" — the expedition finished with nothing left to triage`
591
+ : `"${block.title}" — ${untriaged} bug-fishing finding${untriaged === 1 ? '' : 's'} to triage`,
592
+ body: `The expedition fished ${phases} angle${phases === 1 ? '' : 's'} of this codebase. Open ` +
593
+ 'the task to review what it caught and mark the findings worth fixing — each one you ' +
594
+ 'mark becomes its own bug-fix task.',
595
+ payload: {
596
+ pipelineName: instance.pipelineName,
597
+ phaseCount: phases,
598
+ untriagedFindingCount: untriaged,
599
+ },
600
+ });
601
+ }
602
+ }
603
+ /**
604
+ * The description a spawned fix task carries: the finding, in the shape the bug-fix pipeline's
605
+ * investigator reads a bug report in.
606
+ *
607
+ * The evidence and the suggested fix are labelled as the EXPEDITION's, not asserted as fact: they
608
+ * are one agent's reading of the code, and the investigator that picks the task up is supposed to
609
+ * confirm or reject them rather than treat them as the brief.
610
+ */
611
+ function renderFindingBrief(finding) {
612
+ const lines = [finding.detail];
613
+ if (finding.path) {
614
+ lines.push('', `Where: \`${finding.path}\`${finding.line ? ` line ${finding.line}` : ''}`);
615
+ }
616
+ if (finding.failureScenario)
617
+ lines.push('', `How it fires: ${finding.failureScenario}`);
618
+ if (finding.evidence) {
619
+ lines.push('', 'Evidence the bug-fishing expedition cited (verify it before relying on it):');
620
+ lines.push(finding.evidence);
621
+ }
622
+ if (finding.suggestedFix) {
623
+ lines.push('', `The expedition suggested: ${finding.suggestedFix}`);
624
+ }
625
+ lines.push('', `Found by a bug-fishing expedition (${finding.kind}, ${finding.confidence} confidence).`);
626
+ return lines.join('\n');
627
+ }
628
+ /**
629
+ * The execution id of a started run, when the start entry point reported one.
630
+ *
631
+ * `start` is bound from `ExecutionService` through a callback so the controller does not depend
632
+ * on the engine service that owns it, which costs the return type its shape. Null is a legitimate
633
+ * answer rather than a failure: the task exists and is running either way, and the id is only
634
+ * used to deep-link the spawned run from the expedition window.
635
+ */
636
+ function readExecutionId(started) {
637
+ if (started && typeof started === 'object' && 'id' in started) {
638
+ const id = started.id;
639
+ if (typeof id === 'string' && id.length > 0)
640
+ return id;
641
+ }
642
+ return null;
643
+ }
644
+ //# sourceMappingURL=BugFishingController.js.map