@saeed42/worktree-worker 1.1.0 → 1.3.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 +38 -34
- package/dist/main.js +294 -48
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# @saeed42/worktree-worker
|
|
2
2
|
|
|
3
|
-
Git worktree management service for AI agent trials. Runs as an npm package in CodeSandbox or any
|
|
3
|
+
Git worktree management service for AI agent trials. Runs as an npm package in CodeSandbox or any
|
|
4
|
+
Node.js environment.
|
|
4
5
|
|
|
5
6
|
## Security
|
|
6
7
|
|
|
7
|
-
**GitHub tokens are NEVER persisted to disk.** All authenticated operations require the token to be
|
|
8
|
+
**GitHub tokens are NEVER persisted to disk.** All authenticated operations require the token to be
|
|
9
|
+
passed in the request body. This is critical for sandbox environments where credentials should not
|
|
10
|
+
be stored.
|
|
8
11
|
|
|
9
12
|
## Installation
|
|
10
13
|
|
|
@@ -75,44 +78,44 @@ import { app } from '@saeed42/worktree-worker';
|
|
|
75
78
|
|
|
76
79
|
### Health Checks
|
|
77
80
|
|
|
78
|
-
| Endpoint
|
|
79
|
-
|
|
80
|
-
| `GET /health`
|
|
81
|
-
| `GET /health/ready` | Readiness check
|
|
82
|
-
| `GET /health/live`
|
|
81
|
+
| Endpoint | Description |
|
|
82
|
+
| ------------------- | ------------------ |
|
|
83
|
+
| `GET /health` | Basic health check |
|
|
84
|
+
| `GET /health/ready` | Readiness check |
|
|
85
|
+
| `GET /health/live` | Liveness check |
|
|
83
86
|
|
|
84
87
|
### Repository Management
|
|
85
88
|
|
|
86
|
-
| Endpoint
|
|
87
|
-
|
|
88
|
-
| `POST /v1/repo/init`
|
|
89
|
-
| `POST /v1/repo/update`
|
|
90
|
-
| `GET /v1/repo/status`
|
|
91
|
-
| `GET /v1/repo/branches` | List branches
|
|
89
|
+
| Endpoint | Description |
|
|
90
|
+
| ----------------------- | ----------------- |
|
|
91
|
+
| `POST /v1/repo/init` | Clone repository |
|
|
92
|
+
| `POST /v1/repo/update` | Fetch/pull latest |
|
|
93
|
+
| `GET /v1/repo/status` | Get repo status |
|
|
94
|
+
| `GET /v1/repo/branches` | List branches |
|
|
92
95
|
|
|
93
96
|
### Worktree Management
|
|
94
97
|
|
|
95
|
-
| Endpoint
|
|
96
|
-
|
|
97
|
-
| `POST /v1/trials/:trialId/worktree/reset`
|
|
98
|
-
| `DELETE /v1/trials/:trialId/worktree`
|
|
99
|
-
| `GET /v1/trials/:trialId/worktree/status`
|
|
100
|
-
| `GET /v1/trials/:trialId/worktree/diff`
|
|
101
|
-
| `POST /v1/trials/:trialId/worktree/commit` | Commit changes
|
|
102
|
-
| `POST /v1/trials/:trialId/worktree/push`
|
|
98
|
+
| Endpoint | Description |
|
|
99
|
+
| ------------------------------------------ | --------------------- |
|
|
100
|
+
| `POST /v1/trials/:trialId/worktree/reset` | Create/reset worktree |
|
|
101
|
+
| `DELETE /v1/trials/:trialId/worktree` | Delete worktree |
|
|
102
|
+
| `GET /v1/trials/:trialId/worktree/status` | Get status |
|
|
103
|
+
| `GET /v1/trials/:trialId/worktree/diff` | Get diff |
|
|
104
|
+
| `POST /v1/trials/:trialId/worktree/commit` | Commit changes |
|
|
105
|
+
| `POST /v1/trials/:trialId/worktree/push` | Push to remote |
|
|
103
106
|
|
|
104
107
|
## Configuration
|
|
105
108
|
|
|
106
|
-
| Variable
|
|
107
|
-
|
|
108
|
-
| `PORT`
|
|
109
|
-
| `NODE_ENV`
|
|
110
|
-
| `WORKER_TOKEN`
|
|
111
|
-
| `BASE_WORKSPACE_DIR`
|
|
112
|
-
| `TRIALS_WORKSPACE_DIR` | `/project/workspaces/trials` | Worktrees path
|
|
113
|
-
| `DEFAULT_BRANCH`
|
|
114
|
-
| `GIT_TIMEOUT_MS`
|
|
115
|
-
| `CLEANUP_AFTER_HOURS`
|
|
109
|
+
| Variable | Default | Description |
|
|
110
|
+
| ---------------------- | ---------------------------- | ---------------------------- |
|
|
111
|
+
| `PORT` | `8787` | HTTP server port |
|
|
112
|
+
| `NODE_ENV` | `development` | Environment mode |
|
|
113
|
+
| `WORKER_TOKEN` | `` | Auth token (empty = no auth) |
|
|
114
|
+
| `BASE_WORKSPACE_DIR` | `/project/sandbox` | Main repo path |
|
|
115
|
+
| `TRIALS_WORKSPACE_DIR` | `/project/workspaces/trials` | Worktrees path |
|
|
116
|
+
| `DEFAULT_BRANCH` | `main` | Default base branch |
|
|
117
|
+
| `GIT_TIMEOUT_MS` | `60000` | Git timeout |
|
|
118
|
+
| `CLEANUP_AFTER_HOURS` | `24` | Stale worktree cleanup |
|
|
116
119
|
|
|
117
120
|
## Usage Examples
|
|
118
121
|
|
|
@@ -142,7 +145,8 @@ curl -X POST http://localhost:8787/v1/trials/abc123/worktree/reset \
|
|
|
142
145
|
}'
|
|
143
146
|
```
|
|
144
147
|
|
|
145
|
-
> **Note:** `githubToken` is required for private repositories. The token is used inline and **never
|
|
148
|
+
> **Note:** `githubToken` is required for private repositories. The token is used inline and **never
|
|
149
|
+
> stored on disk**.
|
|
146
150
|
|
|
147
151
|
Response:
|
|
148
152
|
|
|
@@ -184,7 +188,8 @@ curl -X POST http://localhost:8787/v1/trials/abc123/worktree/push \
|
|
|
184
188
|
}'
|
|
185
189
|
```
|
|
186
190
|
|
|
187
|
-
> **Note:** `githubToken` is **required** for push operations. The token is used inline and **never
|
|
191
|
+
> **Note:** `githubToken` is **required** for push operations. The token is used inline and **never
|
|
192
|
+
> stored on disk**.
|
|
188
193
|
|
|
189
194
|
## Development
|
|
190
195
|
|
|
@@ -218,4 +223,3 @@ npm publish --access public
|
|
|
218
223
|
## License
|
|
219
224
|
|
|
220
225
|
MIT
|
|
221
|
-
|
package/dist/main.js
CHANGED
|
@@ -8,6 +8,7 @@ import { logger as honoLogger } from "hono/logger";
|
|
|
8
8
|
import { secureHeaders } from "hono/secure-headers";
|
|
9
9
|
|
|
10
10
|
// src/config/env.ts
|
|
11
|
+
import process from "process";
|
|
11
12
|
var env = {
|
|
12
13
|
PORT: parseInt(process.env.PORT || "8787", 10),
|
|
13
14
|
NODE_ENV: process.env.NODE_ENV || "development",
|
|
@@ -112,33 +113,44 @@ var logger = {
|
|
|
112
113
|
// src/middleware/auth.ts
|
|
113
114
|
async function authMiddleware(c, next) {
|
|
114
115
|
if (!env.WORKER_TOKEN) {
|
|
115
|
-
|
|
116
|
+
await next();
|
|
117
|
+
return;
|
|
116
118
|
}
|
|
117
119
|
const authHeader = c.req.header("Authorization");
|
|
118
120
|
if (!authHeader) {
|
|
119
|
-
return c.json({
|
|
121
|
+
return c.json({
|
|
122
|
+
success: false,
|
|
123
|
+
error: { code: "UNAUTHORIZED", message: "Missing Authorization header" }
|
|
124
|
+
}, 401);
|
|
120
125
|
}
|
|
121
126
|
const [scheme, token] = authHeader.split(" ");
|
|
122
127
|
if (scheme !== "Bearer" || !token) {
|
|
123
|
-
return c.json({
|
|
128
|
+
return c.json({
|
|
129
|
+
success: false,
|
|
130
|
+
error: { code: "UNAUTHORIZED", message: "Invalid Authorization format" }
|
|
131
|
+
}, 401);
|
|
124
132
|
}
|
|
125
133
|
if (token !== env.WORKER_TOKEN) {
|
|
126
|
-
return c.json(
|
|
134
|
+
return c.json(
|
|
135
|
+
{ success: false, error: { code: "UNAUTHORIZED", message: "Invalid token" } },
|
|
136
|
+
401
|
|
137
|
+
);
|
|
127
138
|
}
|
|
128
|
-
|
|
139
|
+
await next();
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
// src/routes/health.routes.ts
|
|
132
143
|
import { Hono } from "hono";
|
|
133
|
-
import {
|
|
144
|
+
import { mkdir, stat } from "fs/promises";
|
|
134
145
|
|
|
135
146
|
// src/services/git.service.ts
|
|
136
147
|
import { spawn } from "child_process";
|
|
148
|
+
import process2 from "process";
|
|
137
149
|
var GitService = class {
|
|
138
150
|
/**
|
|
139
151
|
* Execute a git command
|
|
140
152
|
*/
|
|
141
|
-
|
|
153
|
+
exec(args, cwd) {
|
|
142
154
|
const workDir = cwd || env.BASE_WORKSPACE_DIR;
|
|
143
155
|
const log = logger.child({ git: args.slice(0, 2).join(" "), cwd: workDir });
|
|
144
156
|
return new Promise((resolve, reject) => {
|
|
@@ -146,7 +158,7 @@ var GitService = class {
|
|
|
146
158
|
cwd: workDir,
|
|
147
159
|
timeout: env.GIT_TIMEOUT_MS,
|
|
148
160
|
env: {
|
|
149
|
-
...
|
|
161
|
+
...process2.env,
|
|
150
162
|
GIT_TERMINAL_PROMPT: "0",
|
|
151
163
|
GIT_ASKPASS: ""
|
|
152
164
|
}
|
|
@@ -208,7 +220,11 @@ var GitService = class {
|
|
|
208
220
|
*/
|
|
209
221
|
async fetch(remote = "origin", cwd, auth) {
|
|
210
222
|
if (auth?.githubToken) {
|
|
211
|
-
const authUrl = await this.getAuthenticatedRemoteUrl(
|
|
223
|
+
const authUrl = await this.getAuthenticatedRemoteUrl(
|
|
224
|
+
remote,
|
|
225
|
+
cwd || env.BASE_WORKSPACE_DIR,
|
|
226
|
+
auth.githubToken
|
|
227
|
+
);
|
|
212
228
|
if (authUrl) {
|
|
213
229
|
await this.execOrThrow(["fetch", authUrl], cwd);
|
|
214
230
|
return;
|
|
@@ -299,25 +315,83 @@ var GitService = class {
|
|
|
299
315
|
const args = ["branch", force ? "-D" : "-d", branch];
|
|
300
316
|
await this.exec(args, cwd);
|
|
301
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Check if repository is empty (no commits)
|
|
320
|
+
*/
|
|
321
|
+
async isEmptyRepo(cwd) {
|
|
322
|
+
const result = await this.exec(["rev-parse", "HEAD"], cwd);
|
|
323
|
+
if (result.code !== 0) {
|
|
324
|
+
if (result.stderr.includes("unknown revision") || result.stderr.includes("bad revision") || result.stderr.includes("ambiguous argument")) {
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
302
330
|
/**
|
|
303
331
|
* Get HEAD SHA
|
|
332
|
+
* Returns null for empty repositories (no commits)
|
|
304
333
|
*/
|
|
305
334
|
async getHeadSha(cwd) {
|
|
306
|
-
const result = await this.
|
|
335
|
+
const result = await this.exec(["rev-parse", "HEAD"], cwd);
|
|
336
|
+
if (result.code !== 0) {
|
|
337
|
+
if (result.stderr.includes("unknown revision") || result.stderr.includes("bad revision") || result.stderr.includes("ambiguous argument")) {
|
|
338
|
+
throw new Error("Repository is empty (no commits). Please create an initial commit first.");
|
|
339
|
+
}
|
|
340
|
+
throw new Error(`Git command failed: ${result.stderr || result.stdout}`);
|
|
341
|
+
}
|
|
342
|
+
return result.stdout.trim();
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Get HEAD SHA, returning null for empty repos instead of throwing
|
|
346
|
+
*/
|
|
347
|
+
async getHeadShaSafe(cwd) {
|
|
348
|
+
const result = await this.exec(["rev-parse", "HEAD"], cwd);
|
|
349
|
+
if (result.code !== 0) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
307
352
|
return result.stdout.trim();
|
|
308
353
|
}
|
|
309
354
|
/**
|
|
310
355
|
* Get current branch
|
|
356
|
+
* Returns null for empty repositories or detached HEAD
|
|
311
357
|
*/
|
|
312
358
|
async getCurrentBranch(cwd) {
|
|
313
|
-
const result = await this.
|
|
359
|
+
const result = await this.exec(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
360
|
+
if (result.code !== 0) {
|
|
361
|
+
if (result.stderr.includes("unknown revision") || result.stderr.includes("bad revision") || result.stderr.includes("ambiguous argument")) {
|
|
362
|
+
throw new Error("Repository is empty (no commits). Cannot determine current branch.");
|
|
363
|
+
}
|
|
364
|
+
throw new Error(`Git command failed: ${result.stderr || result.stdout}`);
|
|
365
|
+
}
|
|
314
366
|
return result.stdout.trim();
|
|
315
367
|
}
|
|
316
368
|
/**
|
|
317
|
-
*
|
|
369
|
+
* Get current branch, returning null for empty repos instead of throwing
|
|
370
|
+
*/
|
|
371
|
+
async getCurrentBranchSafe(cwd) {
|
|
372
|
+
const result = await this.exec(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
373
|
+
if (result.code !== 0) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
const branch = result.stdout.trim();
|
|
377
|
+
return branch === "HEAD" ? null : branch;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Check if branch exists (local or remote)
|
|
381
|
+
* For remote branches, pass "origin/branch-name"
|
|
318
382
|
*/
|
|
319
383
|
async branchExists(branch, cwd) {
|
|
320
|
-
|
|
384
|
+
if (branch.includes("/")) {
|
|
385
|
+
const result2 = await this.exec(
|
|
386
|
+
["show-ref", "--verify", "--quiet", `refs/remotes/${branch}`],
|
|
387
|
+
cwd
|
|
388
|
+
);
|
|
389
|
+
return result2.code === 0;
|
|
390
|
+
}
|
|
391
|
+
const result = await this.exec(
|
|
392
|
+
["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
|
|
393
|
+
cwd
|
|
394
|
+
);
|
|
321
395
|
return result.code === 0;
|
|
322
396
|
}
|
|
323
397
|
/**
|
|
@@ -357,7 +431,11 @@ var GitService = class {
|
|
|
357
431
|
*/
|
|
358
432
|
async push(remote, branch, force = false, cwd, auth) {
|
|
359
433
|
if (auth?.githubToken) {
|
|
360
|
-
const authUrl = await this.getAuthenticatedRemoteUrl(
|
|
434
|
+
const authUrl = await this.getAuthenticatedRemoteUrl(
|
|
435
|
+
remote,
|
|
436
|
+
cwd || env.BASE_WORKSPACE_DIR,
|
|
437
|
+
auth.githubToken
|
|
438
|
+
);
|
|
361
439
|
if (authUrl) {
|
|
362
440
|
const args2 = ["push", authUrl, branch];
|
|
363
441
|
if (force) args2.push("--force");
|
|
@@ -391,6 +469,7 @@ var GitService = class {
|
|
|
391
469
|
var gitService = new GitService();
|
|
392
470
|
|
|
393
471
|
// src/routes/health.routes.ts
|
|
472
|
+
import process3 from "process";
|
|
394
473
|
var health = new Hono();
|
|
395
474
|
health.get("/", (c) => {
|
|
396
475
|
return c.json({
|
|
@@ -441,7 +520,7 @@ health.get("/live", (c) => {
|
|
|
441
520
|
return c.json({
|
|
442
521
|
status: "alive",
|
|
443
522
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
444
|
-
uptime: Math.floor(
|
|
523
|
+
uptime: Math.floor(process3.uptime())
|
|
445
524
|
});
|
|
446
525
|
});
|
|
447
526
|
|
|
@@ -465,7 +544,8 @@ var RepoService = class {
|
|
|
465
544
|
path: repoRoot,
|
|
466
545
|
branch: null,
|
|
467
546
|
remote: null,
|
|
468
|
-
headSha: null
|
|
547
|
+
headSha: null,
|
|
548
|
+
isEmpty: false
|
|
469
549
|
};
|
|
470
550
|
}
|
|
471
551
|
const gitDir = await stat2(`${repoRoot}/.git`).catch(() => null);
|
|
@@ -475,18 +555,21 @@ var RepoService = class {
|
|
|
475
555
|
path: repoRoot,
|
|
476
556
|
branch: null,
|
|
477
557
|
remote: null,
|
|
478
|
-
headSha: null
|
|
558
|
+
headSha: null,
|
|
559
|
+
isEmpty: false
|
|
479
560
|
};
|
|
480
561
|
}
|
|
481
|
-
const branch = await gitService.
|
|
482
|
-
const headSha = await gitService.
|
|
562
|
+
const branch = await gitService.getCurrentBranchSafe(repoRoot);
|
|
563
|
+
const headSha = await gitService.getHeadShaSafe(repoRoot);
|
|
483
564
|
const remote = await gitService.getRemoteUrl("origin", repoRoot);
|
|
565
|
+
const isEmpty = await gitService.isEmptyRepo(repoRoot);
|
|
484
566
|
return {
|
|
485
567
|
initialized: true,
|
|
486
568
|
path: repoRoot,
|
|
487
569
|
branch,
|
|
488
570
|
remote,
|
|
489
|
-
headSha
|
|
571
|
+
headSha,
|
|
572
|
+
isEmpty
|
|
490
573
|
};
|
|
491
574
|
} catch {
|
|
492
575
|
return {
|
|
@@ -494,14 +577,15 @@ var RepoService = class {
|
|
|
494
577
|
path: repoRoot,
|
|
495
578
|
branch: null,
|
|
496
579
|
remote: null,
|
|
497
|
-
headSha: null
|
|
580
|
+
headSha: null,
|
|
581
|
+
isEmpty: false
|
|
498
582
|
};
|
|
499
583
|
}
|
|
500
584
|
}
|
|
501
585
|
/**
|
|
502
586
|
* Initialize/clone the repository
|
|
503
587
|
* SECURITY: githubToken is used inline and NEVER persisted
|
|
504
|
-
*
|
|
588
|
+
*
|
|
505
589
|
* Smart init logic:
|
|
506
590
|
* - No repo exists → Clone it
|
|
507
591
|
* - Repo exists, same URL → Fetch latest (idempotent)
|
|
@@ -535,7 +619,10 @@ var RepoService = class {
|
|
|
535
619
|
log.info("Switching to requested branch", { from: currentBranch, to: targetBranch });
|
|
536
620
|
const checkoutResult = await gitService.exec(["checkout", targetBranch], repoRoot);
|
|
537
621
|
if (checkoutResult.code !== 0) {
|
|
538
|
-
await gitService.exec(
|
|
622
|
+
await gitService.exec(
|
|
623
|
+
["checkout", "-b", targetBranch, `origin/${targetBranch}`],
|
|
624
|
+
repoRoot
|
|
625
|
+
);
|
|
539
626
|
}
|
|
540
627
|
}
|
|
541
628
|
const headSha2 = await gitService.getHeadSha(repoRoot);
|
|
@@ -562,10 +649,33 @@ var RepoService = class {
|
|
|
562
649
|
githubToken: options.githubToken
|
|
563
650
|
});
|
|
564
651
|
await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], repoRoot);
|
|
565
|
-
await gitService.exec([
|
|
652
|
+
await gitService.exec([
|
|
653
|
+
"config",
|
|
654
|
+
"--local",
|
|
655
|
+
"user.email",
|
|
656
|
+
"origin-agent[bot]@users.noreply.github.com"
|
|
657
|
+
], repoRoot);
|
|
566
658
|
await gitService.exec(["config", "--local", "safe.directory", repoRoot], repoRoot);
|
|
567
659
|
const cleanUrl = options.repoUrl.replace(/^https:\/\/[^@]+@/, "https://");
|
|
568
660
|
await gitService.exec(["remote", "set-url", "origin", cleanUrl], repoRoot);
|
|
661
|
+
log.info("Fetching all remote refs");
|
|
662
|
+
await gitService.fetch("origin", repoRoot, auth);
|
|
663
|
+
await gitService.exec(["branch", `--set-upstream-to=origin/${branch}`, branch], repoRoot).catch(
|
|
664
|
+
() => {
|
|
665
|
+
}
|
|
666
|
+
);
|
|
667
|
+
const isEmpty = await gitService.isEmptyRepo(repoRoot);
|
|
668
|
+
if (isEmpty) {
|
|
669
|
+
log.warn("Repository is empty (no commits)", { repoUrl: options.repoUrl });
|
|
670
|
+
const remote2 = await gitService.getRemoteUrl("origin", repoRoot);
|
|
671
|
+
return {
|
|
672
|
+
path: repoRoot,
|
|
673
|
+
branch,
|
|
674
|
+
headSha: null,
|
|
675
|
+
// Empty repo has no HEAD
|
|
676
|
+
remote: remote2
|
|
677
|
+
};
|
|
678
|
+
}
|
|
569
679
|
const headSha = await gitService.getHeadSha(repoRoot);
|
|
570
680
|
const remote = await gitService.getRemoteUrl("origin", repoRoot);
|
|
571
681
|
log.info("Repository initialized", { branch, headSha });
|
|
@@ -654,7 +764,10 @@ repo.get("/status", async (c) => {
|
|
|
654
764
|
} catch (err) {
|
|
655
765
|
const error = err;
|
|
656
766
|
logger.error("Failed to get repo status", { error: error.message });
|
|
657
|
-
return c.json(
|
|
767
|
+
return c.json(
|
|
768
|
+
{ success: false, error: { code: "INTERNAL_ERROR", message: error.message } },
|
|
769
|
+
500
|
|
770
|
+
);
|
|
658
771
|
}
|
|
659
772
|
});
|
|
660
773
|
repo.get("/branches", async (c) => {
|
|
@@ -664,7 +777,10 @@ repo.get("/branches", async (c) => {
|
|
|
664
777
|
} catch (err) {
|
|
665
778
|
const error = err;
|
|
666
779
|
logger.error("Failed to list branches", { error: error.message });
|
|
667
|
-
return c.json(
|
|
780
|
+
return c.json(
|
|
781
|
+
{ success: false, error: { code: "INTERNAL_ERROR", message: error.message } },
|
|
782
|
+
500
|
|
783
|
+
);
|
|
668
784
|
}
|
|
669
785
|
});
|
|
670
786
|
repo.post("/init", async (c) => {
|
|
@@ -678,7 +794,10 @@ repo.post("/init", async (c) => {
|
|
|
678
794
|
400
|
|
679
795
|
);
|
|
680
796
|
}
|
|
681
|
-
log.info("Initializing repository", {
|
|
797
|
+
log.info("Initializing repository", {
|
|
798
|
+
repoUrl: parsed.data.repoUrl,
|
|
799
|
+
branch: parsed.data.branch
|
|
800
|
+
});
|
|
682
801
|
const result = await repoService.initRepo(parsed.data);
|
|
683
802
|
return c.json({ success: true, data: result });
|
|
684
803
|
} catch (err) {
|
|
@@ -713,7 +832,7 @@ import { Hono as Hono3 } from "hono";
|
|
|
713
832
|
import { z as z2 } from "zod";
|
|
714
833
|
|
|
715
834
|
// src/services/worktree.service.ts
|
|
716
|
-
import { mkdir as mkdir3,
|
|
835
|
+
import { mkdir as mkdir3, readdir as readdir2, rm as rm2, stat as stat3 } from "fs/promises";
|
|
717
836
|
var WorktreeService = class {
|
|
718
837
|
/**
|
|
719
838
|
* Get the isolated workspace directory for a specific trial.
|
|
@@ -750,9 +869,47 @@ var WorktreeService = class {
|
|
|
750
869
|
branchName,
|
|
751
870
|
baseBranch,
|
|
752
871
|
baseRepoDir,
|
|
753
|
-
hasToken: !!options.githubToken
|
|
872
|
+
hasToken: !!options.githubToken,
|
|
873
|
+
hasRepoUrl: !!options.repoUrl
|
|
754
874
|
});
|
|
755
875
|
await mkdir3(env.TRIALS_WORKSPACE_DIR, { recursive: true });
|
|
876
|
+
let repoStatus = await repoService.getStatus();
|
|
877
|
+
const isValidGitHubRemote = (remote) => {
|
|
878
|
+
if (!remote) return false;
|
|
879
|
+
return remote.includes("github.com") || remote.startsWith("https://");
|
|
880
|
+
};
|
|
881
|
+
const needsInit = !repoStatus.initialized;
|
|
882
|
+
const hasInvalidRemote = repoStatus.initialized && !isValidGitHubRemote(repoStatus.remote);
|
|
883
|
+
if (needsInit || hasInvalidRemote) {
|
|
884
|
+
if (!options.repoUrl) {
|
|
885
|
+
throw new Error(
|
|
886
|
+
"Main repository not initialized or has invalid remote. Provide repoUrl to auto-initialize, or call /v1/repo/init first."
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
if (hasInvalidRemote) {
|
|
890
|
+
log.warn("Repo has invalid remote (likely CodeSandbox template), re-initializing", {
|
|
891
|
+
currentRemote: repoStatus.remote,
|
|
892
|
+
newRepoUrl: options.repoUrl.replace(/ghp_[a-zA-Z0-9]+/, "ghp_***")
|
|
893
|
+
});
|
|
894
|
+
} else {
|
|
895
|
+
log.info("Main repo not initialized, auto-initializing", {
|
|
896
|
+
repoUrl: options.repoUrl.replace(/ghp_[a-zA-Z0-9]+/, "ghp_***")
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
await repoService.initRepo({
|
|
900
|
+
repoUrl: options.repoUrl,
|
|
901
|
+
branch: baseBranch,
|
|
902
|
+
githubToken: options.githubToken,
|
|
903
|
+
force: hasInvalidRemote
|
|
904
|
+
// Force re-init if remote is invalid
|
|
905
|
+
});
|
|
906
|
+
repoStatus = await repoService.getStatus();
|
|
907
|
+
}
|
|
908
|
+
if (repoStatus.isEmpty) {
|
|
909
|
+
throw new Error(
|
|
910
|
+
"Cannot create worktree: repository is empty (no commits). Please create an initial commit in the repository first."
|
|
911
|
+
);
|
|
912
|
+
}
|
|
756
913
|
try {
|
|
757
914
|
const stats = await stat3(worktreePath);
|
|
758
915
|
if (stats.isDirectory()) {
|
|
@@ -761,7 +918,7 @@ var WorktreeService = class {
|
|
|
761
918
|
log.info("Worktree already exists, fetching latest");
|
|
762
919
|
await gitService.fetch("origin", worktreePath, auth).catch(() => {
|
|
763
920
|
});
|
|
764
|
-
const headSha2 = await gitService.
|
|
921
|
+
const headSha2 = await gitService.getHeadShaSafe(worktreePath);
|
|
765
922
|
return { worktreePath, branch: branchName, headSha: headSha2 };
|
|
766
923
|
}
|
|
767
924
|
}
|
|
@@ -792,17 +949,54 @@ var WorktreeService = class {
|
|
|
792
949
|
await gitService.exec(["fetch", "origin", `${branchName}:${branchName}`], baseRepoDir);
|
|
793
950
|
await gitService.execOrThrow(["worktree", "add", worktreePath, branchName], baseRepoDir);
|
|
794
951
|
} else {
|
|
795
|
-
|
|
952
|
+
let baseRef = `origin/${baseBranch}`;
|
|
953
|
+
const baseBranchExistsOnRemote = await gitService.branchExists(baseRef, baseRepoDir);
|
|
954
|
+
if (!baseBranchExistsOnRemote) {
|
|
955
|
+
log.warn("Base branch not found, detecting default branch", { tried: baseRef });
|
|
956
|
+
const fallbackBranches = ["origin/main", "origin/master", "origin/dev", "origin/develop"];
|
|
957
|
+
let found = false;
|
|
958
|
+
for (const fallback of fallbackBranches) {
|
|
959
|
+
const exists = await gitService.branchExists(fallback, baseRepoDir);
|
|
960
|
+
if (exists) {
|
|
961
|
+
baseRef = fallback;
|
|
962
|
+
found = true;
|
|
963
|
+
log.info("Found fallback base branch", { baseRef });
|
|
964
|
+
break;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (!found) {
|
|
968
|
+
const currentBranch = await gitService.getCurrentBranchSafe(baseRepoDir);
|
|
969
|
+
if (currentBranch && currentBranch !== "HEAD") {
|
|
970
|
+
baseRef = currentBranch;
|
|
971
|
+
log.info("Using current branch as base", { baseRef });
|
|
972
|
+
} else {
|
|
973
|
+
const isEmpty = await gitService.isEmptyRepo(baseRepoDir);
|
|
974
|
+
if (isEmpty) {
|
|
975
|
+
throw new Error(
|
|
976
|
+
`Cannot create worktree: repository is empty (no commits). Please create an initial commit in the repository first.`
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
throw new Error(
|
|
980
|
+
`Cannot find base branch. Tried: origin/${baseBranch}, ${fallbackBranches.join(", ")}. Ensure the repository has been fetched properly.`
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
796
985
|
log.info("Creating worktree with new branch from base", { baseRef, branchName });
|
|
797
986
|
await gitService.addWorktreeWithNewBranch(worktreePath, branchName, baseRef, baseRepoDir);
|
|
798
987
|
}
|
|
799
988
|
await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], worktreePath).catch(() => {
|
|
800
989
|
});
|
|
801
|
-
await gitService.exec([
|
|
990
|
+
await gitService.exec([
|
|
991
|
+
"config",
|
|
992
|
+
"--local",
|
|
993
|
+
"user.email",
|
|
994
|
+
"origin-agent[bot]@users.noreply.github.com"
|
|
995
|
+
], worktreePath).catch(() => {
|
|
802
996
|
});
|
|
803
997
|
await gitService.exec(["config", "--local", "safe.directory", worktreePath], worktreePath).catch(() => {
|
|
804
998
|
});
|
|
805
|
-
const headSha = await gitService.
|
|
999
|
+
const headSha = await gitService.getHeadShaSafe(worktreePath);
|
|
806
1000
|
log.info("Worktree created successfully", { headSha });
|
|
807
1001
|
return { worktreePath, branch: branchName, headSha };
|
|
808
1002
|
}
|
|
@@ -864,8 +1058,8 @@ var WorktreeService = class {
|
|
|
864
1058
|
changedFiles: 0
|
|
865
1059
|
};
|
|
866
1060
|
}
|
|
867
|
-
const headSha = await gitService.
|
|
868
|
-
const branch = await gitService.
|
|
1061
|
+
const headSha = await gitService.getHeadShaSafe(worktreePath);
|
|
1062
|
+
const branch = await gitService.getCurrentBranchSafe(worktreePath);
|
|
869
1063
|
const status = await gitService.getStatus(worktreePath);
|
|
870
1064
|
return {
|
|
871
1065
|
exists: true,
|
|
@@ -924,28 +1118,59 @@ var WorktreeService = class {
|
|
|
924
1118
|
return { branch, pushed: true };
|
|
925
1119
|
}
|
|
926
1120
|
/**
|
|
927
|
-
* Cleanup stale worktrees
|
|
1121
|
+
* Cleanup stale worktrees based on age and optionally disk pressure
|
|
1122
|
+
*
|
|
1123
|
+
* Strategy:
|
|
1124
|
+
* 1. Always clean worktrees older than CLEANUP_AFTER_HOURS (default 24h)
|
|
1125
|
+
* 2. If disk usage is high (>80%), also clean worktrees older than 6h
|
|
1126
|
+
* 3. If disk usage is critical (>90%), clean all worktrees older than 1h
|
|
928
1127
|
*/
|
|
929
1128
|
async cleanupStaleWorktrees() {
|
|
930
1129
|
const log = logger.child({ service: "worktree", action: "cleanup" });
|
|
931
1130
|
let cleaned = 0;
|
|
932
1131
|
const errors = [];
|
|
933
|
-
|
|
1132
|
+
let diskUsagePercent;
|
|
1133
|
+
let cutoffHours = env.CLEANUP_AFTER_HOURS;
|
|
1134
|
+
try {
|
|
1135
|
+
const diskInfo = await this.getDiskUsage();
|
|
1136
|
+
diskUsagePercent = diskInfo.usedPercent;
|
|
1137
|
+
if (diskUsagePercent > 90) {
|
|
1138
|
+
cutoffHours = 1;
|
|
1139
|
+
log.warn("Critical disk usage, aggressive cleanup", { diskUsagePercent, cutoffHours });
|
|
1140
|
+
} else if (diskUsagePercent > 80) {
|
|
1141
|
+
cutoffHours = 6;
|
|
1142
|
+
log.info("High disk usage, moderate cleanup", { diskUsagePercent, cutoffHours });
|
|
1143
|
+
}
|
|
1144
|
+
} catch (err) {
|
|
1145
|
+
log.warn("Could not check disk usage", { error: err instanceof Error ? err.message : String(err) });
|
|
1146
|
+
}
|
|
1147
|
+
const cutoffTime = Date.now() - cutoffHours * 60 * 60 * 1e3;
|
|
934
1148
|
try {
|
|
935
1149
|
const entries = await readdir2(env.TRIALS_WORKSPACE_DIR, { withFileTypes: true });
|
|
1150
|
+
const worktreesWithStats = [];
|
|
936
1151
|
for (const entry of entries) {
|
|
937
1152
|
if (!entry.isDirectory()) continue;
|
|
938
1153
|
const worktreePath = `${env.TRIALS_WORKSPACE_DIR}/${entry.name}`;
|
|
939
1154
|
try {
|
|
940
1155
|
const stats = await stat3(worktreePath);
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1156
|
+
worktreesWithStats.push({ path: worktreePath, mtime: stats.mtimeMs });
|
|
1157
|
+
} catch {
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
worktreesWithStats.sort((a, b) => a.mtime - b.mtime);
|
|
1161
|
+
for (const wt of worktreesWithStats) {
|
|
1162
|
+
if (wt.mtime < cutoffTime) {
|
|
1163
|
+
try {
|
|
1164
|
+
log.info("Cleaning up stale worktree", {
|
|
1165
|
+
path: wt.path,
|
|
1166
|
+
ageHours: Math.round((Date.now() - wt.mtime) / (60 * 60 * 1e3))
|
|
1167
|
+
});
|
|
1168
|
+
await rm2(wt.path, { recursive: true, force: true });
|
|
944
1169
|
cleaned++;
|
|
1170
|
+
} catch (err) {
|
|
1171
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1172
|
+
errors.push(`${wt.path}: ${errMsg}`);
|
|
945
1173
|
}
|
|
946
|
-
} catch (err) {
|
|
947
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
948
|
-
errors.push(`${worktreePath}: ${errMsg}`);
|
|
949
1174
|
}
|
|
950
1175
|
}
|
|
951
1176
|
await gitService.pruneWorktrees(env.BASE_WORKSPACE_DIR);
|
|
@@ -953,8 +1178,23 @@ var WorktreeService = class {
|
|
|
953
1178
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
954
1179
|
errors.push(`readdir: ${errMsg}`);
|
|
955
1180
|
}
|
|
956
|
-
log.info("Cleanup completed", { cleaned, errorCount: errors.length });
|
|
957
|
-
return { cleaned, errors };
|
|
1181
|
+
log.info("Cleanup completed", { cleaned, errorCount: errors.length, diskUsagePercent });
|
|
1182
|
+
return { cleaned, errors, diskUsagePercent };
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Get disk usage information for the workspace partition
|
|
1186
|
+
*/
|
|
1187
|
+
async getDiskUsage() {
|
|
1188
|
+
const { exec } = await import("child_process");
|
|
1189
|
+
const { promisify } = await import("util");
|
|
1190
|
+
const execAsync = promisify(exec);
|
|
1191
|
+
const { stdout } = await execAsync(`df -B1 ${env.BASE_WORKSPACE_DIR} | tail -1`);
|
|
1192
|
+
const parts = stdout.trim().split(/\s+/);
|
|
1193
|
+
const total = parseInt(parts[1], 10);
|
|
1194
|
+
const used = parseInt(parts[2], 10);
|
|
1195
|
+
const free = parseInt(parts[3], 10);
|
|
1196
|
+
const usedPercent = parseInt(parts[4].replace("%", ""), 10);
|
|
1197
|
+
return { total, used, free, usedPercent };
|
|
958
1198
|
}
|
|
959
1199
|
};
|
|
960
1200
|
var worktreeService = new WorktreeService();
|
|
@@ -965,8 +1205,10 @@ var resetWorktreeSchema = z2.object({
|
|
|
965
1205
|
baseBranch: z2.string().default("main"),
|
|
966
1206
|
trialBranch: z2.string().optional(),
|
|
967
1207
|
force: z2.boolean().default(false),
|
|
968
|
-
githubToken: z2.string().optional()
|
|
1208
|
+
githubToken: z2.string().optional(),
|
|
969
1209
|
// Required for private repos
|
|
1210
|
+
repoUrl: z2.string().url().optional()
|
|
1211
|
+
// Required if repo not yet initialized
|
|
970
1212
|
});
|
|
971
1213
|
var commitSchema = z2.object({
|
|
972
1214
|
message: z2.string().min(1),
|
|
@@ -1005,7 +1247,8 @@ worktree.post("/trials/:trialId/worktree/reset", async (c) => {
|
|
|
1005
1247
|
baseBranch: input.baseBranch,
|
|
1006
1248
|
trialBranch: input.trialBranch,
|
|
1007
1249
|
force: input.force,
|
|
1008
|
-
githubToken: input.githubToken
|
|
1250
|
+
githubToken: input.githubToken,
|
|
1251
|
+
repoUrl: input.repoUrl
|
|
1009
1252
|
});
|
|
1010
1253
|
return c.json({ success: true, data: result });
|
|
1011
1254
|
} catch (err) {
|
|
@@ -1123,7 +1366,10 @@ worktree.post("/worktrees/cleanup", async (c) => {
|
|
|
1123
1366
|
} catch (err) {
|
|
1124
1367
|
const error = err;
|
|
1125
1368
|
log.error("Failed to cleanup worktrees", { error: error.message });
|
|
1126
|
-
return c.json(
|
|
1369
|
+
return c.json(
|
|
1370
|
+
{ success: false, error: { code: "INTERNAL_ERROR", message: error.message } },
|
|
1371
|
+
500
|
|
1372
|
+
);
|
|
1127
1373
|
}
|
|
1128
1374
|
});
|
|
1129
1375
|
|
package/package.json
CHANGED