@saeed42/worktree-worker 1.0.0
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 +200 -0
- package/dist/main.d.ts +6 -0
- package/dist/main.js +1121 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# @orgn/worktree-worker
|
|
2
|
+
|
|
3
|
+
Git worktree management service for AI agent trials. Runs as an npm package in CodeSandbox or any Node.js environment.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Install globally
|
|
9
|
+
npm install -g @orgn/worktree-worker
|
|
10
|
+
|
|
11
|
+
# Or add to your project
|
|
12
|
+
npm install @orgn/worktree-worker
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
### Run as CLI
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Set environment variables
|
|
21
|
+
export WORKER_TOKEN=your-secret-token
|
|
22
|
+
export PORT=8787
|
|
23
|
+
|
|
24
|
+
# Run the service
|
|
25
|
+
worktree-worker
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Run in CodeSandbox
|
|
29
|
+
|
|
30
|
+
Add to your `package.json`:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@orgn/worktree-worker": "^1.0.0"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"worktree-worker": "worktree-worker"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or run directly:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx @orgn/worktree-worker
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Run Programmatically
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
import { app } from '@orgn/worktree-worker';
|
|
53
|
+
|
|
54
|
+
// The app is a Hono instance
|
|
55
|
+
// You can extend it or use it as middleware
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Architecture
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
/project/
|
|
62
|
+
├── sandbox/ # BASE_WORKSPACE_DIR - Main repo clone
|
|
63
|
+
└── workspaces/
|
|
64
|
+
└── trials/ # TRIALS_WORKSPACE_DIR - Worktrees
|
|
65
|
+
├── feature-auth-a1b2c3d4/
|
|
66
|
+
├── fix-bug-xyz-5e6f7g8h/
|
|
67
|
+
└── ...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## API Endpoints
|
|
71
|
+
|
|
72
|
+
### Health Checks
|
|
73
|
+
|
|
74
|
+
| Endpoint | Description |
|
|
75
|
+
|----------|-------------|
|
|
76
|
+
| `GET /health` | Basic health check |
|
|
77
|
+
| `GET /health/ready` | Readiness check |
|
|
78
|
+
| `GET /health/live` | Liveness check |
|
|
79
|
+
|
|
80
|
+
### Repository Management
|
|
81
|
+
|
|
82
|
+
| Endpoint | Description |
|
|
83
|
+
|----------|-------------|
|
|
84
|
+
| `POST /v1/repo/init` | Clone repository |
|
|
85
|
+
| `POST /v1/repo/update` | Fetch/pull latest |
|
|
86
|
+
| `GET /v1/repo/status` | Get repo status |
|
|
87
|
+
| `GET /v1/repo/branches` | List branches |
|
|
88
|
+
|
|
89
|
+
### Worktree Management
|
|
90
|
+
|
|
91
|
+
| Endpoint | Description |
|
|
92
|
+
|----------|-------------|
|
|
93
|
+
| `POST /v1/trials/:trialId/worktree/reset` | Create/reset worktree |
|
|
94
|
+
| `DELETE /v1/trials/:trialId/worktree` | Delete worktree |
|
|
95
|
+
| `GET /v1/trials/:trialId/worktree/status` | Get status |
|
|
96
|
+
| `GET /v1/trials/:trialId/worktree/diff` | Get diff |
|
|
97
|
+
| `POST /v1/trials/:trialId/worktree/commit` | Commit changes |
|
|
98
|
+
| `POST /v1/trials/:trialId/worktree/push` | Push to remote |
|
|
99
|
+
|
|
100
|
+
## Configuration
|
|
101
|
+
|
|
102
|
+
| Variable | Default | Description |
|
|
103
|
+
|----------|---------|-------------|
|
|
104
|
+
| `PORT` | `8787` | HTTP server port |
|
|
105
|
+
| `NODE_ENV` | `development` | Environment mode |
|
|
106
|
+
| `WORKER_TOKEN` | `` | Auth token (empty = no auth) |
|
|
107
|
+
| `BASE_WORKSPACE_DIR` | `/project/sandbox` | Main repo path |
|
|
108
|
+
| `TRIALS_WORKSPACE_DIR` | `/project/workspaces/trials` | Worktrees path |
|
|
109
|
+
| `DEFAULT_BRANCH` | `main` | Default base branch |
|
|
110
|
+
| `GIT_TIMEOUT_MS` | `60000` | Git timeout |
|
|
111
|
+
| `CLEANUP_AFTER_HOURS` | `24` | Stale worktree cleanup |
|
|
112
|
+
|
|
113
|
+
## Usage Examples
|
|
114
|
+
|
|
115
|
+
### Initialize Repository
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
curl -X POST http://localhost:8787/v1/repo/init \
|
|
119
|
+
-H "Authorization: Bearer your-token" \
|
|
120
|
+
-H "Content-Type: application/json" \
|
|
121
|
+
-d '{
|
|
122
|
+
"repoUrl": "https://github.com/owner/repo",
|
|
123
|
+
"branch": "main",
|
|
124
|
+
"githubToken": "ghp_xxx"
|
|
125
|
+
}'
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Create Worktree
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
curl -X POST http://localhost:8787/v1/trials/abc123/worktree/reset \
|
|
132
|
+
-H "Authorization: Bearer your-token" \
|
|
133
|
+
-H "Content-Type: application/json" \
|
|
134
|
+
-d '{
|
|
135
|
+
"baseBranch": "main",
|
|
136
|
+
"trialBranch": "feature/my-feature"
|
|
137
|
+
}'
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Response:
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"success": true,
|
|
145
|
+
"data": {
|
|
146
|
+
"worktreePath": "/project/workspaces/trials/feature-my-feature-abc12345",
|
|
147
|
+
"branch": "feature/my-feature",
|
|
148
|
+
"headSha": "a1b2c3d4e5f6..."
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Commit Changes
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
curl -X POST http://localhost:8787/v1/trials/abc123/worktree/commit \
|
|
157
|
+
-H "Authorization: Bearer your-token" \
|
|
158
|
+
-H "Content-Type: application/json" \
|
|
159
|
+
-d '{
|
|
160
|
+
"message": "feat: implement auth",
|
|
161
|
+
"author": {
|
|
162
|
+
"name": "AI Agent",
|
|
163
|
+
"email": "agent@origin.ai"
|
|
164
|
+
}
|
|
165
|
+
}'
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# Clone the repo
|
|
172
|
+
cd worker-service-node
|
|
173
|
+
|
|
174
|
+
# Install dependencies
|
|
175
|
+
npm install
|
|
176
|
+
|
|
177
|
+
# Run in dev mode (with watch)
|
|
178
|
+
npm run dev
|
|
179
|
+
|
|
180
|
+
# Type check
|
|
181
|
+
npm run typecheck
|
|
182
|
+
|
|
183
|
+
# Build
|
|
184
|
+
npm run build
|
|
185
|
+
|
|
186
|
+
# Run production build
|
|
187
|
+
npm start
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Publishing
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
# Build and publish to npm
|
|
194
|
+
npm publish --access public
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT
|
|
200
|
+
|
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,1121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/main.ts
|
|
4
|
+
import { serve } from "@hono/node-server";
|
|
5
|
+
import { Hono as Hono4 } from "hono";
|
|
6
|
+
import { cors } from "hono/cors";
|
|
7
|
+
import { logger as honoLogger } from "hono/logger";
|
|
8
|
+
import { secureHeaders } from "hono/secure-headers";
|
|
9
|
+
|
|
10
|
+
// src/config/env.ts
|
|
11
|
+
var env = {
|
|
12
|
+
PORT: parseInt(process.env.PORT || "8787", 10),
|
|
13
|
+
NODE_ENV: process.env.NODE_ENV || "development",
|
|
14
|
+
WORKER_TOKEN: process.env.WORKER_TOKEN || "",
|
|
15
|
+
BASE_WORKSPACE_DIR: process.env.BASE_WORKSPACE_DIR || "/project/sandbox",
|
|
16
|
+
WORKSPACES_ROOT: process.env.WORKSPACES_ROOT || "/project/workspaces",
|
|
17
|
+
TRIALS_WORKSPACE_DIR: process.env.TRIALS_WORKSPACE_DIR || "/project/workspaces/trials",
|
|
18
|
+
DEFAULT_BRANCH: process.env.DEFAULT_BRANCH || "main",
|
|
19
|
+
GIT_TIMEOUT_MS: parseInt(process.env.GIT_TIMEOUT_MS || "60000", 10),
|
|
20
|
+
CLEANUP_AFTER_HOURS: parseInt(process.env.CLEANUP_AFTER_HOURS || "24", 10)
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// src/config/logger.ts
|
|
24
|
+
import pino from "pino";
|
|
25
|
+
var isDev = env.NODE_ENV === "development";
|
|
26
|
+
var baseLogger = pino({
|
|
27
|
+
level: isDev ? "debug" : "info",
|
|
28
|
+
transport: isDev ? {
|
|
29
|
+
target: "pino-pretty",
|
|
30
|
+
options: {
|
|
31
|
+
colorize: true,
|
|
32
|
+
translateTime: "SYS:standard",
|
|
33
|
+
ignore: "pid,hostname"
|
|
34
|
+
}
|
|
35
|
+
} : void 0,
|
|
36
|
+
base: {
|
|
37
|
+
service: "worktree-worker"
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
var logger = {
|
|
41
|
+
info: (msgOrObj, obj) => {
|
|
42
|
+
if (typeof msgOrObj === "string") {
|
|
43
|
+
baseLogger.info(obj || {}, msgOrObj);
|
|
44
|
+
} else {
|
|
45
|
+
const { msg, ...rest } = msgOrObj;
|
|
46
|
+
baseLogger.info(rest, msg);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
error: (msgOrObj, obj) => {
|
|
50
|
+
if (typeof msgOrObj === "string") {
|
|
51
|
+
baseLogger.error(obj || {}, msgOrObj);
|
|
52
|
+
} else {
|
|
53
|
+
const { msg, ...rest } = msgOrObj;
|
|
54
|
+
baseLogger.error(rest, msg);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
warn: (msgOrObj, obj) => {
|
|
58
|
+
if (typeof msgOrObj === "string") {
|
|
59
|
+
baseLogger.warn(obj || {}, msgOrObj);
|
|
60
|
+
} else {
|
|
61
|
+
const { msg, ...rest } = msgOrObj;
|
|
62
|
+
baseLogger.warn(rest, msg);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
debug: (msgOrObj, obj) => {
|
|
66
|
+
if (typeof msgOrObj === "string") {
|
|
67
|
+
baseLogger.debug(obj || {}, msgOrObj);
|
|
68
|
+
} else {
|
|
69
|
+
const { msg, ...rest } = msgOrObj;
|
|
70
|
+
baseLogger.debug(rest, msg);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
child: (bindings) => {
|
|
74
|
+
const childLogger = baseLogger.child(bindings);
|
|
75
|
+
return {
|
|
76
|
+
info: (msgOrObj, obj) => {
|
|
77
|
+
if (typeof msgOrObj === "string") {
|
|
78
|
+
childLogger.info(obj || {}, msgOrObj);
|
|
79
|
+
} else {
|
|
80
|
+
const { msg, ...rest } = msgOrObj;
|
|
81
|
+
childLogger.info(rest, msg);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
error: (msgOrObj, obj) => {
|
|
85
|
+
if (typeof msgOrObj === "string") {
|
|
86
|
+
childLogger.error(obj || {}, msgOrObj);
|
|
87
|
+
} else {
|
|
88
|
+
const { msg, ...rest } = msgOrObj;
|
|
89
|
+
childLogger.error(rest, msg);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
warn: (msgOrObj, obj) => {
|
|
93
|
+
if (typeof msgOrObj === "string") {
|
|
94
|
+
childLogger.warn(obj || {}, msgOrObj);
|
|
95
|
+
} else {
|
|
96
|
+
const { msg, ...rest } = msgOrObj;
|
|
97
|
+
childLogger.warn(rest, msg);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
debug: (msgOrObj, obj) => {
|
|
101
|
+
if (typeof msgOrObj === "string") {
|
|
102
|
+
childLogger.debug(obj || {}, msgOrObj);
|
|
103
|
+
} else {
|
|
104
|
+
const { msg, ...rest } = msgOrObj;
|
|
105
|
+
childLogger.debug(rest, msg);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// src/middleware/auth.ts
|
|
113
|
+
async function authMiddleware(c, next) {
|
|
114
|
+
if (!env.WORKER_TOKEN) {
|
|
115
|
+
return next();
|
|
116
|
+
}
|
|
117
|
+
const authHeader = c.req.header("Authorization");
|
|
118
|
+
if (!authHeader) {
|
|
119
|
+
return c.json({ success: false, error: { code: "UNAUTHORIZED", message: "Missing Authorization header" } }, 401);
|
|
120
|
+
}
|
|
121
|
+
const [scheme, token] = authHeader.split(" ");
|
|
122
|
+
if (scheme !== "Bearer" || !token) {
|
|
123
|
+
return c.json({ success: false, error: { code: "UNAUTHORIZED", message: "Invalid Authorization format" } }, 401);
|
|
124
|
+
}
|
|
125
|
+
if (token !== env.WORKER_TOKEN) {
|
|
126
|
+
return c.json({ success: false, error: { code: "UNAUTHORIZED", message: "Invalid token" } }, 401);
|
|
127
|
+
}
|
|
128
|
+
return next();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/routes/health.routes.ts
|
|
132
|
+
import { Hono } from "hono";
|
|
133
|
+
import { stat, mkdir } from "fs/promises";
|
|
134
|
+
|
|
135
|
+
// src/services/git.service.ts
|
|
136
|
+
import { spawn } from "child_process";
|
|
137
|
+
var GitService = class {
|
|
138
|
+
/**
|
|
139
|
+
* Execute a git command
|
|
140
|
+
*/
|
|
141
|
+
async exec(args, cwd) {
|
|
142
|
+
const workDir = cwd || env.BASE_WORKSPACE_DIR;
|
|
143
|
+
const log = logger.child({ git: args.slice(0, 2).join(" "), cwd: workDir });
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const child = spawn("git", args, {
|
|
146
|
+
cwd: workDir,
|
|
147
|
+
timeout: env.GIT_TIMEOUT_MS,
|
|
148
|
+
env: {
|
|
149
|
+
...process.env,
|
|
150
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
151
|
+
GIT_ASKPASS: ""
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
let stdout = "";
|
|
155
|
+
let stderr = "";
|
|
156
|
+
child.stdout.on("data", (data) => {
|
|
157
|
+
stdout += data.toString();
|
|
158
|
+
});
|
|
159
|
+
child.stderr.on("data", (data) => {
|
|
160
|
+
stderr += data.toString();
|
|
161
|
+
});
|
|
162
|
+
child.on("close", (code) => {
|
|
163
|
+
const exitCode = code ?? 1;
|
|
164
|
+
log.debug("Git command completed", { exitCode, stdoutLen: stdout.length });
|
|
165
|
+
resolve({ code: exitCode, stdout, stderr });
|
|
166
|
+
});
|
|
167
|
+
child.on("error", (err) => {
|
|
168
|
+
log.error("Git command error", { error: err.message });
|
|
169
|
+
reject(err);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Execute git command and throw on error
|
|
175
|
+
*/
|
|
176
|
+
async execOrThrow(args, cwd) {
|
|
177
|
+
const result = await this.exec(args, cwd);
|
|
178
|
+
if (result.code !== 0) {
|
|
179
|
+
throw new Error(`Git command failed: ${result.stderr || result.stdout}`);
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Get git version
|
|
185
|
+
*/
|
|
186
|
+
async getVersion() {
|
|
187
|
+
const result = await this.execOrThrow(["--version"]);
|
|
188
|
+
return result.stdout.trim();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Fetch from remote
|
|
192
|
+
*/
|
|
193
|
+
async fetch(remote = "origin", cwd) {
|
|
194
|
+
await this.execOrThrow(["fetch", remote], cwd);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Clone a repository
|
|
198
|
+
*/
|
|
199
|
+
async cloneRepo(repoUrl, targetDir, options) {
|
|
200
|
+
const args = ["clone"];
|
|
201
|
+
if (options?.blobless) {
|
|
202
|
+
args.push("--filter=blob:none");
|
|
203
|
+
} else if (options?.depth) {
|
|
204
|
+
args.push("--depth", String(options.depth));
|
|
205
|
+
}
|
|
206
|
+
if (options?.branch) {
|
|
207
|
+
args.push("--branch", options.branch);
|
|
208
|
+
}
|
|
209
|
+
args.push(repoUrl, targetDir);
|
|
210
|
+
const parentDir = targetDir.split("/").slice(0, -1).join("/") || "/";
|
|
211
|
+
await this.execOrThrow(args, parentDir);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* List worktrees
|
|
215
|
+
*/
|
|
216
|
+
async listWorktrees(cwd) {
|
|
217
|
+
const result = await this.exec(["worktree", "list", "--porcelain"], cwd);
|
|
218
|
+
if (result.code !== 0) return [];
|
|
219
|
+
const worktrees = [];
|
|
220
|
+
let current = {};
|
|
221
|
+
for (const line of result.stdout.split("\n")) {
|
|
222
|
+
if (line.startsWith("worktree ")) {
|
|
223
|
+
if (current.path) {
|
|
224
|
+
worktrees.push({
|
|
225
|
+
path: current.path,
|
|
226
|
+
branch: current.branch || "",
|
|
227
|
+
head: current.head || ""
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
current = { path: line.slice(9) };
|
|
231
|
+
} else if (line.startsWith("HEAD ")) {
|
|
232
|
+
current.head = line.slice(5);
|
|
233
|
+
} else if (line.startsWith("branch ")) {
|
|
234
|
+
current.branch = line.slice(7).replace("refs/heads/", "");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (current.path) {
|
|
238
|
+
worktrees.push({
|
|
239
|
+
path: current.path,
|
|
240
|
+
branch: current.branch || "",
|
|
241
|
+
head: current.head || ""
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return worktrees;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Add a worktree with a new branch
|
|
248
|
+
*/
|
|
249
|
+
async addWorktreeWithNewBranch(path, newBranch, fromRef, cwd) {
|
|
250
|
+
const branchResult = await this.exec(["branch", newBranch, fromRef], cwd);
|
|
251
|
+
if (branchResult.code === 0) {
|
|
252
|
+
await this.execOrThrow(["worktree", "add", path, newBranch], cwd);
|
|
253
|
+
} else {
|
|
254
|
+
await this.execOrThrow(["worktree", "add", "-b", newBranch, path, fromRef], cwd);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Remove a worktree
|
|
259
|
+
*/
|
|
260
|
+
async removeWorktree(path, force = false, cwd) {
|
|
261
|
+
const args = ["worktree", "remove", path];
|
|
262
|
+
if (force) args.push("--force");
|
|
263
|
+
await this.exec(args, cwd);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Prune worktrees
|
|
267
|
+
*/
|
|
268
|
+
async pruneWorktrees(cwd) {
|
|
269
|
+
await this.exec(["worktree", "prune"], cwd);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Delete a branch
|
|
273
|
+
*/
|
|
274
|
+
async deleteBranch(branch, force = false, cwd) {
|
|
275
|
+
const args = ["branch", force ? "-D" : "-d", branch];
|
|
276
|
+
await this.exec(args, cwd);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Get HEAD SHA
|
|
280
|
+
*/
|
|
281
|
+
async getHeadSha(cwd) {
|
|
282
|
+
const result = await this.execOrThrow(["rev-parse", "HEAD"], cwd);
|
|
283
|
+
return result.stdout.trim();
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Get current branch
|
|
287
|
+
*/
|
|
288
|
+
async getCurrentBranch(cwd) {
|
|
289
|
+
const result = await this.execOrThrow(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
290
|
+
return result.stdout.trim();
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Check if branch exists
|
|
294
|
+
*/
|
|
295
|
+
async branchExists(branch, cwd) {
|
|
296
|
+
const result = await this.exec(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], cwd);
|
|
297
|
+
return result.code === 0;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Get status
|
|
301
|
+
*/
|
|
302
|
+
async getStatus(cwd) {
|
|
303
|
+
const result = await this.exec(["status", "--porcelain"], cwd);
|
|
304
|
+
const files = result.stdout.split("\n").filter((line) => line.trim()).map((line) => line.slice(3));
|
|
305
|
+
return { hasChanges: files.length > 0, files };
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Get diff
|
|
309
|
+
*/
|
|
310
|
+
async getDiff(cwd) {
|
|
311
|
+
const result = await this.exec(["diff", "--no-color"], cwd);
|
|
312
|
+
return result.stdout;
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Add all files
|
|
316
|
+
*/
|
|
317
|
+
async addAll(cwd) {
|
|
318
|
+
await this.execOrThrow(["add", "-A"], cwd);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Commit
|
|
322
|
+
*/
|
|
323
|
+
async commit(message, author, cwd) {
|
|
324
|
+
const args = ["commit", "-m", message];
|
|
325
|
+
if (author) {
|
|
326
|
+
args.push("--author", `${author.name} <${author.email}>`);
|
|
327
|
+
}
|
|
328
|
+
await this.execOrThrow(args, cwd);
|
|
329
|
+
return this.getHeadSha(cwd);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Push
|
|
333
|
+
*/
|
|
334
|
+
async push(remote, branch, force = false, cwd) {
|
|
335
|
+
const args = ["push", remote, branch];
|
|
336
|
+
if (force) args.push("--force");
|
|
337
|
+
await this.execOrThrow(args, cwd);
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* List branches
|
|
341
|
+
*/
|
|
342
|
+
async listBranches(cwd) {
|
|
343
|
+
const localResult = await this.exec(["branch", "--format=%(refname:short)"], cwd);
|
|
344
|
+
const remoteResult = await this.exec(["branch", "-r", "--format=%(refname:short)"], cwd);
|
|
345
|
+
return {
|
|
346
|
+
local: localResult.stdout.split("\n").filter((b) => b.trim()),
|
|
347
|
+
remote: remoteResult.stdout.split("\n").filter((b) => b.trim())
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Get remote URL
|
|
352
|
+
*/
|
|
353
|
+
async getRemoteUrl(remote = "origin", cwd) {
|
|
354
|
+
const result = await this.exec(["remote", "get-url", remote], cwd);
|
|
355
|
+
return result.code === 0 ? result.stdout.trim() : null;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
var gitService = new GitService();
|
|
359
|
+
|
|
360
|
+
// src/routes/health.routes.ts
|
|
361
|
+
var health = new Hono();
|
|
362
|
+
health.get("/", (c) => {
|
|
363
|
+
return c.json({
|
|
364
|
+
status: "ok",
|
|
365
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
366
|
+
service: "worktree-worker",
|
|
367
|
+
version: "1.0.0"
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
health.get("/ready", async (c) => {
|
|
371
|
+
const checks = {};
|
|
372
|
+
try {
|
|
373
|
+
const version = await gitService.getVersion();
|
|
374
|
+
checks.git = { status: "ok", message: version };
|
|
375
|
+
} catch (err) {
|
|
376
|
+
checks.git = { status: "error", message: err.message };
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
const stats = await stat(env.BASE_WORKSPACE_DIR);
|
|
380
|
+
checks.baseWorkspaceDir = {
|
|
381
|
+
status: stats.isDirectory() ? "ok" : "error",
|
|
382
|
+
message: env.BASE_WORKSPACE_DIR
|
|
383
|
+
};
|
|
384
|
+
} catch {
|
|
385
|
+
checks.baseWorkspaceDir = {
|
|
386
|
+
status: "warn",
|
|
387
|
+
message: `${env.BASE_WORKSPACE_DIR} not found (repo not cloned yet)`
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
await mkdir(env.TRIALS_WORKSPACE_DIR, { recursive: true });
|
|
392
|
+
checks.trialsWorkspaceDir = { status: "ok", message: env.TRIALS_WORKSPACE_DIR };
|
|
393
|
+
} catch (err) {
|
|
394
|
+
checks.trialsWorkspaceDir = { status: "error", message: err.message };
|
|
395
|
+
}
|
|
396
|
+
const hasError = Object.values(checks).some((c2) => c2.status === "error");
|
|
397
|
+
const hasWarn = Object.values(checks).some((c2) => c2.status === "warn");
|
|
398
|
+
return c.json(
|
|
399
|
+
{
|
|
400
|
+
status: hasError ? "degraded" : hasWarn ? "ready-warn" : "ready",
|
|
401
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
402
|
+
checks
|
|
403
|
+
},
|
|
404
|
+
hasError ? 503 : 200
|
|
405
|
+
);
|
|
406
|
+
});
|
|
407
|
+
health.get("/live", (c) => {
|
|
408
|
+
return c.json({
|
|
409
|
+
status: "alive",
|
|
410
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
411
|
+
uptime: Math.floor(process.uptime())
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// src/routes/repo.routes.ts
|
|
416
|
+
import { Hono as Hono2 } from "hono";
|
|
417
|
+
import { z } from "zod";
|
|
418
|
+
|
|
419
|
+
// src/services/repo.service.ts
|
|
420
|
+
import { mkdir as mkdir2, rm, stat as stat2, writeFile } from "fs/promises";
|
|
421
|
+
var RepoService = class {
|
|
422
|
+
/**
|
|
423
|
+
* Check if the repository is initialized
|
|
424
|
+
*/
|
|
425
|
+
async getStatus() {
|
|
426
|
+
const repoRoot = env.BASE_WORKSPACE_DIR;
|
|
427
|
+
try {
|
|
428
|
+
const stats = await stat2(repoRoot);
|
|
429
|
+
if (!stats.isDirectory()) {
|
|
430
|
+
return {
|
|
431
|
+
initialized: false,
|
|
432
|
+
path: repoRoot,
|
|
433
|
+
branch: null,
|
|
434
|
+
remote: null,
|
|
435
|
+
headSha: null
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const gitDir = await stat2(`${repoRoot}/.git`).catch(() => null);
|
|
439
|
+
if (!gitDir) {
|
|
440
|
+
return {
|
|
441
|
+
initialized: false,
|
|
442
|
+
path: repoRoot,
|
|
443
|
+
branch: null,
|
|
444
|
+
remote: null,
|
|
445
|
+
headSha: null
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
const branch = await gitService.getCurrentBranch(repoRoot).catch(() => null);
|
|
449
|
+
const headSha = await gitService.getHeadSha(repoRoot).catch(() => null);
|
|
450
|
+
const remote = await gitService.getRemoteUrl("origin", repoRoot);
|
|
451
|
+
return {
|
|
452
|
+
initialized: true,
|
|
453
|
+
path: repoRoot,
|
|
454
|
+
branch,
|
|
455
|
+
remote,
|
|
456
|
+
headSha
|
|
457
|
+
};
|
|
458
|
+
} catch {
|
|
459
|
+
return {
|
|
460
|
+
initialized: false,
|
|
461
|
+
path: repoRoot,
|
|
462
|
+
branch: null,
|
|
463
|
+
remote: null,
|
|
464
|
+
headSha: null
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Initialize/clone the repository
|
|
470
|
+
*/
|
|
471
|
+
async initRepo(options) {
|
|
472
|
+
const log = logger.child({ service: "repo", action: "init" });
|
|
473
|
+
const repoRoot = env.BASE_WORKSPACE_DIR;
|
|
474
|
+
log.info("Initializing repository", {
|
|
475
|
+
repoUrl: options.repoUrl.replace(/ghp_[a-zA-Z0-9]+/, "ghp_***"),
|
|
476
|
+
branch: options.branch,
|
|
477
|
+
force: options.force
|
|
478
|
+
});
|
|
479
|
+
const status = await this.getStatus();
|
|
480
|
+
if (status.initialized && !options.force) {
|
|
481
|
+
log.info("Repository already initialized");
|
|
482
|
+
return {
|
|
483
|
+
path: repoRoot,
|
|
484
|
+
branch: status.branch || env.DEFAULT_BRANCH,
|
|
485
|
+
headSha: status.headSha || "",
|
|
486
|
+
remote: status.remote
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
if (options.force && status.initialized) {
|
|
490
|
+
log.info("Force flag set, removing existing repo");
|
|
491
|
+
await rm(repoRoot, { recursive: true, force: true });
|
|
492
|
+
}
|
|
493
|
+
const parentDir = repoRoot.split("/").slice(0, -1).join("/");
|
|
494
|
+
await mkdir2(parentDir, { recursive: true });
|
|
495
|
+
await mkdir2(env.TRIALS_WORKSPACE_DIR, { recursive: true });
|
|
496
|
+
let cloneUrl = options.repoUrl;
|
|
497
|
+
if (options.githubToken) {
|
|
498
|
+
const urlWithoutProtocol = options.repoUrl.replace(/^https?:\/\//, "");
|
|
499
|
+
cloneUrl = `https://x-access-token:${options.githubToken}@${urlWithoutProtocol}`;
|
|
500
|
+
}
|
|
501
|
+
const branch = options.branch || env.DEFAULT_BRANCH;
|
|
502
|
+
log.info("Cloning repository", { branch });
|
|
503
|
+
await gitService.cloneRepo(cloneUrl, repoRoot, {
|
|
504
|
+
branch,
|
|
505
|
+
blobless: true
|
|
506
|
+
});
|
|
507
|
+
await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], repoRoot);
|
|
508
|
+
await gitService.exec(["config", "--local", "user.email", "origin-agent[bot]@users.noreply.github.com"], repoRoot);
|
|
509
|
+
await gitService.exec(["config", "--local", "safe.directory", repoRoot], repoRoot);
|
|
510
|
+
const cleanUrl = options.repoUrl.replace(/^https:\/\/[^@]+@/, "https://");
|
|
511
|
+
await gitService.exec(["remote", "set-url", "origin", cleanUrl], repoRoot);
|
|
512
|
+
if (options.githubToken) {
|
|
513
|
+
await this.updateCredentials(options.githubToken);
|
|
514
|
+
}
|
|
515
|
+
const headSha = await gitService.getHeadSha(repoRoot);
|
|
516
|
+
const remote = await gitService.getRemoteUrl("origin", repoRoot);
|
|
517
|
+
log.info("Repository initialized", { branch, headSha });
|
|
518
|
+
return { path: repoRoot, branch, headSha, remote };
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Update/pull the repository
|
|
522
|
+
*/
|
|
523
|
+
async updateRepo(options) {
|
|
524
|
+
const log = logger.child({ service: "repo", action: "update" });
|
|
525
|
+
const repoRoot = env.BASE_WORKSPACE_DIR;
|
|
526
|
+
const status = await this.getStatus();
|
|
527
|
+
if (!status.initialized) {
|
|
528
|
+
throw new Error("Repository not initialized");
|
|
529
|
+
}
|
|
530
|
+
if (options?.githubToken) {
|
|
531
|
+
await this.updateCredentials(options.githubToken);
|
|
532
|
+
}
|
|
533
|
+
log.info("Fetching from remote");
|
|
534
|
+
await gitService.fetch("origin", repoRoot);
|
|
535
|
+
if (options?.branch && options.branch !== status.branch) {
|
|
536
|
+
log.info("Checking out branch", { branch: options.branch });
|
|
537
|
+
await gitService.execOrThrow(["checkout", options.branch], repoRoot);
|
|
538
|
+
}
|
|
539
|
+
if (options?.pull) {
|
|
540
|
+
log.info("Pulling latest changes");
|
|
541
|
+
await gitService.exec(["pull", "--ff-only"], repoRoot);
|
|
542
|
+
}
|
|
543
|
+
const headSha = await gitService.getHeadSha(repoRoot);
|
|
544
|
+
const branch = await gitService.getCurrentBranch(repoRoot);
|
|
545
|
+
const remote = await gitService.getRemoteUrl("origin", repoRoot);
|
|
546
|
+
return { path: repoRoot, branch, headSha, remote };
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Update git credentials
|
|
550
|
+
*/
|
|
551
|
+
async updateCredentials(githubToken) {
|
|
552
|
+
const log = logger.child({ service: "repo", action: "updateCredentials" });
|
|
553
|
+
const repoRoot = env.BASE_WORKSPACE_DIR;
|
|
554
|
+
await gitService.exec(["config", "--global", "credential.helper", "store --file=/tmp/.git-credentials"], repoRoot);
|
|
555
|
+
await gitService.exec(["config", "--global", "core.askPass", ""], repoRoot);
|
|
556
|
+
const credentialsContent = `https://x-access-token:${githubToken}@github.com
|
|
557
|
+
`;
|
|
558
|
+
await writeFile("/tmp/.git-credentials", credentialsContent, { mode: 384 });
|
|
559
|
+
log.info("Credentials updated");
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* List branches
|
|
563
|
+
*/
|
|
564
|
+
async listBranches() {
|
|
565
|
+
const repoRoot = env.BASE_WORKSPACE_DIR;
|
|
566
|
+
const status = await this.getStatus();
|
|
567
|
+
if (!status.initialized) {
|
|
568
|
+
return { local: [], remote: [] };
|
|
569
|
+
}
|
|
570
|
+
return gitService.listBranches(repoRoot);
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
var repoService = new RepoService();
|
|
574
|
+
|
|
575
|
+
// src/routes/repo.routes.ts
|
|
576
|
+
var repo = new Hono2();
|
|
577
|
+
var initRepoSchema = z.object({
|
|
578
|
+
repoUrl: z.string().url(),
|
|
579
|
+
branch: z.string().optional(),
|
|
580
|
+
githubToken: z.string().optional(),
|
|
581
|
+
force: z.boolean().default(false)
|
|
582
|
+
});
|
|
583
|
+
var updateRepoSchema = z.object({
|
|
584
|
+
branch: z.string().optional(),
|
|
585
|
+
pull: z.boolean().default(false),
|
|
586
|
+
githubToken: z.string().optional()
|
|
587
|
+
});
|
|
588
|
+
repo.get("/status", async (c) => {
|
|
589
|
+
try {
|
|
590
|
+
const status = await repoService.getStatus();
|
|
591
|
+
return c.json({ success: true, data: status });
|
|
592
|
+
} catch (err) {
|
|
593
|
+
const error = err;
|
|
594
|
+
logger.error("Failed to get repo status", { error: error.message });
|
|
595
|
+
return c.json({ success: false, error: { code: "INTERNAL_ERROR", message: error.message } }, 500);
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
repo.get("/branches", async (c) => {
|
|
599
|
+
try {
|
|
600
|
+
const branches = await repoService.listBranches();
|
|
601
|
+
return c.json({ success: true, data: branches });
|
|
602
|
+
} catch (err) {
|
|
603
|
+
const error = err;
|
|
604
|
+
logger.error("Failed to list branches", { error: error.message });
|
|
605
|
+
return c.json({ success: false, error: { code: "INTERNAL_ERROR", message: error.message } }, 500);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
repo.post("/init", async (c) => {
|
|
609
|
+
const log = logger.child({ route: "repo/init" });
|
|
610
|
+
try {
|
|
611
|
+
const body = await c.req.json();
|
|
612
|
+
const parsed = initRepoSchema.safeParse(body);
|
|
613
|
+
if (!parsed.success) {
|
|
614
|
+
return c.json(
|
|
615
|
+
{ success: false, error: { code: "VALIDATION_ERROR", message: parsed.error.message } },
|
|
616
|
+
400
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
log.info("Initializing repository", { repoUrl: parsed.data.repoUrl, branch: parsed.data.branch });
|
|
620
|
+
const result = await repoService.initRepo(parsed.data);
|
|
621
|
+
return c.json({ success: true, data: result });
|
|
622
|
+
} catch (err) {
|
|
623
|
+
const error = err;
|
|
624
|
+
log.error("Failed to initialize repo", { error: error.message });
|
|
625
|
+
return c.json({ success: false, error: { code: "GIT_ERROR", message: error.message } }, 500);
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
repo.post("/update", async (c) => {
|
|
629
|
+
const log = logger.child({ route: "repo/update" });
|
|
630
|
+
try {
|
|
631
|
+
const body = await c.req.json().catch(() => ({}));
|
|
632
|
+
const parsed = updateRepoSchema.safeParse(body);
|
|
633
|
+
if (!parsed.success) {
|
|
634
|
+
return c.json(
|
|
635
|
+
{ success: false, error: { code: "VALIDATION_ERROR", message: parsed.error.message } },
|
|
636
|
+
400
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
log.info("Updating repository", { branch: parsed.data.branch, pull: parsed.data.pull });
|
|
640
|
+
const result = await repoService.updateRepo(parsed.data);
|
|
641
|
+
return c.json({ success: true, data: result });
|
|
642
|
+
} catch (err) {
|
|
643
|
+
const error = err;
|
|
644
|
+
log.error("Failed to update repo", { error: error.message });
|
|
645
|
+
return c.json({ success: false, error: { code: "GIT_ERROR", message: error.message } }, 500);
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
// src/routes/worktree.routes.ts
|
|
650
|
+
import { Hono as Hono3 } from "hono";
|
|
651
|
+
import { z as z2 } from "zod";
|
|
652
|
+
|
|
653
|
+
// src/services/worktree.service.ts
|
|
654
|
+
import { mkdir as mkdir3, rm as rm2, stat as stat3, readdir } from "fs/promises";
|
|
655
|
+
var WorktreeService = class {
|
|
656
|
+
/**
|
|
657
|
+
* Get the isolated workspace directory for a specific trial.
|
|
658
|
+
* Matches orgn branch-manager.ts getTrialWorkspaceDir()
|
|
659
|
+
*/
|
|
660
|
+
getWorktreePath(trialId, trialBranch) {
|
|
661
|
+
const branch = trialBranch || `trial/${trialId}`;
|
|
662
|
+
const safeBranchName = branch.replace(/[^a-zA-Z0-9-_]/g, "-");
|
|
663
|
+
const shortTrialId = trialId.replace(/-/g, "").slice(0, 8);
|
|
664
|
+
if (!safeBranchName.endsWith(shortTrialId)) {
|
|
665
|
+
return `${env.TRIALS_WORKSPACE_DIR}/${safeBranchName}-${shortTrialId}`;
|
|
666
|
+
}
|
|
667
|
+
return `${env.TRIALS_WORKSPACE_DIR}/${safeBranchName}`;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Get the branch name for a trial
|
|
671
|
+
*/
|
|
672
|
+
getBranchName(trialId) {
|
|
673
|
+
return `trial/${trialId}`;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Reset or create a worktree for a trial
|
|
677
|
+
*/
|
|
678
|
+
async resetWorktree(trialId, options) {
|
|
679
|
+
const log = logger.child({ trialId, service: "worktree" });
|
|
680
|
+
const branchName = options.trialBranch || this.getBranchName(trialId);
|
|
681
|
+
const worktreePath = this.getWorktreePath(trialId, branchName);
|
|
682
|
+
const baseBranch = options.baseBranch || env.DEFAULT_BRANCH;
|
|
683
|
+
const baseRepoDir = env.BASE_WORKSPACE_DIR;
|
|
684
|
+
log.info("Starting worktree reset (v3 architecture)", {
|
|
685
|
+
worktreePath,
|
|
686
|
+
branchName,
|
|
687
|
+
baseBranch,
|
|
688
|
+
baseRepoDir
|
|
689
|
+
});
|
|
690
|
+
await mkdir3(env.TRIALS_WORKSPACE_DIR, { recursive: true });
|
|
691
|
+
try {
|
|
692
|
+
const stats = await stat3(worktreePath);
|
|
693
|
+
if (stats.isDirectory()) {
|
|
694
|
+
const gitFile = await stat3(`${worktreePath}/.git`).catch(() => null);
|
|
695
|
+
if (gitFile) {
|
|
696
|
+
log.info("Worktree already exists, fetching latest");
|
|
697
|
+
await gitService.fetch("origin", worktreePath).catch(() => {
|
|
698
|
+
});
|
|
699
|
+
const headSha2 = await gitService.getHeadSha(worktreePath);
|
|
700
|
+
return { worktreePath, branch: branchName, headSha: headSha2 };
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
} catch {
|
|
704
|
+
}
|
|
705
|
+
log.info("Fetching from remote");
|
|
706
|
+
await gitService.fetch("origin", baseRepoDir);
|
|
707
|
+
try {
|
|
708
|
+
await gitService.removeWorktree(worktreePath, true, baseRepoDir);
|
|
709
|
+
} catch {
|
|
710
|
+
}
|
|
711
|
+
await rm2(worktreePath, { recursive: true, force: true }).catch(() => {
|
|
712
|
+
});
|
|
713
|
+
const worktreeFolderName = worktreePath.split("/").pop() || branchName;
|
|
714
|
+
await rm2(`${baseRepoDir}/.git/worktrees/${worktreeFolderName}`, {
|
|
715
|
+
recursive: true,
|
|
716
|
+
force: true
|
|
717
|
+
}).catch(() => {
|
|
718
|
+
});
|
|
719
|
+
await gitService.pruneWorktrees(baseRepoDir);
|
|
720
|
+
const localBranchExists = await gitService.branchExists(branchName, baseRepoDir);
|
|
721
|
+
const remoteBranchExists = await gitService.branchExists(`origin/${branchName}`, baseRepoDir);
|
|
722
|
+
if (localBranchExists) {
|
|
723
|
+
log.info("Creating worktree from existing local branch", { branchName });
|
|
724
|
+
await gitService.execOrThrow(["worktree", "add", worktreePath, branchName], baseRepoDir);
|
|
725
|
+
} else if (remoteBranchExists) {
|
|
726
|
+
log.info("Creating worktree from remote branch", { branchName });
|
|
727
|
+
await gitService.exec(["fetch", "origin", `${branchName}:${branchName}`], baseRepoDir);
|
|
728
|
+
await gitService.execOrThrow(["worktree", "add", worktreePath, branchName], baseRepoDir);
|
|
729
|
+
} else {
|
|
730
|
+
const baseRef = `origin/${baseBranch}`;
|
|
731
|
+
log.info("Creating worktree with new branch from base", { baseRef, branchName });
|
|
732
|
+
await gitService.addWorktreeWithNewBranch(worktreePath, branchName, baseRef, baseRepoDir);
|
|
733
|
+
}
|
|
734
|
+
await gitService.exec(["config", "--local", "user.name", "origin-agent[bot]"], worktreePath).catch(() => {
|
|
735
|
+
});
|
|
736
|
+
await gitService.exec(["config", "--local", "user.email", "origin-agent[bot]@users.noreply.github.com"], worktreePath).catch(() => {
|
|
737
|
+
});
|
|
738
|
+
await gitService.exec(["config", "--local", "safe.directory", worktreePath], worktreePath).catch(() => {
|
|
739
|
+
});
|
|
740
|
+
const headSha = await gitService.getHeadSha(worktreePath);
|
|
741
|
+
log.info("Worktree created successfully", { headSha });
|
|
742
|
+
return { worktreePath, branch: branchName, headSha };
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Delete a worktree for a trial
|
|
746
|
+
*/
|
|
747
|
+
async deleteWorktree(trialId, trialBranch) {
|
|
748
|
+
const log = logger.child({ trialId, service: "worktree" });
|
|
749
|
+
const branchName = trialBranch || this.getBranchName(trialId);
|
|
750
|
+
const worktreePath = this.getWorktreePath(trialId, branchName);
|
|
751
|
+
const baseRepoDir = env.BASE_WORKSPACE_DIR;
|
|
752
|
+
log.info("Deleting worktree", { worktreePath, branchName });
|
|
753
|
+
try {
|
|
754
|
+
await gitService.removeWorktree(worktreePath, true, baseRepoDir);
|
|
755
|
+
} catch {
|
|
756
|
+
log.warn("Worktree removal failed, cleaning up manually");
|
|
757
|
+
await rm2(worktreePath, { recursive: true, force: true }).catch(() => {
|
|
758
|
+
});
|
|
759
|
+
const worktreeFolderName = worktreePath.split("/").pop() || branchName;
|
|
760
|
+
await rm2(`${baseRepoDir}/.git/worktrees/${worktreeFolderName}`, {
|
|
761
|
+
recursive: true,
|
|
762
|
+
force: true
|
|
763
|
+
}).catch(() => {
|
|
764
|
+
});
|
|
765
|
+
await gitService.pruneWorktrees(baseRepoDir);
|
|
766
|
+
}
|
|
767
|
+
try {
|
|
768
|
+
await gitService.deleteBranch(branchName, true, baseRepoDir);
|
|
769
|
+
} catch {
|
|
770
|
+
}
|
|
771
|
+
log.info("Worktree deleted");
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Get worktree status
|
|
775
|
+
*/
|
|
776
|
+
async getWorktreeStatus(trialId, trialBranch) {
|
|
777
|
+
const branchName = trialBranch || this.getBranchName(trialId);
|
|
778
|
+
const worktreePath = this.getWorktreePath(trialId, branchName);
|
|
779
|
+
try {
|
|
780
|
+
const stats = await stat3(worktreePath);
|
|
781
|
+
if (!stats.isDirectory()) {
|
|
782
|
+
return {
|
|
783
|
+
exists: false,
|
|
784
|
+
worktreePath: null,
|
|
785
|
+
branch: null,
|
|
786
|
+
headSha: null,
|
|
787
|
+
hasChanges: false,
|
|
788
|
+
changedFiles: 0
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
const gitFile = await stat3(`${worktreePath}/.git`).catch(() => null);
|
|
792
|
+
if (!gitFile) {
|
|
793
|
+
return {
|
|
794
|
+
exists: false,
|
|
795
|
+
worktreePath: null,
|
|
796
|
+
branch: null,
|
|
797
|
+
headSha: null,
|
|
798
|
+
hasChanges: false,
|
|
799
|
+
changedFiles: 0
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
const headSha = await gitService.getHeadSha(worktreePath);
|
|
803
|
+
const branch = await gitService.getCurrentBranch(worktreePath);
|
|
804
|
+
const status = await gitService.getStatus(worktreePath);
|
|
805
|
+
return {
|
|
806
|
+
exists: true,
|
|
807
|
+
worktreePath,
|
|
808
|
+
branch,
|
|
809
|
+
headSha,
|
|
810
|
+
hasChanges: status.hasChanges,
|
|
811
|
+
changedFiles: status.files.length
|
|
812
|
+
};
|
|
813
|
+
} catch {
|
|
814
|
+
return {
|
|
815
|
+
exists: false,
|
|
816
|
+
worktreePath: null,
|
|
817
|
+
branch: null,
|
|
818
|
+
headSha: null,
|
|
819
|
+
hasChanges: false,
|
|
820
|
+
changedFiles: 0
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Get worktree diff
|
|
826
|
+
*/
|
|
827
|
+
async getWorktreeDiff(trialId, trialBranch) {
|
|
828
|
+
const branchName = trialBranch || this.getBranchName(trialId);
|
|
829
|
+
const worktreePath = this.getWorktreePath(trialId, branchName);
|
|
830
|
+
const status = await gitService.getStatus(worktreePath);
|
|
831
|
+
const diff = await gitService.getDiff(worktreePath);
|
|
832
|
+
return {
|
|
833
|
+
hasChanges: status.hasChanges,
|
|
834
|
+
changedFiles: status.files,
|
|
835
|
+
diff
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Commit changes in worktree
|
|
840
|
+
*/
|
|
841
|
+
async commitChanges(trialId, message, author, trialBranch) {
|
|
842
|
+
const branchName = trialBranch || this.getBranchName(trialId);
|
|
843
|
+
const worktreePath = this.getWorktreePath(trialId, branchName);
|
|
844
|
+
await gitService.addAll(worktreePath);
|
|
845
|
+
const commitSha = await gitService.commit(message, author, worktreePath);
|
|
846
|
+
const branch = await gitService.getCurrentBranch(worktreePath);
|
|
847
|
+
return { commitSha, branch };
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Push branch
|
|
851
|
+
*/
|
|
852
|
+
async pushBranch(trialId, remote = "origin", force = false, trialBranch) {
|
|
853
|
+
const branchName = trialBranch || this.getBranchName(trialId);
|
|
854
|
+
const worktreePath = this.getWorktreePath(trialId, branchName);
|
|
855
|
+
const branch = await gitService.getCurrentBranch(worktreePath);
|
|
856
|
+
await gitService.push(remote, branch, force, worktreePath);
|
|
857
|
+
return { branch, pushed: true };
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Cleanup stale worktrees
|
|
861
|
+
*/
|
|
862
|
+
async cleanupStaleWorktrees() {
|
|
863
|
+
const log = logger.child({ service: "worktree", action: "cleanup" });
|
|
864
|
+
let cleaned = 0;
|
|
865
|
+
const errors = [];
|
|
866
|
+
const cutoffTime = Date.now() - env.CLEANUP_AFTER_HOURS * 60 * 60 * 1e3;
|
|
867
|
+
try {
|
|
868
|
+
const entries = await readdir(env.TRIALS_WORKSPACE_DIR, { withFileTypes: true });
|
|
869
|
+
for (const entry of entries) {
|
|
870
|
+
if (!entry.isDirectory()) continue;
|
|
871
|
+
const worktreePath = `${env.TRIALS_WORKSPACE_DIR}/${entry.name}`;
|
|
872
|
+
try {
|
|
873
|
+
const stats = await stat3(worktreePath);
|
|
874
|
+
if (stats.mtimeMs < cutoffTime) {
|
|
875
|
+
log.info("Cleaning up stale worktree", { path: worktreePath });
|
|
876
|
+
await rm2(worktreePath, { recursive: true, force: true });
|
|
877
|
+
cleaned++;
|
|
878
|
+
}
|
|
879
|
+
} catch (err) {
|
|
880
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
881
|
+
errors.push(`${worktreePath}: ${errMsg}`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
await gitService.pruneWorktrees(env.BASE_WORKSPACE_DIR);
|
|
885
|
+
} catch (err) {
|
|
886
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
887
|
+
errors.push(`readdir: ${errMsg}`);
|
|
888
|
+
}
|
|
889
|
+
log.info("Cleanup completed", { cleaned, errorCount: errors.length });
|
|
890
|
+
return { cleaned, errors };
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
var worktreeService = new WorktreeService();
|
|
894
|
+
|
|
895
|
+
// src/routes/worktree.routes.ts
|
|
896
|
+
var worktree = new Hono3();
|
|
897
|
+
var resetWorktreeSchema = z2.object({
|
|
898
|
+
baseBranch: z2.string().default("main"),
|
|
899
|
+
trialBranch: z2.string().optional(),
|
|
900
|
+
force: z2.boolean().default(false)
|
|
901
|
+
});
|
|
902
|
+
var commitSchema = z2.object({
|
|
903
|
+
message: z2.string().min(1),
|
|
904
|
+
author: z2.object({
|
|
905
|
+
name: z2.string(),
|
|
906
|
+
email: z2.string().email()
|
|
907
|
+
}).optional()
|
|
908
|
+
});
|
|
909
|
+
var pushSchema = z2.object({
|
|
910
|
+
remote: z2.string().default("origin"),
|
|
911
|
+
force: z2.boolean().default(false)
|
|
912
|
+
});
|
|
913
|
+
worktree.post("/trials/:trialId/worktree/reset", async (c) => {
|
|
914
|
+
const trialId = c.req.param("trialId");
|
|
915
|
+
const log = logger.child({ route: "worktree/reset", trialId });
|
|
916
|
+
try {
|
|
917
|
+
const body = await c.req.json().catch(() => ({}));
|
|
918
|
+
const parsed = resetWorktreeSchema.safeParse(body);
|
|
919
|
+
if (!parsed.success) {
|
|
920
|
+
return c.json(
|
|
921
|
+
{ success: false, error: { code: "VALIDATION_ERROR", message: parsed.error.message } },
|
|
922
|
+
400
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
const input = parsed.data;
|
|
926
|
+
log.info("Creating/resetting worktree", {
|
|
927
|
+
baseBranch: input.baseBranch,
|
|
928
|
+
trialBranch: input.trialBranch,
|
|
929
|
+
force: input.force
|
|
930
|
+
});
|
|
931
|
+
const result = await worktreeService.resetWorktree(trialId, {
|
|
932
|
+
baseBranch: input.baseBranch,
|
|
933
|
+
trialBranch: input.trialBranch,
|
|
934
|
+
force: input.force
|
|
935
|
+
});
|
|
936
|
+
return c.json({ success: true, data: result });
|
|
937
|
+
} catch (err) {
|
|
938
|
+
const error = err;
|
|
939
|
+
log.error("Failed to reset worktree", { error: error.message });
|
|
940
|
+
return c.json({ success: false, error: { code: "GIT_ERROR", message: error.message } }, 500);
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
worktree.delete("/trials/:trialId/worktree", async (c) => {
|
|
944
|
+
const trialId = c.req.param("trialId");
|
|
945
|
+
const log = logger.child({ route: "worktree/delete", trialId });
|
|
946
|
+
try {
|
|
947
|
+
log.info("Deleting worktree");
|
|
948
|
+
await worktreeService.deleteWorktree(trialId);
|
|
949
|
+
return c.json({ success: true, data: { deleted: true } });
|
|
950
|
+
} catch (err) {
|
|
951
|
+
const error = err;
|
|
952
|
+
log.error("Failed to delete worktree", { error: error.message });
|
|
953
|
+
return c.json({ success: false, error: { code: "GIT_ERROR", message: error.message } }, 500);
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
worktree.get("/trials/:trialId/worktree/status", async (c) => {
|
|
957
|
+
const trialId = c.req.param("trialId");
|
|
958
|
+
const log = logger.child({ route: "worktree/status", trialId });
|
|
959
|
+
try {
|
|
960
|
+
const status = await worktreeService.getWorktreeStatus(trialId);
|
|
961
|
+
return c.json({ success: true, data: status });
|
|
962
|
+
} catch (err) {
|
|
963
|
+
const error = err;
|
|
964
|
+
log.error("Failed to get worktree status", { error: error.message });
|
|
965
|
+
return c.json({ success: false, error: { code: "GIT_ERROR", message: error.message } }, 500);
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
worktree.get("/trials/:trialId/worktree/diff", async (c) => {
|
|
969
|
+
const trialId = c.req.param("trialId");
|
|
970
|
+
const log = logger.child({ route: "worktree/diff", trialId });
|
|
971
|
+
try {
|
|
972
|
+
const diff = await worktreeService.getWorktreeDiff(trialId);
|
|
973
|
+
return c.json({ success: true, data: diff });
|
|
974
|
+
} catch (err) {
|
|
975
|
+
const error = err;
|
|
976
|
+
log.error("Failed to get worktree diff", { error: error.message });
|
|
977
|
+
return c.json({ success: false, error: { code: "GIT_ERROR", message: error.message } }, 500);
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
worktree.post("/trials/:trialId/worktree/commit", async (c) => {
|
|
981
|
+
const trialId = c.req.param("trialId");
|
|
982
|
+
const log = logger.child({ route: "worktree/commit", trialId });
|
|
983
|
+
try {
|
|
984
|
+
const body = await c.req.json();
|
|
985
|
+
const parsed = commitSchema.safeParse(body);
|
|
986
|
+
if (!parsed.success) {
|
|
987
|
+
return c.json(
|
|
988
|
+
{ success: false, error: { code: "VALIDATION_ERROR", message: parsed.error.message } },
|
|
989
|
+
400
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
log.info("Committing changes", { message: parsed.data.message.slice(0, 50) });
|
|
993
|
+
const result = await worktreeService.commitChanges(
|
|
994
|
+
trialId,
|
|
995
|
+
parsed.data.message,
|
|
996
|
+
parsed.data.author
|
|
997
|
+
);
|
|
998
|
+
return c.json({ success: true, data: result });
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
const error = err;
|
|
1001
|
+
log.error("Failed to commit changes", { error: error.message });
|
|
1002
|
+
return c.json({ success: false, error: { code: "GIT_ERROR", message: error.message } }, 500);
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
worktree.post("/trials/:trialId/worktree/push", async (c) => {
|
|
1006
|
+
const trialId = c.req.param("trialId");
|
|
1007
|
+
const log = logger.child({ route: "worktree/push", trialId });
|
|
1008
|
+
try {
|
|
1009
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1010
|
+
const parsed = pushSchema.safeParse(body);
|
|
1011
|
+
if (!parsed.success) {
|
|
1012
|
+
return c.json(
|
|
1013
|
+
{ success: false, error: { code: "VALIDATION_ERROR", message: parsed.error.message } },
|
|
1014
|
+
400
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
log.info("Pushing to remote", { remote: parsed.data.remote, force: parsed.data.force });
|
|
1018
|
+
const result = await worktreeService.pushBranch(
|
|
1019
|
+
trialId,
|
|
1020
|
+
parsed.data.remote,
|
|
1021
|
+
parsed.data.force
|
|
1022
|
+
);
|
|
1023
|
+
return c.json({ success: true, data: result });
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
const error = err;
|
|
1026
|
+
log.error("Failed to push", { error: error.message });
|
|
1027
|
+
return c.json({ success: false, error: { code: "GIT_ERROR", message: error.message } }, 500);
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
worktree.post("/worktrees/cleanup", async (c) => {
|
|
1031
|
+
const log = logger.child({ route: "worktrees/cleanup" });
|
|
1032
|
+
try {
|
|
1033
|
+
log.info("Starting worktree cleanup");
|
|
1034
|
+
const result = await worktreeService.cleanupStaleWorktrees();
|
|
1035
|
+
return c.json({ success: true, data: result });
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
const error = err;
|
|
1038
|
+
log.error("Failed to cleanup worktrees", { error: error.message });
|
|
1039
|
+
return c.json({ success: false, error: { code: "INTERNAL_ERROR", message: error.message } }, 500);
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
// src/main.ts
|
|
1044
|
+
var app = new Hono4();
|
|
1045
|
+
app.use("*", secureHeaders());
|
|
1046
|
+
app.use(
|
|
1047
|
+
"*",
|
|
1048
|
+
cors({
|
|
1049
|
+
origin: "*",
|
|
1050
|
+
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
1051
|
+
allowHeaders: ["Content-Type", "Authorization", "X-Request-ID"],
|
|
1052
|
+
exposeHeaders: ["X-Request-ID"],
|
|
1053
|
+
maxAge: 86400
|
|
1054
|
+
})
|
|
1055
|
+
);
|
|
1056
|
+
if (env.NODE_ENV === "development") {
|
|
1057
|
+
app.use("*", honoLogger());
|
|
1058
|
+
}
|
|
1059
|
+
app.use("*", async (c, next) => {
|
|
1060
|
+
const requestId = c.req.header("X-Request-ID") || crypto.randomUUID();
|
|
1061
|
+
c.res.headers.set("X-Request-ID", requestId);
|
|
1062
|
+
await next();
|
|
1063
|
+
});
|
|
1064
|
+
app.route("/health", health);
|
|
1065
|
+
app.get("/", (c) => {
|
|
1066
|
+
return c.json({
|
|
1067
|
+
service: "worktree-worker",
|
|
1068
|
+
version: "1.0.0",
|
|
1069
|
+
status: "ok",
|
|
1070
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
app.use("/v1/*", authMiddleware);
|
|
1074
|
+
app.route("/v1/repo", repo);
|
|
1075
|
+
app.route("/v1", worktree);
|
|
1076
|
+
app.onError((err, c) => {
|
|
1077
|
+
const requestId = c.req.header("X-Request-ID") || "unknown";
|
|
1078
|
+
logger.error("Unhandled error", {
|
|
1079
|
+
requestId,
|
|
1080
|
+
error: err.message,
|
|
1081
|
+
stack: err.stack,
|
|
1082
|
+
path: c.req.path,
|
|
1083
|
+
method: c.req.method
|
|
1084
|
+
});
|
|
1085
|
+
return c.json(
|
|
1086
|
+
{
|
|
1087
|
+
success: false,
|
|
1088
|
+
error: {
|
|
1089
|
+
code: "INTERNAL_ERROR",
|
|
1090
|
+
message: env.NODE_ENV === "development" ? err.message : "Internal server error",
|
|
1091
|
+
requestId
|
|
1092
|
+
}
|
|
1093
|
+
},
|
|
1094
|
+
500
|
|
1095
|
+
);
|
|
1096
|
+
});
|
|
1097
|
+
app.notFound((c) => {
|
|
1098
|
+
return c.json(
|
|
1099
|
+
{
|
|
1100
|
+
success: false,
|
|
1101
|
+
error: {
|
|
1102
|
+
code: "NOT_FOUND",
|
|
1103
|
+
message: `Route ${c.req.method} ${c.req.path} not found`
|
|
1104
|
+
}
|
|
1105
|
+
},
|
|
1106
|
+
404
|
|
1107
|
+
);
|
|
1108
|
+
});
|
|
1109
|
+
var port = env.PORT;
|
|
1110
|
+
logger.info(`Worktree Worker starting on port ${port}`, {
|
|
1111
|
+
port,
|
|
1112
|
+
baseWorkspaceDir: env.BASE_WORKSPACE_DIR,
|
|
1113
|
+
trialsWorkspaceDir: env.TRIALS_WORKSPACE_DIR
|
|
1114
|
+
});
|
|
1115
|
+
serve({
|
|
1116
|
+
fetch: app.fetch,
|
|
1117
|
+
port
|
|
1118
|
+
});
|
|
1119
|
+
export {
|
|
1120
|
+
app
|
|
1121
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saeed42/worktree-worker",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Git worktree management service for AI agent trials",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/main.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"opencode-ai": "./dist/main.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup",
|
|
12
|
+
"dev": "tsx watch src/main.ts",
|
|
13
|
+
"start": "node dist/main.js",
|
|
14
|
+
"typecheck": "tsc --noEmit",
|
|
15
|
+
"lint": "eslint src/",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"git",
|
|
20
|
+
"worktree",
|
|
21
|
+
"codesandbox",
|
|
22
|
+
"agent",
|
|
23
|
+
"trial"
|
|
24
|
+
],
|
|
25
|
+
"author": "Origin",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@hono/node-server": "^1.13.7",
|
|
29
|
+
"hono": "^4.6.0",
|
|
30
|
+
"pino": "^9.5.0",
|
|
31
|
+
"pino-pretty": "^13.0.0",
|
|
32
|
+
"zod": "^3.24.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^22.10.0",
|
|
36
|
+
"tsup": "^8.3.0",
|
|
37
|
+
"tsx": "^4.19.0",
|
|
38
|
+
"typescript": "^5.7.0"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18.0.0"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"README.md"
|
|
46
|
+
],
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|