@withtruing/companion 0.13.3 → 0.14.1
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 +24 -25
- package/dist/anthropic-output-schema.js +41 -0
- package/dist/api-key-caller.js +9 -2
- package/dist/claude-cli-caller.js +107 -8
- package/dist/cli.js +833 -206
- package/dist/codex-cli-caller.js +85 -14
- package/dist/config.js +177 -2
- package/dist/git-range.js +88 -46
- package/dist/http-client.js +107 -10
- package/dist/investigation-reader.js +12 -6
- package/dist/managed-repo.js +132 -0
- package/dist/observed-sources.js +132 -6
- package/dist/reader-result-contract.js +296 -2
- package/dist/repository-provider.js +608 -0
- package/dist/schema-entities.js +18 -18
- package/dist/service.js +7 -2
- package/dist/surface-mapper.js +53 -11
- package/dist/text-model.js +91 -8
- package/dist/whole-read/archive.js +196 -0
- package/dist/whole-read/checks.js +297 -0
- package/dist/whole-read/claims.js +196 -0
- package/dist/whole-read/coverage-ledger.js +51 -0
- package/dist/whole-read/decode.js +234 -0
- package/dist/whole-read/drafting.js +502 -0
- package/dist/whole-read/generated-rules.js +68 -0
- package/dist/whole-read/go-proof.js +255 -0
- package/dist/whole-read/grouping-prompt.js +19 -0
- package/dist/whole-read/grouping.js +247 -0
- package/dist/whole-read/identity.js +103 -0
- package/dist/whole-read/import-graph.js +293 -0
- package/dist/whole-read/inventory-collector.js +55 -0
- package/dist/whole-read/inventory-rules.js +141 -0
- package/dist/whole-read/lockfiles.js +947 -0
- package/dist/whole-read/notes.js +216 -0
- package/dist/whole-read/npm-proof.js +272 -0
- package/dist/whole-read/owner-decision-detectors.js +270 -0
- package/dist/whole-read/packing.js +612 -0
- package/dist/whole-read/pages.js +429 -0
- package/dist/whole-read/phase-policy.js +54 -0
- package/dist/whole-read/prd-build.js +279 -0
- package/dist/whole-read/prd-fidelity.js +14 -0
- package/dist/whole-read/python-proof.js +489 -0
- package/dist/whole-read/read-prompt.js +67 -0
- package/dist/whole-read/redaction.js +411 -0
- package/dist/whole-read/relationship-mapping.js +98 -0
- package/dist/whole-read/sanitize.js +266 -0
- package/dist/whole-read/secret-rules.js +26 -0
- package/dist/whole-read/structure.js +300 -0
- package/dist/whole-read/types.js +2 -0
- package/dist/whole-read-claims.js +12 -0
- package/dist/whole-read-git.js +340 -0
- package/dist/whole-read-pool.js +80 -0
- package/dist/whole-read-runner.js +945 -0
- package/dist/whole-read-shared-source.js +194 -0
- package/dist/whole-read-temp.js +479 -0
- package/package.json +8 -2
package/dist/codex-cli-caller.js
CHANGED
|
@@ -12,6 +12,8 @@ import { assertSingleLineArguments } from './cli-arguments.js';
|
|
|
12
12
|
import { resolveCodexCommand } from './codex-binary.js';
|
|
13
13
|
import { gitErrorMessage } from './sandbox.js';
|
|
14
14
|
import { stripIntroducedNulls, toCodexStrictSchema } from './strict-schema.js';
|
|
15
|
+
import { WholeReadTransportError, } from './text-model.js';
|
|
16
|
+
import { isUsageLimitError, parseUsageLimitReset } from './usage-limit.js';
|
|
15
17
|
const WALL_CLOCK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
16
18
|
const STDOUT_SAMPLE_CHARS = 200;
|
|
17
19
|
const UNPARSEABLE_LINE_SAMPLE_CHARS = 120;
|
|
@@ -78,10 +80,19 @@ function terminateChild(child) {
|
|
|
78
80
|
if (process.platform === 'win32' && child.pid) {
|
|
79
81
|
const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
80
82
|
killer.on('error', () => child.kill());
|
|
83
|
+
killer.on('close', (code) => { if (code !== 0)
|
|
84
|
+
child.kill(); });
|
|
81
85
|
return;
|
|
82
86
|
}
|
|
83
87
|
child.kill();
|
|
84
88
|
}
|
|
89
|
+
async function terminateChildAndWait(child) {
|
|
90
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
91
|
+
return;
|
|
92
|
+
const closed = new Promise((resolvePromise) => child.once('close', () => resolvePromise()));
|
|
93
|
+
terminateChild(child);
|
|
94
|
+
await closed;
|
|
95
|
+
}
|
|
85
96
|
function isWithin(base, candidate) {
|
|
86
97
|
const relation = relative(resolve(base), resolve(candidate));
|
|
87
98
|
return relation === '' || (!relation.startsWith('..') && !isAbsolute(relation));
|
|
@@ -240,6 +251,19 @@ function namedCodexFailure(message, codexHome) {
|
|
|
240
251
|
}
|
|
241
252
|
return null;
|
|
242
253
|
}
|
|
254
|
+
function safeCodexFailure(message) {
|
|
255
|
+
if (isUsageLimitError(message)) {
|
|
256
|
+
const reset = parseUsageLimitReset(message);
|
|
257
|
+
return new WholeReadTransportError('usage_limit', 'codex', reset?.toISOString());
|
|
258
|
+
}
|
|
259
|
+
if (/billing|credit|payment required/i.test(message)) {
|
|
260
|
+
return new WholeReadTransportError('billing_exhausted', 'codex');
|
|
261
|
+
}
|
|
262
|
+
if (/rate_limit_error|http 429|too many requests|overloaded/i.test(message)) {
|
|
263
|
+
return new WholeReadTransportError('provider_rate_limit', 'codex');
|
|
264
|
+
}
|
|
265
|
+
return new WholeReadTransportError('capability_failure', 'codex');
|
|
266
|
+
}
|
|
243
267
|
function usageFrom(value) {
|
|
244
268
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
245
269
|
return null;
|
|
@@ -253,7 +277,7 @@ function usageFrom(value) {
|
|
|
253
277
|
function toolUseAttempt(type) {
|
|
254
278
|
return new Error(`CodexToolUseAttempted: Codex attempted a tool (${type}) in a no-tools call; the result was discarded.`);
|
|
255
279
|
}
|
|
256
|
-
export function buildArgs(request, options, cwd, outputPath, schemaPath, instructionsPath) {
|
|
280
|
+
export function buildArgs(request, options, cwd, outputPath, schemaPath, instructionsPath, tempRoot = tmpdir()) {
|
|
257
281
|
const args = [
|
|
258
282
|
'exec',
|
|
259
283
|
'--json',
|
|
@@ -285,7 +309,7 @@ export function buildArgs(request, options, cwd, outputPath, schemaPath, instruc
|
|
|
285
309
|
'-',
|
|
286
310
|
];
|
|
287
311
|
assertSingleLineArguments('codex', args);
|
|
288
|
-
assertSafeInvocation(args);
|
|
312
|
+
assertSafeInvocation(args, tempRoot);
|
|
289
313
|
return args;
|
|
290
314
|
}
|
|
291
315
|
function diagnostic(exitCode, stderrTail, stdoutBytes, stdoutLines, parsedLines, unparseableLines, stdoutFirst, stdoutLast, firstUnparseableLineLength, firstUnparseableLineFirst, firstUnparseableLineLast, typeCounts, toleratedErrorItems, purpose) {
|
|
@@ -315,10 +339,11 @@ removeDirectory = rm) {
|
|
|
315
339
|
}
|
|
316
340
|
assertAllowedModel(options.model);
|
|
317
341
|
const codexHome = await validatedCodexHome(options.codexHome ?? defaultCodexHome());
|
|
318
|
-
const
|
|
342
|
+
const tempRoot = request.wholeRead?.attemptDirectory ?? tmpdir();
|
|
343
|
+
const invocationCwd = await mkdtemp(join(tempRoot, 'product-console-codex-empty-'));
|
|
319
344
|
let callDir = null;
|
|
320
345
|
try {
|
|
321
|
-
callDir = await mkdtemp(join(
|
|
346
|
+
callDir = await mkdtemp(join(tempRoot, 'product-console-codex-call-'));
|
|
322
347
|
const outputPath = join(callDir, 'last-message.txt');
|
|
323
348
|
const instructionsPath = join(callDir, 'model-instructions.txt');
|
|
324
349
|
await writeFile(instructionsPath, request.system, { encoding: 'utf8', mode: 0o600 });
|
|
@@ -330,13 +355,15 @@ removeDirectory = rm) {
|
|
|
330
355
|
schemaPath = join(callDir, 'schema.json');
|
|
331
356
|
await writeFile(schemaPath, JSON.stringify(strict.schema), { encoding: 'utf8', mode: 0o600 });
|
|
332
357
|
}
|
|
333
|
-
const args = buildArgs(request, options, invocationCwd, outputPath, schemaPath, instructionsPath);
|
|
358
|
+
const args = buildArgs(request, options, invocationCwd, outputPath, schemaPath, instructionsPath, tempRoot);
|
|
334
359
|
let stderrTail = '';
|
|
335
360
|
let timedOut = false;
|
|
336
361
|
let stdinError = null;
|
|
337
362
|
let fatalError = null;
|
|
338
363
|
let turnFailure = '';
|
|
339
364
|
let usage = null;
|
|
365
|
+
let actualModel = null;
|
|
366
|
+
let finishReason = null;
|
|
340
367
|
let toleratedErrorItems = 0;
|
|
341
368
|
let stdoutBytes = 0;
|
|
342
369
|
let stdoutLines = 0;
|
|
@@ -356,7 +383,7 @@ removeDirectory = rm) {
|
|
|
356
383
|
});
|
|
357
384
|
const stdoutDecoder = new StringDecoder('utf8');
|
|
358
385
|
const recordStdout = (text) => {
|
|
359
|
-
if (!text)
|
|
386
|
+
if (!text || request.wholeRead)
|
|
360
387
|
return;
|
|
361
388
|
if (stdoutFirst.length < STDOUT_SAMPLE_CHARS) {
|
|
362
389
|
stdoutFirst += text.slice(0, STDOUT_SAMPLE_CHARS - stdoutFirst.length);
|
|
@@ -373,7 +400,8 @@ removeDirectory = rm) {
|
|
|
373
400
|
terminateChild(child);
|
|
374
401
|
});
|
|
375
402
|
child.stderr.on('data', (chunk) => {
|
|
376
|
-
|
|
403
|
+
if (!request.wholeRead)
|
|
404
|
+
stderrTail = (stderrTail + chunk.toString()).slice(-2000);
|
|
377
405
|
});
|
|
378
406
|
const rl = createInterface({ input: child.stdout });
|
|
379
407
|
rl.on('line', (line) => {
|
|
@@ -384,7 +412,7 @@ removeDirectory = rm) {
|
|
|
384
412
|
}
|
|
385
413
|
catch {
|
|
386
414
|
unparseableLines++;
|
|
387
|
-
if (firstUnparseableLineLength === null) {
|
|
415
|
+
if (!request.wholeRead && firstUnparseableLineLength === null) {
|
|
388
416
|
firstUnparseableLineLength = line.length;
|
|
389
417
|
firstUnparseableLineFirst = line.slice(0, UNPARSEABLE_LINE_SAMPLE_CHARS);
|
|
390
418
|
firstUnparseableLineLast = line.slice(-UNPARSEABLE_LINE_SAMPLE_CHARS);
|
|
@@ -403,22 +431,29 @@ removeDirectory = rm) {
|
|
|
403
431
|
toleratedErrorItems++;
|
|
404
432
|
}
|
|
405
433
|
else if (classified !== 'agent_message' && classified !== 'reasoning') {
|
|
406
|
-
fatalError ??=
|
|
434
|
+
fatalError ??= request.wholeRead
|
|
435
|
+
? new WholeReadTransportError('capability_failure', 'codex')
|
|
436
|
+
: toolUseAttempt(classified);
|
|
407
437
|
terminateChild(child);
|
|
408
438
|
}
|
|
409
439
|
}
|
|
410
|
-
if (eventType === 'turn.completed')
|
|
440
|
+
if (eventType === 'turn.completed') {
|
|
411
441
|
usage = usageFrom(event.usage);
|
|
442
|
+
if (typeof event.model === 'string')
|
|
443
|
+
actualModel = event.model;
|
|
444
|
+
if (typeof event.finish_reason === 'string')
|
|
445
|
+
finishReason = event.finish_reason;
|
|
446
|
+
}
|
|
412
447
|
if (eventType === 'error' || eventType === 'turn.failed') {
|
|
413
448
|
const message = eventMessage(event);
|
|
414
449
|
const named = namedCodexFailure(message, codexHome);
|
|
415
450
|
if (named) {
|
|
416
|
-
fatalError ??= named;
|
|
451
|
+
fatalError ??= request.wholeRead ? safeCodexFailure(message) : named;
|
|
417
452
|
if (/401 Unauthorized/i.test(message))
|
|
418
453
|
terminateChild(child);
|
|
419
454
|
}
|
|
420
455
|
else if (eventType === 'turn.failed') {
|
|
421
|
-
turnFailure = message;
|
|
456
|
+
turnFailure = request.wholeRead ? 'capability_failure' : message;
|
|
422
457
|
}
|
|
423
458
|
}
|
|
424
459
|
});
|
|
@@ -430,6 +465,14 @@ removeDirectory = rm) {
|
|
|
430
465
|
child.on('error', reject);
|
|
431
466
|
child.on('close', (code) => resolvePromise(code ?? 1));
|
|
432
467
|
});
|
|
468
|
+
const unregisterReader = request.wholeRead?.registerReader({
|
|
469
|
+
cancel: () => terminateChildAndWait(child),
|
|
470
|
+
});
|
|
471
|
+
const abort = () => { void terminateChildAndWait(child); };
|
|
472
|
+
if (request.wholeRead?.signal.aborted)
|
|
473
|
+
abort();
|
|
474
|
+
else
|
|
475
|
+
request.wholeRead?.signal.addEventListener('abort', abort, { once: true });
|
|
433
476
|
try {
|
|
434
477
|
if (request.stableText)
|
|
435
478
|
child.stdin.write(`${request.stableText}\n\n`);
|
|
@@ -446,6 +489,11 @@ removeDirectory = rm) {
|
|
|
446
489
|
}
|
|
447
490
|
finally {
|
|
448
491
|
clearTimeout(timer);
|
|
492
|
+
request.wholeRead?.signal.removeEventListener('abort', abort);
|
|
493
|
+
unregisterReader?.();
|
|
494
|
+
}
|
|
495
|
+
if (request.wholeRead?.signal.aborted) {
|
|
496
|
+
throw new WholeReadTransportError('cancelled', 'codex');
|
|
449
497
|
}
|
|
450
498
|
options.onDiagnostic?.({ toleratedErrorItems });
|
|
451
499
|
if (/codex_core::tools::router/i.test(stderrTail) && /(?:spawn|exec_command)/i.test(stderrTail)) {
|
|
@@ -465,29 +513,52 @@ removeDirectory = rm) {
|
|
|
465
513
|
}
|
|
466
514
|
const capturedStdinError = stdinError;
|
|
467
515
|
if (turnFailure) {
|
|
516
|
+
if (request.wholeRead)
|
|
517
|
+
throw new WholeReadTransportError('capability_failure', 'codex');
|
|
468
518
|
const named = namedCodexFailure(turnFailure, codexHome);
|
|
469
519
|
throw named ?? new Error(`Codex turn failed: ${gitErrorMessage(turnFailure)}; ${diagnostic(exitCode, stderrTail, stdoutBytes, stdoutLines, parsedLines, unparseableLines, stdoutFirst, stdoutLast, firstUnparseableLineLength, firstUnparseableLineFirst, firstUnparseableLineLast, typeCounts, toleratedErrorItems, request.purpose)}`);
|
|
470
520
|
}
|
|
471
521
|
if (finalText === null || finalText.length === 0) {
|
|
522
|
+
if (request.wholeRead)
|
|
523
|
+
throw new WholeReadTransportError('capability_failure', 'codex');
|
|
472
524
|
if (capturedStdinError)
|
|
473
525
|
throw promptWriteFailure(capturedStdinError);
|
|
474
526
|
throw new Error(diagnostic(exitCode, stderrTail, stdoutBytes, stdoutLines, parsedLines, unparseableLines, stdoutFirst, stdoutLast, firstUnparseableLineLength, firstUnparseableLineFirst, firstUnparseableLineLast, typeCounts, toleratedErrorItems, request.purpose));
|
|
475
527
|
}
|
|
476
|
-
if (capturedStdinError)
|
|
528
|
+
if (capturedStdinError) {
|
|
529
|
+
if (request.wholeRead)
|
|
530
|
+
throw new WholeReadTransportError('capability_failure', 'codex');
|
|
477
531
|
throw promptWriteFailure(capturedStdinError);
|
|
532
|
+
}
|
|
533
|
+
if (request.wholeRead && finishReason === 'max_tokens') {
|
|
534
|
+
throw new WholeReadTransportError('output_truncated', 'codex');
|
|
535
|
+
}
|
|
536
|
+
if (request.wholeRead && finishReason === 'refusal') {
|
|
537
|
+
throw new WholeReadTransportError('model_declined', 'codex');
|
|
538
|
+
}
|
|
478
539
|
if (request.schema) {
|
|
479
540
|
let result;
|
|
480
541
|
try {
|
|
481
542
|
result = JSON.parse(finalText);
|
|
482
543
|
}
|
|
483
544
|
catch (error) {
|
|
545
|
+
if (request.wholeRead)
|
|
546
|
+
throw new WholeReadTransportError('capability_failure', 'codex');
|
|
484
547
|
throw new Error(`Codex wrote an invalid JSON final message: ${gitErrorMessage(error)}`);
|
|
485
548
|
}
|
|
486
549
|
stripIntroducedNulls(result, schemaRecord);
|
|
487
550
|
finalText = JSON.stringify(result);
|
|
488
551
|
}
|
|
489
552
|
options.onUsage?.(usage);
|
|
490
|
-
return {
|
|
553
|
+
return {
|
|
554
|
+
text: finalText,
|
|
555
|
+
usage,
|
|
556
|
+
...(request.wholeRead ? { metadata: {
|
|
557
|
+
finishReason,
|
|
558
|
+
model: actualModel ?? options.model,
|
|
559
|
+
usageAvailable: usage !== null,
|
|
560
|
+
} } : {}),
|
|
561
|
+
};
|
|
491
562
|
}
|
|
492
563
|
finally {
|
|
493
564
|
await removeTemporaryDirectory(invocationCwd, removeDirectory);
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** Reads/writes ~/.product-console-companion/config.json. */
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { dirname, join } from 'node:path';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { configuredManagedRepository, githubLabel, ManagedCopyRetirementError, retireManagedCopy, usesTemporaryManagedStorage, } from './managed-repo.js';
|
|
5
6
|
// Kept on purpose from the old name so existing pairings continue to load after upgrading.
|
|
6
7
|
const CONFIG_DIR = join(homedir(), '.product-console-companion');
|
|
7
8
|
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
@@ -98,6 +99,180 @@ export function recordWorkspaceId(url, token, workspaceId, configFile) {
|
|
|
98
99
|
save(config, configFile);
|
|
99
100
|
return config;
|
|
100
101
|
}
|
|
102
|
+
export function hasTemporaryManagedRepository(args) {
|
|
103
|
+
const pairing = loadConfig(args.configFile)?.pairings.find((candidate) => samePairing(candidate, args));
|
|
104
|
+
if (!pairing)
|
|
105
|
+
return false;
|
|
106
|
+
const wanted = githubLabel(args.repository).toLowerCase();
|
|
107
|
+
return pairing.connections.some((connection) => {
|
|
108
|
+
if (connection.managed !== true || connection.repositoryStorage !== 'temporary')
|
|
109
|
+
return false;
|
|
110
|
+
try {
|
|
111
|
+
return githubLabel(configuredManagedRepository(connection)).toLowerCase() === wanted;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/** Marks legacy managed connections only after this Console enables temporary repositories.
|
|
119
|
+
* The old path is retained as B38-c's cleanup target; no source storage is changed here. */
|
|
120
|
+
export function migrateManagedRepositoriesToTemporaryStorage(args) {
|
|
121
|
+
const config = loadConfig(args.configFile);
|
|
122
|
+
if (!config)
|
|
123
|
+
return null;
|
|
124
|
+
let pairing = config.pairings.find((candidate) => samePairing(candidate, args));
|
|
125
|
+
if (!pairing) {
|
|
126
|
+
const legacy = config.pairings.filter((candidate) => candidate.workspaceId === undefined
|
|
127
|
+
&& normalizeConsoleUrl(candidate.url) === normalizeConsoleUrl(args.url));
|
|
128
|
+
if (legacy.length === 1)
|
|
129
|
+
pairing = legacy[0];
|
|
130
|
+
}
|
|
131
|
+
if (!pairing)
|
|
132
|
+
return null;
|
|
133
|
+
let changed = false;
|
|
134
|
+
if (pairing.workspaceId === undefined) {
|
|
135
|
+
pairing.workspaceId = args.workspaceId;
|
|
136
|
+
changed = true;
|
|
137
|
+
}
|
|
138
|
+
for (const connection of pairing.connections) {
|
|
139
|
+
if (connection.managed !== true)
|
|
140
|
+
continue;
|
|
141
|
+
try {
|
|
142
|
+
const repository = configuredManagedRepository(connection);
|
|
143
|
+
const identity = { host: 'github.com', port: null, path: githubLabel(repository) };
|
|
144
|
+
if (connection.repositoryStorage !== 'temporary'
|
|
145
|
+
|| connection.repository?.host !== identity.host
|
|
146
|
+
|| connection.repository.port !== identity.port
|
|
147
|
+
|| connection.repository.path !== identity.path) {
|
|
148
|
+
connection.repository = identity;
|
|
149
|
+
connection.repositoryStorage = 'temporary';
|
|
150
|
+
changed = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// An invalid legacy entry is left untouched and cannot be cleaned up as a migrated copy.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (changed)
|
|
158
|
+
save(config, args.configFile);
|
|
159
|
+
return pairing;
|
|
160
|
+
}
|
|
161
|
+
function cleanupPathKey(path) {
|
|
162
|
+
let absolute = resolve(path);
|
|
163
|
+
try {
|
|
164
|
+
absolute = realpathSync.native(absolute);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// A missing path has no physical alias and is compared lexically.
|
|
168
|
+
}
|
|
169
|
+
return process.platform === 'win32' ? absolute.toLowerCase() : absolute;
|
|
170
|
+
}
|
|
171
|
+
/** Deletes historical managed copies only while this workspace advertises the rollout and only
|
|
172
|
+
* after the per-connection migration marker is durable. Every configured Console is considered
|
|
173
|
+
* before a shared path can be removed. */
|
|
174
|
+
export async function cleanupMigratedManagedRepositories(args, dependencies = {}) {
|
|
175
|
+
const result = { removed: [], cleanupPending: [] };
|
|
176
|
+
if (!args.temporaryRepositories)
|
|
177
|
+
return result;
|
|
178
|
+
const config = loadConfig(args.configFile);
|
|
179
|
+
const pairing = config?.pairings.find((candidate) => samePairing(candidate, args));
|
|
180
|
+
if (!config || !pairing)
|
|
181
|
+
return result;
|
|
182
|
+
const reposDir = managedReposDir(args.configFile);
|
|
183
|
+
const handled = new Set();
|
|
184
|
+
for (const connection of pairing.connections) {
|
|
185
|
+
if (!usesTemporaryManagedStorage(connection) || connection.managedCopyCleanup?.state === 'removed')
|
|
186
|
+
continue;
|
|
187
|
+
const path = resolve(connection.repoPath);
|
|
188
|
+
const key = cleanupPathKey(path);
|
|
189
|
+
if (handled.has(key))
|
|
190
|
+
continue;
|
|
191
|
+
handled.add(key);
|
|
192
|
+
const references = config.pairings.flatMap((candidate) => candidate.connections)
|
|
193
|
+
.filter((candidate) => cleanupPathKey(candidate.repoPath) === key);
|
|
194
|
+
let repository;
|
|
195
|
+
try {
|
|
196
|
+
repository = configuredManagedRepository(connection);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
const cleanupAttempts = connection.managedCopyCleanup?.cleanupAttempts ?? 0;
|
|
200
|
+
connection.managedCopyCleanup = {
|
|
201
|
+
state: 'cleanup_pending', path, cleanupAttempts, errorCode: 'cleanup_identity_mismatch',
|
|
202
|
+
};
|
|
203
|
+
save(config, args.configFile);
|
|
204
|
+
result.cleanupPending.push({ path, cleanupAttempts, errorCode: 'cleanup_identity_mismatch' });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const label = githubLabel(repository).toLowerCase();
|
|
208
|
+
const allReferencesMigrated = references.every((candidate) => {
|
|
209
|
+
if (!usesTemporaryManagedStorage(candidate))
|
|
210
|
+
return false;
|
|
211
|
+
try {
|
|
212
|
+
return githubLabel(configuredManagedRepository(candidate)).toLowerCase() === label;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
if (!allReferencesMigrated) {
|
|
219
|
+
const cleanupAttempts = connection.managedCopyCleanup?.cleanupAttempts ?? 0;
|
|
220
|
+
connection.managedCopyCleanup = {
|
|
221
|
+
state: 'cleanup_pending', path, cleanupAttempts, errorCode: 'cleanup_path_referenced',
|
|
222
|
+
};
|
|
223
|
+
save(config, args.configFile);
|
|
224
|
+
result.cleanupPending.push({ path, cleanupAttempts, errorCode: 'cleanup_path_referenced' });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const migratedReferences = references.filter(usesTemporaryManagedStorage);
|
|
228
|
+
try {
|
|
229
|
+
const retired = await retireManagedCopy({
|
|
230
|
+
repoPath: path,
|
|
231
|
+
reposDir,
|
|
232
|
+
repo: repository,
|
|
233
|
+
onValidated() {
|
|
234
|
+
for (const candidate of migratedReferences) {
|
|
235
|
+
candidate.managedCopyCleanup = {
|
|
236
|
+
state: 'cleanup_pending',
|
|
237
|
+
path,
|
|
238
|
+
cleanupAttempts: candidate.managedCopyCleanup?.cleanupAttempts ?? 0,
|
|
239
|
+
errorCode: null,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
save(config, args.configFile);
|
|
243
|
+
},
|
|
244
|
+
}, dependencies);
|
|
245
|
+
for (const candidate of migratedReferences) {
|
|
246
|
+
candidate.managedCopyCleanup = {
|
|
247
|
+
state: 'removed',
|
|
248
|
+
path,
|
|
249
|
+
cleanupAttempts: (candidate.managedCopyCleanup?.cleanupAttempts ?? 0) + retired.cleanupAttempts,
|
|
250
|
+
errorCode: null,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
save(config, args.configFile);
|
|
254
|
+
result.removed.push(path);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
const errorCode = error instanceof ManagedCopyRetirementError
|
|
258
|
+
? error.code
|
|
259
|
+
: 'cleanup_delete_failed';
|
|
260
|
+
for (const candidate of migratedReferences) {
|
|
261
|
+
candidate.managedCopyCleanup = {
|
|
262
|
+
state: 'cleanup_pending',
|
|
263
|
+
path,
|
|
264
|
+
cleanupAttempts: (candidate.managedCopyCleanup?.cleanupAttempts ?? 0)
|
|
265
|
+
+ (errorCode === 'cleanup_delete_failed' ? 5 : 0),
|
|
266
|
+
errorCode,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
save(config, args.configFile);
|
|
270
|
+
const cleanupAttempts = connection.managedCopyCleanup?.cleanupAttempts ?? 0;
|
|
271
|
+
result.cleanupPending.push({ path, cleanupAttempts, errorCode });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
101
276
|
export function removePairing(url, workspaceId, configFile) {
|
|
102
277
|
const config = loadConfig(configFile);
|
|
103
278
|
if (!config)
|
package/dist/git-range.js
CHANGED
|
@@ -28,8 +28,10 @@ export function gitRangeEnvironment(parent = process.env) {
|
|
|
28
28
|
delete environment.GIT_CONFIG_NOSYSTEM;
|
|
29
29
|
return environment;
|
|
30
30
|
}
|
|
31
|
-
function runGit(repoPath, args, outputLimit, input, environment) {
|
|
31
|
+
function runGit(repoPath, args, outputLimit, input, environment, execution) {
|
|
32
32
|
return new Promise((resolve, reject) => {
|
|
33
|
+
if (execution?.signal.aborted)
|
|
34
|
+
return reject(new GitCommandFailure('resource'));
|
|
33
35
|
const child = spawn('git', ['--no-replace-objects', ...args], {
|
|
34
36
|
cwd: repoPath,
|
|
35
37
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -41,19 +43,35 @@ function runGit(repoPath, args, outputLimit, input, environment) {
|
|
|
41
43
|
let stdoutBytes = 0;
|
|
42
44
|
let stderrBytes = 0;
|
|
43
45
|
let settled = false;
|
|
46
|
+
let closeProcess;
|
|
47
|
+
const processClosed = new Promise((done) => { closeProcess = done; });
|
|
48
|
+
const unregister = execution?.registerReader({
|
|
49
|
+
cancel: async () => {
|
|
50
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
51
|
+
child.kill('SIGKILL');
|
|
52
|
+
await processClosed;
|
|
53
|
+
},
|
|
54
|
+
}) ?? (() => undefined);
|
|
55
|
+
const abort = () => {
|
|
56
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
57
|
+
child.kill('SIGKILL');
|
|
58
|
+
};
|
|
59
|
+
execution?.signal.addEventListener('abort', abort, { once: true });
|
|
44
60
|
const finish = (error, value) => {
|
|
45
61
|
if (settled)
|
|
46
62
|
return;
|
|
47
63
|
settled = true;
|
|
48
64
|
clearTimeout(timer);
|
|
65
|
+
execution?.signal.removeEventListener('abort', abort);
|
|
66
|
+
unregister();
|
|
49
67
|
if (error)
|
|
50
68
|
reject(error);
|
|
51
69
|
else
|
|
52
70
|
resolve(value);
|
|
53
71
|
};
|
|
54
72
|
const overflow = () => {
|
|
55
|
-
child.
|
|
56
|
-
|
|
73
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
74
|
+
child.kill('SIGKILL');
|
|
57
75
|
};
|
|
58
76
|
child.stdout?.on('data', (chunk) => {
|
|
59
77
|
stdoutBytes += chunk.length;
|
|
@@ -68,15 +86,19 @@ function runGit(repoPath, args, outputLimit, input, environment) {
|
|
|
68
86
|
if (stdoutBytes + stderrBytes > outputLimit)
|
|
69
87
|
overflow();
|
|
70
88
|
});
|
|
71
|
-
child.on('error', () =>
|
|
89
|
+
child.on('error', () => {
|
|
90
|
+
closeProcess();
|
|
91
|
+
finish(new GitCommandFailure('resource'));
|
|
92
|
+
});
|
|
72
93
|
child.on('close', (code, signal) => {
|
|
94
|
+
closeProcess();
|
|
73
95
|
if (signal || code === null)
|
|
74
96
|
return finish(new GitCommandFailure('resource'));
|
|
75
97
|
finish(undefined, { stdout: Buffer.concat(stdout), exitCode: code, stderr: Buffer.concat(stderr).toString('utf8') });
|
|
76
98
|
});
|
|
77
99
|
const timer = setTimeout(() => {
|
|
78
|
-
child.
|
|
79
|
-
|
|
100
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
101
|
+
child.kill('SIGKILL');
|
|
80
102
|
}, GIT_RANGE_COMMAND_TIMEOUT_MS);
|
|
81
103
|
if (input)
|
|
82
104
|
child.stdin?.end(input);
|
|
@@ -166,7 +188,7 @@ export function fitsGitRangeTransport(result) {
|
|
|
166
188
|
return Buffer.byteLength(JSON.stringify({ result, filesRead: [] }), 'utf8') <= GIT_RANGE_MAX_HTTP_BYTES;
|
|
167
189
|
}
|
|
168
190
|
const failure = (code) => ({ ok: false, code });
|
|
169
|
-
export async function runGitRangeTask(task, repoPath, connection) {
|
|
191
|
+
export async function runGitRangeTask(task, repoPath, connection, acquired) {
|
|
170
192
|
const payload = task.payload;
|
|
171
193
|
const branch = payload.branch;
|
|
172
194
|
const base = payload.base;
|
|
@@ -178,40 +200,54 @@ export async function runGitRangeTask(task, repoPath, connection) {
|
|
|
178
200
|
|| (payload.schemaProbe !== undefined && !validSchemaProbe(payload.schemaProbe))) {
|
|
179
201
|
return { result: failure('history_failed'), filesRead: [] };
|
|
180
202
|
}
|
|
181
|
-
|
|
182
|
-
|
|
203
|
+
if (acquired && (acquired.base !== base || !SHA.test(acquired.head))) {
|
|
204
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
205
|
+
}
|
|
206
|
+
const executeGit = (args, outputLimit, input, environment = acquired?.environment) => runGit(repoPath, args, outputLimit, input, environment, acquired);
|
|
207
|
+
const branchCheck = await executeGit(['check-ref-format', '--branch', branch], GIT_RANGE_METADATA_OUTPUT_BYTES);
|
|
208
|
+
const remoteList = acquired ? null : await executeGit(['remote'], GIT_RANGE_METADATA_OUTPUT_BYTES);
|
|
183
209
|
if (branchCheck.exitCode !== 0)
|
|
184
210
|
return { result: failure('branch_missing'), filesRead: [] };
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
211
|
+
let head;
|
|
212
|
+
let historyShallow;
|
|
213
|
+
if (acquired) {
|
|
214
|
+
head = acquired.head;
|
|
215
|
+
historyShallow = acquired.historyShallow;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
const remotes = exactLine(Buffer.concat([remoteList.stdout,
|
|
219
|
+
remoteList.stdout.length === 0 ? Buffer.from('\n') : Buffer.alloc(0)]));
|
|
220
|
+
if (remoteList.exitCode !== 0 || !remotes.split('\n').includes(remote)) {
|
|
221
|
+
return { result: failure('branch_missing'), filesRead: [] };
|
|
222
|
+
}
|
|
223
|
+
destination = `refs/product-console/git-range/${task.id}/${randomUUID()}/head`;
|
|
224
|
+
const destinationCheck = await executeGit(['check-ref-format', destination], GIT_RANGE_METADATA_OUTPUT_BYTES);
|
|
225
|
+
const sourceCheck = await executeGit(['check-ref-format', `refs/heads/${branch}`], GIT_RANGE_METADATA_OUTPUT_BYTES);
|
|
226
|
+
if (destinationCheck.exitCode !== 0 || sourceCheck.exitCode !== 0)
|
|
227
|
+
return { result: failure('branch_missing'), filesRead: [] };
|
|
228
|
+
const fetchEnvironment = await managedFetchEnvironment(repoPath);
|
|
229
|
+
delete fetchEnvironment.GIT_CONFIG_NOSYSTEM;
|
|
230
|
+
const fetched = await executeGit([
|
|
231
|
+
'fetch', '--no-tags', '--refmap=', '--no-write-fetch-head', '--no-recurse-submodules', '--',
|
|
232
|
+
remote, `+refs/heads/${branch}:${destination}`,
|
|
233
|
+
], GIT_RANGE_METADATA_OUTPUT_BYTES, undefined, fetchEnvironment);
|
|
234
|
+
reportBackgroundFetch(repoPath, { ok: fetched.exitCode === 0, stderr: fetched.stderr });
|
|
235
|
+
if (fetched.exitCode !== 0)
|
|
236
|
+
return { result: failure('fetch_failed'), filesRead: [] };
|
|
237
|
+
const resolved = await executeGit(['rev-parse', '--verify', `${destination}^{commit}`], GIT_RANGE_METADATA_OUTPUT_BYTES);
|
|
238
|
+
if (resolved.exitCode !== 0)
|
|
239
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
240
|
+
head = exactLine(resolved.stdout);
|
|
241
|
+
if (!SHA.test(head))
|
|
242
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
243
|
+
const shallowOutput = await executeGit(['rev-parse', '--is-shallow-repository'], GIT_RANGE_METADATA_OUTPUT_BYTES);
|
|
244
|
+
if (shallowOutput.exitCode !== 0)
|
|
245
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
246
|
+
const shallowText = exactLine(shallowOutput.stdout);
|
|
247
|
+
if (shallowText !== 'true' && shallowText !== 'false')
|
|
248
|
+
return { result: failure('history_failed'), filesRead: [] };
|
|
249
|
+
historyShallow = shallowText === 'true';
|
|
250
|
+
}
|
|
215
251
|
if (base === null) {
|
|
216
252
|
const result = { ok: true, mode: 'head_only', head, base, commits: [], changedPaths: [], totalCommits: null, baseUnavailable: false, truncatedFiles: false, historyShallow };
|
|
217
253
|
return { result: fitsGitRangeTransport(result) ? result : failure('history_failed'), filesRead: [] };
|
|
@@ -220,7 +256,12 @@ export async function runGitRangeTask(task, repoPath, connection) {
|
|
|
220
256
|
const result = { ok: true, mode: 'range', head, base, commits: [], changedPaths: [], totalCommits: 0, baseUnavailable: false, truncatedFiles: false, historyShallow };
|
|
221
257
|
return { result: fitsGitRangeTransport(result) ? result : failure('history_failed'), filesRead: [] };
|
|
222
258
|
}
|
|
223
|
-
|
|
259
|
+
if (acquired?.baseUnavailableReason) {
|
|
260
|
+
return { result: { ok: true, mode: 'base_unavailable', head, base, commits: [], changedPaths: [],
|
|
261
|
+
totalCommits: null, baseUnavailable: true,
|
|
262
|
+
baseUnavailableReason: acquired.baseUnavailableReason, truncatedFiles: false, historyShallow }, filesRead: [] };
|
|
263
|
+
}
|
|
264
|
+
const baseCheck = await executeGit(['cat-file', '--batch-check'], GIT_RANGE_METADATA_OUTPUT_BYTES, Buffer.from(`${base}\n`));
|
|
224
265
|
if (baseCheck.exitCode !== 0)
|
|
225
266
|
return { result: failure('history_failed'), filesRead: [] };
|
|
226
267
|
const baseDescription = exactLine(baseCheck.stdout);
|
|
@@ -229,19 +270,19 @@ export async function runGitRangeTask(task, repoPath, connection) {
|
|
|
229
270
|
}
|
|
230
271
|
if (!new RegExp(`^${base} commit [0-9]+$`).test(baseDescription))
|
|
231
272
|
return { result: failure('history_failed'), filesRead: [] };
|
|
232
|
-
if (historyShallow) {
|
|
273
|
+
if (historyShallow && !acquired) {
|
|
233
274
|
return { result: { ok: true, mode: 'base_unavailable', head, base, commits: [], changedPaths: [], totalCommits: null, baseUnavailable: true, baseUnavailableReason: 'shallow_history', truncatedFiles: false, historyShallow }, filesRead: [] };
|
|
234
275
|
}
|
|
235
|
-
const ancestor = await
|
|
276
|
+
const ancestor = await executeGit(['merge-base', '--is-ancestor', base, head], GIT_RANGE_METADATA_OUTPUT_BYTES);
|
|
236
277
|
if (ancestor.exitCode === 1) {
|
|
237
278
|
return { result: { ok: true, mode: 'base_unavailable', head, base, commits: [], changedPaths: [], totalCommits: null, baseUnavailable: true, baseUnavailableReason: 'not_ancestor', truncatedFiles: false, historyShallow }, filesRead: [] };
|
|
238
279
|
}
|
|
239
280
|
if (ancestor.exitCode !== 0)
|
|
240
281
|
return { result: failure('history_failed'), filesRead: [] };
|
|
241
282
|
const [enumerated, counted, diff] = await Promise.all([
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
283
|
+
executeGit(['rev-list', '--topo-order', '--reverse', `${base}..${head}`, '--'], GIT_RANGE_ENUMERATION_OUTPUT_BYTES),
|
|
284
|
+
executeGit(['rev-list', '--count', `${base}..${head}`, '--'], GIT_RANGE_METADATA_OUTPUT_BYTES),
|
|
285
|
+
executeGit(['diff', '--no-color', '--name-status', '-z', '-M', base, head], GIT_RANGE_PATH_OUTPUT_BYTES),
|
|
245
286
|
]);
|
|
246
287
|
if (enumerated.exitCode !== 0 || counted.exitCode !== 0 || diff.exitCode !== 0)
|
|
247
288
|
return { result: failure('history_failed'), filesRead: [] };
|
|
@@ -259,7 +300,7 @@ export async function runGitRangeTask(task, repoPath, connection) {
|
|
|
259
300
|
return { result: failure('history_failed'), filesRead: [] };
|
|
260
301
|
}
|
|
261
302
|
const selected = oids.slice(0, GIT_RANGE_MAX_COMMITS);
|
|
262
|
-
const objects = await
|
|
303
|
+
const objects = await executeGit(['cat-file', '--batch'], GIT_RANGE_OBJECT_OUTPUT_BYTES, Buffer.from(`${selected.join('\n')}\n`));
|
|
263
304
|
if (objects.exitCode !== 0)
|
|
264
305
|
return { result: failure('history_failed'), filesRead: [] };
|
|
265
306
|
const commits = parseCommitObjects(objects.stdout, selected);
|
|
@@ -272,6 +313,7 @@ export async function runGitRangeTask(task, repoPath, connection) {
|
|
|
272
313
|
head,
|
|
273
314
|
changedPaths: paths.changedPaths,
|
|
274
315
|
probe: payload.schemaProbe,
|
|
316
|
+
environment: acquired?.environment,
|
|
275
317
|
});
|
|
276
318
|
const result = {
|
|
277
319
|
ok: true, mode: 'range', head, base, commits, changedPaths: paths.changedPaths,
|