@peakflo/agent-harness 0.2.17-rc.2 → 0.2.17

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.
@@ -41,6 +41,9 @@ export declare class GitHubWorkspaceProvider implements WorkspaceProvider {
41
41
  /** Never fetch a different repository into a shared durable bare clone. */
42
42
  private bareRepositoryPath;
43
43
  private isUnusableCheckout;
44
+ private checkoutPath;
45
+ /** Locate only this checkout's registration; never prune sibling records. */
46
+ private worktreeRegistration;
44
47
  /**
45
48
  * A session owns its checkout, including branch changes and detached HEAD.
46
49
  * Check its root and repository identity, never its branch or commit. Keep
@@ -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) {
@@ -152,6 +154,41 @@ class GitHubWorkspaceProvider {
152
154
  return (gitError.code === 128 &&
153
155
  /not a git repository|invalid gitfile format|bad config line/i.test(gitError.stderr ?? ''));
154
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;
191
+ }
155
192
  /**
156
193
  * A session owns its checkout, including branch changes and detached HEAD.
157
194
  * Check its root and repository identity, never its branch or commit. Keep
@@ -163,11 +200,10 @@ class GitHubWorkspaceProvider {
163
200
  entry = await (0, promises_1.lstat)(worktree);
164
201
  }
165
202
  catch (error) {
166
- if (error.code === 'ENOENT')
167
- return false;
168
- throw error;
203
+ if (error.code !== 'ENOENT')
204
+ throw error;
169
205
  }
170
- if (entry.isDirectory() && !entry.isSymbolicLink()) {
206
+ if (entry?.isDirectory() && !entry.isSymbolicLink()) {
171
207
  try {
172
208
  const [topLevel, origin, commonDir] = await Promise.all([
173
209
  this.runGit(['rev-parse', '--show-toplevel'], { cwd: worktree }),
@@ -203,27 +239,56 @@ class GitHubWorkspaceProvider {
203
239
  }
204
240
  }
205
241
  }
242
+ const registration = await this.worktreeRegistration(bare, worktree);
243
+ if (!entry && !registration)
244
+ return false;
206
245
  const backupRoot = await (0, promises_1.mkdtemp)((0, node_path_1.join)(this.sessionPath(sessionId), `.preserved-${Date.now()}-`));
207
246
  const backup = (0, node_path_1.join)(backupRoot, 'repository');
208
- // Git updates a linked worktree's administrative paths when possible.
209
- // A plain directory, symlink or broken checkout still keeps all bytes.
210
- if (entry.isDirectory() && !entry.isSymbolicLink()) {
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()) {
211
276
  await this.runGit(['worktree', 'move', worktree, backup], {
212
277
  cwd: worktree
213
278
  }).catch(async () => {
214
279
  await (0, promises_1.rename)(worktree, backup);
215
- await this.runGit(['worktree', 'repair', backup], { cwd: bare }).catch(() => undefined);
280
+ await repairRegistration();
216
281
  });
217
282
  }
218
283
  else {
219
- await (0, promises_1.rename)(worktree, backup);
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();
220
287
  }
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 });
224
288
  const notes = this.preparationNotes.get(sessionId) ?? [];
225
- notes.push(`The previous repository folder could not be reused. All its files ` +
226
- `were kept at ${backup}. A fresh checkout was prepared. ` +
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. ' +
227
292
  'Review the saved folder before you remove it.');
228
293
  this.preparationNotes.set(sessionId, notes);
229
294
  return false;
@@ -439,6 +504,12 @@ class GitHubWorkspaceProvider {
439
504
  let reusedWorktree = false;
440
505
  for (const repository of request.repositories) {
441
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;
442
513
  const bare = await this.bareRepositoryPath((0, node_path_1.join)(repositoriesRoot, `${owner}--${name}.git`), repository.cloneUrl);
443
514
  const worktree = (0, node_path_1.join)(sessionRoot, name);
444
515
  // The empty value resets every inherited/global helper. Without it, Git
@@ -456,7 +527,7 @@ class GitHubWorkspaceProvider {
456
527
  });
457
528
  }
458
529
  catch {
459
- await this.runGit([...gitArgs, 'clone', '--bare', repository.cloneUrl, bare], {
530
+ await this.runGit([...gitArgs, 'clone', '--bare', transportUrl, bare], {
460
531
  timeout: 10 * 60_000,
461
532
  maxBuffer: 10 * 1024 * 1024,
462
533
  environment: gitNetworkEnvironment
@@ -469,16 +540,17 @@ class GitHubWorkspaceProvider {
469
540
  }
470
541
  // The identity check above permits old proxy/sentinel URL forms. Fetch
471
542
  // 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
- });
475
- await this.runGit([...gitArgs, 'fetch', 'origin', '+refs/heads/*:refs/remotes/origin/*'], {
543
+ await this.runGit([
544
+ ...gitArgs,
545
+ 'fetch',
546
+ transportUrl,
547
+ '+refs/heads/*:refs/remotes/origin/*'
548
+ ], {
476
549
  cwd: bare,
477
550
  timeout: 10 * 60_000,
478
551
  maxBuffer: 10 * 1024 * 1024,
479
552
  environment: gitNetworkEnvironment
480
553
  });
481
- await this.runGit(['worktree', 'prune'], { cwd: bare });
482
554
  const branch = `agent/${request.sessionId}`;
483
555
  const reuse = await this.canReuseWorktree(bare, worktree, repository.cloneUrl, request.sessionId);
484
556
  if (!reuse) {
@@ -524,9 +596,9 @@ class GitHubWorkspaceProvider {
524
596
  await this.runGit(['config', '--worktree', 'core.bare', 'false'], {
525
597
  cwd: worktree
526
598
  });
527
- await this.runGit(['config', '--worktree', 'remote.origin.url', repository.cloneUrl], { cwd: worktree });
599
+ await this.runGit(['config', '--worktree', 'remote.origin.url', transportUrl], { cwd: worktree });
528
600
  const hooks = (0, node_path_1.join)(sessionRoot, '.github-hooks', name);
529
- await (0, github_command_guards_1.installPushGuard)(hooks, request.sessionId, repository.defaultBranch, repository.cloneUrl);
601
+ await (0, github_command_guards_1.installPushGuard)(hooks, request.sessionId, repository.defaultBranch, transportUrl);
530
602
  await this.runGit(['config', '--worktree', 'core.hooksPath', hooks], {
531
603
  cwd: worktree
532
604
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.17-rc.2",
3
+ "version": "0.2.17",
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",