@peakflo/agent-harness 0.2.17-rc.1 → 0.2.17-rc.2
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 +1 -0
- package/dist/github-workspace.js +74 -31
- package/package.json +1 -1
|
@@ -40,6 +40,7 @@ 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;
|
|
43
44
|
/**
|
|
44
45
|
* A session owns its checkout, including branch changes and detached HEAD.
|
|
45
46
|
* 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
|
}
|
|
@@ -114,36 +114,50 @@ class GitHubWorkspaceProvider {
|
|
|
114
114
|
}
|
|
115
115
|
/** Never fetch a different repository into a shared durable bare clone. */
|
|
116
116
|
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
117
|
const identity = githubRepositoryIdentity(cloneUrl) ?? cloneUrl;
|
|
135
118
|
const digest = (0, node_crypto_1.createHash)('sha256')
|
|
136
119
|
.update(identity)
|
|
137
120
|
.digest('hex')
|
|
138
121
|
.slice(0, 16);
|
|
139
|
-
|
|
122
|
+
// Leave old clones and linked worktrees intact. Bound collision handling
|
|
123
|
+
// without appending the same suffix recursively to a file name.
|
|
124
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
125
|
+
const candidate = attempt === 0 ? path : `${path}.recovered-${digest}-${attempt}`;
|
|
126
|
+
try {
|
|
127
|
+
const entry = await (0, promises_1.lstat)(candidate);
|
|
128
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error.code === 'ENOENT')
|
|
133
|
+
return candidate;
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
const origin = await this.runGit(['config', '--get', 'remote.origin.url'], {
|
|
137
|
+
cwd: candidate
|
|
138
|
+
}).catch((error) => {
|
|
139
|
+
if (this.isUnusableCheckout(error) || error.code === 1)
|
|
140
|
+
return null;
|
|
141
|
+
throw new Error('Could not inspect repository cache. Existing files were kept.');
|
|
142
|
+
});
|
|
143
|
+
if (origin &&
|
|
144
|
+
(await this.sameRepository(cloneUrl, origin.stdout.trim(), candidate))) {
|
|
145
|
+
return candidate;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
throw new Error('Could not prepare a repository cache. Existing files were kept.');
|
|
149
|
+
}
|
|
150
|
+
isUnusableCheckout(error) {
|
|
151
|
+
const gitError = error;
|
|
152
|
+
return (gitError.code === 128 &&
|
|
153
|
+
/not a git repository|invalid gitfile format|bad config line/i.test(gitError.stderr ?? ''));
|
|
140
154
|
}
|
|
141
155
|
/**
|
|
142
156
|
* A session owns its checkout, including branch changes and detached HEAD.
|
|
143
157
|
* Check its root and repository identity, never its branch or commit. Keep
|
|
144
158
|
* an unrelated or unusable checkout aside before preparing a replacement.
|
|
145
159
|
*/
|
|
146
|
-
async canReuseWorktree(worktree, cloneUrl, sessionId) {
|
|
160
|
+
async canReuseWorktree(bare, worktree, cloneUrl, sessionId) {
|
|
147
161
|
let entry;
|
|
148
162
|
try {
|
|
149
163
|
entry = await (0, promises_1.lstat)(worktree);
|
|
@@ -155,23 +169,38 @@ class GitHubWorkspaceProvider {
|
|
|
155
169
|
}
|
|
156
170
|
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
157
171
|
try {
|
|
158
|
-
const [topLevel, origin] = await Promise.all([
|
|
172
|
+
const [topLevel, origin, commonDir] = await Promise.all([
|
|
159
173
|
this.runGit(['rev-parse', '--show-toplevel'], { cwd: worktree }),
|
|
160
174
|
this.runGit(['config', '--get', 'remote.origin.url'], {
|
|
161
175
|
cwd: worktree
|
|
162
|
-
})
|
|
176
|
+
}).catch((error) => {
|
|
177
|
+
if (error.code === 1 && !error.stderr)
|
|
178
|
+
return { stdout: '' };
|
|
179
|
+
throw error;
|
|
180
|
+
}),
|
|
181
|
+
this.runGit(['rev-parse', '--git-common-dir'], { cwd: worktree })
|
|
163
182
|
]);
|
|
164
|
-
const [expectedWorktree, actualWorktree] = await Promise.all([
|
|
183
|
+
const [expectedWorktree, actualWorktree, repositoriesRoot, actualCommon] = await Promise.all([
|
|
165
184
|
(0, promises_1.realpath)(worktree),
|
|
166
|
-
(0, promises_1.realpath)((0, node_path_1.resolve)(worktree, topLevel.stdout.trim()))
|
|
185
|
+
(0, promises_1.realpath)((0, node_path_1.resolve)(worktree, topLevel.stdout.trim())),
|
|
186
|
+
(0, promises_1.realpath)((0, node_path_1.join)((0, node_path_1.resolve)(this.options.root), '.repositories')),
|
|
187
|
+
(0, promises_1.realpath)((0, node_path_1.resolve)(worktree, commonDir.stdout.trim()))
|
|
167
188
|
]);
|
|
168
189
|
const sameRepository = await this.sameRepository(cloneUrl, origin.stdout.trim(), worktree);
|
|
169
|
-
|
|
190
|
+
const managedCommon = actualCommon.startsWith(`${repositoriesRoot}/`) ||
|
|
191
|
+
actualCommon === (0, node_path_1.join)(expectedWorktree, '.git');
|
|
192
|
+
if (actualWorktree === expectedWorktree &&
|
|
193
|
+
sameRepository &&
|
|
194
|
+
managedCommon)
|
|
170
195
|
return true;
|
|
171
196
|
}
|
|
172
|
-
catch {
|
|
173
|
-
//
|
|
174
|
-
//
|
|
197
|
+
catch (error) {
|
|
198
|
+
// Only a proven broken Git checkout is moved. Resource exhaustion,
|
|
199
|
+
// permission errors and timeouts must leave a healthy checkout in place.
|
|
200
|
+
// Do not echo Git errors: they can contain remote credentials.
|
|
201
|
+
if (!this.isUnusableCheckout(error)) {
|
|
202
|
+
throw new Error('Could not inspect the existing repository workspace. Retry the message. Existing files were not changed.');
|
|
203
|
+
}
|
|
175
204
|
}
|
|
176
205
|
}
|
|
177
206
|
const backupRoot = await (0, promises_1.mkdtemp)((0, node_path_1.join)(this.sessionPath(sessionId), `.preserved-${Date.now()}-`));
|
|
@@ -181,11 +210,17 @@ class GitHubWorkspaceProvider {
|
|
|
181
210
|
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
182
211
|
await this.runGit(['worktree', 'move', worktree, backup], {
|
|
183
212
|
cwd: worktree
|
|
184
|
-
}).catch(() =>
|
|
213
|
+
}).catch(async () => {
|
|
214
|
+
await (0, promises_1.rename)(worktree, backup);
|
|
215
|
+
await this.runGit(['worktree', 'repair', backup], { cwd: bare }).catch(() => undefined);
|
|
216
|
+
});
|
|
185
217
|
}
|
|
186
218
|
else {
|
|
187
219
|
await (0, promises_1.rename)(worktree, backup);
|
|
188
220
|
}
|
|
221
|
+
// A broken .git file cannot be repaired. Remove only obsolete Git
|
|
222
|
+
// registrations now, before reusing the vacated path; keep all saved files.
|
|
223
|
+
await this.runGit(['worktree', 'prune', '--expire=now'], { cwd: bare });
|
|
189
224
|
const notes = this.preparationNotes.get(sessionId) ?? [];
|
|
190
225
|
notes.push(`The previous repository folder could not be reused. All its files ` +
|
|
191
226
|
`were kept at ${backup}. A fresh checkout was prepared. ` +
|
|
@@ -432,6 +467,11 @@ class GitHubWorkspaceProvider {
|
|
|
432
467
|
'+refs/heads/*:refs/remotes/origin/*'
|
|
433
468
|
], { cwd: bare });
|
|
434
469
|
}
|
|
470
|
+
// The identity check above permits old proxy/sentinel URL forms. Fetch
|
|
471
|
+
// through the current lease URL so a stale loopback port cannot fail first.
|
|
472
|
+
await this.runGit(['config', 'remote.origin.url', repository.cloneUrl], {
|
|
473
|
+
cwd: bare
|
|
474
|
+
});
|
|
435
475
|
await this.runGit([...gitArgs, 'fetch', 'origin', '+refs/heads/*:refs/remotes/origin/*'], {
|
|
436
476
|
cwd: bare,
|
|
437
477
|
timeout: 10 * 60_000,
|
|
@@ -440,7 +480,7 @@ class GitHubWorkspaceProvider {
|
|
|
440
480
|
});
|
|
441
481
|
await this.runGit(['worktree', 'prune'], { cwd: bare });
|
|
442
482
|
const branch = `agent/${request.sessionId}`;
|
|
443
|
-
const reuse = await this.canReuseWorktree(worktree, repository.cloneUrl, request.sessionId);
|
|
483
|
+
const reuse = await this.canReuseWorktree(bare, worktree, repository.cloneUrl, request.sessionId);
|
|
444
484
|
if (!reuse) {
|
|
445
485
|
try {
|
|
446
486
|
await this.runGit(['worktree', 'add', worktree, branch], {
|
|
@@ -476,12 +516,15 @@ class GitHubWorkspaceProvider {
|
|
|
476
516
|
});
|
|
477
517
|
// Hooks must be per worktree; a shared bare-repo hook would overwrite
|
|
478
518
|
// another session's allowed destination when sessions overlap.
|
|
519
|
+
// A reused checkout may belong to a recovered cache or a standalone clone.
|
|
520
|
+
// Configure its actual common directory, rather than the default cache.
|
|
479
521
|
await this.runGit(['config', 'extensions.worktreeConfig', 'true'], {
|
|
480
522
|
cwd: worktree
|
|
481
523
|
});
|
|
482
524
|
await this.runGit(['config', '--worktree', 'core.bare', 'false'], {
|
|
483
525
|
cwd: worktree
|
|
484
526
|
});
|
|
527
|
+
await this.runGit(['config', '--worktree', 'remote.origin.url', repository.cloneUrl], { cwd: worktree });
|
|
485
528
|
const hooks = (0, node_path_1.join)(sessionRoot, '.github-hooks', name);
|
|
486
529
|
await (0, github_command_guards_1.installPushGuard)(hooks, request.sessionId, repository.defaultBranch, repository.cloneUrl);
|
|
487
530
|
await this.runGit(['config', '--worktree', 'core.hooksPath', hooks], {
|
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.2",
|
|
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",
|