@withtruing/companion 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +25 -26
  2. package/dist/anthropic-output-schema.js +41 -0
  3. package/dist/api-key-caller.js +9 -2
  4. package/dist/claude-cli-caller.js +107 -8
  5. package/dist/cli.js +837 -207
  6. package/dist/codex-binary.js +96 -0
  7. package/dist/codex-cli-caller.js +90 -16
  8. package/dist/config.js +177 -2
  9. package/dist/git-range.js +88 -46
  10. package/dist/http-client.js +95 -10
  11. package/dist/investigation-reader.js +12 -6
  12. package/dist/managed-repo.js +132 -0
  13. package/dist/observed-sources.js +132 -6
  14. package/dist/reader-result-contract.js +296 -2
  15. package/dist/repository-provider.js +608 -0
  16. package/dist/schema-entities.js +18 -18
  17. package/dist/service.js +7 -2
  18. package/dist/surface-mapper.js +53 -11
  19. package/dist/text-model.js +91 -8
  20. package/dist/whole-read/archive.js +196 -0
  21. package/dist/whole-read/checks.js +297 -0
  22. package/dist/whole-read/claims.js +196 -0
  23. package/dist/whole-read/coverage-ledger.js +51 -0
  24. package/dist/whole-read/decode.js +234 -0
  25. package/dist/whole-read/drafting.js +502 -0
  26. package/dist/whole-read/generated-rules.js +68 -0
  27. package/dist/whole-read/go-proof.js +255 -0
  28. package/dist/whole-read/grouping-prompt.js +19 -0
  29. package/dist/whole-read/grouping.js +247 -0
  30. package/dist/whole-read/identity.js +103 -0
  31. package/dist/whole-read/import-graph.js +293 -0
  32. package/dist/whole-read/inventory-collector.js +55 -0
  33. package/dist/whole-read/inventory-rules.js +141 -0
  34. package/dist/whole-read/lockfiles.js +947 -0
  35. package/dist/whole-read/notes.js +216 -0
  36. package/dist/whole-read/npm-proof.js +272 -0
  37. package/dist/whole-read/owner-decision-detectors.js +270 -0
  38. package/dist/whole-read/packing.js +612 -0
  39. package/dist/whole-read/pages.js +429 -0
  40. package/dist/whole-read/phase-policy.js +54 -0
  41. package/dist/whole-read/prd-build.js +279 -0
  42. package/dist/whole-read/prd-fidelity.js +14 -0
  43. package/dist/whole-read/python-proof.js +489 -0
  44. package/dist/whole-read/read-prompt.js +67 -0
  45. package/dist/whole-read/redaction.js +411 -0
  46. package/dist/whole-read/relationship-mapping.js +98 -0
  47. package/dist/whole-read/sanitize.js +266 -0
  48. package/dist/whole-read/secret-rules.js +26 -0
  49. package/dist/whole-read/structure.js +300 -0
  50. package/dist/whole-read/types.js +2 -0
  51. package/dist/whole-read-claims.js +12 -0
  52. package/dist/whole-read-git.js +340 -0
  53. package/dist/whole-read-pool.js +80 -0
  54. package/dist/whole-read-runner.js +945 -0
  55. package/dist/whole-read-shared-source.js +194 -0
  56. package/dist/whole-read-temp.js +479 -0
  57. package/package.json +8 -2
@@ -0,0 +1,96 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
3
+ const defaultFs = { existsSync, readFileSync };
4
+ let cachedCommand = null;
5
+ let cachedPath;
6
+ let cachedPathExt;
7
+ function envValue(env, name) {
8
+ const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
9
+ return key ? env[key] : undefined;
10
+ }
11
+ function windowsPathEntries(env) {
12
+ return (envValue(env, 'PATH') ?? '')
13
+ .split(';')
14
+ .map((entry) => entry.trim().replace(/^"|"$/g, ''))
15
+ .filter(Boolean);
16
+ }
17
+ function windowsExtensions(env) {
18
+ const pathExt = envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD';
19
+ return pathExt
20
+ .split(';')
21
+ .map((extension) => extension.trim().toLowerCase())
22
+ .filter(Boolean)
23
+ .map((extension) => extension.startsWith('.') ? extension : `.${extension}`);
24
+ }
25
+ function existingCandidate(directory, extension, fs) {
26
+ const lowerCaseCandidate = join(directory, `codex${extension.toLowerCase()}`);
27
+ if (fs.existsSync(lowerCaseCandidate))
28
+ return resolve(lowerCaseCandidate);
29
+ const originalCaseCandidate = join(directory, `codex${extension}`);
30
+ return fs.existsSync(originalCaseCandidate) ? resolve(originalCaseCandidate) : null;
31
+ }
32
+ function parsedShimScript(shimPath, fs) {
33
+ let contents;
34
+ try {
35
+ contents = fs.readFileSync(shimPath, 'utf8');
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ const shimDirectory = dirname(shimPath);
41
+ for (const match of contents.matchAll(/"([^"\r\n]*codex\.js)"/gi)) {
42
+ let target = match[1].replace(/%(?:~dp0|dp0)%?/gi, shimDirectory);
43
+ // Tests inject win32 while running on Linux CI; normalizing both separator forms keeps the
44
+ // resolver testable with a real temporary filesystem and is a no-op for native Windows paths.
45
+ target = target.replace(/[\\/]/g, sep);
46
+ if (!isAbsolute(target))
47
+ target = resolve(shimDirectory, target);
48
+ if (fs.existsSync(target))
49
+ return resolve(target);
50
+ }
51
+ return null;
52
+ }
53
+ function scriptForShim(shimPath, fs) {
54
+ const parsed = parsedShimScript(shimPath, fs);
55
+ if (parsed)
56
+ return parsed;
57
+ const conventional = join(dirname(shimPath), 'node_modules', '@openai', 'codex', 'bin', 'codex.js');
58
+ return fs.existsSync(conventional) ? resolve(conventional) : null;
59
+ }
60
+ function resolveUncached(platform, env, fs) {
61
+ if (platform !== 'win32')
62
+ return { command: 'codex', prefixArgs: [] };
63
+ const extensions = windowsExtensions(env);
64
+ for (const directory of windowsPathEntries(env)) {
65
+ for (const extension of extensions) {
66
+ if (extension !== '.cmd' && extension !== '.exe')
67
+ continue;
68
+ const candidate = existingCandidate(directory, extension, fs);
69
+ if (!candidate)
70
+ continue;
71
+ if (extension === '.exe')
72
+ return { command: candidate, prefixArgs: [] };
73
+ const script = scriptForShim(candidate, fs);
74
+ if (script)
75
+ return { command: process.execPath, prefixArgs: [script] };
76
+ }
77
+ }
78
+ return { command: 'codex', prefixArgs: [] };
79
+ }
80
+ /** Cache each production environment. Injected dependencies bypass the cache so Linux CI can
81
+ * exercise the Windows lookup rules without mutating process globals or depending on Windows
82
+ * filesystem semantics. */
83
+ export function resolveCodexCommand(options = {}) {
84
+ const injected = options.platform !== undefined || options.env !== undefined || options.fs !== undefined;
85
+ const path = envValue(process.env, 'PATH');
86
+ const pathExt = envValue(process.env, 'PATHEXT');
87
+ if (!injected && cachedCommand && path === cachedPath && pathExt === cachedPathExt)
88
+ return cachedCommand;
89
+ const command = resolveUncached(options.platform ?? process.platform, options.env ?? process.env, options.fs ?? defaultFs);
90
+ if (!injected) {
91
+ cachedCommand = command;
92
+ cachedPath = path;
93
+ cachedPathExt = pathExt;
94
+ }
95
+ return command;
96
+ }
@@ -9,8 +9,11 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:pat
9
9
  import { createInterface } from 'node:readline';
10
10
  import { StringDecoder } from 'node:string_decoder';
11
11
  import { assertSingleLineArguments } from './cli-arguments.js';
12
+ import { resolveCodexCommand } from './codex-binary.js';
12
13
  import { gitErrorMessage } from './sandbox.js';
13
14
  import { stripIntroducedNulls, toCodexStrictSchema } from './strict-schema.js';
15
+ import { WholeReadTransportError, } from './text-model.js';
16
+ import { isUsageLimitError, parseUsageLimitReset } from './usage-limit.js';
14
17
  const WALL_CLOCK_TIMEOUT_MS = 10 * 60 * 1000;
15
18
  const STDOUT_SAMPLE_CHARS = 200;
16
19
  const UNPARSEABLE_LINE_SAMPLE_CHARS = 120;
@@ -77,10 +80,19 @@ function terminateChild(child) {
77
80
  if (process.platform === 'win32' && child.pid) {
78
81
  const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
79
82
  killer.on('error', () => child.kill());
83
+ killer.on('close', (code) => { if (code !== 0)
84
+ child.kill(); });
80
85
  return;
81
86
  }
82
87
  child.kill();
83
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
+ }
84
96
  function isWithin(base, candidate) {
85
97
  const relation = relative(resolve(base), resolve(candidate));
86
98
  return relation === '' || (!relation.startsWith('..') && !isAbsolute(relation));
@@ -239,6 +251,19 @@ function namedCodexFailure(message, codexHome) {
239
251
  }
240
252
  return null;
241
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
+ }
242
267
  function usageFrom(value) {
243
268
  if (!value || typeof value !== 'object' || Array.isArray(value))
244
269
  return null;
@@ -252,7 +277,7 @@ function usageFrom(value) {
252
277
  function toolUseAttempt(type) {
253
278
  return new Error(`CodexToolUseAttempted: Codex attempted a tool (${type}) in a no-tools call; the result was discarded.`);
254
279
  }
255
- export function buildArgs(request, options, cwd, outputPath, schemaPath, instructionsPath) {
280
+ export function buildArgs(request, options, cwd, outputPath, schemaPath, instructionsPath, tempRoot = tmpdir()) {
256
281
  const args = [
257
282
  'exec',
258
283
  '--json',
@@ -284,7 +309,7 @@ export function buildArgs(request, options, cwd, outputPath, schemaPath, instruc
284
309
  '-',
285
310
  ];
286
311
  assertSingleLineArguments('codex', args);
287
- assertSafeInvocation(args);
312
+ assertSafeInvocation(args, tempRoot);
288
313
  return args;
289
314
  }
290
315
  function diagnostic(exitCode, stderrTail, stdoutBytes, stdoutLines, parsedLines, unparseableLines, stdoutFirst, stdoutLast, firstUnparseableLineLength, firstUnparseableLineFirst, firstUnparseableLineLast, typeCounts, toleratedErrorItems, purpose) {
@@ -314,10 +339,11 @@ removeDirectory = rm) {
314
339
  }
315
340
  assertAllowedModel(options.model);
316
341
  const codexHome = await validatedCodexHome(options.codexHome ?? defaultCodexHome());
317
- const invocationCwd = await mkdtemp(join(tmpdir(), 'product-console-codex-empty-'));
342
+ const tempRoot = request.wholeRead?.attemptDirectory ?? tmpdir();
343
+ const invocationCwd = await mkdtemp(join(tempRoot, 'product-console-codex-empty-'));
318
344
  let callDir = null;
319
345
  try {
320
- callDir = await mkdtemp(join(tmpdir(), 'product-console-codex-call-'));
346
+ callDir = await mkdtemp(join(tempRoot, 'product-console-codex-call-'));
321
347
  const outputPath = join(callDir, 'last-message.txt');
322
348
  const instructionsPath = join(callDir, 'model-instructions.txt');
323
349
  await writeFile(instructionsPath, request.system, { encoding: 'utf8', mode: 0o600 });
@@ -329,13 +355,15 @@ removeDirectory = rm) {
329
355
  schemaPath = join(callDir, 'schema.json');
330
356
  await writeFile(schemaPath, JSON.stringify(strict.schema), { encoding: 'utf8', mode: 0o600 });
331
357
  }
332
- const args = buildArgs(request, options, invocationCwd, outputPath, schemaPath, instructionsPath);
358
+ const args = buildArgs(request, options, invocationCwd, outputPath, schemaPath, instructionsPath, tempRoot);
333
359
  let stderrTail = '';
334
360
  let timedOut = false;
335
361
  let stdinError = null;
336
362
  let fatalError = null;
337
363
  let turnFailure = '';
338
364
  let usage = null;
365
+ let actualModel = null;
366
+ let finishReason = null;
339
367
  let toleratedErrorItems = 0;
340
368
  let stdoutBytes = 0;
341
369
  let stdoutLines = 0;
@@ -347,14 +375,15 @@ removeDirectory = rm) {
347
375
  let firstUnparseableLineFirst = '';
348
376
  let firstUnparseableLineLast = '';
349
377
  const typeCounts = new Map();
350
- const child = spawn('codex', args, {
378
+ const { command, prefixArgs } = resolveCodexCommand();
379
+ const child = spawn(command, [...prefixArgs, ...args], {
351
380
  cwd: invocationCwd,
352
381
  env: buildCodexEnvironment(codexHome),
353
382
  windowsHide: true,
354
383
  });
355
384
  const stdoutDecoder = new StringDecoder('utf8');
356
385
  const recordStdout = (text) => {
357
- if (!text)
386
+ if (!text || request.wholeRead)
358
387
  return;
359
388
  if (stdoutFirst.length < STDOUT_SAMPLE_CHARS) {
360
389
  stdoutFirst += text.slice(0, STDOUT_SAMPLE_CHARS - stdoutFirst.length);
@@ -371,7 +400,8 @@ removeDirectory = rm) {
371
400
  terminateChild(child);
372
401
  });
373
402
  child.stderr.on('data', (chunk) => {
374
- stderrTail = (stderrTail + chunk.toString()).slice(-2000);
403
+ if (!request.wholeRead)
404
+ stderrTail = (stderrTail + chunk.toString()).slice(-2000);
375
405
  });
376
406
  const rl = createInterface({ input: child.stdout });
377
407
  rl.on('line', (line) => {
@@ -382,7 +412,7 @@ removeDirectory = rm) {
382
412
  }
383
413
  catch {
384
414
  unparseableLines++;
385
- if (firstUnparseableLineLength === null) {
415
+ if (!request.wholeRead && firstUnparseableLineLength === null) {
386
416
  firstUnparseableLineLength = line.length;
387
417
  firstUnparseableLineFirst = line.slice(0, UNPARSEABLE_LINE_SAMPLE_CHARS);
388
418
  firstUnparseableLineLast = line.slice(-UNPARSEABLE_LINE_SAMPLE_CHARS);
@@ -401,22 +431,29 @@ removeDirectory = rm) {
401
431
  toleratedErrorItems++;
402
432
  }
403
433
  else if (classified !== 'agent_message' && classified !== 'reasoning') {
404
- fatalError ??= toolUseAttempt(classified);
434
+ fatalError ??= request.wholeRead
435
+ ? new WholeReadTransportError('capability_failure', 'codex')
436
+ : toolUseAttempt(classified);
405
437
  terminateChild(child);
406
438
  }
407
439
  }
408
- if (eventType === 'turn.completed')
440
+ if (eventType === 'turn.completed') {
409
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
+ }
410
447
  if (eventType === 'error' || eventType === 'turn.failed') {
411
448
  const message = eventMessage(event);
412
449
  const named = namedCodexFailure(message, codexHome);
413
450
  if (named) {
414
- fatalError ??= named;
451
+ fatalError ??= request.wholeRead ? safeCodexFailure(message) : named;
415
452
  if (/401 Unauthorized/i.test(message))
416
453
  terminateChild(child);
417
454
  }
418
455
  else if (eventType === 'turn.failed') {
419
- turnFailure = message;
456
+ turnFailure = request.wholeRead ? 'capability_failure' : message;
420
457
  }
421
458
  }
422
459
  });
@@ -428,6 +465,14 @@ removeDirectory = rm) {
428
465
  child.on('error', reject);
429
466
  child.on('close', (code) => resolvePromise(code ?? 1));
430
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 });
431
476
  try {
432
477
  if (request.stableText)
433
478
  child.stdin.write(`${request.stableText}\n\n`);
@@ -444,6 +489,11 @@ removeDirectory = rm) {
444
489
  }
445
490
  finally {
446
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');
447
497
  }
448
498
  options.onDiagnostic?.({ toleratedErrorItems });
449
499
  if (/codex_core::tools::router/i.test(stderrTail) && /(?:spawn|exec_command)/i.test(stderrTail)) {
@@ -463,29 +513,52 @@ removeDirectory = rm) {
463
513
  }
464
514
  const capturedStdinError = stdinError;
465
515
  if (turnFailure) {
516
+ if (request.wholeRead)
517
+ throw new WholeReadTransportError('capability_failure', 'codex');
466
518
  const named = namedCodexFailure(turnFailure, codexHome);
467
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)}`);
468
520
  }
469
521
  if (finalText === null || finalText.length === 0) {
522
+ if (request.wholeRead)
523
+ throw new WholeReadTransportError('capability_failure', 'codex');
470
524
  if (capturedStdinError)
471
525
  throw promptWriteFailure(capturedStdinError);
472
526
  throw new Error(diagnostic(exitCode, stderrTail, stdoutBytes, stdoutLines, parsedLines, unparseableLines, stdoutFirst, stdoutLast, firstUnparseableLineLength, firstUnparseableLineFirst, firstUnparseableLineLast, typeCounts, toleratedErrorItems, request.purpose));
473
527
  }
474
- if (capturedStdinError)
528
+ if (capturedStdinError) {
529
+ if (request.wholeRead)
530
+ throw new WholeReadTransportError('capability_failure', 'codex');
475
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
+ }
476
539
  if (request.schema) {
477
540
  let result;
478
541
  try {
479
542
  result = JSON.parse(finalText);
480
543
  }
481
544
  catch (error) {
545
+ if (request.wholeRead)
546
+ throw new WholeReadTransportError('capability_failure', 'codex');
482
547
  throw new Error(`Codex wrote an invalid JSON final message: ${gitErrorMessage(error)}`);
483
548
  }
484
549
  stripIntroducedNulls(result, schemaRecord);
485
550
  finalText = JSON.stringify(result);
486
551
  }
487
552
  options.onUsage?.(usage);
488
- return { text: finalText, usage };
553
+ return {
554
+ text: finalText,
555
+ usage,
556
+ ...(request.wholeRead ? { metadata: {
557
+ finishReason,
558
+ model: actualModel ?? options.model,
559
+ usageAvailable: usage !== null,
560
+ } } : {}),
561
+ };
489
562
  }
490
563
  finally {
491
564
  await removeTemporaryDirectory(invocationCwd, removeDirectory);
@@ -522,7 +595,8 @@ export async function checkCodexLogin(codexHome) {
522
595
  throw error;
523
596
  }
524
597
  return new Promise((resolvePromise) => {
525
- const child = spawn('codex', ['login', 'status'], {
598
+ const { command, prefixArgs } = resolveCodexCommand();
599
+ const child = spawn(command, [...prefixArgs, 'login', 'status'], {
526
600
  env: buildCodexEnvironment(validatedHome),
527
601
  windowsHide: true,
528
602
  });
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)