@evomap/evolver-core 2.0.0-beta.5 → 2.0.0-beta.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/dist/algo/conversationSniffer.js +6 -2
- package/dist/events/ingest.js +7 -0
- package/dist/events/paths.d.ts +9 -9
- package/dist/events/paths.js +18 -18
- package/dist/exec/claudeBridge.d.ts +15 -2
- package/dist/exec/claudeBridge.js +322 -36
- package/dist/exec/openPrRegistry.d.ts +8 -2
- package/dist/exec/openPrRegistry.js +32 -22
- package/dist/exec/runnerRegistry.d.ts +26 -0
- package/dist/exec/runnerRegistry.js +305 -47
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/issueReporter/index.d.ts +156 -0
- package/dist/issueReporter/index.js +1688 -0
- package/dist/personality/schema.d.ts +12 -12
- package/dist/util/fetchPort.d.ts +1 -0
- package/dist/util/fetchPort.js +11 -0
- package/dist/util/fileLock.d.ts +5 -3
- package/dist/util/fileLock.js +131 -50
- package/dist/util/index.d.ts +1 -0
- package/dist/util/index.js +1 -0
- package/dist/workflow/dsl.d.ts +24 -3
- package/dist/workflow/dsl.js +4 -0
- package/dist/workflow/engine.d.ts +5 -1
- package/dist/workflow/engine.js +3 -0
- package/dist/workflow/index.d.ts +3 -1
- package/dist/workflow/index.js +3 -1
- package/dist/workflow/runtime.d.ts +110 -0
- package/dist/workflow/runtime.js +1298 -0
- package/dist/workflow/stateStore.d.ts +172 -0
- package/dist/workflow/stateStore.js +1044 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1688 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { ensureAssetStoreDirectory, readRegularBuffer, replaceUtf8Durable, } from '../assetstore/assetStoreStorage.js';
|
|
6
|
+
import { captureEnvFingerprint } from '../bootstrap/envFingerprint.js';
|
|
7
|
+
import { fullLeakCheck, redactString } from '../hub/sanitize.js';
|
|
8
|
+
import { acquireLock, LockReleaseError, releaseLock } from '../util/fileLock.js';
|
|
9
|
+
const STATE_VERSION = 1;
|
|
10
|
+
const DEFAULT_DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const DEFAULT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
|
|
12
|
+
const DEFAULT_MAX_SUBMISSIONS = 2;
|
|
13
|
+
const PENDING_RESOLUTION_GRACE_MS = 5 * 60 * 1000;
|
|
14
|
+
const MAX_IDS = 5;
|
|
15
|
+
const MAX_REMOTE_RECONCILE_PAGES = 10;
|
|
16
|
+
const REMOTE_RECONCILE_PAGE_SIZE = 100;
|
|
17
|
+
const MAX_REMOTE_ISSUE_BODY_CHARS = 1_000_000;
|
|
18
|
+
const MAX_UNTRUSTED_FIELD_CHARS = 512;
|
|
19
|
+
const MAX_SUBMISSION_GUARD_BYTES = 4 * 1024;
|
|
20
|
+
const MAX_SUBMISSION_QUOTA_INDEX_BYTES = 256 * 1024;
|
|
21
|
+
const MAX_SUBMISSION_QUOTA_ENTRIES = 1_024;
|
|
22
|
+
const OPAQUE_REF_RE = /^(cycle|event|trace):[a-f0-9]{16}$/;
|
|
23
|
+
const INTERNAL_FINGERPRINT_RE = /^[a-f0-9]{20}$/;
|
|
24
|
+
const ATTEMPT_ID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
25
|
+
const SAFE_CLASS_RE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
26
|
+
const SAFE_CODE_RE = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
27
|
+
const SAFE_STEP_KEYS = new Set(['retry_cycle', 'run_doctor', 'inspect_cycle', 'inspect_event', 'review_local_draft']);
|
|
28
|
+
const ISSUE_REPORT_SOURCES = new Set(['cycle_failure', 'doctor', 'review', 'event']);
|
|
29
|
+
const CYCLE_FAILURE_CLASSES = new Set([
|
|
30
|
+
'host_no_transcript', 'host_provider_error', 'local_gene_no_blast', 'unclassified',
|
|
31
|
+
]);
|
|
32
|
+
const ERROR_CLASSES = {
|
|
33
|
+
cycle_failure: new Set(['cycle_failed', 'provider_timeout', 'schema_validation', ...CYCLE_FAILURE_CLASSES]),
|
|
34
|
+
doctor: new Set(['doctor_failed', 'doctor_warning']),
|
|
35
|
+
review: new Set(['review_rejected']),
|
|
36
|
+
event: new Set(['cycle_aborted', 'observer_quarantined', 'observer_dead_letter']),
|
|
37
|
+
};
|
|
38
|
+
const DIAGNOSTIC_CODES = {
|
|
39
|
+
cycle_failure: new Set(['cycle_failed', 'provider_timeout', 'schema_validation', ...CYCLE_FAILURE_CLASSES]),
|
|
40
|
+
doctor: new Set([
|
|
41
|
+
'env-file', 'config-no-secrets', 'no-proxy-loopback', 'proxy-loopback', 'memory-graph', 'phub-mode', 'phub-url',
|
|
42
|
+
'phub-token', 'phub-subject', 'phub-adapter', 'phub-proxy', 'phub-reuse', 'phub-live-smoke',
|
|
43
|
+
]),
|
|
44
|
+
review: new Set(['review_rejected']),
|
|
45
|
+
event: new Set(['cycle_aborted', 'observer_quarantined', 'observer_dead_letter']),
|
|
46
|
+
};
|
|
47
|
+
export function isIssueReportSource(value) {
|
|
48
|
+
return typeof value === 'string' && ISSUE_REPORT_SOURCES.has(value);
|
|
49
|
+
}
|
|
50
|
+
export class GithubIssueTransportError extends Error {
|
|
51
|
+
outcome;
|
|
52
|
+
constructor(outcome) {
|
|
53
|
+
super(`github_issue_transport_${outcome}`);
|
|
54
|
+
this.name = 'GithubIssueTransportError';
|
|
55
|
+
this.outcome = outcome;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export class IssueDraftConflictError extends Error {
|
|
59
|
+
errorClass;
|
|
60
|
+
constructor(errorClass) {
|
|
61
|
+
super(errorClass);
|
|
62
|
+
this.name = 'IssueDraftConflictError';
|
|
63
|
+
this.errorClass = errorClass;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function safeToken(value, fallback) {
|
|
67
|
+
if (value && value.length > MAX_UNTRUSTED_FIELD_CHARS)
|
|
68
|
+
return fallback;
|
|
69
|
+
if (!value || redactString(value) !== value || fullLeakCheck(value, {}).found)
|
|
70
|
+
return fallback;
|
|
71
|
+
const normalized = value?.trim().toLowerCase();
|
|
72
|
+
return normalized && SAFE_CLASS_RE.test(normalized) ? normalized : fallback;
|
|
73
|
+
}
|
|
74
|
+
function safeCode(value, fallback) {
|
|
75
|
+
if (value && value.length > MAX_UNTRUSTED_FIELD_CHARS)
|
|
76
|
+
return fallback;
|
|
77
|
+
if (!value || redactString(value) !== value || fullLeakCheck(value, {}).found)
|
|
78
|
+
return fallback;
|
|
79
|
+
const normalized = value.trim().toLowerCase();
|
|
80
|
+
return SAFE_CODE_RE.test(normalized) ? normalized : fallback;
|
|
81
|
+
}
|
|
82
|
+
function safeMetadata(value, fallback) {
|
|
83
|
+
if (value && value.length > MAX_UNTRUSTED_FIELD_CHARS)
|
|
84
|
+
return fallback;
|
|
85
|
+
const normalized = value?.trim();
|
|
86
|
+
return normalized
|
|
87
|
+
&& /^[A-Za-z0-9][A-Za-z0-9_.+-]{0,63}$/.test(normalized)
|
|
88
|
+
&& redactString(normalized) === normalized
|
|
89
|
+
&& !fullLeakCheck(normalized, {}).found
|
|
90
|
+
? normalized
|
|
91
|
+
: fallback;
|
|
92
|
+
}
|
|
93
|
+
function opaqueReference(domain, value) {
|
|
94
|
+
if (value.length === 0 || value.length > MAX_UNTRUSTED_FIELD_CHARS)
|
|
95
|
+
return null;
|
|
96
|
+
const digest = createHash('sha256')
|
|
97
|
+
.update(`evolver-issue-reference:v1:${domain}\0`, 'utf8')
|
|
98
|
+
.update(value, 'utf8')
|
|
99
|
+
.digest('hex')
|
|
100
|
+
.slice(0, 16);
|
|
101
|
+
return `${domain}:${digest}`;
|
|
102
|
+
}
|
|
103
|
+
function safeReferenceIds(domain, values) {
|
|
104
|
+
return [...new Set((values ?? [])
|
|
105
|
+
.filter((value) => typeof value === 'string')
|
|
106
|
+
.map((value) => opaqueReference(domain, value))
|
|
107
|
+
.filter((value) => value !== null))].slice(0, MAX_IDS);
|
|
108
|
+
}
|
|
109
|
+
function safeSteps(values) {
|
|
110
|
+
return [...new Set((values ?? []).filter((value) => SAFE_STEP_KEYS.has(value)))].slice(0, MAX_IDS);
|
|
111
|
+
}
|
|
112
|
+
function allowlistedToken(value, allowed, fallback) {
|
|
113
|
+
const token = safeToken(value, '');
|
|
114
|
+
return token && allowed.has(token) ? token : fallback;
|
|
115
|
+
}
|
|
116
|
+
function isAllowlistedErrorClass(source, value) {
|
|
117
|
+
return isIssueReportSource(source)
|
|
118
|
+
&& typeof value === 'string'
|
|
119
|
+
&& (value === 'unknown' || ERROR_CLASSES[source].has(value));
|
|
120
|
+
}
|
|
121
|
+
function safeDiagnosticCodes(source, values) {
|
|
122
|
+
const allowed = DIAGNOSTIC_CODES[source];
|
|
123
|
+
return [...new Set((values ?? [])
|
|
124
|
+
.map((value) => safeCode(value, ''))
|
|
125
|
+
.filter((value) => allowed.has(value)))].sort().slice(0, MAX_IDS);
|
|
126
|
+
}
|
|
127
|
+
function stableFingerprint(input) {
|
|
128
|
+
return createHash('sha256').update(JSON.stringify(input)).digest('hex').slice(0, 20);
|
|
129
|
+
}
|
|
130
|
+
function canonicalPath(path) {
|
|
131
|
+
const absolute = resolve(path);
|
|
132
|
+
try {
|
|
133
|
+
return realpathSync.native(absolute);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return absolute;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function canonicalWorkspaceScope(options) {
|
|
140
|
+
const configured = options.env?.['EVOLVER_REPO_ROOT']?.trim();
|
|
141
|
+
if (configured)
|
|
142
|
+
return canonicalPath(configured);
|
|
143
|
+
const start = canonicalPath(options.workspaceScope ?? process.cwd());
|
|
144
|
+
try {
|
|
145
|
+
const root = execFileSync('git', ['-C', start, 'rev-parse', '--show-toplevel'], {
|
|
146
|
+
encoding: 'utf8',
|
|
147
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
148
|
+
}).trim();
|
|
149
|
+
if (root)
|
|
150
|
+
return canonicalPath(root);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Non-Git callers are scoped to the canonical current directory.
|
|
154
|
+
}
|
|
155
|
+
return start;
|
|
156
|
+
}
|
|
157
|
+
function fingerprintMarker(fingerprint) {
|
|
158
|
+
return `<!-- evolver-issue-fingerprint:${fingerprint} -->`;
|
|
159
|
+
}
|
|
160
|
+
function issueReporterStorageError(cause) {
|
|
161
|
+
const error = new Error('issue_report_storage_error');
|
|
162
|
+
error.cause = cause;
|
|
163
|
+
return error;
|
|
164
|
+
}
|
|
165
|
+
function atomicWriteJson(rootDir, path, value) {
|
|
166
|
+
try {
|
|
167
|
+
ensureAssetStoreDirectory(rootDir);
|
|
168
|
+
ensureAssetStoreDirectory(dirname(path));
|
|
169
|
+
replaceUtf8Durable(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
throw issueReporterStorageError(error);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function issueDraftFromDisk(rootDir, expectedFingerprint) {
|
|
176
|
+
let value;
|
|
177
|
+
try {
|
|
178
|
+
ensureAssetStoreDirectory(rootDir);
|
|
179
|
+
ensureAssetStoreDirectory(join(rootDir, 'drafts'));
|
|
180
|
+
const encoded = readRegularBuffer(draftPath(rootDir, expectedFingerprint));
|
|
181
|
+
if (encoded === null)
|
|
182
|
+
return null;
|
|
183
|
+
value = JSON.parse(encoded.toString('utf8'));
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
throw new TypeError('invalid_issue_draft');
|
|
187
|
+
}
|
|
188
|
+
const draft = canonicalIssueDraft(value, expectedFingerprint);
|
|
189
|
+
if (draft)
|
|
190
|
+
return draft;
|
|
191
|
+
throw new TypeError('invalid_issue_draft');
|
|
192
|
+
}
|
|
193
|
+
function safeEnvironment(options) {
|
|
194
|
+
const captured = captureEnvFingerprint({ env: options.env ?? {} });
|
|
195
|
+
const supplied = options.envFingerprint ?? {};
|
|
196
|
+
return {
|
|
197
|
+
node_version: safeMetadata(supplied.node_version ?? captured.node_version, 'unknown'),
|
|
198
|
+
platform: safeMetadata(supplied.platform ?? captured.platform, 'unknown'),
|
|
199
|
+
arch: safeMetadata(supplied.arch ?? captured.arch, 'unknown'),
|
|
200
|
+
container: supplied.container ?? captured.container,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function renderDraft(input, options, fingerprint, createdAt) {
|
|
204
|
+
const errorClass = allowlistedToken(input.errorClass, ERROR_CLASSES[input.source], 'unknown');
|
|
205
|
+
const failureClass = input.source === 'cycle_failure'
|
|
206
|
+
? allowlistedToken(input.failureClass, CYCLE_FAILURE_CLASSES, 'unclassified')
|
|
207
|
+
: 'unclassified';
|
|
208
|
+
const cycleIds = safeReferenceIds('cycle', input.cycleIds);
|
|
209
|
+
const eventIds = safeReferenceIds('event', input.eventIds);
|
|
210
|
+
const traceRefs = safeReferenceIds('trace', input.traceIds);
|
|
211
|
+
const steps = safeSteps(input.reproductionSteps);
|
|
212
|
+
const diagnosticCodes = safeDiagnosticCodes(input.source, input.diagnosticCodes);
|
|
213
|
+
const environment = safeEnvironment(options);
|
|
214
|
+
const version = safeMetadata(input.version, 'unknown');
|
|
215
|
+
const title = `[${input.source}] ${errorClass}`;
|
|
216
|
+
const body = [
|
|
217
|
+
'## Summary',
|
|
218
|
+
`- Source: \`${input.source}\``,
|
|
219
|
+
`- Sanitized error class: \`${errorClass}\``,
|
|
220
|
+
`- Failure class: \`${failureClass}\``,
|
|
221
|
+
`- Fingerprint: \`${fingerprint}\``,
|
|
222
|
+
'',
|
|
223
|
+
'## Environment',
|
|
224
|
+
`- Evolver version: \`${version}\``,
|
|
225
|
+
`- Node.js: \`${environment.node_version}\``,
|
|
226
|
+
`- Platform: \`${environment.platform}\` / \`${environment.arch}\``,
|
|
227
|
+
`- Container: \`${environment.container ? 'yes' : 'no'}\``,
|
|
228
|
+
'',
|
|
229
|
+
'## Reproduction',
|
|
230
|
+
...(steps.length > 0 ? steps.map((step, index) => `${index + 1}. ${step}`) : ['1. review_local_draft']),
|
|
231
|
+
'',
|
|
232
|
+
'## Correlation',
|
|
233
|
+
`- Cycle refs: ${cycleIds.length > 0 ? cycleIds.map((id) => `\`${id}\``).join(', ') : 'none'}`,
|
|
234
|
+
`- Event refs: ${eventIds.length > 0 ? eventIds.map((id) => `\`${id}\``).join(', ') : 'none'}`,
|
|
235
|
+
`- Trace refs: ${traceRefs.length > 0 ? traceRefs.map((id) => `\`${id}\``).join(', ') : 'none'}`,
|
|
236
|
+
'',
|
|
237
|
+
'## Limited diagnostics',
|
|
238
|
+
diagnosticCodes.length > 0 ? diagnosticCodes.map((code) => `- \`${code}\``).join('\n') : '- none',
|
|
239
|
+
'',
|
|
240
|
+
fingerprintMarker(fingerprint),
|
|
241
|
+
'',
|
|
242
|
+
'> This draft intentionally excludes transcripts, prompts, raw errors, filesystem paths, environment values, credentials, and request headers.',
|
|
243
|
+
].join('\n');
|
|
244
|
+
return {
|
|
245
|
+
schemaVersion: STATE_VERSION,
|
|
246
|
+
fingerprint,
|
|
247
|
+
status: 'draft',
|
|
248
|
+
createdAt,
|
|
249
|
+
updatedAt: createdAt,
|
|
250
|
+
title,
|
|
251
|
+
body,
|
|
252
|
+
source: input.source,
|
|
253
|
+
errorClass,
|
|
254
|
+
cycleIds,
|
|
255
|
+
eventIds,
|
|
256
|
+
traceRefs,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function statePath(rootDir) {
|
|
260
|
+
return join(rootDir, 'state.json');
|
|
261
|
+
}
|
|
262
|
+
function submissionGuardDirectory(rootDir) {
|
|
263
|
+
return join(rootDir, 'submission-guards');
|
|
264
|
+
}
|
|
265
|
+
function submissionGuardPath(rootDir, fingerprint) {
|
|
266
|
+
if (!INTERNAL_FINGERPRINT_RE.test(fingerprint))
|
|
267
|
+
throw new TypeError('invalid_issue_draft');
|
|
268
|
+
return join(submissionGuardDirectory(rootDir), `${fingerprint}.json`);
|
|
269
|
+
}
|
|
270
|
+
function submissionQuotaDirectory(rootDir) {
|
|
271
|
+
return join(rootDir, 'submission-quota');
|
|
272
|
+
}
|
|
273
|
+
function submissionQuotaIndexPath(rootDir) {
|
|
274
|
+
return join(submissionQuotaDirectory(rootDir), 'recent.json');
|
|
275
|
+
}
|
|
276
|
+
function draftPath(rootDir, fingerprint) {
|
|
277
|
+
if (!INTERNAL_FINGERPRINT_RE.test(fingerprint))
|
|
278
|
+
throw new TypeError('invalid_issue_draft');
|
|
279
|
+
return join(rootDir, 'drafts', `${fingerprint}.json`);
|
|
280
|
+
}
|
|
281
|
+
function emptyState() {
|
|
282
|
+
return { version: STATE_VERSION, submissions: [], rejections: [], attempts: [], reservations: [] };
|
|
283
|
+
}
|
|
284
|
+
function isRecord(value) {
|
|
285
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
286
|
+
}
|
|
287
|
+
function hasOnlyKeys(value, allowed) {
|
|
288
|
+
return Object.keys(value).every((key) => allowed.includes(key));
|
|
289
|
+
}
|
|
290
|
+
function isTimestamp(value) {
|
|
291
|
+
return typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
292
|
+
}
|
|
293
|
+
function isInternalFingerprint(value) {
|
|
294
|
+
return typeof value === 'string' && INTERNAL_FINGERPRINT_RE.test(value);
|
|
295
|
+
}
|
|
296
|
+
function isSafeGitHubIssueUrl(value, issueNumber, expectedRepo) {
|
|
297
|
+
if (typeof value !== 'string'
|
|
298
|
+
|| value.length > MAX_UNTRUSTED_FIELD_CHARS
|
|
299
|
+
|| typeof issueNumber !== 'number'
|
|
300
|
+
|| !Number.isInteger(issueNumber)
|
|
301
|
+
|| issueNumber <= 0) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
let parsed;
|
|
305
|
+
try {
|
|
306
|
+
parsed = new URL(value);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
if (parsed.origin !== 'https://github.com'
|
|
312
|
+
|| parsed.username !== ''
|
|
313
|
+
|| parsed.password !== ''
|
|
314
|
+
|| parsed.search !== ''
|
|
315
|
+
|| parsed.hash !== '') {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
const segments = parsed.pathname.split('/').slice(1);
|
|
319
|
+
if (segments.length !== 4
|
|
320
|
+
|| !/^[A-Za-z0-9_.-]+$/.test(segments[0] ?? '')
|
|
321
|
+
|| !/^[A-Za-z0-9_.-]+$/.test(segments[1] ?? '')
|
|
322
|
+
|| segments[2] !== 'issues'
|
|
323
|
+
|| segments[3] !== String(issueNumber)) {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
if (segments.slice(0, 2).some((segment) => redactString(segment) !== segment || fullLeakCheck(segment, {}).found)) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
return expectedRepo === undefined
|
|
330
|
+
|| `${segments[0]}/${segments[1]}`.toLowerCase() === expectedRepo.toLowerCase();
|
|
331
|
+
}
|
|
332
|
+
function isSafeGitHubRepo(value, env) {
|
|
333
|
+
if (value.length > MAX_UNTRUSTED_FIELD_CHARS)
|
|
334
|
+
return false;
|
|
335
|
+
const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(value);
|
|
336
|
+
if (!match)
|
|
337
|
+
return false;
|
|
338
|
+
return match.slice(1).every((segment) => segment !== '.'
|
|
339
|
+
&& segment !== '..'
|
|
340
|
+
&& redactString(segment) === segment
|
|
341
|
+
&& !fullLeakCheck(segment, env).found);
|
|
342
|
+
}
|
|
343
|
+
function isSafeDraftIdArray(value, domain) {
|
|
344
|
+
return Array.isArray(value)
|
|
345
|
+
&& value.length <= MAX_IDS
|
|
346
|
+
&& value.every((entry) => typeof entry === 'string'
|
|
347
|
+
&& entry.startsWith(`${domain}:`)
|
|
348
|
+
&& OPAQUE_REF_RE.test(entry));
|
|
349
|
+
}
|
|
350
|
+
function canonicalIssueDraft(value, expectedFingerprint) {
|
|
351
|
+
if (!isRecord(value)
|
|
352
|
+
|| !hasOnlyKeys(value, [
|
|
353
|
+
'schemaVersion', 'fingerprint', 'status', 'createdAt', 'updatedAt', 'title', 'body',
|
|
354
|
+
'source', 'errorClass', 'cycleIds', 'eventIds', 'traceRefs', 'github',
|
|
355
|
+
])
|
|
356
|
+
|| value['schemaVersion'] !== STATE_VERSION
|
|
357
|
+
|| !isInternalFingerprint(value['fingerprint'])
|
|
358
|
+
|| (expectedFingerprint !== undefined && value['fingerprint'] !== expectedFingerprint)
|
|
359
|
+
|| (value['status'] !== 'draft' && value['status'] !== 'rejected' && value['status'] !== 'submitted')
|
|
360
|
+
|| !isTimestamp(value['createdAt'])
|
|
361
|
+
|| !isTimestamp(value['updatedAt'])
|
|
362
|
+
|| typeof value['title'] !== 'string'
|
|
363
|
+
|| value['title'].length > 256
|
|
364
|
+
|| typeof value['body'] !== 'string'
|
|
365
|
+
|| value['body'].length > 16_384
|
|
366
|
+
|| !isIssueReportSource(value['source'])
|
|
367
|
+
|| !isAllowlistedErrorClass(value['source'], value['errorClass'])
|
|
368
|
+
|| value['title'] !== `[${value['source']}] ${value['errorClass']}`
|
|
369
|
+
|| !value['body'].includes(fingerprintMarker(value['fingerprint']))
|
|
370
|
+
|| Date.parse(value['updatedAt']) < Date.parse(value['createdAt'])
|
|
371
|
+
|| !isSafeDraftIdArray(value['cycleIds'], 'cycle')
|
|
372
|
+
|| !isSafeDraftIdArray(value['eventIds'], 'event')
|
|
373
|
+
|| !isSafeDraftIdArray(value['traceRefs'], 'trace')) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
const draft = {
|
|
377
|
+
schemaVersion: STATE_VERSION,
|
|
378
|
+
fingerprint: value['fingerprint'],
|
|
379
|
+
status: value['status'],
|
|
380
|
+
createdAt: value['createdAt'],
|
|
381
|
+
updatedAt: value['updatedAt'],
|
|
382
|
+
title: value['title'],
|
|
383
|
+
body: value['body'],
|
|
384
|
+
source: value['source'],
|
|
385
|
+
errorClass: value['errorClass'],
|
|
386
|
+
cycleIds: [...value['cycleIds']],
|
|
387
|
+
eventIds: [...value['eventIds']],
|
|
388
|
+
traceRefs: [...value['traceRefs']],
|
|
389
|
+
};
|
|
390
|
+
if (value['status'] === 'submitted') {
|
|
391
|
+
const github = value['github'];
|
|
392
|
+
if (!isRecord(github) || !isSafeGitHubIssueUrl(github['url'], github['issueNumber']))
|
|
393
|
+
return null;
|
|
394
|
+
draft.github = { issueNumber: github['issueNumber'], url: github['url'] };
|
|
395
|
+
}
|
|
396
|
+
else if (value['github'] !== undefined) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
return draft;
|
|
400
|
+
}
|
|
401
|
+
function requireCanonicalIssueDraft(value) {
|
|
402
|
+
const draft = canonicalIssueDraft(value);
|
|
403
|
+
if (!draft)
|
|
404
|
+
throw new TypeError('invalid_issue_draft');
|
|
405
|
+
return draft;
|
|
406
|
+
}
|
|
407
|
+
function isSubmissionRecord(value) {
|
|
408
|
+
return isRecord(value)
|
|
409
|
+
&& hasOnlyKeys(value, ['fingerprint', 'submittedAt', 'issueNumber', 'url'])
|
|
410
|
+
&& isInternalFingerprint(value['fingerprint'])
|
|
411
|
+
&& isTimestamp(value['submittedAt'])
|
|
412
|
+
&& isSafeGitHubIssueUrl(value['url'], value['issueNumber']);
|
|
413
|
+
}
|
|
414
|
+
function isRejectionRecord(value) {
|
|
415
|
+
return isRecord(value)
|
|
416
|
+
&& hasOnlyKeys(value, ['fingerprint', 'rejectedAt'])
|
|
417
|
+
&& isInternalFingerprint(value['fingerprint'])
|
|
418
|
+
&& isTimestamp(value['rejectedAt']);
|
|
419
|
+
}
|
|
420
|
+
function isAttemptRecord(value) {
|
|
421
|
+
return isRecord(value)
|
|
422
|
+
&& hasOnlyKeys(value, ['fingerprint', 'attemptedAt'])
|
|
423
|
+
&& isInternalFingerprint(value['fingerprint'])
|
|
424
|
+
&& isTimestamp(value['attemptedAt']);
|
|
425
|
+
}
|
|
426
|
+
function isReservationRecord(value) {
|
|
427
|
+
return isRecord(value)
|
|
428
|
+
&& hasOnlyKeys(value, ['fingerprint', 'attemptId', 'reservedAt', 'status'])
|
|
429
|
+
&& isInternalFingerprint(value['fingerprint'])
|
|
430
|
+
&& typeof value['attemptId'] === 'string'
|
|
431
|
+
&& ATTEMPT_ID_RE.test(value['attemptId'])
|
|
432
|
+
&& isTimestamp(value['reservedAt'])
|
|
433
|
+
&& (value['status'] === undefined || value['status'] === 'pending' || value['status'] === 'ambiguous');
|
|
434
|
+
}
|
|
435
|
+
function isSubmissionGuardRecord(value, expectedFingerprint) {
|
|
436
|
+
return isRecord(value)
|
|
437
|
+
&& hasOnlyKeys(value, ['version', 'fingerprint', 'attemptId', 'reservedAt', 'status'])
|
|
438
|
+
&& value['version'] === STATE_VERSION
|
|
439
|
+
&& value['fingerprint'] === expectedFingerprint
|
|
440
|
+
&& isInternalFingerprint(value['fingerprint'])
|
|
441
|
+
&& typeof value['attemptId'] === 'string'
|
|
442
|
+
&& ATTEMPT_ID_RE.test(value['attemptId'])
|
|
443
|
+
&& isTimestamp(value['reservedAt'])
|
|
444
|
+
&& (value['status'] === 'preparing'
|
|
445
|
+
|| value['status'] === 'cancelled'
|
|
446
|
+
|| value['status'] === 'pending'
|
|
447
|
+
|| value['status'] === 'ambiguous'
|
|
448
|
+
|| value['status'] === 'submitted');
|
|
449
|
+
}
|
|
450
|
+
function isSubmissionQuotaEntry(value) {
|
|
451
|
+
return isRecord(value)
|
|
452
|
+
&& hasOnlyKeys(value, ['fingerprint', 'attemptId', 'countedAt'])
|
|
453
|
+
&& isInternalFingerprint(value['fingerprint'])
|
|
454
|
+
&& typeof value['attemptId'] === 'string'
|
|
455
|
+
&& ATTEMPT_ID_RE.test(value['attemptId'])
|
|
456
|
+
&& isTimestamp(value['countedAt']);
|
|
457
|
+
}
|
|
458
|
+
function isSubmissionQuotaIndex(value) {
|
|
459
|
+
if (!isRecord(value)
|
|
460
|
+
|| !hasOnlyKeys(value, ['kind', 'version', 'entries'])
|
|
461
|
+
|| value['kind'] !== 'issue_report_submission_quota'
|
|
462
|
+
|| value['version'] !== STATE_VERSION
|
|
463
|
+
|| !Array.isArray(value['entries'])
|
|
464
|
+
|| value['entries'].length > MAX_SUBMISSION_QUOTA_ENTRIES
|
|
465
|
+
|| !value['entries'].every(isSubmissionQuotaEntry)) {
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
468
|
+
const fingerprints = new Set();
|
|
469
|
+
for (const entry of value['entries']) {
|
|
470
|
+
if (fingerprints.has(entry.fingerprint))
|
|
471
|
+
return false;
|
|
472
|
+
fingerprints.add(entry.fingerprint);
|
|
473
|
+
}
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
function invalidState(cause) {
|
|
477
|
+
const error = new Error('invalid_issue_reporter_state');
|
|
478
|
+
if (cause !== undefined)
|
|
479
|
+
error.cause = cause;
|
|
480
|
+
return error;
|
|
481
|
+
}
|
|
482
|
+
function readState(rootDir) {
|
|
483
|
+
let raw;
|
|
484
|
+
try {
|
|
485
|
+
ensureAssetStoreDirectory(rootDir);
|
|
486
|
+
const encoded = readRegularBuffer(statePath(rootDir));
|
|
487
|
+
if (encoded === null)
|
|
488
|
+
return emptyState();
|
|
489
|
+
raw = encoded.toString('utf8');
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
throw invalidState(error);
|
|
493
|
+
}
|
|
494
|
+
let state;
|
|
495
|
+
try {
|
|
496
|
+
state = JSON.parse(raw);
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
throw invalidState(error);
|
|
500
|
+
}
|
|
501
|
+
if (!isRecord(state)
|
|
502
|
+
|| !hasOnlyKeys(state, ['version', 'submissions', 'rejections', 'attempts', 'reservations'])
|
|
503
|
+
|| state['version'] !== STATE_VERSION
|
|
504
|
+
|| !Array.isArray(state['submissions']) || !state['submissions'].every(isSubmissionRecord)
|
|
505
|
+
|| !Array.isArray(state['rejections']) || !state['rejections'].every(isRejectionRecord)
|
|
506
|
+
|| !Array.isArray(state['attempts']) || !state['attempts'].every(isAttemptRecord)
|
|
507
|
+
|| (state['reservations'] !== undefined
|
|
508
|
+
&& (!Array.isArray(state['reservations']) || !state['reservations'].every(isReservationRecord)))) {
|
|
509
|
+
throw invalidState();
|
|
510
|
+
}
|
|
511
|
+
const reservations = (state['reservations'] ?? []);
|
|
512
|
+
return {
|
|
513
|
+
version: STATE_VERSION,
|
|
514
|
+
submissions: state['submissions'],
|
|
515
|
+
rejections: state['rejections'],
|
|
516
|
+
attempts: state['attempts'],
|
|
517
|
+
reservations: reservations.map((record) => ({ ...record, status: record.status ?? 'pending' })),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
function readSubmissionGuard(rootDir, fingerprint) {
|
|
521
|
+
let encoded;
|
|
522
|
+
try {
|
|
523
|
+
ensureAssetStoreDirectory(submissionGuardDirectory(rootDir));
|
|
524
|
+
encoded = readRegularBuffer(submissionGuardPath(rootDir, fingerprint), MAX_SUBMISSION_GUARD_BYTES);
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
throw invalidState(error);
|
|
528
|
+
}
|
|
529
|
+
if (encoded === null)
|
|
530
|
+
return null;
|
|
531
|
+
let value;
|
|
532
|
+
try {
|
|
533
|
+
value = JSON.parse(encoded.toString('utf8'));
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
throw invalidState(error);
|
|
537
|
+
}
|
|
538
|
+
if (!isSubmissionGuardRecord(value, fingerprint))
|
|
539
|
+
throw invalidState();
|
|
540
|
+
return value;
|
|
541
|
+
}
|
|
542
|
+
function readSubmissionQuotaIndex(rootDir) {
|
|
543
|
+
let encoded;
|
|
544
|
+
try {
|
|
545
|
+
ensureAssetStoreDirectory(submissionQuotaDirectory(rootDir));
|
|
546
|
+
encoded = readRegularBuffer(submissionQuotaIndexPath(rootDir), MAX_SUBMISSION_QUOTA_INDEX_BYTES);
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
throw invalidState(error);
|
|
550
|
+
}
|
|
551
|
+
if (encoded === null) {
|
|
552
|
+
return { kind: 'issue_report_submission_quota', version: STATE_VERSION, entries: [] };
|
|
553
|
+
}
|
|
554
|
+
let value;
|
|
555
|
+
try {
|
|
556
|
+
value = JSON.parse(encoded.toString('utf8'));
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
throw invalidState(error);
|
|
560
|
+
}
|
|
561
|
+
if (!isSubmissionQuotaIndex(value))
|
|
562
|
+
throw invalidState();
|
|
563
|
+
return {
|
|
564
|
+
kind: 'issue_report_submission_quota',
|
|
565
|
+
version: STATE_VERSION,
|
|
566
|
+
entries: value.entries.map((entry) => ({ ...entry })),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
function writeState(rootDir, state) {
|
|
570
|
+
atomicWriteJson(rootDir, statePath(rootDir), state);
|
|
571
|
+
}
|
|
572
|
+
function writeSubmissionGuard(rootDir, guard) {
|
|
573
|
+
try {
|
|
574
|
+
ensureAssetStoreDirectory(submissionGuardDirectory(rootDir));
|
|
575
|
+
replaceUtf8Durable(submissionGuardPath(rootDir, guard.fingerprint), `${JSON.stringify(guard, null, 2)}\n`);
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
throw issueReporterStorageError(error);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function writeSubmissionQuotaIndex(rootDir, index) {
|
|
582
|
+
if (!isSubmissionQuotaIndex(index))
|
|
583
|
+
throw issueReporterStorageError(new Error('invalid submission quota index'));
|
|
584
|
+
const encoded = `${JSON.stringify(index, null, 2)}\n`;
|
|
585
|
+
if (Buffer.byteLength(encoded, 'utf8') > MAX_SUBMISSION_QUOTA_INDEX_BYTES) {
|
|
586
|
+
throw issueReporterStorageError(new Error('submission quota index exceeds write limit'));
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
ensureAssetStoreDirectory(submissionQuotaDirectory(rootDir));
|
|
590
|
+
replaceUtf8Durable(submissionQuotaIndexPath(rootDir), encoded);
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
throw issueReporterStorageError(error);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
function removeReservationEvidence(state, index, guard) {
|
|
597
|
+
const reservationsBefore = state.reservations.length;
|
|
598
|
+
const attemptsBefore = state.attempts.length;
|
|
599
|
+
const quotaEntriesBefore = index.entries.length;
|
|
600
|
+
state.reservations = state.reservations.filter((record) => !(record.fingerprint === guard.fingerprint
|
|
601
|
+
&& record.attemptId === guard.attemptId
|
|
602
|
+
&& record.reservedAt === guard.reservedAt));
|
|
603
|
+
state.attempts = state.attempts.filter((record) => !(record.fingerprint === guard.fingerprint
|
|
604
|
+
&& record.attemptedAt === guard.reservedAt));
|
|
605
|
+
index.entries = index.entries.filter((entry) => !(entry.fingerprint === guard.fingerprint
|
|
606
|
+
&& entry.attemptId === guard.attemptId
|
|
607
|
+
&& entry.countedAt === guard.reservedAt));
|
|
608
|
+
return {
|
|
609
|
+
stateChanged: state.reservations.length !== reservationsBefore
|
|
610
|
+
|| state.attempts.length !== attemptsBefore,
|
|
611
|
+
quotaChanged: index.entries.length !== quotaEntriesBefore,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
function recoverPreparingReservation(rootDir, state, index, guard) {
|
|
615
|
+
if (guard?.status !== 'preparing')
|
|
616
|
+
return guard;
|
|
617
|
+
const { stateChanged, quotaChanged } = removeReservationEvidence(state, index, guard);
|
|
618
|
+
if (stateChanged)
|
|
619
|
+
writeState(rootDir, state);
|
|
620
|
+
if (quotaChanged)
|
|
621
|
+
writeSubmissionQuotaIndex(rootDir, index);
|
|
622
|
+
const cancelled = { ...guard, status: 'cancelled' };
|
|
623
|
+
writeSubmissionGuard(rootDir, cancelled);
|
|
624
|
+
return cancelled;
|
|
625
|
+
}
|
|
626
|
+
function rollbackReservationBestEffort(rootDir, state, index, guard) {
|
|
627
|
+
const preparing = { ...guard, status: 'preparing' };
|
|
628
|
+
try {
|
|
629
|
+
writeSubmissionGuard(rootDir, preparing);
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
// Continue rolling back the independently persisted state and quota evidence.
|
|
633
|
+
}
|
|
634
|
+
const { stateChanged, quotaChanged } = removeReservationEvidence(state, index, preparing);
|
|
635
|
+
let cleanupSucceeded = true;
|
|
636
|
+
if (stateChanged) {
|
|
637
|
+
try {
|
|
638
|
+
writeState(rootDir, state);
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
cleanupSucceeded = false;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (quotaChanged) {
|
|
645
|
+
try {
|
|
646
|
+
writeSubmissionQuotaIndex(rootDir, index);
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
cleanupSucceeded = false;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
writeSubmissionGuard(rootDir, {
|
|
654
|
+
...preparing,
|
|
655
|
+
status: cleanupSucceeded ? 'cancelled' : 'preparing',
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
// Preserve the primary preflight failure; clean evidence or a preparing marker remains when possible.
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
function compactSubmissionQuotaIndex(index, nowMs, rateWindow) {
|
|
663
|
+
const active = index.entries.filter((entry) => nowMs - Date.parse(entry.countedAt) < rateWindow);
|
|
664
|
+
if (active.length === index.entries.length)
|
|
665
|
+
return false;
|
|
666
|
+
index.entries = active;
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
function upsertSubmissionQuotaEntry(index, entry) {
|
|
670
|
+
const existing = index.entries.find((candidate) => candidate.fingerprint === entry.fingerprint);
|
|
671
|
+
if (existing) {
|
|
672
|
+
existing.attemptId = entry.attemptId;
|
|
673
|
+
existing.countedAt = entry.countedAt;
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
if (index.entries.length >= MAX_SUBMISSION_QUOTA_ENTRIES)
|
|
677
|
+
return false;
|
|
678
|
+
index.entries.push(entry);
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
function withReporterLock(rootDir, operation) {
|
|
682
|
+
const lockPath = join(rootDir, '.issue-reporter.lock');
|
|
683
|
+
try {
|
|
684
|
+
ensureAssetStoreDirectory(rootDir);
|
|
685
|
+
acquireLock(lockPath);
|
|
686
|
+
}
|
|
687
|
+
catch (error) {
|
|
688
|
+
throw issueReporterStorageError(error);
|
|
689
|
+
}
|
|
690
|
+
let operationResult;
|
|
691
|
+
let operationError;
|
|
692
|
+
let operationFailed = false;
|
|
693
|
+
try {
|
|
694
|
+
operationResult = operation();
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
operationFailed = true;
|
|
698
|
+
operationError = error;
|
|
699
|
+
}
|
|
700
|
+
let released;
|
|
701
|
+
try {
|
|
702
|
+
released = releaseLock(lockPath);
|
|
703
|
+
}
|
|
704
|
+
catch (error) {
|
|
705
|
+
if (operationFailed)
|
|
706
|
+
throw operationError;
|
|
707
|
+
throw issueReporterStorageError(error);
|
|
708
|
+
}
|
|
709
|
+
if (operationFailed)
|
|
710
|
+
throw operationError;
|
|
711
|
+
if (!released.released)
|
|
712
|
+
throw issueReporterStorageError(new LockReleaseError(released.reason));
|
|
713
|
+
return operationResult;
|
|
714
|
+
}
|
|
715
|
+
function advanceSubmissionQuotaTimestamp(rootDir, fingerprint, attemptId, countedAt, rateWindow) {
|
|
716
|
+
try {
|
|
717
|
+
return withReporterLock(rootDir, () => {
|
|
718
|
+
const index = readSubmissionQuotaIndex(rootDir);
|
|
719
|
+
compactSubmissionQuotaIndex(index, Date.parse(countedAt), rateWindow);
|
|
720
|
+
if (!upsertSubmissionQuotaEntry(index, { fingerprint, attemptId, countedAt }))
|
|
721
|
+
return false;
|
|
722
|
+
writeSubmissionQuotaIndex(rootDir, index);
|
|
723
|
+
return true;
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
function markReservationAmbiguousInState(state, fingerprint, attemptId, reservedAt) {
|
|
731
|
+
let reservation = state.reservations.find((record) => record.fingerprint === fingerprint && record.attemptId === attemptId);
|
|
732
|
+
reservation ??= state.reservations.find((record) => record.fingerprint === fingerprint);
|
|
733
|
+
if (!reservation) {
|
|
734
|
+
reservation = { fingerprint, attemptId, reservedAt, status: 'ambiguous' };
|
|
735
|
+
state.reservations.push(reservation);
|
|
736
|
+
}
|
|
737
|
+
if (!state.attempts.some((record) => record.fingerprint === fingerprint)) {
|
|
738
|
+
state.attempts.push({ fingerprint, attemptedAt: reservedAt });
|
|
739
|
+
}
|
|
740
|
+
reservation.status = 'ambiguous';
|
|
741
|
+
}
|
|
742
|
+
function markReservationAmbiguous(rootDir, fingerprint, attemptId, reservedAt) {
|
|
743
|
+
withReporterLock(rootDir, () => {
|
|
744
|
+
const state = readState(rootDir);
|
|
745
|
+
if (latestSubmission(state, fingerprint) || latestRejection(state, fingerprint))
|
|
746
|
+
return;
|
|
747
|
+
markReservationAmbiguousInState(state, fingerprint, attemptId, reservedAt);
|
|
748
|
+
writeState(rootDir, state);
|
|
749
|
+
writeSubmissionGuard(rootDir, {
|
|
750
|
+
version: STATE_VERSION,
|
|
751
|
+
fingerprint,
|
|
752
|
+
attemptId,
|
|
753
|
+
reservedAt,
|
|
754
|
+
status: 'ambiguous',
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
function markReservationAmbiguousBestEffort(rootDir, fingerprint, attemptId, reservedAt) {
|
|
759
|
+
try {
|
|
760
|
+
markReservationAmbiguous(rootDir, fingerprint, attemptId, reservedAt);
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
try {
|
|
764
|
+
writeSubmissionGuard(rootDir, {
|
|
765
|
+
version: STATE_VERSION,
|
|
766
|
+
fingerprint,
|
|
767
|
+
attemptId,
|
|
768
|
+
reservedAt,
|
|
769
|
+
status: 'ambiguous',
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
catch {
|
|
773
|
+
// The pending guard was persisted before transport and remains fail-closed.
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
function cancelConfirmedNotCreatedAttempt(rootDir, fingerprint, attemptId) {
|
|
778
|
+
withReporterLock(rootDir, () => {
|
|
779
|
+
const state = readState(rootDir);
|
|
780
|
+
const index = readSubmissionQuotaIndex(rootDir);
|
|
781
|
+
const reservation = state.reservations.find((record) => record.fingerprint === fingerprint && record.attemptId === attemptId);
|
|
782
|
+
const guard = readSubmissionGuard(rootDir, fingerprint);
|
|
783
|
+
if (!reservation && guard?.attemptId !== attemptId)
|
|
784
|
+
return;
|
|
785
|
+
const reservedAt = reservation?.reservedAt ?? guard.reservedAt;
|
|
786
|
+
writeSubmissionGuard(rootDir, {
|
|
787
|
+
version: STATE_VERSION,
|
|
788
|
+
fingerprint,
|
|
789
|
+
attemptId,
|
|
790
|
+
reservedAt,
|
|
791
|
+
status: 'cancelled',
|
|
792
|
+
});
|
|
793
|
+
state.reservations = state.reservations.filter((record) => !(record.fingerprint === fingerprint && record.attemptId === attemptId));
|
|
794
|
+
state.attempts = state.attempts.filter((record) => !(record.fingerprint === fingerprint && record.attemptedAt === reservedAt));
|
|
795
|
+
index.entries = index.entries.filter((entry) => !(entry.fingerprint === fingerprint && entry.attemptId === attemptId));
|
|
796
|
+
writeState(rootDir, state);
|
|
797
|
+
writeSubmissionQuotaIndex(rootDir, index);
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
function durableOutcome(rootDir, fingerprint) {
|
|
801
|
+
let state;
|
|
802
|
+
try {
|
|
803
|
+
state = readState(rootDir);
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
// The independently persisted draft or guard can still classify the outcome.
|
|
807
|
+
}
|
|
808
|
+
if (state) {
|
|
809
|
+
if (latestRejection(state, fingerprint))
|
|
810
|
+
return 'rejected';
|
|
811
|
+
if (latestSubmission(state, fingerprint))
|
|
812
|
+
return 'submitted';
|
|
813
|
+
}
|
|
814
|
+
try {
|
|
815
|
+
const draft = issueDraftFromDisk(rootDir, fingerprint);
|
|
816
|
+
if (draft?.status === 'rejected')
|
|
817
|
+
return 'rejected';
|
|
818
|
+
if (draft?.status === 'submitted')
|
|
819
|
+
return 'submitted';
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
// A strict same-fingerprint guard can still preserve the remote outcome.
|
|
823
|
+
}
|
|
824
|
+
if (state?.reservations.some((record) => (record.fingerprint === fingerprint && record.status === 'ambiguous')))
|
|
825
|
+
return 'submitted';
|
|
826
|
+
try {
|
|
827
|
+
const status = readSubmissionGuard(rootDir, fingerprint)?.status;
|
|
828
|
+
return status === 'ambiguous' || status === 'submitted' ? 'submitted' : 'none';
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
return 'none';
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
function persistSubmittedDraftFallback(rootDir, draft, attemptId, reservedAt) {
|
|
835
|
+
try {
|
|
836
|
+
return withReporterLock(rootDir, () => {
|
|
837
|
+
let state;
|
|
838
|
+
try {
|
|
839
|
+
state = readState(rootDir);
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
// A validated remote receipt remains the only durable fallback when the state ledger is unavailable.
|
|
843
|
+
}
|
|
844
|
+
if (state && latestRejection(state, draft.fingerprint))
|
|
845
|
+
return 'rejected';
|
|
846
|
+
const hasSubmission = state ? latestSubmission(state, draft.fingerprint) !== undefined : false;
|
|
847
|
+
let persistedDraft = null;
|
|
848
|
+
try {
|
|
849
|
+
persistedDraft = issueDraftFromDisk(rootDir, draft.fingerprint);
|
|
850
|
+
}
|
|
851
|
+
catch {
|
|
852
|
+
// A validated receipt may safely replace a corrupt regular draft through the no-follow writer.
|
|
853
|
+
}
|
|
854
|
+
if (persistedDraft?.status === 'rejected')
|
|
855
|
+
return 'rejected';
|
|
856
|
+
writeSubmissionGuard(rootDir, {
|
|
857
|
+
version: STATE_VERSION,
|
|
858
|
+
fingerprint: draft.fingerprint,
|
|
859
|
+
attemptId,
|
|
860
|
+
reservedAt,
|
|
861
|
+
status: 'submitted',
|
|
862
|
+
});
|
|
863
|
+
if (state && !hasSubmission) {
|
|
864
|
+
markReservationAmbiguousInState(state, draft.fingerprint, attemptId, reservedAt);
|
|
865
|
+
try {
|
|
866
|
+
writeState(rootDir, state);
|
|
867
|
+
}
|
|
868
|
+
catch {
|
|
869
|
+
// The terminal guard already preserves the validated remote outcome.
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (persistedDraft?.status !== 'submitted') {
|
|
873
|
+
try {
|
|
874
|
+
persistDraft(rootDir, draft);
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
// The terminal guard preserves the validated remote outcome across draft repair failure.
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return 'submitted';
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
catch {
|
|
884
|
+
return 'none';
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
function log(options, entry) {
|
|
888
|
+
try {
|
|
889
|
+
options.logger?.(entry);
|
|
890
|
+
}
|
|
891
|
+
catch {
|
|
892
|
+
// Reporter state and remote outcomes must not depend on telemetry availability.
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
function safeIsoNow(now) {
|
|
896
|
+
try {
|
|
897
|
+
const value = (now ?? (() => new Date()))();
|
|
898
|
+
if (Number.isFinite(value.getTime()))
|
|
899
|
+
return value.toISOString();
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
// Fall through to the system clock after an injected clock failure.
|
|
903
|
+
}
|
|
904
|
+
return new Date().toISOString();
|
|
905
|
+
}
|
|
906
|
+
function pendingSubmissionIsLive(reservedAt, nowMs) {
|
|
907
|
+
const reservedAtMs = Date.parse(reservedAt);
|
|
908
|
+
return !Number.isFinite(reservedAtMs)
|
|
909
|
+
|| nowMs - reservedAtMs < PENDING_RESOLUTION_GRACE_MS;
|
|
910
|
+
}
|
|
911
|
+
function latestSubmission(state, fingerprint) {
|
|
912
|
+
for (let index = state.submissions.length - 1; index >= 0; index -= 1) {
|
|
913
|
+
const record = state.submissions[index];
|
|
914
|
+
if (record?.fingerprint === fingerprint)
|
|
915
|
+
return record;
|
|
916
|
+
}
|
|
917
|
+
return undefined;
|
|
918
|
+
}
|
|
919
|
+
function latestRejection(state, fingerprint) {
|
|
920
|
+
for (let index = state.rejections.length - 1; index >= 0; index -= 1) {
|
|
921
|
+
const record = state.rejections[index];
|
|
922
|
+
if (record?.fingerprint === fingerprint)
|
|
923
|
+
return record;
|
|
924
|
+
}
|
|
925
|
+
return undefined;
|
|
926
|
+
}
|
|
927
|
+
function issueDraftConflict(state, fingerprint, guard, quotaEntry) {
|
|
928
|
+
if (latestSubmission(state, fingerprint) || latestRejection(state, fingerprint))
|
|
929
|
+
return undefined;
|
|
930
|
+
const reservation = state.reservations.find((record) => record.fingerprint === fingerprint);
|
|
931
|
+
if (reservation?.status === 'ambiguous'
|
|
932
|
+
|| guard?.status === 'ambiguous'
|
|
933
|
+
|| guard?.status === 'submitted') {
|
|
934
|
+
return 'issue_report_submission_ambiguous';
|
|
935
|
+
}
|
|
936
|
+
if (reservation || guard?.status === 'pending')
|
|
937
|
+
return 'issue_report_submission_in_flight';
|
|
938
|
+
return state.attempts.some((record) => record.fingerprint === fingerprint) || quotaEntry !== undefined
|
|
939
|
+
? 'issue_report_submission_ambiguous'
|
|
940
|
+
: undefined;
|
|
941
|
+
}
|
|
942
|
+
function terminalDraftFromState(draft, state) {
|
|
943
|
+
const submission = latestSubmission(state, draft.fingerprint);
|
|
944
|
+
if (submission) {
|
|
945
|
+
return {
|
|
946
|
+
...draft,
|
|
947
|
+
status: 'submitted',
|
|
948
|
+
github: { issueNumber: submission.issueNumber, url: submission.url },
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
const rejection = latestRejection(state, draft.fingerprint);
|
|
952
|
+
if (rejection) {
|
|
953
|
+
const { github: _github, ...base } = draft;
|
|
954
|
+
return { ...base, status: 'rejected' };
|
|
955
|
+
}
|
|
956
|
+
return undefined;
|
|
957
|
+
}
|
|
958
|
+
function persistDraft(rootDir, draft) {
|
|
959
|
+
atomicWriteJson(rootDir, draftPath(rootDir, draft.fingerprint), draft);
|
|
960
|
+
}
|
|
961
|
+
function submittedResult(draft, alreadySubmitted) {
|
|
962
|
+
return { status: alreadySubmitted ? 'already_submitted' : 'submitted', draft };
|
|
963
|
+
}
|
|
964
|
+
async function reconcileOpenRemoteIssue(transport, repo, fingerprint) {
|
|
965
|
+
const marker = fingerprintMarker(fingerprint);
|
|
966
|
+
for (let page = 1; page <= MAX_REMOTE_RECONCILE_PAGES; page += 1) {
|
|
967
|
+
const result = await transport.listOpenIssues({
|
|
968
|
+
repo,
|
|
969
|
+
page,
|
|
970
|
+
perPage: REMOTE_RECONCILE_PAGE_SIZE,
|
|
971
|
+
});
|
|
972
|
+
if (!isRecord(result)
|
|
973
|
+
|| !Array.isArray(result['issues'])
|
|
974
|
+
|| typeof result['hasNextPage'] !== 'boolean') {
|
|
975
|
+
throw new GithubIssueTransportError('not_created');
|
|
976
|
+
}
|
|
977
|
+
for (const candidate of result['issues']) {
|
|
978
|
+
if (!isRecord(candidate)
|
|
979
|
+
|| typeof candidate['isPullRequest'] !== 'boolean'
|
|
980
|
+
|| typeof candidate['body'] !== 'string'
|
|
981
|
+
|| candidate['body'].length > MAX_REMOTE_ISSUE_BODY_CHARS) {
|
|
982
|
+
throw new GithubIssueTransportError('not_created');
|
|
983
|
+
}
|
|
984
|
+
if (candidate['isPullRequest'] || !candidate['body'].includes(marker))
|
|
985
|
+
continue;
|
|
986
|
+
if (!isSafeGitHubIssueUrl(candidate['url'], candidate['number'], repo)) {
|
|
987
|
+
throw new GithubIssueTransportError('not_created');
|
|
988
|
+
}
|
|
989
|
+
return { issueNumber: candidate['number'], url: candidate['url'] };
|
|
990
|
+
}
|
|
991
|
+
if (!result['hasNextPage'])
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
throw new GithubIssueTransportError('not_created');
|
|
995
|
+
}
|
|
996
|
+
function persistReconciledRemoteIssue(draft, receipt, options) {
|
|
997
|
+
return withReporterLock(options.rootDir, () => {
|
|
998
|
+
const current = issueDraftFromDisk(options.rootDir, draft.fingerprint) ?? draft;
|
|
999
|
+
const state = readState(options.rootDir);
|
|
1000
|
+
const terminal = terminalDraftFromState(current, state);
|
|
1001
|
+
if (terminal) {
|
|
1002
|
+
persistDraft(options.rootDir, terminal);
|
|
1003
|
+
return terminal;
|
|
1004
|
+
}
|
|
1005
|
+
if (current.status !== 'draft')
|
|
1006
|
+
return current;
|
|
1007
|
+
const submittedAt = safeIsoNow(options.now);
|
|
1008
|
+
const submitted = {
|
|
1009
|
+
...current,
|
|
1010
|
+
status: 'submitted',
|
|
1011
|
+
updatedAt: submittedAt,
|
|
1012
|
+
github: { issueNumber: receipt.issueNumber, url: receipt.url },
|
|
1013
|
+
};
|
|
1014
|
+
const guard = readSubmissionGuard(options.rootDir, draft.fingerprint);
|
|
1015
|
+
writeSubmissionGuard(options.rootDir, {
|
|
1016
|
+
version: STATE_VERSION,
|
|
1017
|
+
fingerprint: draft.fingerprint,
|
|
1018
|
+
attemptId: guard?.attemptId ?? randomUUID(),
|
|
1019
|
+
reservedAt: guard?.reservedAt ?? submittedAt,
|
|
1020
|
+
status: 'submitted',
|
|
1021
|
+
});
|
|
1022
|
+
state.submissions.push({
|
|
1023
|
+
fingerprint: draft.fingerprint,
|
|
1024
|
+
submittedAt,
|
|
1025
|
+
issueNumber: receipt.issueNumber,
|
|
1026
|
+
url: receipt.url,
|
|
1027
|
+
});
|
|
1028
|
+
state.reservations = state.reservations.filter((record) => record.fingerprint !== draft.fingerprint);
|
|
1029
|
+
writeState(options.rootDir, state);
|
|
1030
|
+
persistDraft(options.rootDir, submitted);
|
|
1031
|
+
return submitted;
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
function validateDraftSafety(draft, env) {
|
|
1035
|
+
const content = [
|
|
1036
|
+
draft.title,
|
|
1037
|
+
draft.body,
|
|
1038
|
+
draft.source,
|
|
1039
|
+
draft.errorClass,
|
|
1040
|
+
...draft.cycleIds,
|
|
1041
|
+
...draft.eventIds,
|
|
1042
|
+
...draft.traceRefs,
|
|
1043
|
+
].join('\n');
|
|
1044
|
+
if (content.includes('/Users/') || content.includes('/home/') || /[A-Za-z]:\\Users\\/.test(content))
|
|
1045
|
+
return false;
|
|
1046
|
+
if (/authorization|cookie|transcript|full prompt|environment variable value/i.test(draft.title))
|
|
1047
|
+
return false;
|
|
1048
|
+
return !fullLeakCheck(content, env).found && redactString(content) === content;
|
|
1049
|
+
}
|
|
1050
|
+
export function createIssueDraft(input, options) {
|
|
1051
|
+
if (!isIssueReportSource(input.source))
|
|
1052
|
+
throw new TypeError('invalid issue report source');
|
|
1053
|
+
const errorClass = allowlistedToken(input.errorClass, ERROR_CLASSES[input.source], 'unknown');
|
|
1054
|
+
const failureClass = input.source === 'cycle_failure'
|
|
1055
|
+
? allowlistedToken(input.failureClass, CYCLE_FAILURE_CLASSES, 'unclassified')
|
|
1056
|
+
: 'unclassified';
|
|
1057
|
+
const diagnosticCodes = safeDiagnosticCodes(input.source, input.diagnosticCodes);
|
|
1058
|
+
const workspaceScope = createHash('sha256').update(canonicalWorkspaceScope(options)).digest('hex').slice(0, 16);
|
|
1059
|
+
const fingerprint = stableFingerprint({ source: input.source, errorClass, failureClass, diagnosticCodes, workspaceScope });
|
|
1060
|
+
const now = (options.now ?? (() => new Date()))();
|
|
1061
|
+
const dedupWindowMs = options.dedupWindowMs ?? DEFAULT_DEDUP_WINDOW_MS;
|
|
1062
|
+
const draft = renderDraft(input, options, fingerprint, now.toISOString());
|
|
1063
|
+
if (!validateDraftSafety(draft, options.env ?? {}))
|
|
1064
|
+
throw new TypeError('unsafe_issue_draft');
|
|
1065
|
+
const result = withReporterLock(options.rootDir, () => {
|
|
1066
|
+
const state = readState(options.rootDir);
|
|
1067
|
+
const existing = issueDraftFromDisk(options.rootDir, fingerprint);
|
|
1068
|
+
if (existing && !validateDraftSafety(existing, options.env ?? {}))
|
|
1069
|
+
throw new TypeError('unsafe_issue_draft');
|
|
1070
|
+
const terminal = terminalDraftFromState(existing ?? draft, state);
|
|
1071
|
+
if (terminal) {
|
|
1072
|
+
persistDraft(options.rootDir, terminal);
|
|
1073
|
+
return { status: 'duplicate', draft: terminal };
|
|
1074
|
+
}
|
|
1075
|
+
if (existing && existing.status !== 'draft')
|
|
1076
|
+
return { status: 'duplicate', draft: existing };
|
|
1077
|
+
const rateWindow = options.rateLimitWindowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS;
|
|
1078
|
+
const quotaIndex = readSubmissionQuotaIndex(options.rootDir);
|
|
1079
|
+
const guard = recoverPreparingReservation(options.rootDir, state, quotaIndex, readSubmissionGuard(options.rootDir, fingerprint));
|
|
1080
|
+
if (compactSubmissionQuotaIndex(quotaIndex, now.getTime(), rateWindow)) {
|
|
1081
|
+
writeSubmissionQuotaIndex(options.rootDir, quotaIndex);
|
|
1082
|
+
}
|
|
1083
|
+
const conflict = issueDraftConflict(state, fingerprint, guard, quotaIndex.entries.find((entry) => entry.fingerprint === fingerprint));
|
|
1084
|
+
if (conflict) {
|
|
1085
|
+
if (!existing)
|
|
1086
|
+
persistDraft(options.rootDir, draft);
|
|
1087
|
+
return { status: 'duplicate', draft: existing ?? draft };
|
|
1088
|
+
}
|
|
1089
|
+
if (existing && now.getTime() - Date.parse(existing.createdAt) < dedupWindowMs) {
|
|
1090
|
+
return { status: 'duplicate', draft: existing };
|
|
1091
|
+
}
|
|
1092
|
+
persistDraft(options.rootDir, draft);
|
|
1093
|
+
return { status: 'created', draft };
|
|
1094
|
+
});
|
|
1095
|
+
log(options, { fingerprint, status: result.status === 'created' ? 'draft_created' : 'duplicate' });
|
|
1096
|
+
return result;
|
|
1097
|
+
}
|
|
1098
|
+
export function rejectIssueDraft(draft, options) {
|
|
1099
|
+
const inputDraft = requireCanonicalIssueDraft(draft);
|
|
1100
|
+
if (!validateDraftSafety(inputDraft, options.env ?? {}))
|
|
1101
|
+
throw new TypeError('unsafe_issue_draft');
|
|
1102
|
+
const nowDate = (options.now ?? (() => new Date()))();
|
|
1103
|
+
const now = nowDate.toISOString();
|
|
1104
|
+
const rejected = withReporterLock(options.rootDir, () => {
|
|
1105
|
+
const current = issueDraftFromDisk(options.rootDir, inputDraft.fingerprint) ?? inputDraft;
|
|
1106
|
+
if (!validateDraftSafety(current, options.env ?? {}))
|
|
1107
|
+
throw new TypeError('unsafe_issue_draft');
|
|
1108
|
+
const state = readState(options.rootDir);
|
|
1109
|
+
const terminal = terminalDraftFromState(current, state);
|
|
1110
|
+
if (terminal) {
|
|
1111
|
+
persistDraft(options.rootDir, terminal);
|
|
1112
|
+
return terminal;
|
|
1113
|
+
}
|
|
1114
|
+
if (current.status !== 'draft')
|
|
1115
|
+
return current;
|
|
1116
|
+
const quotaIndex = readSubmissionQuotaIndex(options.rootDir);
|
|
1117
|
+
const guard = recoverPreparingReservation(options.rootDir, state, quotaIndex, readSubmissionGuard(options.rootDir, current.fingerprint));
|
|
1118
|
+
if (compactSubmissionQuotaIndex(quotaIndex, nowDate.getTime(), options.rateLimitWindowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS)) {
|
|
1119
|
+
writeSubmissionQuotaIndex(options.rootDir, quotaIndex);
|
|
1120
|
+
}
|
|
1121
|
+
const conflict = issueDraftConflict(state, current.fingerprint, guard, quotaIndex.entries.find((entry) => entry.fingerprint === current.fingerprint));
|
|
1122
|
+
if (conflict)
|
|
1123
|
+
throw new IssueDraftConflictError(conflict);
|
|
1124
|
+
const result = { ...current, status: 'rejected', updatedAt: now };
|
|
1125
|
+
state.rejections.push({ fingerprint: current.fingerprint, rejectedAt: now });
|
|
1126
|
+
writeState(options.rootDir, state);
|
|
1127
|
+
persistDraft(options.rootDir, result);
|
|
1128
|
+
return result;
|
|
1129
|
+
});
|
|
1130
|
+
log(options, {
|
|
1131
|
+
fingerprint: inputDraft.fingerprint,
|
|
1132
|
+
status: rejected.status === 'submitted' ? 'submitted'
|
|
1133
|
+
: rejected.status === 'rejected' ? 'rejected' : 'submission_failed',
|
|
1134
|
+
});
|
|
1135
|
+
return rejected;
|
|
1136
|
+
}
|
|
1137
|
+
export async function submitIssueDraft(draft, submit, options) {
|
|
1138
|
+
const inputDraft = requireCanonicalIssueDraft(draft);
|
|
1139
|
+
const rateWindow = options.rateLimitWindowMs ?? DEFAULT_RATE_LIMIT_WINDOW_MS;
|
|
1140
|
+
const reconciliationCandidate = withReporterLock(options.rootDir, () => {
|
|
1141
|
+
const current = issueDraftFromDisk(options.rootDir, inputDraft.fingerprint) ?? inputDraft;
|
|
1142
|
+
const state = readState(options.rootDir);
|
|
1143
|
+
if (!validateDraftSafety(current, options.env ?? {})) {
|
|
1144
|
+
return { kind: 'result', result: { status: 'failed', draft: current, errorClass: 'unsafe_draft' } };
|
|
1145
|
+
}
|
|
1146
|
+
const terminal = terminalDraftFromState(current, state);
|
|
1147
|
+
if (terminal) {
|
|
1148
|
+
persistDraft(options.rootDir, terminal);
|
|
1149
|
+
return {
|
|
1150
|
+
kind: 'result',
|
|
1151
|
+
result: terminal.status === 'submitted'
|
|
1152
|
+
? submittedResult(terminal, true)
|
|
1153
|
+
: { status: 'rejected', draft: terminal },
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
if (current.status === 'submitted')
|
|
1157
|
+
return { kind: 'result', result: submittedResult(current, true) };
|
|
1158
|
+
if (current.status === 'rejected')
|
|
1159
|
+
return { kind: 'result', result: { status: 'rejected', draft: current } };
|
|
1160
|
+
if (!submit.approved || (submit.approvalSource !== 'human' && submit.approvalSource !== 'operator_policy')) {
|
|
1161
|
+
return { kind: 'result', result: { status: 'approval_required', draft: current } };
|
|
1162
|
+
}
|
|
1163
|
+
if (!isSafeGitHubRepo(submit.repo, options.env ?? {})) {
|
|
1164
|
+
return { kind: 'result', result: { status: 'failed', draft: current, errorClass: 'invalid_response' } };
|
|
1165
|
+
}
|
|
1166
|
+
return { kind: 'candidate', draft: current };
|
|
1167
|
+
});
|
|
1168
|
+
if (reconciliationCandidate.kind === 'result') {
|
|
1169
|
+
return reconciliationCandidate.result;
|
|
1170
|
+
}
|
|
1171
|
+
let remoteReceipt;
|
|
1172
|
+
try {
|
|
1173
|
+
remoteReceipt = await reconcileOpenRemoteIssue(submit.transport, submit.repo, reconciliationCandidate.draft.fingerprint);
|
|
1174
|
+
}
|
|
1175
|
+
catch {
|
|
1176
|
+
log(options, {
|
|
1177
|
+
fingerprint: reconciliationCandidate.draft.fingerprint,
|
|
1178
|
+
status: 'submission_failed',
|
|
1179
|
+
errorClass: 'transport_error',
|
|
1180
|
+
});
|
|
1181
|
+
return {
|
|
1182
|
+
status: 'failed',
|
|
1183
|
+
draft: reconciliationCandidate.draft,
|
|
1184
|
+
errorClass: 'transport_error',
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
if (remoteReceipt) {
|
|
1188
|
+
const reconciled = persistReconciledRemoteIssue(reconciliationCandidate.draft, remoteReceipt, options);
|
|
1189
|
+
if (reconciled.status === 'rejected') {
|
|
1190
|
+
log(options, { fingerprint: reconciled.fingerprint, status: 'rejected' });
|
|
1191
|
+
return { status: 'rejected', draft: reconciled };
|
|
1192
|
+
}
|
|
1193
|
+
log(options, { fingerprint: reconciled.fingerprint, status: 'submitted' });
|
|
1194
|
+
return submittedResult(reconciled, true);
|
|
1195
|
+
}
|
|
1196
|
+
const prepared = withReporterLock(options.rootDir, () => {
|
|
1197
|
+
const current = issueDraftFromDisk(options.rootDir, inputDraft.fingerprint) ?? inputDraft;
|
|
1198
|
+
const state = readState(options.rootDir);
|
|
1199
|
+
if (!validateDraftSafety(current, options.env ?? {})) {
|
|
1200
|
+
return {
|
|
1201
|
+
kind: 'result',
|
|
1202
|
+
result: { status: 'failed', draft: current, errorClass: 'unsafe_draft' },
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
const terminal = terminalDraftFromState(current, state);
|
|
1206
|
+
if (terminal) {
|
|
1207
|
+
persistDraft(options.rootDir, terminal);
|
|
1208
|
+
return {
|
|
1209
|
+
kind: 'result',
|
|
1210
|
+
result: terminal.status === 'submitted'
|
|
1211
|
+
? submittedResult(terminal, true)
|
|
1212
|
+
: { status: 'rejected', draft: terminal },
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
if (current.status === 'submitted') {
|
|
1216
|
+
return { kind: 'result', result: submittedResult(current, true) };
|
|
1217
|
+
}
|
|
1218
|
+
if (current.status === 'rejected') {
|
|
1219
|
+
return { kind: 'result', result: { status: 'rejected', draft: current } };
|
|
1220
|
+
}
|
|
1221
|
+
if (!submit.approved || (submit.approvalSource !== 'human' && submit.approvalSource !== 'operator_policy')) {
|
|
1222
|
+
return { kind: 'result', result: { status: 'approval_required', draft: current } };
|
|
1223
|
+
}
|
|
1224
|
+
if (!isSafeGitHubRepo(submit.repo, options.env ?? {})) {
|
|
1225
|
+
return {
|
|
1226
|
+
kind: 'result',
|
|
1227
|
+
result: { status: 'failed', draft: current, errorClass: 'invalid_response' },
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
const now = (options.now ?? (() => new Date()))();
|
|
1231
|
+
const quotaIndex = readSubmissionQuotaIndex(options.rootDir);
|
|
1232
|
+
const guard = recoverPreparingReservation(options.rootDir, state, quotaIndex, readSubmissionGuard(options.rootDir, current.fingerprint));
|
|
1233
|
+
const quotaCompacted = compactSubmissionQuotaIndex(quotaIndex, now.getTime(), rateWindow);
|
|
1234
|
+
const conflict = issueDraftConflict(state, current.fingerprint, guard, quotaIndex.entries.find((entry) => entry.fingerprint === current.fingerprint));
|
|
1235
|
+
if (conflict) {
|
|
1236
|
+
if (quotaCompacted)
|
|
1237
|
+
writeSubmissionQuotaIndex(options.rootDir, quotaIndex);
|
|
1238
|
+
return {
|
|
1239
|
+
kind: 'result',
|
|
1240
|
+
result: {
|
|
1241
|
+
status: 'rate_limited',
|
|
1242
|
+
draft: current,
|
|
1243
|
+
errorClass: conflict,
|
|
1244
|
+
},
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
const quotaFingerprints = new Set();
|
|
1248
|
+
for (const record of state.submissions) {
|
|
1249
|
+
if (now.getTime() - Date.parse(record.submittedAt) < rateWindow) {
|
|
1250
|
+
quotaFingerprints.add(record.fingerprint);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
for (const record of state.attempts) {
|
|
1254
|
+
if (now.getTime() - Date.parse(record.attemptedAt) < rateWindow) {
|
|
1255
|
+
quotaFingerprints.add(record.fingerprint);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
for (const record of state.reservations) {
|
|
1259
|
+
if (now.getTime() - Date.parse(record.reservedAt) < rateWindow) {
|
|
1260
|
+
quotaFingerprints.add(record.fingerprint);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
for (const entry of quotaIndex.entries)
|
|
1264
|
+
quotaFingerprints.add(entry.fingerprint);
|
|
1265
|
+
const maxSubmissions = Math.min(options.maxSubmissionsPerWindow ?? DEFAULT_MAX_SUBMISSIONS, MAX_SUBMISSION_QUOTA_ENTRIES);
|
|
1266
|
+
if (quotaFingerprints.size >= maxSubmissions) {
|
|
1267
|
+
if (quotaCompacted)
|
|
1268
|
+
writeSubmissionQuotaIndex(options.rootDir, quotaIndex);
|
|
1269
|
+
return { kind: 'result', result: { status: 'rate_limited', draft: current } };
|
|
1270
|
+
}
|
|
1271
|
+
const attemptedAt = now.toISOString();
|
|
1272
|
+
const attemptId = randomUUID();
|
|
1273
|
+
if (!upsertSubmissionQuotaEntry(quotaIndex, {
|
|
1274
|
+
fingerprint: current.fingerprint,
|
|
1275
|
+
attemptId,
|
|
1276
|
+
countedAt: attemptedAt,
|
|
1277
|
+
})) {
|
|
1278
|
+
if (quotaCompacted)
|
|
1279
|
+
writeSubmissionQuotaIndex(options.rootDir, quotaIndex);
|
|
1280
|
+
return { kind: 'result', result: { status: 'rate_limited', draft: current } };
|
|
1281
|
+
}
|
|
1282
|
+
// Transport is allowed only after this marker advances from recoverable preparation to pending.
|
|
1283
|
+
const guardRecord = {
|
|
1284
|
+
version: STATE_VERSION,
|
|
1285
|
+
fingerprint: current.fingerprint,
|
|
1286
|
+
attemptId,
|
|
1287
|
+
reservedAt: attemptedAt,
|
|
1288
|
+
status: 'preparing',
|
|
1289
|
+
};
|
|
1290
|
+
try {
|
|
1291
|
+
writeSubmissionGuard(options.rootDir, guardRecord);
|
|
1292
|
+
state.attempts.push({ fingerprint: current.fingerprint, attemptedAt });
|
|
1293
|
+
state.reservations.push({
|
|
1294
|
+
fingerprint: current.fingerprint,
|
|
1295
|
+
attemptId,
|
|
1296
|
+
reservedAt: attemptedAt,
|
|
1297
|
+
status: 'pending',
|
|
1298
|
+
});
|
|
1299
|
+
writeState(options.rootDir, state);
|
|
1300
|
+
writeSubmissionQuotaIndex(options.rootDir, quotaIndex);
|
|
1301
|
+
writeSubmissionGuard(options.rootDir, { ...guardRecord, status: 'pending' });
|
|
1302
|
+
}
|
|
1303
|
+
catch (error) {
|
|
1304
|
+
rollbackReservationBestEffort(options.rootDir, state, quotaIndex, guardRecord);
|
|
1305
|
+
throw error;
|
|
1306
|
+
}
|
|
1307
|
+
return { kind: 'reserved', attemptId, reservedAt: attemptedAt, draft: current };
|
|
1308
|
+
});
|
|
1309
|
+
if (prepared.kind === 'result') {
|
|
1310
|
+
const status = prepared.result.status === 'failed' || prepared.result.status === 'approval_required'
|
|
1311
|
+
? 'submission_failed'
|
|
1312
|
+
: prepared.result.status === 'already_submitted' ? 'submitted' : prepared.result.status;
|
|
1313
|
+
log(options, {
|
|
1314
|
+
fingerprint: draft.fingerprint,
|
|
1315
|
+
status,
|
|
1316
|
+
...(prepared.result.status === 'failed' ? { errorClass: prepared.result.errorClass } : {}),
|
|
1317
|
+
});
|
|
1318
|
+
return prepared.result;
|
|
1319
|
+
}
|
|
1320
|
+
let result;
|
|
1321
|
+
try {
|
|
1322
|
+
result = await submit.transport.createIssue({
|
|
1323
|
+
repo: submit.repo,
|
|
1324
|
+
title: prepared.draft.title,
|
|
1325
|
+
body: prepared.draft.body,
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
catch (error) {
|
|
1329
|
+
if (error instanceof GithubIssueTransportError && error.outcome === 'not_created') {
|
|
1330
|
+
try {
|
|
1331
|
+
cancelConfirmedNotCreatedAttempt(options.rootDir, prepared.draft.fingerprint, prepared.attemptId);
|
|
1332
|
+
}
|
|
1333
|
+
catch {
|
|
1334
|
+
markReservationAmbiguousBestEffort(options.rootDir, prepared.draft.fingerprint, prepared.attemptId, prepared.reservedAt);
|
|
1335
|
+
}
|
|
1336
|
+
log(options, { fingerprint: draft.fingerprint, status: 'submission_failed', errorClass: 'transport_error' });
|
|
1337
|
+
return { status: 'failed', draft: prepared.draft, errorClass: 'transport_error' };
|
|
1338
|
+
}
|
|
1339
|
+
markReservationAmbiguousBestEffort(options.rootDir, prepared.draft.fingerprint, prepared.attemptId, prepared.reservedAt);
|
|
1340
|
+
log(options, { fingerprint: draft.fingerprint, status: 'submission_failed', errorClass: 'transport_error' });
|
|
1341
|
+
return { status: 'failed', draft: prepared.draft, errorClass: 'transport_error' };
|
|
1342
|
+
}
|
|
1343
|
+
if (!isSafeGitHubIssueUrl(result.url, result.number, submit.repo)) {
|
|
1344
|
+
markReservationAmbiguousBestEffort(options.rootDir, prepared.draft.fingerprint, prepared.attemptId, prepared.reservedAt);
|
|
1345
|
+
log(options, { fingerprint: draft.fingerprint, status: 'submission_failed', errorClass: 'invalid_response' });
|
|
1346
|
+
return { status: 'failed', draft: prepared.draft, errorClass: 'invalid_response' };
|
|
1347
|
+
}
|
|
1348
|
+
const submittedAt = safeIsoNow(options.now);
|
|
1349
|
+
const quotaTimestampPersisted = advanceSubmissionQuotaTimestamp(options.rootDir, prepared.draft.fingerprint, prepared.attemptId, submittedAt, rateWindow);
|
|
1350
|
+
let finalized;
|
|
1351
|
+
let remoteOutcomePersisted = false;
|
|
1352
|
+
try {
|
|
1353
|
+
finalized = withReporterLock(options.rootDir, () => {
|
|
1354
|
+
const state = readState(options.rootDir);
|
|
1355
|
+
const priorRejection = latestRejection(state, prepared.draft.fingerprint);
|
|
1356
|
+
if (priorRejection) {
|
|
1357
|
+
persistDraft(options.rootDir, {
|
|
1358
|
+
...prepared.draft,
|
|
1359
|
+
status: 'rejected',
|
|
1360
|
+
updatedAt: priorRejection.rejectedAt,
|
|
1361
|
+
});
|
|
1362
|
+
const submittedGuard = {
|
|
1363
|
+
version: STATE_VERSION,
|
|
1364
|
+
fingerprint: prepared.draft.fingerprint,
|
|
1365
|
+
attemptId: prepared.attemptId,
|
|
1366
|
+
reservedAt: prepared.reservedAt,
|
|
1367
|
+
status: 'submitted',
|
|
1368
|
+
};
|
|
1369
|
+
writeSubmissionGuard(options.rootDir, submittedGuard);
|
|
1370
|
+
state.reservations = state.reservations.filter((record) => !(record.fingerprint === prepared.draft.fingerprint
|
|
1371
|
+
&& record.attemptId === prepared.attemptId
|
|
1372
|
+
&& record.reservedAt === prepared.reservedAt));
|
|
1373
|
+
writeState(options.rootDir, state);
|
|
1374
|
+
throw new Error('issue_report_rejected_before_finalize');
|
|
1375
|
+
}
|
|
1376
|
+
const priorSubmission = latestSubmission(state, prepared.draft.fingerprint);
|
|
1377
|
+
if (priorSubmission) {
|
|
1378
|
+
const terminal = terminalDraftFromState(prepared.draft, state);
|
|
1379
|
+
writeSubmissionGuard(options.rootDir, {
|
|
1380
|
+
version: STATE_VERSION,
|
|
1381
|
+
fingerprint: prepared.draft.fingerprint,
|
|
1382
|
+
attemptId: prepared.attemptId,
|
|
1383
|
+
reservedAt: prepared.reservedAt,
|
|
1384
|
+
status: 'submitted',
|
|
1385
|
+
});
|
|
1386
|
+
remoteOutcomePersisted = true;
|
|
1387
|
+
persistDraft(options.rootDir, terminal);
|
|
1388
|
+
return submittedResult(terminal, true);
|
|
1389
|
+
}
|
|
1390
|
+
const reservation = state.reservations.find((record) => record.fingerprint === prepared.draft.fingerprint
|
|
1391
|
+
&& record.attemptId === prepared.attemptId);
|
|
1392
|
+
if (!reservation)
|
|
1393
|
+
throw new Error('issue_report_reservation_lost_after_submit');
|
|
1394
|
+
const current = prepared.draft;
|
|
1395
|
+
const saved = {
|
|
1396
|
+
...current,
|
|
1397
|
+
status: 'submitted',
|
|
1398
|
+
updatedAt: submittedAt,
|
|
1399
|
+
github: { issueNumber: result.number, url: result.url },
|
|
1400
|
+
};
|
|
1401
|
+
writeSubmissionGuard(options.rootDir, {
|
|
1402
|
+
version: STATE_VERSION,
|
|
1403
|
+
fingerprint: prepared.draft.fingerprint,
|
|
1404
|
+
attemptId: prepared.attemptId,
|
|
1405
|
+
reservedAt: prepared.reservedAt,
|
|
1406
|
+
status: 'submitted',
|
|
1407
|
+
});
|
|
1408
|
+
remoteOutcomePersisted = true;
|
|
1409
|
+
state.submissions.push({
|
|
1410
|
+
fingerprint: saved.fingerprint,
|
|
1411
|
+
submittedAt,
|
|
1412
|
+
issueNumber: result.number,
|
|
1413
|
+
url: result.url,
|
|
1414
|
+
});
|
|
1415
|
+
state.reservations = state.reservations.filter((record) => !(record.fingerprint === prepared.draft.fingerprint
|
|
1416
|
+
&& record.attemptId === prepared.attemptId
|
|
1417
|
+
&& record.reservedAt === prepared.reservedAt));
|
|
1418
|
+
writeState(options.rootDir, state);
|
|
1419
|
+
persistDraft(options.rootDir, saved);
|
|
1420
|
+
return submittedResult(saved, false);
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
catch {
|
|
1424
|
+
const submittedDraft = {
|
|
1425
|
+
...prepared.draft,
|
|
1426
|
+
status: 'submitted',
|
|
1427
|
+
updatedAt: submittedAt,
|
|
1428
|
+
github: { issueNumber: result.number, url: result.url },
|
|
1429
|
+
};
|
|
1430
|
+
let outcome = remoteOutcomePersisted
|
|
1431
|
+
? 'submitted'
|
|
1432
|
+
: durableOutcome(options.rootDir, prepared.draft.fingerprint);
|
|
1433
|
+
if (outcome === 'none') {
|
|
1434
|
+
outcome = persistSubmittedDraftFallback(options.rootDir, submittedDraft, prepared.attemptId, prepared.reservedAt);
|
|
1435
|
+
}
|
|
1436
|
+
if (outcome === 'none') {
|
|
1437
|
+
markReservationAmbiguousBestEffort(options.rootDir, prepared.draft.fingerprint, prepared.attemptId, prepared.reservedAt);
|
|
1438
|
+
outcome = durableOutcome(options.rootDir, prepared.draft.fingerprint);
|
|
1439
|
+
}
|
|
1440
|
+
if (outcome !== 'submitted') {
|
|
1441
|
+
log(options, {
|
|
1442
|
+
fingerprint: prepared.draft.fingerprint,
|
|
1443
|
+
status: 'submission_failed',
|
|
1444
|
+
errorClass: 'local_finalize_error',
|
|
1445
|
+
});
|
|
1446
|
+
return {
|
|
1447
|
+
status: 'failed',
|
|
1448
|
+
draft: prepared.draft,
|
|
1449
|
+
errorClass: 'local_finalize_error',
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
const fallback = {
|
|
1453
|
+
status: 'submitted',
|
|
1454
|
+
draft: submittedDraft,
|
|
1455
|
+
errorClass: 'local_finalize_error',
|
|
1456
|
+
};
|
|
1457
|
+
log(options, {
|
|
1458
|
+
fingerprint: submittedDraft.fingerprint,
|
|
1459
|
+
status: 'submitted',
|
|
1460
|
+
errorClass: 'local_finalize_error',
|
|
1461
|
+
});
|
|
1462
|
+
return fallback;
|
|
1463
|
+
}
|
|
1464
|
+
if (!quotaTimestampPersisted) {
|
|
1465
|
+
const degraded = {
|
|
1466
|
+
status: 'submitted',
|
|
1467
|
+
draft: finalized.draft,
|
|
1468
|
+
errorClass: 'quota_persistence_error',
|
|
1469
|
+
};
|
|
1470
|
+
log(options, {
|
|
1471
|
+
fingerprint: prepared.draft.fingerprint,
|
|
1472
|
+
status: 'submitted',
|
|
1473
|
+
errorClass: 'quota_persistence_error',
|
|
1474
|
+
});
|
|
1475
|
+
return degraded;
|
|
1476
|
+
}
|
|
1477
|
+
log(options, { fingerprint: prepared.draft.fingerprint, status: 'submitted' });
|
|
1478
|
+
return finalized;
|
|
1479
|
+
}
|
|
1480
|
+
export function lookupIssueDraft(rootDir, fingerprint) {
|
|
1481
|
+
if (!INTERNAL_FINGERPRINT_RE.test(fingerprint))
|
|
1482
|
+
return { status: 'invalid' };
|
|
1483
|
+
try {
|
|
1484
|
+
const draft = issueDraftFromDisk(rootDir, fingerprint);
|
|
1485
|
+
return draft ? { status: 'found', draft } : { status: 'missing' };
|
|
1486
|
+
}
|
|
1487
|
+
catch {
|
|
1488
|
+
return { status: 'invalid' };
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
export function loadIssueDraft(rootDir, fingerprint) {
|
|
1492
|
+
const result = lookupIssueDraft(rootDir, fingerprint);
|
|
1493
|
+
return result.status === 'found' ? result.draft : null;
|
|
1494
|
+
}
|
|
1495
|
+
export function resolveIssueSubmission(fingerprint, resolution, options) {
|
|
1496
|
+
if (!INTERNAL_FINGERPRINT_RE.test(fingerprint))
|
|
1497
|
+
throw new TypeError('invalid_issue_draft');
|
|
1498
|
+
if (resolution.outcome === 'submitted'
|
|
1499
|
+
&& (!isSafeGitHubRepo(resolution.repo, options.env ?? {})
|
|
1500
|
+
|| !isSafeGitHubIssueUrl(resolution.url, resolution.issueNumber, resolution.repo))) {
|
|
1501
|
+
throw new TypeError('invalid_issue_resolution');
|
|
1502
|
+
}
|
|
1503
|
+
return withReporterLock(options.rootDir, () => {
|
|
1504
|
+
const draft = issueDraftFromDisk(options.rootDir, fingerprint);
|
|
1505
|
+
if (!draft)
|
|
1506
|
+
throw new TypeError('invalid_issue_draft');
|
|
1507
|
+
const state = readState(options.rootDir);
|
|
1508
|
+
const terminal = terminalDraftFromState(draft, state);
|
|
1509
|
+
if (terminal) {
|
|
1510
|
+
persistDraft(options.rootDir, terminal);
|
|
1511
|
+
return terminal;
|
|
1512
|
+
}
|
|
1513
|
+
if (draft.status !== 'draft')
|
|
1514
|
+
return draft;
|
|
1515
|
+
const quotaIndex = readSubmissionQuotaIndex(options.rootDir);
|
|
1516
|
+
const storedGuard = readSubmissionGuard(options.rootDir, fingerprint);
|
|
1517
|
+
const guard = recoverPreparingReservation(options.rootDir, state, quotaIndex, storedGuard);
|
|
1518
|
+
const reservations = state.reservations.filter((record) => record.fingerprint === fingerprint);
|
|
1519
|
+
const timestamp = safeIsoNow(options.now);
|
|
1520
|
+
const nowMs = Date.parse(timestamp);
|
|
1521
|
+
if ((guard?.status === 'pending' && pendingSubmissionIsLive(guard.reservedAt, nowMs))
|
|
1522
|
+
|| reservations.some((record) => (record.status === 'pending' && pendingSubmissionIsLive(record.reservedAt, nowMs)))) {
|
|
1523
|
+
throw new IssueDraftConflictError('issue_report_submission_in_flight');
|
|
1524
|
+
}
|
|
1525
|
+
if (guard?.status === 'submitted' && resolution.outcome !== 'submitted') {
|
|
1526
|
+
throw new IssueDraftConflictError('issue_report_submission_ambiguous');
|
|
1527
|
+
}
|
|
1528
|
+
const reservation = reservations.filter((record) => record.status === 'ambiguous' || record.status === 'pending').at(-1);
|
|
1529
|
+
const quotaEntry = quotaIndex.entries.filter((entry) => entry.fingerprint === fingerprint).at(-1);
|
|
1530
|
+
const attempt = state.attempts.filter((record) => record.fingerprint === fingerprint).at(-1);
|
|
1531
|
+
const guardEvidence = guard?.status === 'ambiguous'
|
|
1532
|
+
|| (guard?.status === 'submitted' && resolution.outcome === 'submitted')
|
|
1533
|
+
|| guard?.status === 'pending'
|
|
1534
|
+
? guard
|
|
1535
|
+
: storedGuard?.status === 'preparing' ? storedGuard : undefined;
|
|
1536
|
+
if (!guardEvidence && !reservation && !quotaEntry && !attempt) {
|
|
1537
|
+
throw new IssueDraftConflictError('issue_report_submission_ambiguous');
|
|
1538
|
+
}
|
|
1539
|
+
const attemptId = guardEvidence?.attemptId
|
|
1540
|
+
?? reservation?.attemptId
|
|
1541
|
+
?? quotaEntry?.attemptId
|
|
1542
|
+
?? randomUUID();
|
|
1543
|
+
const reservedAt = guardEvidence?.reservedAt
|
|
1544
|
+
?? reservation?.reservedAt
|
|
1545
|
+
?? quotaEntry?.countedAt
|
|
1546
|
+
?? attempt.attemptedAt;
|
|
1547
|
+
if (resolution.outcome === 'submitted') {
|
|
1548
|
+
const submitted = {
|
|
1549
|
+
...draft,
|
|
1550
|
+
status: 'submitted',
|
|
1551
|
+
updatedAt: timestamp,
|
|
1552
|
+
github: { issueNumber: resolution.issueNumber, url: resolution.url },
|
|
1553
|
+
};
|
|
1554
|
+
writeSubmissionGuard(options.rootDir, {
|
|
1555
|
+
version: STATE_VERSION,
|
|
1556
|
+
fingerprint,
|
|
1557
|
+
attemptId,
|
|
1558
|
+
reservedAt,
|
|
1559
|
+
status: 'submitted',
|
|
1560
|
+
});
|
|
1561
|
+
state.submissions.push({
|
|
1562
|
+
fingerprint,
|
|
1563
|
+
submittedAt: timestamp,
|
|
1564
|
+
issueNumber: resolution.issueNumber,
|
|
1565
|
+
url: resolution.url,
|
|
1566
|
+
});
|
|
1567
|
+
state.reservations = state.reservations.filter((record) => record.fingerprint !== fingerprint);
|
|
1568
|
+
writeState(options.rootDir, state);
|
|
1569
|
+
persistDraft(options.rootDir, submitted);
|
|
1570
|
+
return submitted;
|
|
1571
|
+
}
|
|
1572
|
+
writeSubmissionGuard(options.rootDir, {
|
|
1573
|
+
version: STATE_VERSION,
|
|
1574
|
+
fingerprint,
|
|
1575
|
+
attemptId,
|
|
1576
|
+
reservedAt,
|
|
1577
|
+
status: 'cancelled',
|
|
1578
|
+
});
|
|
1579
|
+
state.reservations = state.reservations.filter((record) => record.fingerprint !== fingerprint);
|
|
1580
|
+
state.attempts = state.attempts.filter((record) => !(record.fingerprint === fingerprint
|
|
1581
|
+
&& (reservations.some((reservation) => reservation.reservedAt === record.attemptedAt)
|
|
1582
|
+
|| record.attemptedAt === reservedAt)));
|
|
1583
|
+
quotaIndex.entries = quotaIndex.entries.filter((entry) => entry.fingerprint !== fingerprint);
|
|
1584
|
+
if (resolution.outcome === 'abandoned') {
|
|
1585
|
+
const rejected = { ...draft, status: 'rejected', updatedAt: timestamp };
|
|
1586
|
+
state.rejections.push({ fingerprint, rejectedAt: timestamp });
|
|
1587
|
+
writeState(options.rootDir, state);
|
|
1588
|
+
writeSubmissionQuotaIndex(options.rootDir, quotaIndex);
|
|
1589
|
+
persistDraft(options.rootDir, rejected);
|
|
1590
|
+
return rejected;
|
|
1591
|
+
}
|
|
1592
|
+
writeState(options.rootDir, state);
|
|
1593
|
+
writeSubmissionQuotaIndex(options.rootDir, quotaIndex);
|
|
1594
|
+
persistDraft(options.rootDir, draft);
|
|
1595
|
+
return draft;
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
function structuredTraceIds(payload) {
|
|
1599
|
+
if (!payload)
|
|
1600
|
+
return [];
|
|
1601
|
+
return [payload['traceId'], payload['trace_id']]
|
|
1602
|
+
.filter((value) => typeof value === 'string' && value.length > 0);
|
|
1603
|
+
}
|
|
1604
|
+
export function issueReportInputFromCycle(events, cycleId) {
|
|
1605
|
+
if (cycleId.length === 0 || cycleId.length > MAX_UNTRUSTED_FIELD_CHARS)
|
|
1606
|
+
return null;
|
|
1607
|
+
const related = events.filter((event) => event.payload?.['cycleId'] === cycleId);
|
|
1608
|
+
const terminal = [...related].reverse().find((event) => event.type === 'cycle.failed');
|
|
1609
|
+
if (!terminal)
|
|
1610
|
+
return null;
|
|
1611
|
+
const failureClass = allowlistedToken(typeof terminal.payload?.['failure_class'] === 'string' ? terminal.payload['failure_class'] : undefined, CYCLE_FAILURE_CLASSES, 'unclassified');
|
|
1612
|
+
return {
|
|
1613
|
+
source: 'cycle_failure',
|
|
1614
|
+
errorClass: failureClass === 'unclassified' ? 'cycle_failed' : failureClass,
|
|
1615
|
+
failureClass,
|
|
1616
|
+
cycleIds: [cycleId],
|
|
1617
|
+
eventIds: related.map((event) => event.eventId).filter((id) => typeof id === 'string'),
|
|
1618
|
+
traceIds: related.flatMap((event) => structuredTraceIds(event.payload)),
|
|
1619
|
+
reproductionSteps: ['inspect_cycle', 'run_doctor', 'retry_cycle'],
|
|
1620
|
+
diagnosticCodes: ['cycle_failed', failureClass],
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1623
|
+
export function issueReportInputFromDoctor(checks) {
|
|
1624
|
+
const failing = checks.filter((check) => check.status !== 'pass')
|
|
1625
|
+
.map((check) => safeCode(check.name, ''))
|
|
1626
|
+
.filter((code) => DIAGNOSTIC_CODES.doctor.has(code));
|
|
1627
|
+
if (failing.length === 0)
|
|
1628
|
+
return null;
|
|
1629
|
+
return {
|
|
1630
|
+
source: 'doctor',
|
|
1631
|
+
errorClass: checks.some((check) => check.status === 'fail') ? 'doctor_failed' : 'doctor_warning',
|
|
1632
|
+
reproductionSteps: ['run_doctor', 'review_local_draft'],
|
|
1633
|
+
diagnosticCodes: failing,
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
export function issueReportInputFromReview(record) {
|
|
1637
|
+
if (!record.assetId || record.assetId.length > MAX_UNTRUSTED_FIELD_CHARS || record.state !== 'rejected')
|
|
1638
|
+
return null;
|
|
1639
|
+
return {
|
|
1640
|
+
source: 'review',
|
|
1641
|
+
errorClass: 'review_rejected',
|
|
1642
|
+
reproductionSteps: ['review_local_draft'],
|
|
1643
|
+
diagnosticCodes: ['review_rejected'],
|
|
1644
|
+
eventIds: [record.assetId],
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
export function issueReportInputFromEvent(event) {
|
|
1648
|
+
const mapping = {
|
|
1649
|
+
'cycle.aborted': 'cycle_aborted',
|
|
1650
|
+
'observer.quarantined': 'observer_quarantined',
|
|
1651
|
+
'observer.dead_letter': 'observer_dead_letter',
|
|
1652
|
+
};
|
|
1653
|
+
const errorClass = mapping[event.type];
|
|
1654
|
+
if (!errorClass)
|
|
1655
|
+
return null;
|
|
1656
|
+
const cycleId = typeof event.payload?.['cycleId'] === 'string' ? event.payload['cycleId'] : undefined;
|
|
1657
|
+
return {
|
|
1658
|
+
source: 'event',
|
|
1659
|
+
errorClass,
|
|
1660
|
+
...(cycleId ? { cycleIds: [cycleId] } : {}),
|
|
1661
|
+
...(event.eventId ? { eventIds: [event.eventId] } : {}),
|
|
1662
|
+
traceIds: structuredTraceIds(event.payload),
|
|
1663
|
+
reproductionSteps: ['inspect_event', 'review_local_draft'],
|
|
1664
|
+
diagnosticCodes: [errorClass],
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
export function createIssueDraftForEventBestEffort(event, options) {
|
|
1668
|
+
try {
|
|
1669
|
+
let input;
|
|
1670
|
+
if (event.type === 'cycle.failed') {
|
|
1671
|
+
const cycleId = typeof event.payload?.['cycleId'] === 'string' ? event.payload['cycleId'] : undefined;
|
|
1672
|
+
input = cycleId ? issueReportInputFromCycle([event], cycleId) : null;
|
|
1673
|
+
}
|
|
1674
|
+
else if (event.type === 'actor.human.review.reject') {
|
|
1675
|
+
const assetId = typeof event.payload?.['assetId'] === 'string' ? event.payload['assetId'] : undefined;
|
|
1676
|
+
input = assetId ? issueReportInputFromReview({ assetId, state: 'rejected' }) : null;
|
|
1677
|
+
if (input)
|
|
1678
|
+
input.traceIds = structuredTraceIds(event.payload);
|
|
1679
|
+
}
|
|
1680
|
+
else {
|
|
1681
|
+
input = issueReportInputFromEvent(event);
|
|
1682
|
+
}
|
|
1683
|
+
return input ? createIssueDraft(input, options) : null;
|
|
1684
|
+
}
|
|
1685
|
+
catch {
|
|
1686
|
+
return null;
|
|
1687
|
+
}
|
|
1688
|
+
}
|