@counterfact-labs/cli 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/bundle/client-release.json +1 -1
- package/dist/adapters/completion-lock.js +95 -0
- package/dist/adapters/completion.js +132 -17
- package/dist/adapters/entry-files.js +126 -0
- package/dist/adapters/entry-selection.js +45 -0
- package/dist/adapters/files.js +2 -1
- package/dist/adapters/results.js +10 -2
- package/dist/adapters/selection.js +104 -0
- package/dist/adapters/settings.js +29 -2
- package/dist/analysis-core/analysis/schemas.js +1 -1
- package/dist/analysis-core/chunks/2PZF54KH.js +10 -0
- package/dist/analysis-core/chunks/{OPMA32A6.js → 6RWEWBHK.js} +1 -1
- package/dist/analysis-core/chunks/{PECX3OXB.js → 7NEWZ74F.js} +1 -1
- package/dist/analysis-core/chunks/{6QY5BV5B.js → CGCJN73D.js} +1 -1
- package/dist/analysis-core/chunks/CWYVUGG4.js +1 -0
- package/dist/analysis-core/chunks/{3JWEFCTJ.js → FBTOOUPL.js} +1 -1
- package/dist/analysis-core/chunks/GOQVKWP3.js +32 -0
- package/dist/analysis-core/chunks/IHAVDX4L.js +66 -0
- package/dist/analysis-core/chunks/IVULXWQN.js +21 -0
- package/dist/analysis-core/chunks/K5FUABWW.js +1 -0
- package/dist/analysis-core/chunks/KGQYR62R.js +7 -0
- package/dist/analysis-core/chunks/PY5M22CL.js +1 -0
- package/dist/analysis-core/chunks/{YDBO7P4X.js → RNTMH4MU.js} +1 -1
- package/dist/analysis-core/chunks/RUVMFAST.js +1 -0
- package/dist/analysis-core/chunks/S27VIQMZ.js +1 -0
- package/dist/analysis-core/chunks/{5DGPLXYB.js → SLJ6CW7W.js} +1 -1
- package/dist/analysis-core/chunks/{STI4JE7L.js → UEORNVQB.js} +1 -1
- package/dist/analysis-core/chunks/{X7WZAA7S.js → WYLIV2P2.js} +1 -1
- package/dist/analysis-core/contracts/analysis-selection.js +1 -0
- package/dist/analysis-core/contracts/module-resolution.js +1 -0
- package/dist/analysis-core/contracts/relay-analysis.js +1 -1
- package/dist/analysis-core/contracts/requests.js +1 -1
- package/dist/analysis-core/contracts/selection-paths.js +1 -0
- package/dist/analysis-core/conventions/analysis-fit.js +1 -1
- package/dist/analysis-core/conventions/file-admission.js +1 -1
- package/dist/analysis-core/import/calls/modules.js +1 -0
- package/dist/analysis-core/import/outside-loader-sources.js +1 -1
- package/dist/analysis-core/index.js +3 -3
- package/dist/analysis-core/requests/local-plan.js +1 -1
- package/dist/analysis-core/requests/registry.js +1 -1
- package/dist/analysis-core/requests/template-version.js +1 -1
- package/dist/analysis-core/requests/tools.js +1 -1
- package/dist/analysis-core/settings.js +1 -1
- package/dist/analysis-core/stages/engine-entry.js +1 -1
- package/dist/cli/agent-folder.js +3 -3
- package/dist/cli/analysis-selection.js +91 -0
- package/dist/cli/commands/analyze.js +34 -8
- package/dist/cli/commands/balance.js +6 -3
- package/dist/cli/completion-delivery.js +63 -0
- package/dist/cli/completion-recovery.js +68 -14
- package/dist/cli/context.js +4 -1
- package/dist/cli/main.js +21 -9
- package/dist/cli/mcp-failure.js +8 -0
- package/dist/cli/mcp.js +130 -30
- package/dist/cli/prior-completion.js +35 -0
- package/dist/cli/relay-check.js +163 -97
- package/dist/cli/relay-failure.js +32 -0
- package/dist/cli/relay-preparation.js +24 -11
- package/dist/cli/results-upload.js +131 -0
- package/dist/cli/scope-choices.js +57 -0
- package/dist/cli/sign-in.js +61 -2
- package/dist/contract/cli-api-messages.js +2 -0
- package/dist/contract/cli-refusals.js +74 -1
- package/dist/contract/cli-version.js +1 -1
- package/dist/contract/console.js +7 -2
- package/dist/contract/email-sign-in-messages.js +4 -0
- package/dist/contract/scope-choice.js +78 -0
- package/dist/contract/selection-metadata.js +9 -0
- package/dist/contract/selection-offer.js +43 -0
- package/dist/gatekeeper/cli-api.js +16 -7
- package/dist/gatekeeper/connection-exchange.js +1 -0
- package/dist/gatekeeper/console.js +61 -36
- package/package.json +2 -2
- package/dist/analysis-core/chunks/377MLK4M.js +0 -21
- package/dist/analysis-core/chunks/DLPVYWHZ.js +0 -1
- package/dist/analysis-core/chunks/FOKDE64Y.js +0 -1
- package/dist/analysis-core/chunks/KTNA2KTN.js +0 -7
- package/dist/analysis-core/chunks/NIOS7U6C.js +0 -41
- package/dist/analysis-core/chunks/QFY6A3MS.js +0 -66
- package/dist/analysis-core/chunks/SVMEWGD3.js +0 -1
- package/dist/analysis-core/chunks/ZTOVBAG7.js +0 -1
package/dist/cli/relay-check.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { disabledSetupPings } from "../setup-pings/client.js";
|
|
3
|
-
import {
|
|
3
|
+
import { scopeChoicesEnabled } from "../adapters/settings.js";
|
|
4
|
+
import { saveCompletion, discardCompletion, lockCompletion } from "../adapters/completion.js";
|
|
4
5
|
import { saveLocalResults } from "../adapters/results.js";
|
|
5
6
|
import { GITIGNORE_OUTPUT_NOTICE } from "../adapters/gitignore-output.js";
|
|
7
|
+
import { priorCompletion } from "./prior-completion.js";
|
|
6
8
|
import { OutputPathError, outputPaths } from "../adapters/output-paths.js";
|
|
7
9
|
import { validateClientRelease } from "../adapters/release.js";
|
|
8
10
|
import { selectAnalysisExecution } from "../adapters/models.js";
|
|
@@ -10,7 +12,7 @@ import { resolveFolderPath } from "../adapters/folder-path.js";
|
|
|
10
12
|
import { AgentChoiceError } from "../contract/agent-choice.js";
|
|
11
13
|
import { savedAnalysisUrl } from "../contract/console-url.js";
|
|
12
14
|
import { prepareRelayCheck } from "./relay-preparation.js";
|
|
13
|
-
import { cliRefusals,
|
|
15
|
+
import { cliRefusals, refusalExitCode } from "../contract/cli-refusals.js";
|
|
14
16
|
import { ConsoleClient, ConsoleError } from "../gatekeeper/console.js";
|
|
15
17
|
import { GatekeeperClient } from "../gatekeeper/client.js";
|
|
16
18
|
import { connectRelay, relayOrigin } from "../gatekeeper/connection.js";
|
|
@@ -19,51 +21,16 @@ import { runAnalysisToEnd } from "../core/analysis/run-to-end.js";
|
|
|
19
21
|
import { findingPresentation } from "../analysis-core/findings/presentation.js";
|
|
20
22
|
import { formatSummary } from "./summary.js";
|
|
21
23
|
import { buildIntakePayload } from "./intake-payload.js";
|
|
22
|
-
import {
|
|
24
|
+
import { matchesCheck, observedCompletionTiming } from "./completion-recovery.js";
|
|
23
25
|
import { savedFindings } from "./finding-display.js";
|
|
24
|
-
import { INVALID_PROPOSAL_MESSAGE } from "../contract/agent-proposal.js";
|
|
25
26
|
import { readFindingSettings } from "./finding-settings.js";
|
|
27
|
+
import { deliverCompletedResults } from "./completion-delivery.js";
|
|
28
|
+
import { requireIntakeReservation, ResultsUploadError, submitResults, uploadFailure } from "./results-upload.js";
|
|
29
|
+
import { recoverRelayFailure } from "./relay-failure.js";
|
|
30
|
+
import { analysisSaveOptions, resolveRunSelection } from "./analysis-selection.js";
|
|
31
|
+
import { SCOPE_CHOICE_EXIT_CODE, progressLine } from "../contract/scope-choice.js";
|
|
32
|
+
import { ScopeChoiceError, scopeChoiceRefusal, selectionScopeChoices } from "../contract/selection-offer.js";
|
|
26
33
|
const stateLine = (status) => `Reservation ${status.state}; settlement ${status.settlement}; lease ${new Date(status.expiresAt).toLocaleString("en-US", { timeZone: "America/Los_Angeles", timeZoneName: "short" })}.\n`;
|
|
27
|
-
function requireIntakeReservation(status) {
|
|
28
|
-
if (status.state === "reserved")
|
|
29
|
-
return;
|
|
30
|
-
if (status.state === "closing")
|
|
31
|
-
throw new ConsoleError("reservation_closing");
|
|
32
|
-
if (status.state === "closed")
|
|
33
|
-
throw new ConsoleError("reservation_closed");
|
|
34
|
-
throw new ConsoleError("reservation_expired", "intake");
|
|
35
|
-
}
|
|
36
|
-
/** Retry an exact completed payload; a lost intake response must never purchase another analysis. */
|
|
37
|
-
async function submit(client, payload, signal) {
|
|
38
|
-
signal.throwIfAborted();
|
|
39
|
-
const status = await client.status(payload.reservationId);
|
|
40
|
-
const committed = (value) => ({ checkId: value.checkId, projectId: value.projectId, analysisId: value.analysisId, findingIds: value.findingIds,
|
|
41
|
-
...(value.findings ? { findings: value.findings } : {}), settlement: value.settlement });
|
|
42
|
-
if (!matchesCheck(status, payload))
|
|
43
|
-
throw new ConsoleError("check_conflict");
|
|
44
|
-
if (status.uploadState === "saved")
|
|
45
|
-
return committed(status);
|
|
46
|
-
requireIntakeReservation(status);
|
|
47
|
-
signal.throwIfAborted();
|
|
48
|
-
try {
|
|
49
|
-
return await client.intake(payload);
|
|
50
|
-
}
|
|
51
|
-
catch (error) {
|
|
52
|
-
// Keep the refusal for display; giving up the saved payload requires confirmed recovery status.
|
|
53
|
-
const after = await client.status(payload.reservationId).catch(() => { throw error; });
|
|
54
|
-
if (!matchesCheck(after, payload))
|
|
55
|
-
throw new ConsoleError("check_conflict");
|
|
56
|
-
if (after.uploadState === "saved")
|
|
57
|
-
return committed(after);
|
|
58
|
-
// Exact idempotent retry only; a second uncertainty leaves the completion record for restart.
|
|
59
|
-
if (error instanceof ConsoleError && error.code === "unavailable") {
|
|
60
|
-
requireIntakeReservation(after);
|
|
61
|
-
signal.throwIfAborted();
|
|
62
|
-
return client.intake(payload);
|
|
63
|
-
}
|
|
64
|
-
throw error;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
34
|
export function runRelayCheck(folder, options) {
|
|
68
35
|
return runRelayLifecycle(folder, options);
|
|
69
36
|
}
|
|
@@ -73,23 +40,53 @@ export function runReservedRelayCheck(folder, options) {
|
|
|
73
40
|
}
|
|
74
41
|
async function runRelayLifecycle(folder, options, existing) {
|
|
75
42
|
const { config, stdout, stderr } = options;
|
|
43
|
+
const retainUnsaved = config.CF_LOCAL_RESULTS_RETRY !== "0";
|
|
76
44
|
let settings = options.settings;
|
|
77
45
|
const summaryOptions = { audienceEnabled: false };
|
|
78
46
|
let client, reservationId, projectId;
|
|
79
47
|
let connection = existing?.connection;
|
|
80
|
-
let identity;
|
|
81
48
|
let completedPayload;
|
|
82
49
|
let displayFindings;
|
|
83
|
-
let completionOrigin;
|
|
50
|
+
let completionOrigin, completionRecordPath;
|
|
51
|
+
let completionTiming = {};
|
|
84
52
|
let completed = false, uploaded = false, heartbeat;
|
|
53
|
+
let selection, membership;
|
|
54
|
+
let selectedPaths, completionIdentity;
|
|
55
|
+
let outputDir = options.outputDir;
|
|
85
56
|
let renewal;
|
|
57
|
+
let releaseCompletion;
|
|
58
|
+
let localErrorMessage;
|
|
59
|
+
let completionStored = false, lostCompletionLock = false;
|
|
60
|
+
let uploadOutcome;
|
|
61
|
+
let savedResult;
|
|
86
62
|
const controller = new AbortController();
|
|
87
63
|
const signal = options.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal;
|
|
64
|
+
// A completed upload must survive the analysis lease closing during local report writes.
|
|
65
|
+
const uploadSignal = retainUnsaved ? options.signal ?? new AbortController().signal : signal;
|
|
66
|
+
const persist = async (payload, origin, uploaded = false, finished = false) => {
|
|
67
|
+
if (lostCompletionLock)
|
|
68
|
+
throw new OutputPathError();
|
|
69
|
+
const path = await saveCompletion(folder, payload, origin, { uploaded, finished, completedAt: completionTiming.completedAt,
|
|
70
|
+
absoluteExpiresAt: completionTiming.absoluteExpiresAt, retainUnsaved, identity: completionIdentity });
|
|
71
|
+
completionStored = true;
|
|
72
|
+
completionRecordPath = path;
|
|
73
|
+
return path;
|
|
74
|
+
};
|
|
75
|
+
const submit = (payload, alreadySaved = false) => {
|
|
76
|
+
if (retainUnsaved) {
|
|
77
|
+
try {
|
|
78
|
+
options.onSaving?.();
|
|
79
|
+
stderr("Saving the results…\n");
|
|
80
|
+
}
|
|
81
|
+
catch { /* Progress output must not prevent saving completed work. */ }
|
|
82
|
+
}
|
|
83
|
+
return submitResults(client, payload, uploadSignal, { retry: retainUnsaved, alreadySaved, timing: completionTiming }).then(result => { savedResult = result; return result; });
|
|
84
|
+
};
|
|
88
85
|
const showSaved = (result) => {
|
|
89
86
|
const url = savedAnalysisUrl(result.projectId, config.CF_CONSOLE_URL);
|
|
90
87
|
const findings = savedFindings({ findingIds: result.findingIds ?? [], findings: result.findings }, displayFindings ?? completedPayload?.findings ?? []);
|
|
91
88
|
options.onSaved?.({ ...result, findings, url, audienceEnabled: summaryOptions.audienceEnabled });
|
|
92
|
-
stdout(`Saved analysis ${result.analysisId}: ${url}\n`);
|
|
89
|
+
stdout(result.analysisId ? `Saved analysis ${result.analysisId}: ${url}\n` : `Full results: ${url}\n`);
|
|
93
90
|
};
|
|
94
91
|
try {
|
|
95
92
|
if (!config.CF_CONSOLE_URL || !existing && (!config.CF_RELAY_URL || !config.CF_CONSOLE_TOKEN)) {
|
|
@@ -101,39 +98,54 @@ async function runRelayLifecycle(folder, options, existing) {
|
|
|
101
98
|
throw new ConsoleError("check_conflict");
|
|
102
99
|
reservationId = existing.reservation.reservationId;
|
|
103
100
|
projectId = existing.reservation.projectId;
|
|
104
|
-
identity = existing.reservation;
|
|
105
101
|
client = existing.client;
|
|
106
102
|
}
|
|
107
103
|
else {
|
|
108
104
|
relayOrigin(config.CF_RELAY_URL);
|
|
109
105
|
client = new ConsoleClient({ url: config.CF_CONSOLE_URL, token: config.CF_CONSOLE_TOKEN });
|
|
110
106
|
}
|
|
111
|
-
// Signature verification precedes all
|
|
107
|
+
// Signature verification precedes all file content reads, status calls and spending.
|
|
112
108
|
const validated = validateClientRelease(options.release, settings);
|
|
113
109
|
const { bundle } = validated;
|
|
114
110
|
settings = validated.settings;
|
|
115
111
|
summaryOptions.audienceEnabled = readFindingSettings(settings).audienceEnabled;
|
|
116
|
-
|
|
112
|
+
selection = await resolveRunSelection(folder, options.cwd ?? process.cwd(), settings, options);
|
|
113
|
+
folder = selection?.outputAnchor ?? await resolveFolderPath(folder);
|
|
114
|
+
outputDir ??= selection?.outputDir;
|
|
115
|
+
if (selection?.kind === "file")
|
|
116
|
+
completionIdentity = {
|
|
117
|
+
consoleOrigin: new URL(config.CF_CONSOLE_URL).origin, selectionKey: selection.selectionKey,
|
|
118
|
+
};
|
|
117
119
|
signal.throwIfAborted();
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
if (retainUnsaved)
|
|
121
|
+
releaseCompletion = await lockCompletion(folder, () => { lostCompletionLock = true; controller.abort(); }, completionIdentity);
|
|
122
|
+
const recovery = await priorCompletion(folder, { retryEnabled: retainUnsaved,
|
|
123
|
+
consoleOrigin: new URL(config.CF_CONSOLE_URL).origin, projectId: existing?.expectedProjectId, retryUpload: options.retryUpload,
|
|
124
|
+
identity: completionIdentity });
|
|
125
|
+
for (const notice of recovery.notices) {
|
|
126
|
+
try {
|
|
127
|
+
options.onNotice?.(notice);
|
|
128
|
+
stderr(`${notice}\n`);
|
|
129
|
+
}
|
|
130
|
+
catch { /* Notices must not prevent saving the current setup's results. */ }
|
|
131
|
+
}
|
|
132
|
+
if (recovery.prior) {
|
|
133
|
+
const { record: prior, path } = recovery.prior;
|
|
120
134
|
// Recovering another check would leave the caller's paid reservation unexecuted.
|
|
121
135
|
if (existing && (prior.payload.reservationId !== reservationId || !matchesCheck(prior.payload, existing.reservation))) {
|
|
122
136
|
throw new ConsoleError("check_conflict");
|
|
123
137
|
}
|
|
138
|
+
completionStored = true;
|
|
139
|
+
completionRecordPath = path;
|
|
124
140
|
completedPayload = prior.payload;
|
|
125
141
|
completionOrigin = prior.consoleOrigin;
|
|
142
|
+
completionTiming = { completedAt: prior.completedAt, absoluteExpiresAt: prior.absoluteExpiresAt };
|
|
126
143
|
uploaded = prior.uploaded ?? false;
|
|
127
|
-
const recovery = completionRecoveryDecision({ statusSucceeded: false,
|
|
128
|
-
localMismatch: prior.consoleOrigin !== new URL(config.CF_CONSOLE_URL).origin });
|
|
129
|
-
if (recovery.message)
|
|
130
|
-
throw new Error(recovery.message);
|
|
131
144
|
reservationId = prior.payload.reservationId;
|
|
132
|
-
identity = prior.payload;
|
|
133
145
|
completed = true;
|
|
134
|
-
const saved = await submit(
|
|
146
|
+
const saved = await submit(prior.payload, retainUnsaved && uploaded);
|
|
135
147
|
uploaded = true;
|
|
136
|
-
await
|
|
148
|
+
await persist(prior.payload, prior.consoleOrigin, true);
|
|
137
149
|
showSaved(saved);
|
|
138
150
|
return 0;
|
|
139
151
|
}
|
|
@@ -141,13 +153,17 @@ async function runRelayLifecycle(folder, options, existing) {
|
|
|
141
153
|
if (options.retryUpload)
|
|
142
154
|
throw new Error("The saved completion record is missing. Keep the local report and check the output directory.");
|
|
143
155
|
try {
|
|
144
|
-
await outputPaths(folder, ["findings.json", "summary.md"],
|
|
156
|
+
await outputPaths(folder, ["findings.json", "summary.md", ...(selection?.kind === "file" ? ["selection.json"] : [])], outputDir);
|
|
145
157
|
}
|
|
146
158
|
catch (error) {
|
|
147
159
|
if (error.code !== "ENOENT")
|
|
148
160
|
throw error;
|
|
149
161
|
}
|
|
150
|
-
const
|
|
162
|
+
const prepared = await prepareRelayCheck(selection?.path ?? folder, { ...options, selection, signal, expectedCommit: existing?.expectedCommit, expectedFiles: existing?.expectedFiles }, validated);
|
|
163
|
+
const { source, provenance, plan, start, startMetadata } = prepared;
|
|
164
|
+
membership = prepared.membership;
|
|
165
|
+
const includedPaths = Object.keys(source.files);
|
|
166
|
+
selectedPaths = includedPaths;
|
|
151
167
|
let reservation = existing?.reservation;
|
|
152
168
|
if (!reservation) {
|
|
153
169
|
const started = await client.start(start, startMetadata);
|
|
@@ -155,7 +171,6 @@ async function runRelayLifecycle(folder, options, existing) {
|
|
|
155
171
|
signal.throwIfAborted();
|
|
156
172
|
const created = await client.create({ ...started, checkId: randomUUID(), plan });
|
|
157
173
|
reservationId = created.reservationId;
|
|
158
|
-
identity = created;
|
|
159
174
|
signal.throwIfAborted();
|
|
160
175
|
// Attestation contains no source and precedes reserve, so a bad relay never starts paid work.
|
|
161
176
|
connection = await (options.connect ?? (url => connectRelay(url, { trust: productionTrust })))(config.CF_RELAY_URL);
|
|
@@ -180,17 +195,27 @@ async function runRelayLifecycle(folder, options, existing) {
|
|
|
180
195
|
reservationId: activeId, reservationToken: reservation.reservationToken, instructions: { bundle, verificationKey: options.release.verificationKey }, signal,
|
|
181
196
|
});
|
|
182
197
|
options.onStart?.();
|
|
183
|
-
stderr(
|
|
198
|
+
stderr(progressLine(selection));
|
|
184
199
|
(options.setupPings ?? disabledSetupPings).ping("first_analysis_started");
|
|
185
200
|
const report = await runAnalysisToEnd(source, execution.model, settings, { execution });
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
201
|
+
completionTiming = observedCompletionTiming({ completedAt: Date.now() }, { kind: "found", status: reservation }, reservation);
|
|
202
|
+
// The 30-minute lease covers upload retries; renewal failure must not cancel completed results.
|
|
203
|
+
if (retainUnsaved)
|
|
204
|
+
clearInterval(heartbeat);
|
|
205
|
+
else
|
|
206
|
+
signal.throwIfAborted();
|
|
207
|
+
const saveReports = async () => {
|
|
208
|
+
const paths = await saveLocalResults(folder, report, source.skippedFiles, outputDir, { ...summaryOptions, settings,
|
|
209
|
+
...analysisSaveOptions(selection, options.outputDir), selection: membership, includedPaths });
|
|
210
|
+
try {
|
|
211
|
+
options.onLocalSaved?.(paths);
|
|
212
|
+
if (paths.ignoreAdded)
|
|
213
|
+
stderr(GITIGNORE_OUTPUT_NOTICE);
|
|
214
|
+
}
|
|
215
|
+
catch { /* Output failure must not change a server outcome. */ }
|
|
216
|
+
return paths;
|
|
217
|
+
};
|
|
218
|
+
let paths = retainUnsaved ? undefined : await saveReports();
|
|
194
219
|
const { payload, resultCheck, findingIndexes } = buildIntakePayload({ checkId: reservation.checkId, projectId: projectId, reservationId,
|
|
195
220
|
bundleVersion: bundle.version, relayVersion: options.release.relayVersion, counterfactLocalVersion: options.release.localVersion }, provenance, report, { settings, files: source.snapshot });
|
|
196
221
|
displayFindings = findingIndexes.map(index => {
|
|
@@ -207,54 +232,94 @@ async function runRelayLifecycle(folder, options, existing) {
|
|
|
207
232
|
catch { /* Output failure must not change a server outcome. */ }
|
|
208
233
|
}
|
|
209
234
|
completionOrigin = new URL(config.CF_CONSOLE_URL).origin;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
235
|
+
let saved;
|
|
236
|
+
if (retainUnsaved) {
|
|
237
|
+
completedPayload = payload;
|
|
238
|
+
completed = true;
|
|
239
|
+
const delivery = await deliverCompletedResults({ persist: uploaded => persist(payload, completionOrigin, uploaded),
|
|
240
|
+
submit: () => submit(payload), saveReports });
|
|
241
|
+
uploaded = delivery.uploaded;
|
|
242
|
+
saved = delivery.saved;
|
|
243
|
+
paths = delivery.paths;
|
|
244
|
+
localErrorMessage = delivery.localErrorMessage;
|
|
245
|
+
uploadOutcome = delivery.outcome;
|
|
246
|
+
completionTiming = delivery.timing ?? completionTiming;
|
|
247
|
+
if (delivery.error)
|
|
248
|
+
throw delivery.error;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
await persist(payload, completionOrigin);
|
|
252
|
+
completedPayload = payload;
|
|
253
|
+
completed = true;
|
|
254
|
+
paths ??= await saveReports();
|
|
255
|
+
saved = await submit(payload);
|
|
256
|
+
uploaded = true;
|
|
257
|
+
await persist(payload, completionOrigin, true);
|
|
258
|
+
}
|
|
216
259
|
stdout(formatSummary(report, summaryOptions));
|
|
217
260
|
stdout(`\nSaved ${paths.findingsPath}\nSaved ${paths.summaryPath}\n`);
|
|
218
261
|
showSaved(saved);
|
|
219
262
|
return 0;
|
|
220
263
|
}
|
|
221
|
-
catch (
|
|
264
|
+
catch (caught) {
|
|
265
|
+
let error = caught;
|
|
222
266
|
let status;
|
|
267
|
+
let statusLookup = { kind: "skipped" };
|
|
223
268
|
if (client && reservationId) {
|
|
224
269
|
try {
|
|
225
270
|
status = await (completed || uploaded ? client.status(reservationId) : client.fail(reservationId));
|
|
271
|
+
statusLookup = { kind: "found", status };
|
|
226
272
|
}
|
|
227
|
-
catch {
|
|
228
|
-
|
|
273
|
+
catch (statusError) {
|
|
274
|
+
statusLookup = { ...uploadFailure(statusError), kind: "failed" };
|
|
229
275
|
}
|
|
230
276
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
277
|
+
const sizeRefusal = error instanceof ConsoleError && ["plan_over_limit", "folder_too_large"].includes(error.code);
|
|
278
|
+
const closed = !reservationId || status && (status.state === "closed" || status.state === "expired");
|
|
279
|
+
if (scopeChoicesEnabled(settings) && !completedPayload && closed) {
|
|
280
|
+
const choice = error instanceof ScopeChoiceError ? error.choice
|
|
281
|
+
: sizeRefusal && membership ? selectionScopeChoices(membership, { currentPaths: selectedPaths,
|
|
282
|
+
oversizedPaths: membership.kind === "file" && selectedPaths?.length === 1 && selectedPaths[0] === membership.entry
|
|
283
|
+
? [membership.entry] : undefined }) : undefined;
|
|
284
|
+
if (choice) {
|
|
285
|
+
const refusal = scopeChoiceRefusal(choice);
|
|
286
|
+
if (refusal)
|
|
287
|
+
error = new Error(refusal);
|
|
288
|
+
else {
|
|
289
|
+
options.onScopeChoice?.(choice);
|
|
290
|
+
if (!options.onScopeChoice)
|
|
291
|
+
stderr(`${choice.text}\n`);
|
|
292
|
+
return SCOPE_CHOICE_EXIT_CODE;
|
|
235
293
|
}
|
|
236
|
-
catch { /* Committed server state still survives a local write failure. */ }
|
|
237
294
|
}
|
|
295
|
+
}
|
|
296
|
+
if (error instanceof ResultsUploadError)
|
|
297
|
+
completionTiming = error.timing ?? completionTiming;
|
|
298
|
+
const failure = await recoverRelayFailure({ error, retryEnabled: retainUnsaved, payload: completedPayload, completed,
|
|
299
|
+
timing: completionTiming, statusLookup, uploadOutcome, savedResult,
|
|
300
|
+
canWrite: !lostCompletionLock, completionStored, localErrorMessage, fileSelection: selection?.kind === "file" }, {
|
|
301
|
+
markUploaded: () => persist(completedPayload, completionOrigin, true),
|
|
302
|
+
keepPending: timing => { completionTiming = timing; return persist(completedPayload, completionOrigin); },
|
|
303
|
+
retire: () => retainUnsaved ? completionRecordPath ? discardCompletion(folder, completionRecordPath, completionIdentity) : Promise.resolve() : persist(completedPayload, completionOrigin, false, true),
|
|
304
|
+
});
|
|
305
|
+
if (failure.saved) {
|
|
238
306
|
try {
|
|
239
|
-
showSaved({ projectId: status.projectId, analysisId: status.analysisId, findingIds: status.findingIds, findings: status.findings });
|
|
307
|
+
showSaved(savedResult ?? { projectId: status.projectId, analysisId: status.analysisId, findingIds: status.findingIds, findings: status.findings });
|
|
240
308
|
}
|
|
241
309
|
catch { /* Output failure must not change a server outcome. */ }
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const recovery = completionRecoveryDecision({ statusSucceeded: status !== undefined, status, completedPayload });
|
|
245
|
-
if (completedPayload && recovery.finished) {
|
|
310
|
+
if (!failure.message)
|
|
311
|
+
return 0;
|
|
246
312
|
try {
|
|
247
|
-
|
|
313
|
+
options.onError?.({ reason: failure.reason, message: failure.message });
|
|
314
|
+
stderr(`${failure.message}\n`);
|
|
248
315
|
}
|
|
249
|
-
catch { /*
|
|
316
|
+
catch { /* Reporting failure cannot undo saved results. */ }
|
|
317
|
+
return 1;
|
|
250
318
|
}
|
|
251
|
-
const known = error instanceof AgentChoiceError || error instanceof ConsoleError || error instanceof OutputPathError || error instanceof Error && ([cliRefusals.release_unavailable, COMMITTED_FILES_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, INVALID_PROPOSAL_MESSAGE].includes(error.message)
|
|
252
|
-
|| error.message.startsWith("The saved completion") || error.message === COMPLETION_SETTINGS_MISMATCH_MESSAGE);
|
|
253
|
-
const message = known ? error.message : FINISH_FAILURE_MESSAGE;
|
|
254
319
|
try {
|
|
255
|
-
options.onError?.({ code: error instanceof ConsoleError ? error.code : undefined, message,
|
|
320
|
+
options.onError?.({ code: error instanceof ConsoleError ? error.code : undefined, reason: failure.reason, message: failure.message,
|
|
256
321
|
...(error instanceof AgentChoiceError ? { agentChoice: error.agentChoice } : {}) });
|
|
257
|
-
stderr(`${message}\n`);
|
|
322
|
+
stderr(`${failure.message}\n`);
|
|
258
323
|
if (status && !(error instanceof ConsoleError && error.code === "low_balance"))
|
|
259
324
|
stderr(stateLine(status));
|
|
260
325
|
}
|
|
@@ -264,6 +329,7 @@ async function runRelayLifecycle(folder, options, existing) {
|
|
|
264
329
|
finally {
|
|
265
330
|
clearInterval(heartbeat);
|
|
266
331
|
await renewal;
|
|
332
|
+
await releaseCompletion?.().catch(() => { });
|
|
267
333
|
try {
|
|
268
334
|
connection?.close();
|
|
269
335
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { CompletionBusyError } from "../adapters/completion-lock.js";
|
|
2
|
+
import { OutputPathError } from "../adapters/output-paths.js";
|
|
3
|
+
import { AgentChoiceError } from "../contract/agent-choice.js";
|
|
4
|
+
import { INVALID_PROPOSAL_MESSAGE } from "../contract/agent-proposal.js";
|
|
5
|
+
import { cliRefusals, COMMITTED_FILES_MESSAGE, failureReason, FINISH_FAILURE_MESSAGE, RESULTS_TOO_LARGE_MESSAGE } from "../contract/cli-refusals.js";
|
|
6
|
+
import { isSelectionRefusal } from "../contract/scope-choice.js";
|
|
7
|
+
import { ConsoleError } from "../gatekeeper/console.js";
|
|
8
|
+
import { recoverCompletedResults } from "./completion-delivery.js";
|
|
9
|
+
import { CompletionSettingsMismatchError } from "./prior-completion.js";
|
|
10
|
+
import { ResultsUploadError } from "./results-upload.js";
|
|
11
|
+
/** Cleanup reports the recovery owner's decision without exposing private diagnostics. */
|
|
12
|
+
export async function recoverRelayFailure(input, operations) {
|
|
13
|
+
const { error, retryEnabled } = input;
|
|
14
|
+
const carried = retryEnabled ? input.uploadOutcome ?? (error instanceof ResultsUploadError ? error.outcome : undefined) : undefined;
|
|
15
|
+
const recovery = input.payload && input.completed ? await recoverCompletedResults({
|
|
16
|
+
retryEnabled, payload: input.payload, timing: input.timing, statusLookup: input.statusLookup,
|
|
17
|
+
uploadOutcome: retryEnabled && input.savedResult ? { kind: "saved" } : carried ?? { kind: "cancelled" },
|
|
18
|
+
canWrite: input.canWrite, completionStored: input.completionStored, localErrorMessage: input.localErrorMessage,
|
|
19
|
+
}, operations) : undefined;
|
|
20
|
+
if (recovery?.decision.action === "saved")
|
|
21
|
+
return { saved: true, message: recovery.message, reason: "results_saved_report_failed" };
|
|
22
|
+
const known = error instanceof AgentChoiceError || error instanceof ConsoleError || error instanceof OutputPathError
|
|
23
|
+
|| isSelectionRefusal(error, input.fileSelection ?? false)
|
|
24
|
+
|| error instanceof CompletionBusyError || error instanceof CompletionSettingsMismatchError || error instanceof Error
|
|
25
|
+
&& ([cliRefusals.release_unavailable, COMMITTED_FILES_MESSAGE, RESULTS_TOO_LARGE_MESSAGE, INVALID_PROPOSAL_MESSAGE].includes(error.message)
|
|
26
|
+
|| error.message.startsWith("The saved completion"));
|
|
27
|
+
const message = recovery?.message ?? input.localErrorMessage ?? (known ? error.message : FINISH_FAILURE_MESSAGE);
|
|
28
|
+
const reason = !retryEnabled ? failureReason(error)
|
|
29
|
+
: recovery?.decision.action === "retry" && input.completionStored ? "results_pending"
|
|
30
|
+
: recovery?.decision.action === "give-up" ? "results_discarded" : failureReason(error);
|
|
31
|
+
return { saved: false, message, reason };
|
|
32
|
+
}
|
|
@@ -6,9 +6,11 @@ import { readLocalFolder } from "../adapters/files.js";
|
|
|
6
6
|
import { snapshotProvenance, sameFileHashes } from "../adapters/snapshot.js";
|
|
7
7
|
import { validateClientRelease } from "../adapters/release.js";
|
|
8
8
|
import { prepareRelayPlan } from "../adapters/relay-plan.js";
|
|
9
|
+
import { readAnalysisSource } from "../adapters/selection.js";
|
|
9
10
|
import { AgentChoiceError } from "../contract/agent-choice.js";
|
|
10
11
|
import { COMMITTED_FILES_MESSAGE } from "../contract/cli-refusals.js";
|
|
11
12
|
import { INVALID_PROPOSAL_MESSAGE } from "../contract/agent-proposal.js";
|
|
13
|
+
import { selectionStartIdentity } from "../contract/selection-metadata.js";
|
|
12
14
|
import { repositoryFacts } from "./git.js";
|
|
13
15
|
/** Absolute checkout paths stay local; repository facts own the start's commit. */
|
|
14
16
|
export function buildRelayStartPayload(facts, files) {
|
|
@@ -20,14 +22,22 @@ export function committedFilesOnly(settings) {
|
|
|
20
22
|
}
|
|
21
23
|
/** Recovery can reuse signature validation without reading the folder again. */
|
|
22
24
|
export async function prepareRelayCheck(folder, options, validated = validateClientRelease(options.release, options.settings)) {
|
|
23
|
-
const { bundle, tools
|
|
25
|
+
const { bundle, tools } = validated;
|
|
24
26
|
options.signal?.throwIfAborted();
|
|
25
27
|
// Discovery must agree with Git before publishing repository-relative captures.
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
const selection = options.selection;
|
|
29
|
+
const factsFolder = selection?.factsFolder ?? folder;
|
|
30
|
+
let earlyFacts;
|
|
31
|
+
const { source: read, membership } = await readAnalysisSource(options.settings, folder, {
|
|
32
|
+
selection, provider: options.selectionProvider, proposal: options.proposal, admission: options.trialAdmission, signal: options.signal,
|
|
33
|
+
readFolder: async () => {
|
|
34
|
+
if (outsideLoadersEnabled(options.settings))
|
|
35
|
+
earlyFacts = await (options.facts ?? repositoryFacts)(factsFolder);
|
|
36
|
+
options.signal?.throwIfAborted();
|
|
37
|
+
return readLocalFolder(options.settings, folder, options.trialAdmission, { captureSnapshot: true, proposal: options.proposal,
|
|
38
|
+
...(earlyFacts ? { expectedRepositoryRoot: earlyFacts.repoRoot ?? null } : {}) });
|
|
39
|
+
},
|
|
40
|
+
});
|
|
31
41
|
if (read.proposalCounts?.fromProposal === 0 && read.proposalCounts.rejected > 0)
|
|
32
42
|
throw new Error(INVALID_PROPOSAL_MESSAGE);
|
|
33
43
|
if (read.agentChoice) {
|
|
@@ -40,17 +50,20 @@ export async function prepareRelayCheck(folder, options, validated = validateCli
|
|
|
40
50
|
});
|
|
41
51
|
}
|
|
42
52
|
const source = { ...read, files: Object.freeze({ ...read.files }) };
|
|
43
|
-
const provenance = await snapshotProvenance(source.sourceBase ?? folder, source.snapshot, { selectionFolder:
|
|
53
|
+
const provenance = await snapshotProvenance(selection?.kind === "file" ? selection.launchRoot : source.sourceBase ?? folder, source.snapshot, { selectionFolder: factsFolder, committedOnly: committedFilesOnly(options.settings) });
|
|
44
54
|
if (options.expectedCommit === null) {
|
|
45
55
|
if (!sameFileHashes(options.expectedFiles ?? [], source.snapshot))
|
|
46
56
|
throw new Error(COMMITTED_FILES_MESSAGE);
|
|
47
57
|
}
|
|
48
58
|
else if (options.expectedCommit !== undefined && provenance.commit !== options.expectedCommit)
|
|
49
59
|
throw new Error(COMMITTED_FILES_MESSAGE);
|
|
50
|
-
const { plan, files } = prepareRelayPlan({ bundle, tools, settings, relayVersion: options.release.relayVersion, source });
|
|
60
|
+
const { plan, files } = prepareRelayPlan({ bundle, tools, settings: validated.settings, relayVersion: options.release.relayVersion, source });
|
|
51
61
|
options.signal?.throwIfAborted();
|
|
52
|
-
const facts = earlyFacts ?? await (options.facts ?? repositoryFacts)(
|
|
62
|
+
const facts = earlyFacts ?? await (options.facts ?? repositoryFacts)(factsFolder);
|
|
53
63
|
options.signal?.throwIfAborted();
|
|
54
|
-
const startMetadata = { analysisSize: source.analysisSize, startAttempt: randomUUID()
|
|
55
|
-
|
|
64
|
+
const startMetadata = { analysisSize: source.analysisSize, startAttempt: randomUUID(),
|
|
65
|
+
...(selection?.kind === "file" && membership
|
|
66
|
+
? { selection: selectionStartIdentity(selection.selectionKey, membership.snapshot) } : {}) };
|
|
67
|
+
return { source, provenance, plan, start: buildRelayStartPayload(source.sourceBase && selection?.kind !== "file" ? { ...facts, relPath: "" } : facts, files), startMetadata,
|
|
68
|
+
...(membership ? { membership } : {}) };
|
|
56
69
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { ConsoleError } from "../gatekeeper/console.js";
|
|
3
|
+
import { completionRecoveryDecision, completionRetryDeadline, observedCompletionTiming, matchesCheck } from "./completion-recovery.js";
|
|
4
|
+
export function requireIntakeReservation(status) {
|
|
5
|
+
if (status.state === "reserved")
|
|
6
|
+
return;
|
|
7
|
+
if (status.state === "closing")
|
|
8
|
+
throw new ConsoleError("reservation_closing");
|
|
9
|
+
if (status.state === "closed")
|
|
10
|
+
throw new ConsoleError("reservation_closed");
|
|
11
|
+
throw new ConsoleError("reservation_expired", "intake");
|
|
12
|
+
}
|
|
13
|
+
/** Carry the owner's answer so cleanup cannot turn a definite refusal into an endless retry. */
|
|
14
|
+
export class ResultsUploadError extends ConsoleError {
|
|
15
|
+
decision;
|
|
16
|
+
timing;
|
|
17
|
+
outcome;
|
|
18
|
+
constructor(error, decision, timing, httpStatus = error instanceof ConsoleError ? error.httpStatus : undefined) {
|
|
19
|
+
super(error instanceof ConsoleError ? error.code : "unavailable", "intake", error instanceof ConsoleError ? { localReason: error.localReason, responseCode: error.responseCode, httpStatus: error.httpStatus } : {});
|
|
20
|
+
this.decision = decision;
|
|
21
|
+
this.timing = timing;
|
|
22
|
+
this.outcome = { kind: "decided", decision, ...(httpStatus === undefined ? {} : { httpStatus }) };
|
|
23
|
+
if (error instanceof Error)
|
|
24
|
+
this.name = error.name;
|
|
25
|
+
if (error instanceof ConsoleError)
|
|
26
|
+
this.message = error.message;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export const uploadFailure = (error) => {
|
|
30
|
+
if (!(error instanceof ConsoleError))
|
|
31
|
+
return { kind: "refused", code: "unavailable", transportFailure: true };
|
|
32
|
+
return { kind: "refused", code: error.responseCode ?? error.code, httpStatus: error.httpStatus };
|
|
33
|
+
};
|
|
34
|
+
const committed = (value) => ({ checkId: value.checkId, projectId: value.projectId,
|
|
35
|
+
analysisId: value.analysisId, findingIds: value.findingIds,
|
|
36
|
+
...(value.findings ? { findings: value.findings } : {}), settlement: value.settlement });
|
|
37
|
+
/** Status confirms committed identity; intake owns whether a completed payload can still be saved. */
|
|
38
|
+
export async function submitResults(client, payload, signal, options) {
|
|
39
|
+
const now = options.now ?? Date.now;
|
|
40
|
+
const wait = options.wait ?? ((ms, signal) => sleep(ms, undefined, { signal }));
|
|
41
|
+
let timing = { ...options.timing, completedAt: options.timing?.completedAt ?? now(), retryStartedAt: now() };
|
|
42
|
+
let lookup = { kind: "skipped" };
|
|
43
|
+
let outcome = { kind: options.retry && options.alreadySaved ? "saved" : "pending" };
|
|
44
|
+
const uploadHttpStatus = () => "httpStatus" in outcome ? outcome.httpStatus : undefined;
|
|
45
|
+
const failure = (error, decision) => new ResultsUploadError(error, decision, timing, uploadHttpStatus());
|
|
46
|
+
const deadline = () => completionRetryDeadline(timing, lookup, payload, outcome);
|
|
47
|
+
const receipt = (value) => value && matchesCheck(value, payload) && value.uploadState === "saved"
|
|
48
|
+
? committed(value) : { checkId: payload.checkId, projectId: payload.projectId };
|
|
49
|
+
const decide = () => completionRecoveryDecision({ retryEnabled: options.retry, payload, statusLookup: lookup, uploadOutcome: outcome, timing, now: now() });
|
|
50
|
+
const status = async () => {
|
|
51
|
+
try {
|
|
52
|
+
const value = await client.status(payload.reservationId);
|
|
53
|
+
lookup = { kind: "found", status: value };
|
|
54
|
+
timing = observedCompletionTiming(timing, lookup, payload);
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
lookup = { ...uploadFailure(error), kind: "failed" };
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
63
|
+
signal.throwIfAborted();
|
|
64
|
+
const before = await status().catch(error => {
|
|
65
|
+
const decision = decide();
|
|
66
|
+
if (options.retry && decision.action === "saved")
|
|
67
|
+
return undefined;
|
|
68
|
+
if (!options.retry)
|
|
69
|
+
throw error;
|
|
70
|
+
return undefined;
|
|
71
|
+
});
|
|
72
|
+
const initial = decide();
|
|
73
|
+
if (initial.action === "saved")
|
|
74
|
+
return receipt(before);
|
|
75
|
+
if (options.retry && initial.action === "give-up")
|
|
76
|
+
throw failure(new ConsoleError(initial.code === "check_conflict" ? "check_conflict" : initial.code === "reservation_expired" ? "reservation_expired" : "reservation_closed", "intake"), initial);
|
|
77
|
+
if (before && !options.retry) {
|
|
78
|
+
if (!matchesCheck(before, payload))
|
|
79
|
+
throw new ConsoleError("check_conflict");
|
|
80
|
+
requireIntakeReservation(before);
|
|
81
|
+
}
|
|
82
|
+
if (options.retry && now() >= deadline())
|
|
83
|
+
throw failure(new ConsoleError(), initial);
|
|
84
|
+
let delay = 1000;
|
|
85
|
+
for (;;) {
|
|
86
|
+
signal.throwIfAborted();
|
|
87
|
+
try {
|
|
88
|
+
const saved = await client.intake(payload, { acceptAnySuccess: options.retry });
|
|
89
|
+
outcome = { kind: "saved" };
|
|
90
|
+
decide();
|
|
91
|
+
return saved;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
outcome = uploadFailure(error);
|
|
95
|
+
// A lost response may have committed; confirm it before retrying the same payload.
|
|
96
|
+
const after = await status().catch(() => undefined);
|
|
97
|
+
const decision = decide();
|
|
98
|
+
if (decision.action === "saved")
|
|
99
|
+
return receipt(after);
|
|
100
|
+
if (!options.retry) {
|
|
101
|
+
if (after && !matchesCheck(after, payload))
|
|
102
|
+
throw new ConsoleError("check_conflict");
|
|
103
|
+
if (error instanceof ConsoleError && error.code === "unavailable" && after) {
|
|
104
|
+
requireIntakeReservation(after);
|
|
105
|
+
signal.throwIfAborted();
|
|
106
|
+
return await client.intake(payload, { acceptAnySuccess: options.retry });
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
const remaining = deadline() - now();
|
|
111
|
+
if (decision.action !== "retry" || !decision.automatic || remaining <= 0)
|
|
112
|
+
throw failure(error, decision);
|
|
113
|
+
await wait(Math.min(delay, remaining), signal);
|
|
114
|
+
signal.throwIfAborted();
|
|
115
|
+
if (now() >= deadline())
|
|
116
|
+
throw failure(error, decide());
|
|
117
|
+
delay = Math.min(delay * 2, 30_000);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (error instanceof ResultsUploadError)
|
|
123
|
+
throw error;
|
|
124
|
+
if (signal.aborted && outcome.kind !== "saved")
|
|
125
|
+
outcome = { kind: "cancelled", httpStatus: uploadHttpStatus() };
|
|
126
|
+
const decision = decide();
|
|
127
|
+
if (decision.action === "saved")
|
|
128
|
+
return receipt();
|
|
129
|
+
throw failure(error, decision);
|
|
130
|
+
}
|
|
131
|
+
}
|