@cat-factory/orchestration 0.298.1 → 0.301.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/container/execution-service.d.ts.map +1 -1
- package/dist/container/execution-service.js +5 -0
- package/dist/container/execution-service.js.map +1 -1
- package/dist/container/inline-producers.d.ts +55 -0
- package/dist/container/inline-producers.d.ts.map +1 -0
- package/dist/container/inline-producers.js +163 -0
- package/dist/container/inline-producers.js.map +1 -0
- package/dist/container/modules.d.ts +2 -46
- package/dist/container/modules.d.ts.map +1 -1
- package/dist/container/modules.js +6 -143
- package/dist/container/modules.js.map +1 -1
- package/dist/modules/bootstrap/BootstrapService.d.ts +17 -8
- package/dist/modules/bootstrap/BootstrapService.d.ts.map +1 -1
- package/dist/modules/bootstrap/BootstrapService.js +127 -49
- package/dist/modules/bootstrap/BootstrapService.js.map +1 -1
- package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.d.ts +22 -1
- package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.d.ts.map +1 -1
- package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.js +69 -12
- package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.js.map +1 -1
- package/dist/modules/bootstrap/MonorepoBootstrapController.d.ts +0 -2
- package/dist/modules/bootstrap/MonorepoBootstrapController.d.ts.map +1 -1
- package/dist/modules/bootstrap/MonorepoBootstrapController.js +4 -5
- package/dist/modules/bootstrap/MonorepoBootstrapController.js.map +1 -1
- package/dist/modules/bootstrap/bootstrapDelivery.d.ts +20 -0
- package/dist/modules/bootstrap/bootstrapDelivery.d.ts.map +1 -0
- package/dist/modules/bootstrap/bootstrapDelivery.js +83 -0
- package/dist/modules/bootstrap/bootstrapDelivery.js.map +1 -0
- package/dist/modules/execution/BugFishingController.d.ts +277 -0
- package/dist/modules/execution/BugFishingController.d.ts.map +1 -0
- package/dist/modules/execution/BugFishingController.js +644 -0
- package/dist/modules/execution/BugFishingController.js.map +1 -0
- package/dist/modules/execution/DeployFixController.d.ts.map +1 -1
- package/dist/modules/execution/DeployFixController.js +13 -3
- package/dist/modules/execution/DeployFixController.js.map +1 -1
- package/dist/modules/execution/EnvironmentInvestigationController.d.ts.map +1 -1
- package/dist/modules/execution/EnvironmentInvestigationController.js +42 -9
- package/dist/modules/execution/EnvironmentInvestigationController.js.map +1 -1
- package/dist/modules/execution/ExecutionService.d.ts +9 -0
- package/dist/modules/execution/ExecutionService.d.ts.map +1 -1
- package/dist/modules/execution/ExecutionService.js +37 -11
- package/dist/modules/execution/ExecutionService.js.map +1 -1
- package/dist/modules/execution/ExecutionServiceDependencies.d.ts +16 -1
- package/dist/modules/execution/ExecutionServiceDependencies.d.ts.map +1 -1
- package/dist/modules/execution/PollCompletionController.d.ts +3 -0
- package/dist/modules/execution/PollCompletionController.d.ts.map +1 -1
- package/dist/modules/execution/PollCompletionController.js +16 -0
- package/dist/modules/execution/PollCompletionController.js.map +1 -1
- package/dist/modules/execution/RunDispatcher.d.ts +1 -0
- package/dist/modules/execution/RunDispatcher.d.ts.map +1 -1
- package/dist/modules/execution/RunDispatcher.js +6 -1
- package/dist/modules/execution/RunDispatcher.js.map +1 -1
- package/dist/modules/execution/RunDispatcherDependencies.d.ts +2 -0
- package/dist/modules/execution/RunDispatcherDependencies.d.ts.map +1 -1
- package/dist/modules/execution/RunDispatcherDependencies.js.map +1 -1
- package/dist/modules/execution/StepGraph.d.ts.map +1 -1
- package/dist/modules/execution/StepGraph.js +10 -6
- package/dist/modules/execution/StepGraph.js.map +1 -1
- package/dist/modules/execution/bugFishing.logic.d.ts +129 -0
- package/dist/modules/execution/bugFishing.logic.d.ts.map +1 -0
- package/dist/modules/execution/bugFishing.logic.js +284 -0
- package/dist/modules/execution/bugFishing.logic.js.map +1 -0
- package/dist/modules/execution/deployer.logic.d.ts +41 -0
- package/dist/modules/execution/deployer.logic.d.ts.map +1 -1
- package/dist/modules/execution/deployer.logic.js +86 -0
- package/dist/modules/execution/deployer.logic.js.map +1 -1
- package/dist/modules/execution/dispatcher-registries.d.ts +4 -2
- package/dist/modules/execution/dispatcher-registries.d.ts.map +1 -1
- package/dist/modules/execution/dispatcher-registries.js +69 -1
- package/dist/modules/execution/dispatcher-registries.js.map +1 -1
- package/dist/modules/execution/gate-window-controllers.d.ts +22 -1
- package/dist/modules/execution/gate-window-controllers.d.ts.map +1 -1
- package/dist/modules/execution/gate-window-controllers.js +29 -0
- package/dist/modules/execution/gate-window-controllers.js.map +1 -1
- package/dist/modules/execution/prReport.environments.d.ts +2 -1
- package/dist/modules/execution/prReport.environments.d.ts.map +1 -1
- package/dist/modules/execution/prReport.environments.js +265 -12
- package/dist/modules/execution/prReport.environments.js.map +1 -1
- package/dist/modules/execution/run-decision-surfaces.d.ts +16 -3
- package/dist/modules/execution/run-decision-surfaces.d.ts.map +1 -1
- package/dist/modules/execution/run-decision-surfaces.js +26 -2
- package/dist/modules/execution/run-decision-surfaces.js.map +1 -1
- package/dist/modules/recurring/RecurringPipelineService.d.ts.map +1 -1
- package/dist/modules/recurring/RecurringPipelineService.js +1 -0
- package/dist/modules/recurring/RecurringPipelineService.js.map +1 -1
- package/dist/modules/settings/workspaceSettingsMerge.d.ts.map +1 -1
- package/dist/modules/settings/workspaceSettingsMerge.js +6 -0
- package/dist/modules/settings/workspaceSettingsMerge.js.map +1 -1
- 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
|