@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/http-client.js
CHANGED
|
@@ -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
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
return this.call(`/api/companion/
|
|
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,
|
|
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
|
}
|
package/dist/managed-repo.js
CHANGED
|
@@ -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';
|
package/dist/observed-sources.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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;
|