@saeed42/worktree-worker 1.3.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 +245 -52
- 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,30 +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
|
}
|
|
368
|
+
/**
|
|
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
|
+
}
|
|
316
379
|
/**
|
|
317
380
|
* Check if branch exists (local or remote)
|
|
318
381
|
* For remote branches, pass "origin/branch-name"
|
|
319
382
|
*/
|
|
320
383
|
async branchExists(branch, cwd) {
|
|
321
384
|
if (branch.includes("/")) {
|
|
322
|
-
const result2 = await this.exec(
|
|
385
|
+
const result2 = await this.exec(
|
|
386
|
+
["show-ref", "--verify", "--quiet", `refs/remotes/${branch}`],
|
|
387
|
+
cwd
|
|
388
|
+
);
|
|
323
389
|
return result2.code === 0;
|
|
324
390
|
}
|
|
325
|
-
const result = await this.exec(
|
|
391
|
+
const result = await this.exec(
|
|
392
|
+
["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
|
|
393
|
+
cwd
|
|
394
|
+
);
|
|
326
395
|
return result.code === 0;
|
|
327
396
|
}
|
|
328
397
|
/**
|
|
@@ -362,7 +431,11 @@ var GitService = class {
|
|
|
362
431
|
*/
|
|
363
432
|
async push(remote, branch, force = false, cwd, auth) {
|
|
364
433
|
if (auth?.githubToken) {
|
|
365
|
-
const authUrl = await this.getAuthenticatedRemoteUrl(
|
|
434
|
+
const authUrl = await this.getAuthenticatedRemoteUrl(
|
|
435
|
+
remote,
|
|
436
|
+
cwd || env.BASE_WORKSPACE_DIR,
|
|
437
|
+
auth.githubToken
|
|
438
|
+
);
|
|
366
439
|
if (authUrl) {
|
|
367
440
|
const args2 = ["push", authUrl, branch];
|
|
368
441
|
if (force) args2.push("--force");
|
|
@@ -396,6 +469,7 @@ var GitService = class {
|
|
|
396
469
|
var gitService = new GitService();
|
|
397
470
|
|
|
398
471
|
// src/routes/health.routes.ts
|
|
472
|
+
import process3 from "process";
|
|
399
473
|
var health = new Hono();
|
|
400
474
|
health.get("/", (c) => {
|
|
401
475
|
return c.json({
|
|
@@ -446,7 +520,7 @@ health.get("/live", (c) => {
|
|
|
446
520
|
return c.json({
|
|
447
521
|
status: "alive",
|
|
448
522
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
449
|
-
uptime: Math.floor(
|
|
523
|
+
uptime: Math.floor(process3.uptime())
|
|
450
524
|
});
|
|
451
525
|
});
|
|
452
526
|
|
|
@@ -470,7 +544,8 @@ var RepoService = class {
|
|
|
470
544
|
path: repoRoot,
|
|
471
545
|
branch: null,
|
|
472
546
|
remote: null,
|
|
473
|
-
headSha: null
|
|
547
|
+
headSha: null,
|
|
548
|
+
isEmpty: false
|
|
474
549
|
};
|
|
475
550
|
}
|
|
476
551
|
const gitDir = await stat2(`${repoRoot}/.git`).catch(() => null);
|
|
@@ -480,18 +555,21 @@ var RepoService = class {
|
|
|
480
555
|
path: repoRoot,
|
|
481
556
|
branch: null,
|
|
482
557
|
remote: null,
|
|
483
|
-
headSha: null
|
|
558
|
+
headSha: null,
|
|
559
|
+
isEmpty: false
|
|
484
560
|
};
|
|
485
561
|
}
|
|
486
|
-
const branch = await gitService.
|
|
487
|
-
const headSha = await gitService.
|
|
562
|
+
const branch = await gitService.getCurrentBranchSafe(repoRoot);
|
|
563
|
+
const headSha = await gitService.getHeadShaSafe(repoRoot);
|
|
488
564
|
const remote = await gitService.getRemoteUrl("origin", repoRoot);
|
|
565
|
+
const isEmpty = await gitService.isEmptyRepo(repoRoot);
|
|
489
566
|
return {
|
|
490
567
|
initialized: true,
|
|
491
568
|
path: repoRoot,
|
|
492
569
|
branch,
|
|
493
570
|
remote,
|
|
494
|
-
headSha
|
|
571
|
+
headSha,
|
|
572
|
+
isEmpty
|
|
495
573
|
};
|
|
496
574
|
} catch {
|
|
497
575
|
return {
|
|
@@ -499,14 +577,15 @@ var RepoService = class {
|
|
|
499
577
|
path: repoRoot,
|
|
500
578
|
branch: null,
|
|
501
579
|
remote: null,
|
|
502
|
-
headSha: null
|
|
580
|
+
headSha: null,
|
|
581
|
+
isEmpty: false
|
|
503
582
|
};
|
|
504
583
|
}
|
|
505
584
|
}
|
|
506
585
|
/**
|
|
507
586
|
* Initialize/clone the repository
|
|
508
587
|
* SECURITY: githubToken is used inline and NEVER persisted
|
|
509
|
-
*
|
|
588
|
+
*
|
|
510
589
|
* Smart init logic:
|
|
511
590
|
* - No repo exists → Clone it
|
|
512
591
|
* - Repo exists, same URL → Fetch latest (idempotent)
|
|
@@ -540,7 +619,10 @@ var RepoService = class {
|
|
|
540
619
|
log.info("Switching to requested branch", { from: currentBranch, to: targetBranch });
|
|
541
620
|
const checkoutResult = await gitService.exec(["checkout", targetBranch], repoRoot);
|
|
542
621
|
if (checkoutResult.code !== 0) {
|
|
543
|
-
await gitService.exec(
|
|
622
|
+
await gitService.exec(
|
|
623
|
+
["checkout", "-b", targetBranch, `origin/${targetBranch}`],
|
|
624
|
+
repoRoot
|
|
625
|
+
);
|
|
544
626
|
}
|
|
545
627
|
}
|
|
546
628
|
const headSha2 = await gitService.getHeadSha(repoRoot);
|
|
@@ -567,14 +649,33 @@ var RepoService = class {
|
|
|
567
649
|
githubToken: options.githubToken
|
|
568
650
|
});
|
|
569
651
|
await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], repoRoot);
|
|
570
|
-
await gitService.exec([
|
|
652
|
+
await gitService.exec([
|
|
653
|
+
"config",
|
|
654
|
+
"--local",
|
|
655
|
+
"user.email",
|
|
656
|
+
"origin-agent[bot]@users.noreply.github.com"
|
|
657
|
+
], repoRoot);
|
|
571
658
|
await gitService.exec(["config", "--local", "safe.directory", repoRoot], repoRoot);
|
|
572
659
|
const cleanUrl = options.repoUrl.replace(/^https:\/\/[^@]+@/, "https://");
|
|
573
660
|
await gitService.exec(["remote", "set-url", "origin", cleanUrl], repoRoot);
|
|
574
661
|
log.info("Fetching all remote refs");
|
|
575
662
|
await gitService.fetch("origin", repoRoot, auth);
|
|
576
|
-
await gitService.exec(["branch", `--set-upstream-to=origin/${branch}`, branch], repoRoot).catch(
|
|
577
|
-
|
|
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
|
+
}
|
|
578
679
|
const headSha = await gitService.getHeadSha(repoRoot);
|
|
579
680
|
const remote = await gitService.getRemoteUrl("origin", repoRoot);
|
|
580
681
|
log.info("Repository initialized", { branch, headSha });
|
|
@@ -663,7 +764,10 @@ repo.get("/status", async (c) => {
|
|
|
663
764
|
} catch (err) {
|
|
664
765
|
const error = err;
|
|
665
766
|
logger.error("Failed to get repo status", { error: error.message });
|
|
666
|
-
return c.json(
|
|
767
|
+
return c.json(
|
|
768
|
+
{ success: false, error: { code: "INTERNAL_ERROR", message: error.message } },
|
|
769
|
+
500
|
|
770
|
+
);
|
|
667
771
|
}
|
|
668
772
|
});
|
|
669
773
|
repo.get("/branches", async (c) => {
|
|
@@ -673,7 +777,10 @@ repo.get("/branches", async (c) => {
|
|
|
673
777
|
} catch (err) {
|
|
674
778
|
const error = err;
|
|
675
779
|
logger.error("Failed to list branches", { error: error.message });
|
|
676
|
-
return c.json(
|
|
780
|
+
return c.json(
|
|
781
|
+
{ success: false, error: { code: "INTERNAL_ERROR", message: error.message } },
|
|
782
|
+
500
|
|
783
|
+
);
|
|
677
784
|
}
|
|
678
785
|
});
|
|
679
786
|
repo.post("/init", async (c) => {
|
|
@@ -687,7 +794,10 @@ repo.post("/init", async (c) => {
|
|
|
687
794
|
400
|
|
688
795
|
);
|
|
689
796
|
}
|
|
690
|
-
log.info("Initializing repository", {
|
|
797
|
+
log.info("Initializing repository", {
|
|
798
|
+
repoUrl: parsed.data.repoUrl,
|
|
799
|
+
branch: parsed.data.branch
|
|
800
|
+
});
|
|
691
801
|
const result = await repoService.initRepo(parsed.data);
|
|
692
802
|
return c.json({ success: true, data: result });
|
|
693
803
|
} catch (err) {
|
|
@@ -722,7 +832,7 @@ import { Hono as Hono3 } from "hono";
|
|
|
722
832
|
import { z as z2 } from "zod";
|
|
723
833
|
|
|
724
834
|
// src/services/worktree.service.ts
|
|
725
|
-
import { mkdir as mkdir3,
|
|
835
|
+
import { mkdir as mkdir3, readdir as readdir2, rm as rm2, stat as stat3 } from "fs/promises";
|
|
726
836
|
var WorktreeService = class {
|
|
727
837
|
/**
|
|
728
838
|
* Get the isolated workspace directory for a specific trial.
|
|
@@ -763,19 +873,42 @@ var WorktreeService = class {
|
|
|
763
873
|
hasRepoUrl: !!options.repoUrl
|
|
764
874
|
});
|
|
765
875
|
await mkdir3(env.TRIALS_WORKSPACE_DIR, { recursive: true });
|
|
766
|
-
|
|
767
|
-
|
|
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) {
|
|
768
884
|
if (!options.repoUrl) {
|
|
769
885
|
throw new Error(
|
|
770
|
-
"Main repository not initialized. Provide repoUrl to auto-initialize, or call /v1/repo/init first."
|
|
886
|
+
"Main repository not initialized or has invalid remote. Provide repoUrl to auto-initialize, or call /v1/repo/init first."
|
|
771
887
|
);
|
|
772
888
|
}
|
|
773
|
-
|
|
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
|
+
}
|
|
774
899
|
await repoService.initRepo({
|
|
775
900
|
repoUrl: options.repoUrl,
|
|
776
901
|
branch: baseBranch,
|
|
777
|
-
githubToken: options.githubToken
|
|
902
|
+
githubToken: options.githubToken,
|
|
903
|
+
force: hasInvalidRemote
|
|
904
|
+
// Force re-init if remote is invalid
|
|
778
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
|
+
);
|
|
779
912
|
}
|
|
780
913
|
try {
|
|
781
914
|
const stats = await stat3(worktreePath);
|
|
@@ -785,7 +918,7 @@ var WorktreeService = class {
|
|
|
785
918
|
log.info("Worktree already exists, fetching latest");
|
|
786
919
|
await gitService.fetch("origin", worktreePath, auth).catch(() => {
|
|
787
920
|
});
|
|
788
|
-
const headSha2 = await gitService.
|
|
921
|
+
const headSha2 = await gitService.getHeadShaSafe(worktreePath);
|
|
789
922
|
return { worktreePath, branch: branchName, headSha: headSha2 };
|
|
790
923
|
}
|
|
791
924
|
}
|
|
@@ -832,11 +965,17 @@ var WorktreeService = class {
|
|
|
832
965
|
}
|
|
833
966
|
}
|
|
834
967
|
if (!found) {
|
|
835
|
-
const currentBranch = await gitService.
|
|
968
|
+
const currentBranch = await gitService.getCurrentBranchSafe(baseRepoDir);
|
|
836
969
|
if (currentBranch && currentBranch !== "HEAD") {
|
|
837
970
|
baseRef = currentBranch;
|
|
838
971
|
log.info("Using current branch as base", { baseRef });
|
|
839
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
|
+
}
|
|
840
979
|
throw new Error(
|
|
841
980
|
`Cannot find base branch. Tried: origin/${baseBranch}, ${fallbackBranches.join(", ")}. Ensure the repository has been fetched properly.`
|
|
842
981
|
);
|
|
@@ -848,11 +987,16 @@ var WorktreeService = class {
|
|
|
848
987
|
}
|
|
849
988
|
await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], worktreePath).catch(() => {
|
|
850
989
|
});
|
|
851
|
-
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(() => {
|
|
852
996
|
});
|
|
853
997
|
await gitService.exec(["config", "--local", "safe.directory", worktreePath], worktreePath).catch(() => {
|
|
854
998
|
});
|
|
855
|
-
const headSha = await gitService.
|
|
999
|
+
const headSha = await gitService.getHeadShaSafe(worktreePath);
|
|
856
1000
|
log.info("Worktree created successfully", { headSha });
|
|
857
1001
|
return { worktreePath, branch: branchName, headSha };
|
|
858
1002
|
}
|
|
@@ -914,8 +1058,8 @@ var WorktreeService = class {
|
|
|
914
1058
|
changedFiles: 0
|
|
915
1059
|
};
|
|
916
1060
|
}
|
|
917
|
-
const headSha = await gitService.
|
|
918
|
-
const branch = await gitService.
|
|
1061
|
+
const headSha = await gitService.getHeadShaSafe(worktreePath);
|
|
1062
|
+
const branch = await gitService.getCurrentBranchSafe(worktreePath);
|
|
919
1063
|
const status = await gitService.getStatus(worktreePath);
|
|
920
1064
|
return {
|
|
921
1065
|
exists: true,
|
|
@@ -974,28 +1118,59 @@ var WorktreeService = class {
|
|
|
974
1118
|
return { branch, pushed: true };
|
|
975
1119
|
}
|
|
976
1120
|
/**
|
|
977
|
-
* 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
|
|
978
1127
|
*/
|
|
979
1128
|
async cleanupStaleWorktrees() {
|
|
980
1129
|
const log = logger.child({ service: "worktree", action: "cleanup" });
|
|
981
1130
|
let cleaned = 0;
|
|
982
1131
|
const errors = [];
|
|
983
|
-
|
|
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;
|
|
984
1148
|
try {
|
|
985
1149
|
const entries = await readdir2(env.TRIALS_WORKSPACE_DIR, { withFileTypes: true });
|
|
1150
|
+
const worktreesWithStats = [];
|
|
986
1151
|
for (const entry of entries) {
|
|
987
1152
|
if (!entry.isDirectory()) continue;
|
|
988
1153
|
const worktreePath = `${env.TRIALS_WORKSPACE_DIR}/${entry.name}`;
|
|
989
1154
|
try {
|
|
990
1155
|
const stats = await stat3(worktreePath);
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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 });
|
|
994
1169
|
cleaned++;
|
|
1170
|
+
} catch (err) {
|
|
1171
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1172
|
+
errors.push(`${wt.path}: ${errMsg}`);
|
|
995
1173
|
}
|
|
996
|
-
} catch (err) {
|
|
997
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
998
|
-
errors.push(`${worktreePath}: ${errMsg}`);
|
|
999
1174
|
}
|
|
1000
1175
|
}
|
|
1001
1176
|
await gitService.pruneWorktrees(env.BASE_WORKSPACE_DIR);
|
|
@@ -1003,8 +1178,23 @@ var WorktreeService = class {
|
|
|
1003
1178
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1004
1179
|
errors.push(`readdir: ${errMsg}`);
|
|
1005
1180
|
}
|
|
1006
|
-
log.info("Cleanup completed", { cleaned, errorCount: errors.length });
|
|
1007
|
-
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 };
|
|
1008
1198
|
}
|
|
1009
1199
|
};
|
|
1010
1200
|
var worktreeService = new WorktreeService();
|
|
@@ -1176,7 +1366,10 @@ worktree.post("/worktrees/cleanup", async (c) => {
|
|
|
1176
1366
|
} catch (err) {
|
|
1177
1367
|
const error = err;
|
|
1178
1368
|
log.error("Failed to cleanup worktrees", { error: error.message });
|
|
1179
|
-
return c.json(
|
|
1369
|
+
return c.json(
|
|
1370
|
+
{ success: false, error: { code: "INTERNAL_ERROR", message: error.message } },
|
|
1371
|
+
500
|
|
1372
|
+
);
|
|
1180
1373
|
}
|
|
1181
1374
|
});
|
|
1182
1375
|
|
package/package.json
CHANGED