@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.
Files changed (56) hide show
  1. package/README.md +24 -25
  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 +833 -206
  6. package/dist/codex-cli-caller.js +85 -14
  7. package/dist/config.js +177 -2
  8. package/dist/git-range.js +88 -46
  9. package/dist/http-client.js +107 -10
  10. package/dist/investigation-reader.js +12 -6
  11. package/dist/managed-repo.js +132 -0
  12. package/dist/observed-sources.js +132 -6
  13. package/dist/reader-result-contract.js +296 -2
  14. package/dist/repository-provider.js +608 -0
  15. package/dist/schema-entities.js +18 -18
  16. package/dist/service.js +7 -2
  17. package/dist/surface-mapper.js +53 -11
  18. package/dist/text-model.js +91 -8
  19. package/dist/whole-read/archive.js +196 -0
  20. package/dist/whole-read/checks.js +297 -0
  21. package/dist/whole-read/claims.js +196 -0
  22. package/dist/whole-read/coverage-ledger.js +51 -0
  23. package/dist/whole-read/decode.js +234 -0
  24. package/dist/whole-read/drafting.js +502 -0
  25. package/dist/whole-read/generated-rules.js +68 -0
  26. package/dist/whole-read/go-proof.js +255 -0
  27. package/dist/whole-read/grouping-prompt.js +19 -0
  28. package/dist/whole-read/grouping.js +247 -0
  29. package/dist/whole-read/identity.js +103 -0
  30. package/dist/whole-read/import-graph.js +293 -0
  31. package/dist/whole-read/inventory-collector.js +55 -0
  32. package/dist/whole-read/inventory-rules.js +141 -0
  33. package/dist/whole-read/lockfiles.js +947 -0
  34. package/dist/whole-read/notes.js +216 -0
  35. package/dist/whole-read/npm-proof.js +272 -0
  36. package/dist/whole-read/owner-decision-detectors.js +270 -0
  37. package/dist/whole-read/packing.js +612 -0
  38. package/dist/whole-read/pages.js +429 -0
  39. package/dist/whole-read/phase-policy.js +54 -0
  40. package/dist/whole-read/prd-build.js +279 -0
  41. package/dist/whole-read/prd-fidelity.js +14 -0
  42. package/dist/whole-read/python-proof.js +489 -0
  43. package/dist/whole-read/read-prompt.js +67 -0
  44. package/dist/whole-read/redaction.js +411 -0
  45. package/dist/whole-read/relationship-mapping.js +98 -0
  46. package/dist/whole-read/sanitize.js +266 -0
  47. package/dist/whole-read/secret-rules.js +26 -0
  48. package/dist/whole-read/structure.js +300 -0
  49. package/dist/whole-read/types.js +2 -0
  50. package/dist/whole-read-claims.js +12 -0
  51. package/dist/whole-read-git.js +340 -0
  52. package/dist/whole-read-pool.js +80 -0
  53. package/dist/whole-read-runner.js +945 -0
  54. package/dist/whole-read-shared-source.js +194 -0
  55. package/dist/whole-read-temp.js +479 -0
  56. package/package.json +8 -2
@@ -2,6 +2,7 @@
2
2
  * fetch (Node 20+, no dependency needed) — see package.json's engines field. */
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { manifestHash } from './surface-mapper.js';
5
+ import { WHOLE_READ_COMPANION_CAPABILITIES } from './whole-read-claims.js';
5
6
  const WHOAMI_TIMEOUT_MS = 15_000;
6
7
  const CLAIM_TIMEOUT_MS = 10_000;
7
8
  const HEARTBEAT_TIMEOUT_MS = 10_000;
@@ -9,6 +10,7 @@ const RESULT_TIMEOUT_MS = 60_000;
9
10
  const ERROR_REPORT_TIMEOUT_MS = 30_000;
10
11
  const PAIRING_TIMEOUT_MS = 15_000;
11
12
  const READINESS_TIMEOUT_MS = 10_000;
13
+ const BUILD_RESULT_TIMEOUT_MS = 60_000;
12
14
  const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
13
15
  if (typeof packageVersion !== 'string' || !packageVersion) {
14
16
  throw new Error('companion/package.json does not contain a version');
@@ -23,9 +25,25 @@ export const COMPANION_CAPABILITIES = [
23
25
  'schema-entities@1',
24
26
  'verify-automatic-surface@1',
25
27
  'map-module-expansion@1',
28
+ ...WHOLE_READ_COMPANION_CAPABILITIES,
26
29
  ];
27
- const capabilityHeaders = () => ({
28
- 'X-Companion-Capabilities': COMPANION_CAPABILITIES.join(','),
30
+ export const CLAUDE_CLI_FAST_MODEL = 'claude-haiku-5-5';
31
+ export const CODEX_FAST_MODEL = 'gpt-5.6-luna';
32
+ export const WHOLE_READ_FAST_MODEL_CAPABILITY = 'whole-read-fast-model@';
33
+ export const WHOLE_READ_BACKEND_CAPABILITY = 'whole-read-backend@';
34
+ /** The one mapping from a connection's backend to the name the server freezes and the readiness report uses. */
35
+ export function wholeReadBackendName(backend) {
36
+ if (backend === 'api-key')
37
+ return 'anthropic_api';
38
+ if (backend === 'cli')
39
+ return 'claude_cli';
40
+ return 'codex';
41
+ }
42
+ export const capabilityHeaders = (fastModel, backend) => ({
43
+ 'X-Companion-Capabilities': [...COMPANION_CAPABILITIES,
44
+ ...(fastModel && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(fastModel)
45
+ ? [`${WHOLE_READ_FAST_MODEL_CAPABILITY}${fastModel}`] : []),
46
+ ...(backend && /^[a-z_]{1,32}$/.test(backend) ? [`${WHOLE_READ_BACKEND_CAPABILITY}${backend}`] : [])].join(','),
29
47
  });
30
48
  export class CompanionHttpError extends Error {
31
49
  status;
@@ -52,9 +70,16 @@ export class PairingHttpError extends Error {
52
70
  export class CompanionClient {
53
71
  url;
54
72
  token;
55
- constructor(url, token) {
73
+ fastModelsByConnection;
74
+ backendsByConnection;
75
+ constructor(url, token, fastModelsByConnection = {}, backendsByConnection = {}) {
56
76
  this.url = url;
57
77
  this.token = token;
78
+ this.fastModelsByConnection = fastModelsByConnection;
79
+ this.backendsByConnection = backendsByConnection;
80
+ }
81
+ capabilityHeaders(connectionId) {
82
+ return capabilityHeaders(connectionId ? this.fastModelsByConnection[connectionId] : undefined, connectionId ? this.backendsByConnection[connectionId] : undefined);
58
83
  }
59
84
  request(path, timeoutMs, init) {
60
85
  return fetch(`${this.url}${path}`, {
@@ -82,6 +107,68 @@ export class CompanionClient {
82
107
  whoami() {
83
108
  return this.call('/api/companion/whoami', WHOAMI_TIMEOUT_MS);
84
109
  }
110
+ listBuildAssignments() {
111
+ return this.call('/api/companion/builds/assignments', CLAIM_TIMEOUT_MS, {
112
+ headers: this.capabilityHeaders(),
113
+ });
114
+ }
115
+ acceptBuildAssignment(assignment, report) {
116
+ return this.call(`/api/companion/builds/assignments/${encodeURIComponent(assignment.id)}/accept`, BUILD_RESULT_TIMEOUT_MS, {
117
+ method: 'POST',
118
+ headers: this.capabilityHeaders(assignment.connectionId),
119
+ body: JSON.stringify({
120
+ assignmentRevision: assignment.assignmentRevision,
121
+ canonicalRepository: assignment.canonicalRepository,
122
+ snapshotSha: assignment.snapshotSha,
123
+ ...report,
124
+ }),
125
+ });
126
+ }
127
+ claimBuildUnit(assignment, input) {
128
+ return this.call(`/api/companion/builds/${encodeURIComponent(assignment.buildId)}/claim`, CLAIM_TIMEOUT_MS, {
129
+ method: 'POST',
130
+ headers: this.capabilityHeaders(assignment.connectionId),
131
+ body: JSON.stringify({ assignmentId: assignment.id,
132
+ assignmentRevision: assignment.assignmentRevision, ...input }),
133
+ });
134
+ }
135
+ buildAttemptPath(claim) {
136
+ return `/api/companion/builds/${encodeURIComponent(claim.buildId)}`
137
+ + `/assignments/${encodeURIComponent(claim.assignmentId)}`
138
+ + `/units/${encodeURIComponent(claim.unitId)}/attempts/${encodeURIComponent(claim.attemptId)}`;
139
+ }
140
+ putBuildResultPage(claim, page) {
141
+ return this.call(`${this.buildAttemptPath(claim)}/pages`, BUILD_RESULT_TIMEOUT_MS, {
142
+ method: 'POST', headers: capabilityHeaders(), body: JSON.stringify({
143
+ claimToken: claim.claimToken, controlRevision: claim.controlRevision,
144
+ assignmentRevision: claim.assignmentRevision, page,
145
+ }),
146
+ });
147
+ }
148
+ sealBuildResult(claim, value) {
149
+ return this.call(`${this.buildAttemptPath(claim)}/seal`, BUILD_RESULT_TIMEOUT_MS, {
150
+ method: 'POST', headers: capabilityHeaders(), body: JSON.stringify({
151
+ claimToken: claim.claimToken, controlRevision: claim.controlRevision,
152
+ assignmentRevision: claim.assignmentRevision, ...value,
153
+ }),
154
+ });
155
+ }
156
+ acceptBuildResult(claim, value) {
157
+ return this.call(`${this.buildAttemptPath(claim)}/result`, BUILD_RESULT_TIMEOUT_MS, {
158
+ method: 'POST', headers: capabilityHeaders(), body: JSON.stringify({
159
+ claimToken: claim.claimToken, controlRevision: claim.controlRevision,
160
+ assignmentRevision: claim.assignmentRevision, ...value,
161
+ }),
162
+ });
163
+ }
164
+ reportBuildError(claim, failure, usage) {
165
+ return this.call(`${this.buildAttemptPath(claim)}/error`, ERROR_REPORT_TIMEOUT_MS, {
166
+ method: 'POST', headers: capabilityHeaders(), body: JSON.stringify({
167
+ claimToken: claim.claimToken, controlRevision: claim.controlRevision,
168
+ assignmentRevision: claim.assignmentRevision, failure, ...(usage === undefined ? {} : { usage }),
169
+ }),
170
+ });
171
+ }
85
172
  async githubToken(repo) {
86
173
  try {
87
174
  const response = await this.call('/api/companion/github-token', CLAIM_TIMEOUT_MS, { method: 'POST', body: JSON.stringify({ repo }) });
@@ -101,7 +188,7 @@ export class CompanionClient {
101
188
  claimNext(connectionId) {
102
189
  return this.call(connectionId
103
190
  ? `/api/companion/tasks/next?connectionId=${encodeURIComponent(connectionId)}`
104
- : '/api/companion/tasks/next', CLAIM_TIMEOUT_MS, { headers: capabilityHeaders() });
191
+ : '/api/companion/tasks/next', CLAIM_TIMEOUT_MS, { headers: this.capabilityHeaders(connectionId) });
105
192
  }
106
193
  async claimNextInvestigation() {
107
194
  const response = await this.request('/api/companion/investigations/next', CLAIM_TIMEOUT_MS, {
@@ -182,14 +269,24 @@ export class CompanionClient {
182
269
  }),
183
270
  });
184
271
  }
185
- heartbeat(taskId) {
186
- return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/heartbeat`, HEARTBEAT_TIMEOUT_MS, { method: 'POST' });
272
+ heartbeatSurfaceMapping(job) {
273
+ return this.call(`/api/companion/surface-mappings/${encodeURIComponent(job.jobId)}/heartbeat`, HEARTBEAT_TIMEOUT_MS, {
274
+ method: 'POST',
275
+ headers: {
276
+ 'X-Companion-Version': COMPANION_VERSION,
277
+ ...capabilityHeaders(),
278
+ },
279
+ body: JSON.stringify({ attempt: job.attempt, claimToken: job.claimToken }),
280
+ });
281
+ }
282
+ heartbeat(taskId, claimToken) {
283
+ return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/heartbeat`, HEARTBEAT_TIMEOUT_MS, { method: 'POST', ...(claimToken ? { body: JSON.stringify({ claimToken }) } : {}) });
187
284
  }
188
- reportResult(taskId, result, filesRead, snapshot) {
285
+ reportResult(taskId, result, filesRead, snapshot, claimToken) {
189
286
  return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/result`, RESULT_TIMEOUT_MS, {
190
287
  method: 'POST',
191
288
  headers: capabilityHeaders(),
192
- body: JSON.stringify({ result, filesRead, ...(snapshot ? { snapshot } : {}) }),
289
+ body: JSON.stringify({ result, filesRead, ...(snapshot ? { snapshot } : {}), ...(claimToken ? { claimToken } : {}) }),
193
290
  });
194
291
  }
195
292
  reportGitRangeResult(taskId, result) {
@@ -199,8 +296,8 @@ export class CompanionClient {
199
296
  }
200
297
  return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/result`, RESULT_TIMEOUT_MS, { method: 'POST', headers: capabilityHeaders(), body });
201
298
  }
202
- reportError(taskId, error) {
203
- return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/error`, ERROR_REPORT_TIMEOUT_MS, { method: 'POST', headers: capabilityHeaders(), body: JSON.stringify({ error }) });
299
+ reportError(taskId, error, claimToken) {
300
+ return this.call(`/api/companion/tasks/${encodeURIComponent(taskId)}/error`, ERROR_REPORT_TIMEOUT_MS, { method: 'POST', headers: capabilityHeaders(), body: JSON.stringify({ error, ...(claimToken ? { claimToken } : {}) }) });
204
301
  }
205
302
  reportInvestigationResult(investigationId, result) {
206
303
  return this.call(`/api/companion/investigations/${encodeURIComponent(investigationId)}/result`, RESULT_TIMEOUT_MS, { method: 'POST', headers: capabilityHeaders(), body: JSON.stringify(result) });
@@ -4,7 +4,7 @@ import { readFile, lstat, stat } from 'node:fs/promises';
4
4
  import { resolve, sep } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { listRepoFiles } from './local-scan.js';
7
- import { obtainRequestedSources } from './observed-sources.js';
7
+ import { obtainRequestedSources, } from './observed-sources.js';
8
8
  import { scopeCovers, validateReaderResult } from './reader-result-contract.js';
9
9
  import { gitErrorMessage } from './sandbox.js';
10
10
  import { isUsageLimitError } from './usage-limit.js';
@@ -1152,7 +1152,7 @@ async function enumerateTrees(request, obtained) {
1152
1152
  }
1153
1153
  export async function runInvestigation(request, connections, callModel, reportDiagnostic = (diagnostic) => {
1154
1154
  console.error(formatInvestigationReaderDiagnostic(diagnostic));
1155
- }, execution) {
1155
+ }, execution, sourceAcquisition) {
1156
1156
  if (request.policyVersion === 'policy-4' && !execution) {
1157
1157
  throw new MissingPolicy4ExecutionError();
1158
1158
  }
@@ -1160,7 +1160,7 @@ export async function runInvestigation(request, connections, callModel, reportDi
1160
1160
  let observedSources = [];
1161
1161
  let evidence = [];
1162
1162
  try {
1163
- obtained = await obtainRequestedSources(request, connections);
1163
+ obtained = await obtainRequestedSources(request, connections, sourceAcquisition);
1164
1164
  observedSources = obtained.observedSources;
1165
1165
  if (obtained.unavailable) {
1166
1166
  return honestResult(request, observedSources, evidence, 'source_identity_unavailable', execution);
@@ -1173,17 +1173,20 @@ export async function runInvestigation(request, connections, callModel, reportDi
1173
1173
  if (!sandbox)
1174
1174
  return honestResult(request, observedSources, evidence, 'files_unreadable', execution);
1175
1175
  const reads = await readSurfaceEvidence(request, sandbox);
1176
+ obtained.assertAvailable();
1176
1177
  evidence = reads.map((read) => read.evidence);
1177
1178
  const retrievalReason = surfaceRetrievalReason(evidence);
1178
1179
  if (retrievalReason)
1179
1180
  return honestResult(request, observedSources, evidence, retrievalReason, execution);
1181
+ const assessments = await assessCompleteSurface(request, observedSources, reads, callModel, reportDiagnostic, execution);
1182
+ obtained.assertAvailable();
1180
1183
  const result = {
1181
1184
  investigationId: request.investigationId,
1182
1185
  attempt: request.attempt,
1183
1186
  resultKey: resultKeyFor(request),
1184
1187
  observedSources,
1185
1188
  evidence,
1186
- assessments: ensureAutomaticClaimShapes(request, await assessCompleteSurface(request, observedSources, reads, callModel, reportDiagnostic, execution)),
1189
+ assessments: ensureAutomaticClaimShapes(request, assessments),
1187
1190
  execution: { ...execution },
1188
1191
  };
1189
1192
  try {
@@ -1214,6 +1217,7 @@ export async function runInvestigation(request, connections, callModel, reportDi
1214
1217
  schema: selectionSchema,
1215
1218
  });
1216
1219
  selected = filterSelectedPaths(selection, trees);
1220
+ obtained.assertAvailable();
1217
1221
  }
1218
1222
  catch (error) {
1219
1223
  const treeFileCount = trees.reduce((count, tree) => count + tree.paths.length, 0);
@@ -1223,6 +1227,7 @@ export async function runInvestigation(request, connections, callModel, reportDi
1223
1227
  return honestResult(request, observedSources, evidence, 'no_plausible_path');
1224
1228
  }
1225
1229
  const reads = await readSelectedEvidence(request, selected, obtained.sandboxes);
1230
+ obtained.assertAvailable();
1226
1231
  const budgeted = buildBudgetedAssessmentPrompt(request, reads);
1227
1232
  evidence = budgeted.reads.map((read) => read.evidence);
1228
1233
  if (budgeted.prompt === null) {
@@ -1257,6 +1262,7 @@ export async function runInvestigation(request, connections, callModel, reportDi
1257
1262
  prompt: assessmentPrompt,
1258
1263
  schema: assessmentResponseSchema,
1259
1264
  });
1265
+ obtained.assertAvailable();
1260
1266
  }
1261
1267
  catch (error) {
1262
1268
  return honestResult(request, observedSources, evidence, reasonForModelFailure(error, 'assessment', assessmentPrompt, budgeted.reads.length, reportDiagnostic));
@@ -1293,11 +1299,11 @@ export async function runInvestigation(request, connections, callModel, reportDi
1293
1299
  await obtained?.cleanup();
1294
1300
  }
1295
1301
  }
1296
- export async function runAndReportInvestigation(request, connections, callModel, report, reportDiagnostic, execution) {
1302
+ export async function runAndReportInvestigation(request, connections, callModel, report, reportDiagnostic, execution, sourceAcquisition) {
1297
1303
  if (request.policyVersion === 'policy-4' && !execution) {
1298
1304
  throw new MissingPolicy4ExecutionError();
1299
1305
  }
1300
- const result = await runInvestigation(request, connections, callModel, reportDiagnostic, execution);
1306
+ const result = await runInvestigation(request, connections, callModel, reportDiagnostic, execution, sourceAcquisition);
1301
1307
  await report(result);
1302
1308
  return result;
1303
1309
  }
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { spawn } from 'node:child_process';
10
10
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { lstat, realpath, rm } from 'node:fs/promises';
11
12
  import { basename, isAbsolute, join, relative, resolve } from 'node:path';
12
13
  import { parseRemoteUrl } from './local-scan.js';
13
14
  export class ManagedRepoError extends Error {
@@ -84,6 +85,20 @@ export function labelFromCopyPath(repoPath) {
84
85
  const match = /^github\.com__(.+?)__(.+)$/.exec(basename(repoPath));
85
86
  return match ? `${match[1]}/${match[2]}` : basename(repoPath);
86
87
  }
88
+ /** Reads a managed repository's durable identity, with the old label/path convention retained
89
+ * only as a compatibility fallback for configs that have not entered the B38 migration yet. */
90
+ export function configuredManagedRepository(connection) {
91
+ if (connection.repository) {
92
+ if (connection.repository.host !== 'github.com' || connection.repository.port !== null) {
93
+ throw new ManagedRepoError(NOT_GITHUB_MESSAGE);
94
+ }
95
+ return parseGitHubRepo(connection.repository.path);
96
+ }
97
+ return parseGitHubRepo(connection.label ?? labelFromCopyPath(connection.repoPath));
98
+ }
99
+ export function usesTemporaryManagedStorage(connection) {
100
+ return connection.managed === true && connection.repositoryStorage === 'temporary';
101
+ }
87
102
  const MAX_CAPTURED_STDERR = 16_384;
88
103
  /** `stdio: 'inherit'` lets Git (and Git Credential Manager) talk to the person directly; stderr
89
104
  * is still teed to the terminal and captured so a failure can be explained in one line. */
@@ -201,6 +216,10 @@ export function watchManagedCopy(repoPath, label, log, getToken) {
201
216
  export function forgetManagedCopies() {
202
217
  watchedCopies.clear();
203
218
  }
219
+ /** Stops background reporting for one copy before its retirement starts. */
220
+ export function forgetManagedCopy(repoPath) {
221
+ watchedCopies.delete(watchKey(repoPath));
222
+ }
204
223
  async function tokenFrom(getToken) {
205
224
  if (!getToken)
206
225
  return null;
@@ -389,6 +408,119 @@ export async function removeManagedCopy(repoPath, reposDir) {
389
408
  rmSync(managedStatePath(target), { force: true });
390
409
  return true;
391
410
  }
411
+ export class ManagedCopyRetirementError extends Error {
412
+ code;
413
+ constructor(code) {
414
+ super(code);
415
+ this.code = code;
416
+ this.name = 'ManagedCopyRetirementError';
417
+ }
418
+ }
419
+ async function pathMissing(path) {
420
+ try {
421
+ await lstat(path);
422
+ return false;
423
+ }
424
+ catch (error) {
425
+ if (error.code === 'ENOENT')
426
+ return true;
427
+ throw error;
428
+ }
429
+ }
430
+ function hasManagedCopyMarker(repoPath) {
431
+ try {
432
+ const marker = JSON.parse(readFileSync(managedStatePath(repoPath), 'utf8'));
433
+ return marker !== null
434
+ && typeof marker === 'object'
435
+ && !Array.isArray(marker)
436
+ && typeof marker.signInNeeded === 'boolean'
437
+ && typeof marker.lastUpdatedAt === 'string'
438
+ && Number.isFinite(Date.parse(marker.lastUpdatedAt));
439
+ }
440
+ catch {
441
+ return false;
442
+ }
443
+ }
444
+ function normalizedPath(path) {
445
+ const absolute = resolve(path);
446
+ return process.platform === 'win32' ? absolute.toLowerCase() : absolute;
447
+ }
448
+ async function validateManagedCopyTargetPath(repoPath, reposDir, repo) {
449
+ const root = resolve(reposDir);
450
+ const target = resolve(repoPath);
451
+ if (normalizedPath(target) !== normalizedPath(managedCopyPath(root, repo))) {
452
+ throw new ManagedCopyRetirementError('cleanup_path_outside_root');
453
+ }
454
+ if (await pathMissing(root))
455
+ return 'missing';
456
+ const rootStats = await lstat(root);
457
+ if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) {
458
+ throw new ManagedCopyRetirementError('cleanup_unsafe_link');
459
+ }
460
+ if (await pathMissing(target))
461
+ return 'missing';
462
+ const targetStats = await lstat(target);
463
+ if (!targetStats.isDirectory() || targetStats.isSymbolicLink()) {
464
+ throw new ManagedCopyRetirementError('cleanup_unsafe_link');
465
+ }
466
+ const [canonicalRoot, canonicalTarget] = await Promise.all([realpath(root), realpath(target)]);
467
+ if (normalizedPath(join(canonicalRoot, basename(target))) !== normalizedPath(canonicalTarget)) {
468
+ throw new ManagedCopyRetirementError('cleanup_path_outside_root');
469
+ }
470
+ return 'present';
471
+ }
472
+ /** Validates the old clone from independent companion records before deletion. The target must be
473
+ * the one deterministic child for this identity, have the state marker written on clone, and
474
+ * still be the top of the expected Git repository. */
475
+ async function validateManagedCopyRetirement(repoPath, reposDir, repo) {
476
+ const target = resolve(repoPath);
477
+ const presence = await validateManagedCopyTargetPath(repoPath, reposDir, repo);
478
+ if (presence === 'missing')
479
+ return presence;
480
+ if (!hasManagedCopyMarker(target)) {
481
+ throw new ManagedCopyRetirementError('cleanup_marker_missing');
482
+ }
483
+ if (!(await isCopyOf(target, repo))) {
484
+ throw new ManagedCopyRetirementError('cleanup_identity_mismatch');
485
+ }
486
+ return 'present';
487
+ }
488
+ /** Retires one validated historical managed clone. `onValidated` durably records cleanup_pending
489
+ * before the first delete. Validation is repeated afterwards to close the write/delete race. */
490
+ export async function retireManagedCopy(options, dependencies = {}) {
491
+ const wait = dependencies.wait
492
+ ?? ((delayMs) => new Promise((resolveWait) => setTimeout(resolveWait, delayMs)));
493
+ const retryDelaysMs = dependencies.retryDelaysMs ?? [25, 50, 100, 200];
494
+ const removeTree = dependencies.removeTree ?? ((path) => rm(path, { recursive: true, force: false }));
495
+ const initial = await validateManagedCopyRetirement(options.repoPath, options.reposDir, options.repo);
496
+ if (initial === 'missing') {
497
+ rmSync(managedStatePath(options.repoPath), { force: true });
498
+ return { cleanupAttempts: 0 };
499
+ }
500
+ await options.onValidated();
501
+ await validateManagedCopyRetirement(options.repoPath, options.reposDir, options.repo);
502
+ forgetManagedCopy(options.repoPath);
503
+ await runGit(options.repoPath, ['worktree', 'prune']);
504
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
505
+ try {
506
+ const presence = await validateManagedCopyTargetPath(options.repoPath, options.reposDir, options.repo);
507
+ if (presence === 'present')
508
+ await removeTree(options.repoPath);
509
+ if (!(await pathMissing(options.repoPath)))
510
+ throw new Error('cleanup_delete_failed');
511
+ rmSync(managedStatePath(options.repoPath), { force: true });
512
+ return { cleanupAttempts: attempt };
513
+ }
514
+ catch (error) {
515
+ if (error instanceof ManagedCopyRetirementError)
516
+ throw error;
517
+ if (attempt < 5) {
518
+ await wait(retryDelaysMs[Math.min(attempt - 1, retryDelaysMs.length - 1)] ?? 0);
519
+ }
520
+ }
521
+ }
522
+ throw new ManagedCopyRetirementError('cleanup_delete_failed');
523
+ }
392
524
  export function relativeTime(iso, now = new Date()) {
393
525
  if (!iso)
394
526
  return 'not yet';
@@ -1,7 +1,18 @@
1
1
  import { createSandbox } from './sandbox.js';
2
+ import { RepositoryProviderError, } from './repository-provider.js';
3
+ const isPrdSource = (source) => source.kind === 'prd';
4
+ function unavailableSource(source, reason) {
5
+ return {
6
+ sourceEntryId: source.sourceEntryId,
7
+ role: source.role,
8
+ connectionId: source.connectionId,
9
+ sourceIdentity: `unavailable:${reason}`,
10
+ obtainedBy: 'unavailable',
11
+ };
12
+ }
2
13
  /** Resolve every immutable identity before exposing any worktree for reading. This prevents an
3
14
  * earlier source from being inspected before a later required source proves unavailable. */
4
- export async function obtainRequestedSources(request, connections) {
15
+ export async function obtainRequestedSources(request, connections, acquisition) {
5
16
  const sourceEntryIds = new Set();
6
17
  for (const source of request.requestedSources) {
7
18
  if (sourceEntryIds.has(source.sourceEntryId)) {
@@ -11,13 +22,123 @@ export async function obtainRequestedSources(request, connections) {
11
22
  }
12
23
  const observedSources = [];
13
24
  const sandboxes = new Map();
25
+ const leases = new Map();
26
+ const leasesByIdentity = new Map();
27
+ const verifiedSources = new Set();
14
28
  const cleanup = async () => {
15
- const pending = [...sandboxes.values()];
29
+ const pending = acquisition
30
+ ? [...new Set(leases.values())].map((lease) => () => lease.release().then(() => undefined))
31
+ : [...sandboxes.values()].map((sandbox) => () => sandbox.cleanup());
16
32
  sandboxes.clear();
17
- await Promise.all(pending.map((sandbox) => sandbox.cleanup()));
33
+ leases.clear();
34
+ leasesByIdentity.clear();
35
+ const results = await Promise.allSettled(pending.map((release) => release()));
36
+ const failure = results.find((result) => result.status === 'rejected');
37
+ if (failure)
38
+ throw failure.reason;
18
39
  };
40
+ if (acquisition) {
41
+ let failureCode = null;
42
+ try {
43
+ await acquisition.repositoryProvider.sweepRepositories();
44
+ for (const source of request.requestedSources) {
45
+ if (isPrdSource(source))
46
+ continue;
47
+ const connection = connections.find((candidate) => candidate.connectionId === source.connectionId);
48
+ if (!connection) {
49
+ failureCode = 'source_identity_unavailable';
50
+ break;
51
+ }
52
+ try {
53
+ const identityKey = JSON.stringify([source.connectionId, source.commit]);
54
+ const lease = leasesByIdentity.get(identityKey)
55
+ ?? await acquisition.repositoryProvider.acquireRepository({
56
+ connection,
57
+ target: { kind: 'snapshot', sha: source.commit },
58
+ owner: acquisition.owner,
59
+ signal: acquisition.signal,
60
+ });
61
+ if (lease.identities.connectionId !== source.connectionId
62
+ || lease.identities.source !== `commit:${source.commit}`
63
+ || lease.identities.target.kind !== 'snapshot'
64
+ || lease.identities.target.sha !== source.commit) {
65
+ await lease.release();
66
+ throw new RepositoryProviderError('snapshot_unavailable');
67
+ }
68
+ leasesByIdentity.set(identityKey, lease);
69
+ leases.set(source.sourceEntryId, lease);
70
+ verifiedSources.add(source.sourceEntryId);
71
+ }
72
+ catch (error) {
73
+ failureCode = error instanceof RepositoryProviderError ? error.code : 'snapshot_unavailable';
74
+ break;
75
+ }
76
+ }
77
+ if (failureCode === null) {
78
+ for (const source of request.requestedSources) {
79
+ if (isPrdSource(source))
80
+ continue;
81
+ const lease = leases.get(source.sourceEntryId);
82
+ try {
83
+ const checkout = await lease.checkout();
84
+ sandboxes.set(source.sourceEntryId, {
85
+ target: 'requested_commit',
86
+ obtainedBy: 'sandbox_worktree',
87
+ path: checkout.path,
88
+ resolvedCommit: checkout.commit,
89
+ sourceIdentity: `commit:${checkout.commit}`,
90
+ cleanup: () => lease.release().then(() => undefined),
91
+ });
92
+ }
93
+ catch (error) {
94
+ failureCode = error instanceof RepositoryProviderError ? error.code : 'snapshot_unavailable';
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ if (failureCode !== null)
100
+ await cleanup();
101
+ for (const source of request.requestedSources) {
102
+ if (isPrdSource(source)) {
103
+ // B31 document identities are already immutable and use their own content delivery path.
104
+ observedSources.push({ ...source });
105
+ continue;
106
+ }
107
+ const sandbox = sandboxes.get(source.sourceEntryId);
108
+ observedSources.push(sandbox || verifiedSources.has(source.sourceEntryId)
109
+ ? {
110
+ sourceEntryId: source.sourceEntryId,
111
+ role: source.role,
112
+ connectionId: source.connectionId,
113
+ sourceIdentity: sandbox?.sourceIdentity ?? `commit:${source.commit}`,
114
+ obtainedBy: 'sandbox_worktree',
115
+ }
116
+ : unavailableSource(source, failureCode ?? 'source_identity_unavailable'));
117
+ }
118
+ }
119
+ catch (error) {
120
+ await cleanup();
121
+ throw error;
122
+ }
123
+ return {
124
+ observedSources,
125
+ sandboxes,
126
+ unavailable: failureCode !== null,
127
+ assertAvailable() {
128
+ if (acquisition.signal.aborted)
129
+ throw new RepositoryProviderError('repository_lease_lost');
130
+ for (const lease of new Set(leases.values()))
131
+ lease.assertLive();
132
+ },
133
+ cleanup,
134
+ };
135
+ }
19
136
  try {
20
137
  for (const source of request.requestedSources) {
138
+ if (isPrdSource(source)) {
139
+ observedSources.push({ ...source });
140
+ continue;
141
+ }
21
142
  const connection = connections.find((candidate) => candidate.connectionId === source.connectionId);
22
143
  if (!connection) {
23
144
  observedSources.push({
@@ -59,22 +180,27 @@ export async function obtainRequestedSources(request, connections) {
59
180
  return {
60
181
  observedSources,
61
182
  sandboxes,
62
- unavailable: observedSources.some((source) => source.obtainedBy === 'unavailable'),
183
+ unavailable: observedSources.some((source) => 'obtainedBy' in source && source.obtainedBy === 'unavailable'),
184
+ assertAvailable() { },
63
185
  cleanup,
64
186
  };
65
187
  }
66
188
  /** Obtain each requested immutable source, optionally inspect it while its worktree is alive, and
67
189
  * return the observed-source half of a reader result. Every created worktree is cleaned in a
68
190
  * finally block, including when the inspection callback crashes. */
69
- export async function buildObservedSources(request, connections, inspectSource = async () => { }) {
70
- const obtained = await obtainRequestedSources(request, connections);
191
+ export async function buildObservedSources(request, connections, inspectSource = async () => { }, acquisition) {
192
+ const obtained = await obtainRequestedSources(request, connections, acquisition);
71
193
  try {
72
194
  if (!obtained.unavailable) {
195
+ obtained.assertAvailable();
73
196
  for (const source of request.requestedSources) {
197
+ if (isPrdSource(source))
198
+ continue;
74
199
  const sandbox = obtained.sandboxes.get(source.sourceEntryId);
75
200
  if (!sandbox)
76
201
  throw new Error(`No sandbox obtained for ${source.sourceEntryId}`);
77
202
  await inspectSource(source, sandbox);
203
+ obtained.assertAvailable();
78
204
  }
79
205
  }
80
206
  return obtained.observedSources;