@peakflo/agent-harness 0.2.17-rc.1 → 0.2.17-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/github-workspace.d.ts +4 -0
- package/dist/github-workspace.js +160 -45
- package/package.json +1 -1
|
@@ -40,6 +40,10 @@ export declare class GitHubWorkspaceProvider implements WorkspaceProvider {
|
|
|
40
40
|
private sameRepository;
|
|
41
41
|
/** Never fetch a different repository into a shared durable bare clone. */
|
|
42
42
|
private bareRepositoryPath;
|
|
43
|
+
private isUnusableCheckout;
|
|
44
|
+
private checkoutPath;
|
|
45
|
+
/** Locate only this checkout's registration; never prune sibling records. */
|
|
46
|
+
private worktreeRegistration;
|
|
43
47
|
/**
|
|
44
48
|
* A session owns its checkout, including branch changes and detached HEAD.
|
|
45
49
|
* Check its root and repository identity, never its branch or commit. Keep
|
package/dist/github-workspace.js
CHANGED
|
@@ -25,8 +25,8 @@ function githubRepositoryIdentity(remote) {
|
|
|
25
25
|
try {
|
|
26
26
|
const url = new URL(ssh ? `https://github.com/${ssh[1]}` : remote);
|
|
27
27
|
const host = url.hostname.toLowerCase();
|
|
28
|
-
//
|
|
29
|
-
//
|
|
28
|
+
// Within a harness-owned checkout/cache, loopback owner/repo URLs denote
|
|
29
|
+
// the GitHub proxy. This is not proof of identity for arbitrary local URLs.
|
|
30
30
|
if (!['github.com', '127.0.0.1', 'localhost', '[::1]'].includes(host)) {
|
|
31
31
|
return null;
|
|
32
32
|
}
|
|
@@ -87,6 +87,8 @@ class GitHubWorkspaceProvider {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
this.gitEnvironment.GIT_TERMINAL_PROMPT = '0';
|
|
90
|
+
// Recovery classifies Git's stable diagnostics, never localized text.
|
|
91
|
+
this.gitEnvironment.LC_ALL = 'C';
|
|
90
92
|
this.rememberDirectUrls(options.directUrls ?? []);
|
|
91
93
|
}
|
|
92
94
|
rememberDirectUrls(urls) {
|
|
@@ -114,81 +116,179 @@ class GitHubWorkspaceProvider {
|
|
|
114
116
|
}
|
|
115
117
|
/** Never fetch a different repository into a shared durable bare clone. */
|
|
116
118
|
async bareRepositoryPath(path, cloneUrl) {
|
|
117
|
-
try {
|
|
118
|
-
await (0, promises_1.lstat)(path);
|
|
119
|
-
}
|
|
120
|
-
catch (error) {
|
|
121
|
-
if (error.code === 'ENOENT')
|
|
122
|
-
return path;
|
|
123
|
-
throw error;
|
|
124
|
-
}
|
|
125
|
-
const origin = await this.runGit(['config', '--get', 'remote.origin.url'], {
|
|
126
|
-
cwd: path
|
|
127
|
-
}).catch(() => null);
|
|
128
|
-
if (origin &&
|
|
129
|
-
(await this.sameRepository(cloneUrl, origin.stdout.trim(), path))) {
|
|
130
|
-
return path;
|
|
131
|
-
}
|
|
132
|
-
// Leave the old clone and all its linked worktrees intact. A separate
|
|
133
|
-
// clone also prevents an unrelated agent/<session> ref from being reused.
|
|
134
119
|
const identity = githubRepositoryIdentity(cloneUrl) ?? cloneUrl;
|
|
135
120
|
const digest = (0, node_crypto_1.createHash)('sha256')
|
|
136
121
|
.update(identity)
|
|
137
122
|
.digest('hex')
|
|
138
123
|
.slice(0, 16);
|
|
139
|
-
|
|
124
|
+
// Leave old clones and linked worktrees intact. Bound collision handling
|
|
125
|
+
// without appending the same suffix recursively to a file name.
|
|
126
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
127
|
+
const candidate = attempt === 0 ? path : `${path}.recovered-${digest}-${attempt}`;
|
|
128
|
+
try {
|
|
129
|
+
const entry = await (0, promises_1.lstat)(candidate);
|
|
130
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error.code === 'ENOENT')
|
|
135
|
+
return candidate;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
const origin = await this.runGit(['config', '--get', 'remote.origin.url'], {
|
|
139
|
+
cwd: candidate
|
|
140
|
+
}).catch((error) => {
|
|
141
|
+
if (this.isUnusableCheckout(error) || error.code === 1)
|
|
142
|
+
return null;
|
|
143
|
+
throw new Error('Could not inspect repository cache. Existing files were kept.');
|
|
144
|
+
});
|
|
145
|
+
if (origin &&
|
|
146
|
+
(await this.sameRepository(cloneUrl, origin.stdout.trim(), candidate))) {
|
|
147
|
+
return candidate;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
throw new Error('Could not prepare a repository cache. Existing files were kept.');
|
|
151
|
+
}
|
|
152
|
+
isUnusableCheckout(error) {
|
|
153
|
+
const gitError = error;
|
|
154
|
+
return (gitError.code === 128 &&
|
|
155
|
+
/not a git repository|invalid gitfile format|bad config line/i.test(gitError.stderr ?? ''));
|
|
156
|
+
}
|
|
157
|
+
async checkoutPath(path) {
|
|
158
|
+
// Resolve the parent, not the checkout entry: it may now be a symlink or
|
|
159
|
+
// be missing. Match the registered location without following its target.
|
|
160
|
+
return (0, node_path_1.join)(await (0, promises_1.realpath)((0, node_path_1.dirname)(path)), (0, node_path_1.basename)(path));
|
|
161
|
+
}
|
|
162
|
+
/** Locate only this checkout's registration; never prune sibling records. */
|
|
163
|
+
async worktreeRegistration(bare, worktree) {
|
|
164
|
+
const canonical = await this.checkoutPath(worktree);
|
|
165
|
+
const root = (0, node_path_1.join)(bare, 'worktrees');
|
|
166
|
+
const entries = await (0, promises_1.readdir)(root, { withFileTypes: true }).catch((error) => {
|
|
167
|
+
if (error.code === 'ENOENT')
|
|
168
|
+
return [];
|
|
169
|
+
throw error;
|
|
170
|
+
});
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
173
|
+
continue;
|
|
174
|
+
const admin = (0, node_path_1.join)(root, entry.name);
|
|
175
|
+
const target = await (0, promises_1.readFile)((0, node_path_1.join)(admin, 'gitdir'), 'utf8').catch((error) => {
|
|
176
|
+
if (error.code === 'ENOENT')
|
|
177
|
+
return '';
|
|
178
|
+
throw error;
|
|
179
|
+
});
|
|
180
|
+
if ((0, node_path_1.basename)(target.trim()) !== '.git')
|
|
181
|
+
continue;
|
|
182
|
+
const parent = await this.checkoutPath((0, node_path_1.dirname)(target.trim())).catch((error) => {
|
|
183
|
+
if (error.code === 'ENOENT')
|
|
184
|
+
return null;
|
|
185
|
+
throw error;
|
|
186
|
+
});
|
|
187
|
+
if (parent === canonical)
|
|
188
|
+
return admin;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
140
191
|
}
|
|
141
192
|
/**
|
|
142
193
|
* A session owns its checkout, including branch changes and detached HEAD.
|
|
143
194
|
* Check its root and repository identity, never its branch or commit. Keep
|
|
144
195
|
* an unrelated or unusable checkout aside before preparing a replacement.
|
|
145
196
|
*/
|
|
146
|
-
async canReuseWorktree(worktree, cloneUrl, sessionId) {
|
|
197
|
+
async canReuseWorktree(bare, worktree, cloneUrl, sessionId) {
|
|
147
198
|
let entry;
|
|
148
199
|
try {
|
|
149
200
|
entry = await (0, promises_1.lstat)(worktree);
|
|
150
201
|
}
|
|
151
202
|
catch (error) {
|
|
152
|
-
if (error.code
|
|
153
|
-
|
|
154
|
-
throw error;
|
|
203
|
+
if (error.code !== 'ENOENT')
|
|
204
|
+
throw error;
|
|
155
205
|
}
|
|
156
|
-
if (entry
|
|
206
|
+
if (entry?.isDirectory() && !entry.isSymbolicLink()) {
|
|
157
207
|
try {
|
|
158
|
-
const [topLevel, origin] = await Promise.all([
|
|
208
|
+
const [topLevel, origin, commonDir] = await Promise.all([
|
|
159
209
|
this.runGit(['rev-parse', '--show-toplevel'], { cwd: worktree }),
|
|
160
210
|
this.runGit(['config', '--get', 'remote.origin.url'], {
|
|
161
211
|
cwd: worktree
|
|
162
|
-
})
|
|
212
|
+
}).catch((error) => {
|
|
213
|
+
if (error.code === 1 && !error.stderr)
|
|
214
|
+
return { stdout: '' };
|
|
215
|
+
throw error;
|
|
216
|
+
}),
|
|
217
|
+
this.runGit(['rev-parse', '--git-common-dir'], { cwd: worktree })
|
|
163
218
|
]);
|
|
164
|
-
const [expectedWorktree, actualWorktree] = await Promise.all([
|
|
219
|
+
const [expectedWorktree, actualWorktree, repositoriesRoot, actualCommon] = await Promise.all([
|
|
165
220
|
(0, promises_1.realpath)(worktree),
|
|
166
|
-
(0, promises_1.realpath)((0, node_path_1.resolve)(worktree, topLevel.stdout.trim()))
|
|
221
|
+
(0, promises_1.realpath)((0, node_path_1.resolve)(worktree, topLevel.stdout.trim())),
|
|
222
|
+
(0, promises_1.realpath)((0, node_path_1.join)((0, node_path_1.resolve)(this.options.root), '.repositories')),
|
|
223
|
+
(0, promises_1.realpath)((0, node_path_1.resolve)(worktree, commonDir.stdout.trim()))
|
|
167
224
|
]);
|
|
168
225
|
const sameRepository = await this.sameRepository(cloneUrl, origin.stdout.trim(), worktree);
|
|
169
|
-
|
|
226
|
+
const managedCommon = actualCommon.startsWith(`${repositoriesRoot}/`) ||
|
|
227
|
+
actualCommon === (0, node_path_1.join)(expectedWorktree, '.git');
|
|
228
|
+
if (actualWorktree === expectedWorktree &&
|
|
229
|
+
sameRepository &&
|
|
230
|
+
managedCommon)
|
|
170
231
|
return true;
|
|
171
232
|
}
|
|
172
|
-
catch {
|
|
173
|
-
//
|
|
174
|
-
//
|
|
233
|
+
catch (error) {
|
|
234
|
+
// Only a proven broken Git checkout is moved. Resource exhaustion,
|
|
235
|
+
// permission errors and timeouts must leave a healthy checkout in place.
|
|
236
|
+
// Do not echo Git errors: they can contain remote credentials.
|
|
237
|
+
if (!this.isUnusableCheckout(error)) {
|
|
238
|
+
throw new Error('Could not inspect the existing repository workspace. Retry the message. Existing files were not changed.');
|
|
239
|
+
}
|
|
175
240
|
}
|
|
176
241
|
}
|
|
242
|
+
const registration = await this.worktreeRegistration(bare, worktree);
|
|
243
|
+
if (!entry && !registration)
|
|
244
|
+
return false;
|
|
177
245
|
const backupRoot = await (0, promises_1.mkdtemp)((0, node_path_1.join)(this.sessionPath(sessionId), `.preserved-${Date.now()}-`));
|
|
178
246
|
const backup = (0, node_path_1.join)(backupRoot, 'repository');
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
247
|
+
const repairRegistration = async () => {
|
|
248
|
+
try {
|
|
249
|
+
if (registration) {
|
|
250
|
+
// Keep the index, HEAD and reflog. Retain the original .git bytes too.
|
|
251
|
+
const gitFile = (0, node_path_1.join)(backup, '.git');
|
|
252
|
+
await (0, promises_1.rename)(gitFile, (0, node_path_1.join)(backupRoot, 'original-git-file')).catch((error) => {
|
|
253
|
+
if (error.code !== 'ENOENT')
|
|
254
|
+
throw error;
|
|
255
|
+
});
|
|
256
|
+
await (0, promises_1.writeFile)(gitFile, `gitdir: ${registration}\n`, {
|
|
257
|
+
mode: 0o600
|
|
258
|
+
});
|
|
259
|
+
await (0, promises_1.writeFile)((0, node_path_1.join)(registration, 'gitdir'), `${gitFile}\n`, {
|
|
260
|
+
mode: 0o600
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
await this.runGit(['worktree', 'repair', backup], { cwd: bare }).catch(() => undefined);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
throw new Error(`Repository files were kept at ${backup}. Git state repair failed. ` +
|
|
267
|
+
'Keep the saved folder and retry after the filesystem is available.');
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
if (!entry) {
|
|
271
|
+
// A removed checkout can still have staged work in its Git index.
|
|
272
|
+
await (0, promises_1.mkdir)(backup, { mode: 0o700 });
|
|
273
|
+
await repairRegistration();
|
|
274
|
+
}
|
|
275
|
+
else if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
182
276
|
await this.runGit(['worktree', 'move', worktree, backup], {
|
|
183
277
|
cwd: worktree
|
|
184
|
-
}).catch(() =>
|
|
278
|
+
}).catch(async () => {
|
|
279
|
+
await (0, promises_1.rename)(worktree, backup);
|
|
280
|
+
await repairRegistration();
|
|
281
|
+
});
|
|
185
282
|
}
|
|
186
283
|
else {
|
|
187
|
-
await (0, promises_1.
|
|
284
|
+
await (0, promises_1.mkdir)(backup, { mode: 0o700 });
|
|
285
|
+
await (0, promises_1.rename)(worktree, (0, node_path_1.join)(backup, 'original-entry'));
|
|
286
|
+
await repairRegistration();
|
|
188
287
|
}
|
|
189
288
|
const notes = this.preparationNotes.get(sessionId) ?? [];
|
|
190
|
-
notes.push(`The previous repository folder could not be reused.
|
|
191
|
-
`were kept
|
|
289
|
+
notes.push(`The previous repository folder could not be reused. Its files and Git state ` +
|
|
290
|
+
`were kept under ${backupRoot}. Saved checkout: ${backup}. ` +
|
|
291
|
+
'A fresh checkout was prepared. ' +
|
|
192
292
|
'Review the saved folder before you remove it.');
|
|
193
293
|
this.preparationNotes.set(sessionId, notes);
|
|
194
294
|
return false;
|
|
@@ -404,6 +504,12 @@ class GitHubWorkspaceProvider {
|
|
|
404
504
|
let reusedWorktree = false;
|
|
405
505
|
for (const repository of request.repositories) {
|
|
406
506
|
const [owner, name] = repository.fullName.split('/');
|
|
507
|
+
const githubIdentity = githubRepositoryIdentity(repository.cloneUrl);
|
|
508
|
+
// GitHub's canonical clone endpoint is HTTPS owner/repo.git. Strip URL
|
|
509
|
+
// credentials and old loopback ports; the credential proxy supplies auth.
|
|
510
|
+
const transportUrl = githubIdentity
|
|
511
|
+
? `https://${githubIdentity}.git`
|
|
512
|
+
: repository.cloneUrl;
|
|
407
513
|
const bare = await this.bareRepositoryPath((0, node_path_1.join)(repositoriesRoot, `${owner}--${name}.git`), repository.cloneUrl);
|
|
408
514
|
const worktree = (0, node_path_1.join)(sessionRoot, name);
|
|
409
515
|
// The empty value resets every inherited/global helper. Without it, Git
|
|
@@ -421,7 +527,7 @@ class GitHubWorkspaceProvider {
|
|
|
421
527
|
});
|
|
422
528
|
}
|
|
423
529
|
catch {
|
|
424
|
-
await this.runGit([...gitArgs, 'clone', '--bare',
|
|
530
|
+
await this.runGit([...gitArgs, 'clone', '--bare', transportUrl, bare], {
|
|
425
531
|
timeout: 10 * 60_000,
|
|
426
532
|
maxBuffer: 10 * 1024 * 1024,
|
|
427
533
|
environment: gitNetworkEnvironment
|
|
@@ -432,15 +538,21 @@ class GitHubWorkspaceProvider {
|
|
|
432
538
|
'+refs/heads/*:refs/remotes/origin/*'
|
|
433
539
|
], { cwd: bare });
|
|
434
540
|
}
|
|
435
|
-
|
|
541
|
+
// The identity check above permits old proxy/sentinel URL forms. Fetch
|
|
542
|
+
// through the current lease URL so a stale loopback port cannot fail first.
|
|
543
|
+
await this.runGit([
|
|
544
|
+
...gitArgs,
|
|
545
|
+
'fetch',
|
|
546
|
+
transportUrl,
|
|
547
|
+
'+refs/heads/*:refs/remotes/origin/*'
|
|
548
|
+
], {
|
|
436
549
|
cwd: bare,
|
|
437
550
|
timeout: 10 * 60_000,
|
|
438
551
|
maxBuffer: 10 * 1024 * 1024,
|
|
439
552
|
environment: gitNetworkEnvironment
|
|
440
553
|
});
|
|
441
|
-
await this.runGit(['worktree', 'prune'], { cwd: bare });
|
|
442
554
|
const branch = `agent/${request.sessionId}`;
|
|
443
|
-
const reuse = await this.canReuseWorktree(worktree, repository.cloneUrl, request.sessionId);
|
|
555
|
+
const reuse = await this.canReuseWorktree(bare, worktree, repository.cloneUrl, request.sessionId);
|
|
444
556
|
if (!reuse) {
|
|
445
557
|
try {
|
|
446
558
|
await this.runGit(['worktree', 'add', worktree, branch], {
|
|
@@ -476,14 +588,17 @@ class GitHubWorkspaceProvider {
|
|
|
476
588
|
});
|
|
477
589
|
// Hooks must be per worktree; a shared bare-repo hook would overwrite
|
|
478
590
|
// another session's allowed destination when sessions overlap.
|
|
591
|
+
// A reused checkout may belong to a recovered cache or a standalone clone.
|
|
592
|
+
// Configure its actual common directory, rather than the default cache.
|
|
479
593
|
await this.runGit(['config', 'extensions.worktreeConfig', 'true'], {
|
|
480
594
|
cwd: worktree
|
|
481
595
|
});
|
|
482
596
|
await this.runGit(['config', '--worktree', 'core.bare', 'false'], {
|
|
483
597
|
cwd: worktree
|
|
484
598
|
});
|
|
599
|
+
await this.runGit(['config', '--worktree', 'remote.origin.url', transportUrl], { cwd: worktree });
|
|
485
600
|
const hooks = (0, node_path_1.join)(sessionRoot, '.github-hooks', name);
|
|
486
|
-
await (0, github_command_guards_1.installPushGuard)(hooks, request.sessionId, repository.defaultBranch,
|
|
601
|
+
await (0, github_command_guards_1.installPushGuard)(hooks, request.sessionId, repository.defaultBranch, transportUrl);
|
|
487
602
|
await this.runGit(['config', '--worktree', 'core.hooksPath', hooks], {
|
|
488
603
|
cwd: worktree
|
|
489
604
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peakflo/agent-harness",
|
|
3
|
-
"version": "0.2.17-rc.
|
|
3
|
+
"version": "0.2.17-rc.3",
|
|
4
4
|
"description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|