@gaia-ai/gaia 0.1.4
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/LICENSE +21 -0
- package/README.md +63 -0
- package/bin/gaia +2 -0
- package/dist/index.js +2041 -0
- package/dist/plugin.js +78 -0
- package/dist/plugins.js +1557 -0
- package/package.json +49 -0
package/dist/plugins.js
ADDED
|
@@ -0,0 +1,1557 @@
|
|
|
1
|
+
// src/plugins/auth/basic.ts
|
|
2
|
+
function basicAuthProvider(tokenBase64) {
|
|
3
|
+
const authProvider = {
|
|
4
|
+
id: "basic_auth",
|
|
5
|
+
displayName: "HTTP Basic (inline)",
|
|
6
|
+
capabilities: { login: false, logout: false, status: true },
|
|
7
|
+
async login() {
|
|
8
|
+
throw new Error("GAIA uses static inline HTTP Basic auth.");
|
|
9
|
+
},
|
|
10
|
+
async logout() {
|
|
11
|
+
},
|
|
12
|
+
async status() {
|
|
13
|
+
return { loggedIn: true, provider: "basic_auth" };
|
|
14
|
+
},
|
|
15
|
+
createAdapter() {
|
|
16
|
+
return {
|
|
17
|
+
async apply(req) {
|
|
18
|
+
return {
|
|
19
|
+
...req,
|
|
20
|
+
headers: {
|
|
21
|
+
...req.headers ?? {},
|
|
22
|
+
Authorization: `Basic ${tokenBase64}`
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
return { id: "gaia-basic-auth", authProvider };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/plugins/plugins.ts
|
|
33
|
+
async function selectRemote(config) {
|
|
34
|
+
return config.remote.createRemote(config);
|
|
35
|
+
}
|
|
36
|
+
async function selectExecutor(config) {
|
|
37
|
+
return config.executor.createExecutor(config);
|
|
38
|
+
}
|
|
39
|
+
async function selectWorkspace(config) {
|
|
40
|
+
return config.workspace.createWorkspace(config);
|
|
41
|
+
}
|
|
42
|
+
async function selectAgent(config) {
|
|
43
|
+
return config.agent.createAgent(config);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/plugins/remote/drupal.ts
|
|
47
|
+
import { resolveAuth } from "dropsh";
|
|
48
|
+
import { createHttpClient, createJsonApiClient } from "dropsh/plugin";
|
|
49
|
+
var ACTIVE = ["claimed", "running"];
|
|
50
|
+
function parseTicketComments(included) {
|
|
51
|
+
if (!Array.isArray(included)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const comments = [];
|
|
55
|
+
for (const res of included) {
|
|
56
|
+
if (res?.type !== "gaia_comment--gaia_comment") {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const attrs = res.attributes ?? {};
|
|
60
|
+
const body = attrs.body;
|
|
61
|
+
const value = typeof body === "string" ? body : typeof body?.value === "string" ? body.value : "";
|
|
62
|
+
comments.push({
|
|
63
|
+
type: typeof attrs.gaia_comment_type === "string" ? attrs.gaia_comment_type : "comment",
|
|
64
|
+
body: value,
|
|
65
|
+
created: typeof attrs.created === "string" ? attrs.created : ""
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return comments;
|
|
69
|
+
}
|
|
70
|
+
var DrupalGaiaRemote = class {
|
|
71
|
+
constructor(api) {
|
|
72
|
+
this.api = api;
|
|
73
|
+
}
|
|
74
|
+
api;
|
|
75
|
+
async fetchActiveRuns(id) {
|
|
76
|
+
const rows = await this.api.collection("gaia_run").where("conductor_id.machine_id", "=", id).whereIn("state", ACTIVE).fields(["state", "state_at_start", "worktree_path"]).page(100).list();
|
|
77
|
+
return Promise.all(
|
|
78
|
+
rows.map(async (r) => ({
|
|
79
|
+
runUuid: r.id,
|
|
80
|
+
ticketUuid: r.rel("ticket_id") ?? "",
|
|
81
|
+
ticketIdentifier: await this.getRunTicketIdentifier(r.id),
|
|
82
|
+
branchName: await this.getRunTicketBranchName(r.id),
|
|
83
|
+
state: r.attr("state") ?? "",
|
|
84
|
+
stateAtStart: r.attr("state_at_start") ?? "",
|
|
85
|
+
worktreePath: r.attr("worktree_path") ?? ""
|
|
86
|
+
}))
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
async activeRunCount(id) {
|
|
90
|
+
return this.api.collection("gaia_run").where("conductor_id.machine_id", "=", id).whereIn("state", ACTIVE).fields(["drupal_internal__id"]).page(100).count();
|
|
91
|
+
}
|
|
92
|
+
async claimNext(c) {
|
|
93
|
+
const res = await this.api.post("gaia/claim-next", {
|
|
94
|
+
data: {
|
|
95
|
+
attributes: {
|
|
96
|
+
lease_seconds: c.leaseSeconds,
|
|
97
|
+
// Server (`ConductorResolverTrait`) narrows to the caller's conductor
|
|
98
|
+
// by the `machine_id` attribute; `c.conductorId` IS the machine_id.
|
|
99
|
+
machine_id: c.conductorId
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
if (!res?.data) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const d = res.data;
|
|
107
|
+
const rawId = d.attributes?.drupal_internal__id;
|
|
108
|
+
if (rawId === void 0 || rawId === null) {
|
|
109
|
+
throw new Error(`claimNext: missing drupal_internal__id on run ${d.id}`);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
runUuid: d.id,
|
|
113
|
+
runId: Number(rawId),
|
|
114
|
+
ticketUuid: d.relationships?.ticket_id?.data?.id ?? "",
|
|
115
|
+
stateAtStart: String(d.attributes?.state_at_start ?? ""),
|
|
116
|
+
// handler_id was dropped; the handler is the work the run does, i.e. the
|
|
117
|
+
// ticket state the run started in.
|
|
118
|
+
handler: String(d.attributes?.state_at_start ?? "")
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
async getTicket(uuid) {
|
|
122
|
+
const doc = await this.api.get(
|
|
123
|
+
`gaia_ticket/gaia_ticket/${uuid}?include=comments`
|
|
124
|
+
);
|
|
125
|
+
const attrs = doc.data?.attributes ?? {};
|
|
126
|
+
const issueUrl = typeof attrs.origin === "string" ? attrs.origin : void 0;
|
|
127
|
+
return {
|
|
128
|
+
uuid: doc.data?.id ?? uuid,
|
|
129
|
+
identifier: typeof attrs.identifier === "string" ? attrs.identifier : uuid,
|
|
130
|
+
title: typeof attrs.title === "string" ? attrs.title : "",
|
|
131
|
+
state: typeof attrs.state === "string" ? attrs.state : "",
|
|
132
|
+
branchName: typeof attrs.branch_name === "string" ? attrs.branch_name : "",
|
|
133
|
+
...typeof attrs.base_branch === "string" && attrs.base_branch !== "" ? { baseBranch: attrs.base_branch } : {},
|
|
134
|
+
comments: parseTicketComments(doc.included),
|
|
135
|
+
...issueUrl ? { issueUrl } : {}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
async getRunWorktree(uuid) {
|
|
139
|
+
const r = await this.api.resource("gaia_run", uuid);
|
|
140
|
+
return r.attr("worktree_path") ?? "";
|
|
141
|
+
}
|
|
142
|
+
async getRunTicketIdentifier(uuid) {
|
|
143
|
+
const run = await this.api.resource("gaia_run", uuid);
|
|
144
|
+
const ticketUuid = run.rel("ticket_id");
|
|
145
|
+
if (!ticketUuid) {
|
|
146
|
+
return "";
|
|
147
|
+
}
|
|
148
|
+
const ticket = await this.api.resource("gaia_ticket", ticketUuid);
|
|
149
|
+
return ticket.attr("identifier") ?? "";
|
|
150
|
+
}
|
|
151
|
+
async getRunTicketBranchName(uuid) {
|
|
152
|
+
const run = await this.api.resource("gaia_run", uuid);
|
|
153
|
+
const ticketUuid = run.rel("ticket_id");
|
|
154
|
+
if (!ticketUuid) {
|
|
155
|
+
return "";
|
|
156
|
+
}
|
|
157
|
+
const ticket = await this.api.resource("gaia_ticket", ticketUuid);
|
|
158
|
+
return ticket.attr("branch_name") ?? "";
|
|
159
|
+
}
|
|
160
|
+
async registerConductor(reg) {
|
|
161
|
+
const r = await this.api.upsert(
|
|
162
|
+
"gaia_conductor",
|
|
163
|
+
{ path: "machine_id", value: reg.id },
|
|
164
|
+
{
|
|
165
|
+
attributes: {
|
|
166
|
+
machine_id: reg.id,
|
|
167
|
+
status: "online",
|
|
168
|
+
workspace_root: reg.workspace,
|
|
169
|
+
label: reg.label,
|
|
170
|
+
states: reg.states,
|
|
171
|
+
max_parallel: reg.max_parallel
|
|
172
|
+
},
|
|
173
|
+
relationships: {
|
|
174
|
+
owner_user_id: {
|
|
175
|
+
data: { type: "user--user", id: await this.api.me() }
|
|
176
|
+
},
|
|
177
|
+
project_id: {
|
|
178
|
+
data: {
|
|
179
|
+
type: "gaia_project--gaia_project",
|
|
180
|
+
id: await this.projectUuid(reg.project)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
return r.id;
|
|
187
|
+
}
|
|
188
|
+
async heartbeat(reg, load, lease = 300) {
|
|
189
|
+
const res = await this.api.post("gaia/heartbeat", {
|
|
190
|
+
data: {
|
|
191
|
+
attributes: {
|
|
192
|
+
machine_id: reg.id,
|
|
193
|
+
project: reg.project,
|
|
194
|
+
states: reg.states,
|
|
195
|
+
workspace_root: reg.workspace,
|
|
196
|
+
label: reg.label,
|
|
197
|
+
max_parallel: reg.max_parallel,
|
|
198
|
+
current_load: load,
|
|
199
|
+
lease_seconds: lease
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
const status = res?.data?.attributes?.status;
|
|
204
|
+
return typeof status === "string" ? status : "online";
|
|
205
|
+
}
|
|
206
|
+
async getConductorStatus(id) {
|
|
207
|
+
const c = await this.api.collection("gaia_conductor").where("machine_id", "=", id).fields(["status"]).first();
|
|
208
|
+
return c ? c.attr("status") ?? null : null;
|
|
209
|
+
}
|
|
210
|
+
async setConductorStatus(id, status) {
|
|
211
|
+
const c = await this.api.collection("gaia_conductor").where("machine_id", "=", id).first();
|
|
212
|
+
if (c) {
|
|
213
|
+
await this.api.update("gaia_conductor", c.id, { attributes: { status } });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async listConductors(owner) {
|
|
217
|
+
let col = this.api.collection("gaia_conductor");
|
|
218
|
+
if (owner === "me") {
|
|
219
|
+
col = col.where("owner_user_id.id", "=", await this.api.me());
|
|
220
|
+
}
|
|
221
|
+
const rows = await col.page(200).list();
|
|
222
|
+
return rows.map((r) => ({
|
|
223
|
+
id: r.attr("machine_id") ?? r.id,
|
|
224
|
+
project: "",
|
|
225
|
+
label: r.attr("label") ?? "",
|
|
226
|
+
status: r.attr("status") ?? "",
|
|
227
|
+
lastSeen: r.attr("last_seen") ?? 0,
|
|
228
|
+
load: r.attr("current_load") ?? 0
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
async markRunning(uuid, attrs) {
|
|
232
|
+
const t = Math.floor(Date.now() / 1e3);
|
|
233
|
+
await this.api.update("gaia_run", uuid, {
|
|
234
|
+
attributes: {
|
|
235
|
+
state: "running",
|
|
236
|
+
heartbeat: t,
|
|
237
|
+
...attrs?.worktree_path ? { worktree_path: attrs.worktree_path } : {}
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
async fetchFinalizableRuns(id) {
|
|
242
|
+
const rows = await this.api.collection("gaia_run").where("conductor_id.machine_id", "=", id).where("state", "=", "done").where("closed", "=", "0").fields(["worktree_path"]).page(100).list();
|
|
243
|
+
return rows.map((r) => ({
|
|
244
|
+
runUuid: r.id,
|
|
245
|
+
worktreePath: r.attr("worktree_path") ?? ""
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
async finalizeRun(uuid, log) {
|
|
249
|
+
const t = Math.floor(Date.now() / 1e3);
|
|
250
|
+
await this.api.update("gaia_run", uuid, {
|
|
251
|
+
attributes: {
|
|
252
|
+
closed: true,
|
|
253
|
+
closed_date: t,
|
|
254
|
+
...log ? { log } : {}
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async fetchFinalizableTickets(id) {
|
|
259
|
+
const rows = await this.api.collection("gaia_ticket").where("conductor_id.machine_id", "=", id).where("state", "=", "done").where("closed", "=", "0").fields(["branch_name"]).page(100).list();
|
|
260
|
+
return Promise.all(
|
|
261
|
+
rows.map(async (r) => ({
|
|
262
|
+
ticketUuid: r.id,
|
|
263
|
+
branchName: r.attr("branch_name") ?? "",
|
|
264
|
+
worktreePath: await this.latestRunWorktree(r.id)
|
|
265
|
+
}))
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
async closeTicket(uuid) {
|
|
269
|
+
const t = Math.floor(Date.now() / 1e3);
|
|
270
|
+
await this.api.update("gaia_ticket", uuid, {
|
|
271
|
+
attributes: { closed: true, closed_date: t }
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Absolute worktree path of the ticket's latest run (highest run id with a
|
|
276
|
+
* non-empty worktree_path), or '' when none — the cwd the cleanup command
|
|
277
|
+
* runs in. The conductor persists worktree_path on markRunning, so a done
|
|
278
|
+
* ticket's run carries the path even after the run closed.
|
|
279
|
+
*/
|
|
280
|
+
async latestRunWorktree(ticketUuid) {
|
|
281
|
+
const rows = await this.api.collection("gaia_run").where("ticket_id.id", "=", ticketUuid).fields(["worktree_path", "drupal_internal__id"]).sort("-drupal_internal__id").page(100).list();
|
|
282
|
+
for (const r of rows) {
|
|
283
|
+
const path = r.attr("worktree_path");
|
|
284
|
+
if (path) {
|
|
285
|
+
return path;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return "";
|
|
289
|
+
}
|
|
290
|
+
async projectUuid(name) {
|
|
291
|
+
const p = await this.api.collection("gaia_project").where("name", "=", name).first();
|
|
292
|
+
if (!p) {
|
|
293
|
+
throw new Error(`gaia project "${name}" not found`);
|
|
294
|
+
}
|
|
295
|
+
return p.id;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
function drupalRemote() {
|
|
299
|
+
return {
|
|
300
|
+
kind: "remote",
|
|
301
|
+
id: "drupal",
|
|
302
|
+
requiredModules: [],
|
|
303
|
+
async createRemote(config) {
|
|
304
|
+
const http = createHttpClient();
|
|
305
|
+
const auth = await resolveAuth({
|
|
306
|
+
baseUrl: config.site.base_url,
|
|
307
|
+
plugins: config.plugins ?? [],
|
|
308
|
+
http,
|
|
309
|
+
now: Date.now
|
|
310
|
+
// stateDir omitted → dropsh defaultStateDir() (~/.config/dropsh)
|
|
311
|
+
});
|
|
312
|
+
return new DrupalGaiaRemote(
|
|
313
|
+
createJsonApiClient({
|
|
314
|
+
baseUrl: config.site.base_url,
|
|
315
|
+
prefix: config.site.jsonapi_prefix,
|
|
316
|
+
http,
|
|
317
|
+
auth
|
|
318
|
+
})
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/plugins/remote/fake.ts
|
|
325
|
+
var ACTIVE2 = ["claimed", "running"];
|
|
326
|
+
var FakeGaiaRemote = class {
|
|
327
|
+
calls = {
|
|
328
|
+
markRunning: [],
|
|
329
|
+
finalizeRun: [],
|
|
330
|
+
closeTicket: []
|
|
331
|
+
};
|
|
332
|
+
/**
|
|
333
|
+
* Override for reconcile tests: when set, `fetchActiveRuns` returns this
|
|
334
|
+
* list verbatim instead of deriving it from the internal runs map.
|
|
335
|
+
*/
|
|
336
|
+
activeRuns = null;
|
|
337
|
+
runs = /* @__PURE__ */ new Map();
|
|
338
|
+
queue = [];
|
|
339
|
+
tickets;
|
|
340
|
+
conductorStatus = "online";
|
|
341
|
+
/** Stable counter for assigning numeric ids to unseeded runs. */
|
|
342
|
+
runIdCounter = 0;
|
|
343
|
+
constructor(seed = {}) {
|
|
344
|
+
this.tickets = seed.tickets ?? {};
|
|
345
|
+
for (const r of seed.runs ?? []) {
|
|
346
|
+
const handler = r.handler ?? "code";
|
|
347
|
+
const runId = r.id ?? ++this.runIdCounter;
|
|
348
|
+
this.runs.set(r.runUuid, {
|
|
349
|
+
runUuid: r.runUuid,
|
|
350
|
+
runId,
|
|
351
|
+
ticketUuid: r.ticketUuid,
|
|
352
|
+
handler,
|
|
353
|
+
state: r.state ?? r.stateAtStart,
|
|
354
|
+
stateAtStart: r.stateAtStart,
|
|
355
|
+
...r.worktreePath !== void 0 ? { worktreePath: r.worktreePath } : {},
|
|
356
|
+
...r.closed !== void 0 ? { closed: r.closed } : {}
|
|
357
|
+
});
|
|
358
|
+
this.queue.push({
|
|
359
|
+
runUuid: r.runUuid,
|
|
360
|
+
runId,
|
|
361
|
+
ticketUuid: r.ticketUuid,
|
|
362
|
+
stateAtStart: r.stateAtStart,
|
|
363
|
+
handler
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async registerConductor(_reg) {
|
|
368
|
+
this.conductorStatus = "online";
|
|
369
|
+
return "fake-conductor-uuid";
|
|
370
|
+
}
|
|
371
|
+
async heartbeat() {
|
|
372
|
+
this.conductorStatus = "online";
|
|
373
|
+
return this.conductorStatus;
|
|
374
|
+
}
|
|
375
|
+
async getConductorStatus(_conductorId) {
|
|
376
|
+
return this.conductorStatus;
|
|
377
|
+
}
|
|
378
|
+
async setConductorStatus(_conductorId, status) {
|
|
379
|
+
this.conductorStatus = status;
|
|
380
|
+
}
|
|
381
|
+
async listConductors(_owner) {
|
|
382
|
+
return [];
|
|
383
|
+
}
|
|
384
|
+
async activeRunCount(_conductorId) {
|
|
385
|
+
return this.internalActiveRuns().length;
|
|
386
|
+
}
|
|
387
|
+
async fetchActiveRuns(_conductorId) {
|
|
388
|
+
if (this.activeRuns !== null) {
|
|
389
|
+
return this.activeRuns;
|
|
390
|
+
}
|
|
391
|
+
return this.internalActiveRuns().map((r) => {
|
|
392
|
+
const ticket = this.tickets[r.ticketUuid];
|
|
393
|
+
const identifier = ticket?.identifier ?? "";
|
|
394
|
+
const branchName = ticket?.branchName ?? (identifier ? `gaia/${identifier.toLowerCase()}` : "");
|
|
395
|
+
return {
|
|
396
|
+
runUuid: r.runUuid,
|
|
397
|
+
ticketUuid: r.ticketUuid,
|
|
398
|
+
ticketIdentifier: identifier,
|
|
399
|
+
branchName,
|
|
400
|
+
state: r.state,
|
|
401
|
+
stateAtStart: r.stateAtStart,
|
|
402
|
+
worktreePath: r.worktreePath ?? ""
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
async claimNext(_claim) {
|
|
407
|
+
return this.queue.shift() ?? null;
|
|
408
|
+
}
|
|
409
|
+
async getTicket(ticketUuid) {
|
|
410
|
+
const t = this.tickets[ticketUuid];
|
|
411
|
+
if (!t) {
|
|
412
|
+
throw new Error(`fake remote: ticket ${ticketUuid} not seeded`);
|
|
413
|
+
}
|
|
414
|
+
const branchName = t.branchName ?? `gaia/${t.identifier.toLowerCase()}`;
|
|
415
|
+
return {
|
|
416
|
+
uuid: ticketUuid,
|
|
417
|
+
identifier: t.identifier,
|
|
418
|
+
title: t.title,
|
|
419
|
+
state: t.state,
|
|
420
|
+
branchName,
|
|
421
|
+
...t.baseBranch ? { baseBranch: t.baseBranch } : {},
|
|
422
|
+
comments: t.comments ?? [],
|
|
423
|
+
...t.url ? { issueUrl: t.url } : {}
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
async getRunWorktree(runUuid) {
|
|
427
|
+
return this.runs.get(runUuid)?.worktreePath ?? "";
|
|
428
|
+
}
|
|
429
|
+
async getRunTicketIdentifier(runUuid) {
|
|
430
|
+
const ticketUuid = this.runs.get(runUuid)?.ticketUuid;
|
|
431
|
+
if (!ticketUuid) {
|
|
432
|
+
return "";
|
|
433
|
+
}
|
|
434
|
+
return this.tickets[ticketUuid]?.identifier ?? "";
|
|
435
|
+
}
|
|
436
|
+
async getRunTicketBranchName(runUuid) {
|
|
437
|
+
const ticketUuid = this.runs.get(runUuid)?.ticketUuid;
|
|
438
|
+
if (!ticketUuid) {
|
|
439
|
+
return "";
|
|
440
|
+
}
|
|
441
|
+
const ticket = this.tickets[ticketUuid];
|
|
442
|
+
if (!ticket) {
|
|
443
|
+
return "";
|
|
444
|
+
}
|
|
445
|
+
return ticket.branchName ?? `gaia/${ticket.identifier.toLowerCase()}`;
|
|
446
|
+
}
|
|
447
|
+
async markRunning(runUuid, attrs) {
|
|
448
|
+
this.calls.markRunning.push({
|
|
449
|
+
runUuid,
|
|
450
|
+
...attrs ? {
|
|
451
|
+
attrs: {
|
|
452
|
+
...attrs.worktree_path ? { worktree_path: attrs.worktree_path } : {}
|
|
453
|
+
}
|
|
454
|
+
} : {}
|
|
455
|
+
});
|
|
456
|
+
const run = this.runs.get(runUuid);
|
|
457
|
+
if (run) {
|
|
458
|
+
run.state = "running";
|
|
459
|
+
if (attrs?.worktree_path) {
|
|
460
|
+
run.worktreePath = attrs.worktree_path;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async fetchFinalizableRuns(_conductorId) {
|
|
465
|
+
return [...this.runs.values()].filter((r) => r.state === "done" && !r.closed).map((r) => ({ runUuid: r.runUuid, worktreePath: r.worktreePath ?? "" }));
|
|
466
|
+
}
|
|
467
|
+
async finalizeRun(runUuid, log) {
|
|
468
|
+
this.calls.finalizeRun.push({ runUuid, log });
|
|
469
|
+
const run = this.runs.get(runUuid);
|
|
470
|
+
if (run) {
|
|
471
|
+
run.closed = true;
|
|
472
|
+
if (log) run.log = log;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async fetchFinalizableTickets(_conductorId) {
|
|
476
|
+
return Object.entries(this.tickets).filter(([, t]) => t.state === "done" && !t.closed).map(([ticketUuid, t]) => ({
|
|
477
|
+
ticketUuid,
|
|
478
|
+
branchName: t.branchName ?? `gaia/${t.identifier.toLowerCase()}`,
|
|
479
|
+
worktreePath: this.latestRunWorktree(ticketUuid)
|
|
480
|
+
}));
|
|
481
|
+
}
|
|
482
|
+
async closeTicket(uuid) {
|
|
483
|
+
this.calls.closeTicket.push(uuid);
|
|
484
|
+
const t = this.tickets[uuid];
|
|
485
|
+
if (t) {
|
|
486
|
+
t.closed = true;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/** Worktree path of the ticket's most recently seeded run, or '' when none. */
|
|
490
|
+
latestRunWorktree(ticketUuid) {
|
|
491
|
+
let worktree = "";
|
|
492
|
+
for (const r of this.runs.values()) {
|
|
493
|
+
if (r.ticketUuid === ticketUuid && r.worktreePath) {
|
|
494
|
+
worktree = r.worktreePath;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return worktree;
|
|
498
|
+
}
|
|
499
|
+
internalActiveRuns() {
|
|
500
|
+
return [...this.runs.values()].filter((r) => ACTIVE2.includes(r.state));
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
function fakeRemote(seed = {}) {
|
|
504
|
+
const remote = new FakeGaiaRemote(seed);
|
|
505
|
+
return {
|
|
506
|
+
kind: "remote",
|
|
507
|
+
id: "fake",
|
|
508
|
+
requiredModules: [],
|
|
509
|
+
async createRemote(_config) {
|
|
510
|
+
return remote;
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/plugins/workspace/fake.ts
|
|
516
|
+
var FakeWorkspace = class {
|
|
517
|
+
async ensure(identifier, _title) {
|
|
518
|
+
return { path: `/fake/${identifier}`, instructions: null };
|
|
519
|
+
}
|
|
520
|
+
async beforeRun(_path) {
|
|
521
|
+
}
|
|
522
|
+
async afterRun(_path) {
|
|
523
|
+
}
|
|
524
|
+
async afterDone(_path) {
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
function fakeWorkspace() {
|
|
528
|
+
const workspace = new FakeWorkspace();
|
|
529
|
+
return {
|
|
530
|
+
kind: "workspace",
|
|
531
|
+
id: "fake",
|
|
532
|
+
requiredModules: [],
|
|
533
|
+
async createWorkspace(_config) {
|
|
534
|
+
return workspace;
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/plugins/workspace/git.ts
|
|
540
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
541
|
+
import { dirname, join, resolve as resolve2 } from "node:path";
|
|
542
|
+
|
|
543
|
+
// src/core/exec.ts
|
|
544
|
+
import { execFile } from "node:child_process";
|
|
545
|
+
import { promisify } from "node:util";
|
|
546
|
+
var execFileAsync = promisify(execFile);
|
|
547
|
+
var ExecError = class extends Error {
|
|
548
|
+
constructor(file, args, exitCode, stderr, options) {
|
|
549
|
+
super(`exec failed: ${file}`, options);
|
|
550
|
+
this.file = file;
|
|
551
|
+
this.args = args;
|
|
552
|
+
this.exitCode = exitCode;
|
|
553
|
+
this.stderr = stderr;
|
|
554
|
+
this.name = "ExecError";
|
|
555
|
+
}
|
|
556
|
+
file;
|
|
557
|
+
args;
|
|
558
|
+
exitCode;
|
|
559
|
+
stderr;
|
|
560
|
+
};
|
|
561
|
+
var CommandRunner = class {
|
|
562
|
+
constructor(logger) {
|
|
563
|
+
this.logger = logger;
|
|
564
|
+
}
|
|
565
|
+
logger;
|
|
566
|
+
async run(file, args, opts = {}) {
|
|
567
|
+
try {
|
|
568
|
+
const { stdout } = await execFileAsync(file, args, {
|
|
569
|
+
encoding: "utf8",
|
|
570
|
+
...opts
|
|
571
|
+
});
|
|
572
|
+
this.logger.debug({ file, args, cwd: opts.cwd }, "exec ok");
|
|
573
|
+
return stdout;
|
|
574
|
+
} catch (err) {
|
|
575
|
+
const e = err;
|
|
576
|
+
const exitCode = typeof e.code === "number" ? e.code : null;
|
|
577
|
+
const stderr = e.stderr ?? "";
|
|
578
|
+
this.logger.error(
|
|
579
|
+
{ file, args, cwd: opts.cwd, exitCode, stderr },
|
|
580
|
+
"exec failed"
|
|
581
|
+
);
|
|
582
|
+
throw new ExecError(file, args, exitCode, stderr, { cause: err });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
var noopLogger = {
|
|
587
|
+
debug() {
|
|
588
|
+
},
|
|
589
|
+
info() {
|
|
590
|
+
},
|
|
591
|
+
warn() {
|
|
592
|
+
},
|
|
593
|
+
error() {
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
var defaultRunner = new CommandRunner(noopLogger);
|
|
597
|
+
function exec(file, args, opts = {}) {
|
|
598
|
+
return defaultRunner.run(file, args, opts);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// src/core/slug.ts
|
|
602
|
+
var DEFAULT_SLUG_MAX_LENGTH = 40;
|
|
603
|
+
function slugify(text, maxLength = DEFAULT_SLUG_MAX_LENGTH) {
|
|
604
|
+
const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
605
|
+
if (slug.length <= maxLength) {
|
|
606
|
+
return slug;
|
|
607
|
+
}
|
|
608
|
+
return slug.slice(0, maxLength).replace(/-+$/g, "");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/plugins/workspace/instructions.ts
|
|
612
|
+
import { createHash } from "node:crypto";
|
|
613
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
614
|
+
import { resolve } from "node:path";
|
|
615
|
+
function loadInstructions(workspacePath) {
|
|
616
|
+
const path = resolve(workspacePath, "WORKFLOW.md");
|
|
617
|
+
if (!existsSync(path)) {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
const text = readFileSync(path, "utf8");
|
|
621
|
+
return {
|
|
622
|
+
path,
|
|
623
|
+
sha256: createHash("sha256").update(text).digest("hex"),
|
|
624
|
+
text
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// src/plugins/workspace/git.ts
|
|
629
|
+
var DEFAULT_BRANCH_TEMPLATE = "{branchPrefix}{key}-{titleSlug}";
|
|
630
|
+
function renderBranchTemplate(template, vars) {
|
|
631
|
+
const rendered = template.replace(/\{(\w+)\}/g, (whole, key) => {
|
|
632
|
+
const value = vars[key];
|
|
633
|
+
return value === void 0 ? whole : value;
|
|
634
|
+
});
|
|
635
|
+
return rendered.replace(/[-_./]+$/g, "");
|
|
636
|
+
}
|
|
637
|
+
var defaultHookRunner = async (command, cwd) => {
|
|
638
|
+
await exec("sh", ["-c", command], { cwd });
|
|
639
|
+
};
|
|
640
|
+
var defaultGitRunner = async (args, cwd) => {
|
|
641
|
+
await exec("git", args, { cwd });
|
|
642
|
+
};
|
|
643
|
+
var GitWorkspace = class {
|
|
644
|
+
constructor(options) {
|
|
645
|
+
this.options = options;
|
|
646
|
+
this.runHook = options.runHook ?? defaultHookRunner;
|
|
647
|
+
this.runGit = options.runGit ?? defaultGitRunner;
|
|
648
|
+
this.root = resolve2(options.root);
|
|
649
|
+
this.worktreesRoot = options.worktreesRoot ? resolve2(options.worktreesRoot) : `${this.root}-worktrees`;
|
|
650
|
+
this.branchPrefix = options.branchPrefix ?? "gaia/";
|
|
651
|
+
this.branchTemplate = options.branchTemplate ?? DEFAULT_BRANCH_TEMPLATE;
|
|
652
|
+
}
|
|
653
|
+
options;
|
|
654
|
+
runHook;
|
|
655
|
+
runGit;
|
|
656
|
+
root;
|
|
657
|
+
worktreesRoot;
|
|
658
|
+
branchPrefix;
|
|
659
|
+
branchTemplate;
|
|
660
|
+
/** Render the branch name for a ticket from the configured template. */
|
|
661
|
+
branchFor(identifier, title) {
|
|
662
|
+
return renderBranchTemplate(this.branchTemplate, {
|
|
663
|
+
branchPrefix: this.branchPrefix,
|
|
664
|
+
key: this.keyFor(identifier),
|
|
665
|
+
identifier,
|
|
666
|
+
titleSlug: slugify(title ?? "")
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
keyFor(identifier) {
|
|
670
|
+
return identifier.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
671
|
+
}
|
|
672
|
+
pathFor(key) {
|
|
673
|
+
return resolve2(join(this.worktreesRoot, key));
|
|
674
|
+
}
|
|
675
|
+
async ensure(identifier, title, _baseRef) {
|
|
676
|
+
const key = this.keyFor(identifier);
|
|
677
|
+
if (key === "" || key.startsWith(".")) {
|
|
678
|
+
throw new Error(`invalid workspace identifier: ${identifier}`);
|
|
679
|
+
}
|
|
680
|
+
const path = this.pathFor(key);
|
|
681
|
+
if (existsSync2(path)) {
|
|
682
|
+
return { path, instructions: loadInstructions(path) };
|
|
683
|
+
}
|
|
684
|
+
const branch = this.branchFor(identifier, title);
|
|
685
|
+
await this.runGit(
|
|
686
|
+
["-C", this.root, "worktree", "add", "--force", "-B", branch, path],
|
|
687
|
+
this.root
|
|
688
|
+
);
|
|
689
|
+
if (this.options.hooks?.after_create) {
|
|
690
|
+
await this.runHook(this.options.hooks.after_create, path);
|
|
691
|
+
}
|
|
692
|
+
return { path, instructions: loadInstructions(path) };
|
|
693
|
+
}
|
|
694
|
+
async remove(identifier) {
|
|
695
|
+
const key = this.keyFor(identifier);
|
|
696
|
+
const path = this.pathFor(key);
|
|
697
|
+
await this.runGit(
|
|
698
|
+
["-C", this.root, "worktree", "remove", "--force", path],
|
|
699
|
+
this.root
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
async beforeRun(path) {
|
|
703
|
+
if (this.options.hooks?.before_run) {
|
|
704
|
+
await this.runHook(this.options.hooks.before_run, path);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
async afterRun(path) {
|
|
708
|
+
if (this.options.hooks?.after_run) {
|
|
709
|
+
await this.runHook(this.options.hooks.after_run, path);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
async afterDone(path) {
|
|
713
|
+
if (this.options.hooks?.after_done) {
|
|
714
|
+
await this.runHook(this.options.hooks.after_done, path);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
function gitWorkspace(opts = {}) {
|
|
719
|
+
return {
|
|
720
|
+
kind: "workspace",
|
|
721
|
+
id: "git",
|
|
722
|
+
requiredModules: [],
|
|
723
|
+
async createWorkspace(config) {
|
|
724
|
+
const root = config.config_path ? dirname(config.config_path) : process.cwd();
|
|
725
|
+
return new GitWorkspace({
|
|
726
|
+
root,
|
|
727
|
+
...opts.hooks ? { hooks: opts.hooks } : {},
|
|
728
|
+
...opts.branchPrefix ? { branchPrefix: opts.branchPrefix } : {},
|
|
729
|
+
...opts.branchTemplate ? { branchTemplate: opts.branchTemplate } : {}
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// src/plugins/workspace/herdr.ts
|
|
736
|
+
import { dirname as dirname2, join as join2, resolve as resolve3 } from "node:path";
|
|
737
|
+
var defaultExec = (args) => exec("herdr", args);
|
|
738
|
+
var defaultGitExec = (args) => exec("git", args);
|
|
739
|
+
var defaultHookRunner2 = async (command, cwd) => {
|
|
740
|
+
await exec("sh", ["-c", command], { cwd });
|
|
741
|
+
};
|
|
742
|
+
function isRecord(value) {
|
|
743
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
744
|
+
}
|
|
745
|
+
function parseJson(output, command) {
|
|
746
|
+
try {
|
|
747
|
+
return JSON.parse(output);
|
|
748
|
+
} catch {
|
|
749
|
+
throw new Error(`herdr ${command} returned invalid JSON`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
function parseWorktreeList(output) {
|
|
753
|
+
const parsed = parseJson(output, "worktree list");
|
|
754
|
+
const worktrees = isRecord(parsed) ? parsed.result?.worktrees : void 0;
|
|
755
|
+
if (!Array.isArray(worktrees)) {
|
|
756
|
+
throw new Error("herdr worktree list returned invalid schema");
|
|
757
|
+
}
|
|
758
|
+
return worktrees.filter(isRecord).filter((w) => typeof w.branch === "string" && typeof w.path === "string").map((w) => ({
|
|
759
|
+
branch: w.branch,
|
|
760
|
+
path: w.path,
|
|
761
|
+
...typeof w.open_workspace_id === "string" ? { open_workspace_id: w.open_workspace_id } : {}
|
|
762
|
+
}));
|
|
763
|
+
}
|
|
764
|
+
function parseWorktreePath(output, command) {
|
|
765
|
+
const parsed = parseJson(output, command);
|
|
766
|
+
const path = isRecord(parsed) ? parsed.result?.worktree?.path : void 0;
|
|
767
|
+
if (typeof path !== "string") {
|
|
768
|
+
throw new Error(`herdr ${command} returned invalid schema`);
|
|
769
|
+
}
|
|
770
|
+
return path;
|
|
771
|
+
}
|
|
772
|
+
function validateBranch(branch) {
|
|
773
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(branch) || branch.startsWith(".") || branch.includes("..")) {
|
|
774
|
+
throw new Error(`invalid workspace branch: ${branch}`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
var HerdrWorkspace = class {
|
|
778
|
+
constructor(options) {
|
|
779
|
+
this.options = options;
|
|
780
|
+
this.execHerdr = options.execHerdr ?? defaultExec;
|
|
781
|
+
this.execGit = options.execGit ?? defaultGitExec;
|
|
782
|
+
this.runHook = options.runHook ?? defaultHookRunner2;
|
|
783
|
+
this.root = resolve3(options.root);
|
|
784
|
+
this.worktreeDir = options.worktreeDir ?? ".gaia-worktrees";
|
|
785
|
+
}
|
|
786
|
+
options;
|
|
787
|
+
execHerdr;
|
|
788
|
+
execGit;
|
|
789
|
+
runHook;
|
|
790
|
+
root;
|
|
791
|
+
worktreeDir;
|
|
792
|
+
/**
|
|
793
|
+
* Resolve the base ref for a new worktree branch. Prefers the configured
|
|
794
|
+
* `baseBranch`; otherwise the remote tracking branch of the repo's HEAD
|
|
795
|
+
* (e.g. `origin/develop`). Best-effort: a repo with no upstream returns
|
|
796
|
+
* `undefined`, leaving herdr to fall back to the parent workspace HEAD.
|
|
797
|
+
*/
|
|
798
|
+
async resolveBaseRef() {
|
|
799
|
+
if (this.options.baseBranch) {
|
|
800
|
+
return this.options.baseBranch;
|
|
801
|
+
}
|
|
802
|
+
try {
|
|
803
|
+
const ref = (await this.execGit([
|
|
804
|
+
"-C",
|
|
805
|
+
this.root,
|
|
806
|
+
"rev-parse",
|
|
807
|
+
"--abbrev-ref",
|
|
808
|
+
"--symbolic-full-name",
|
|
809
|
+
"@{u}"
|
|
810
|
+
])).trim();
|
|
811
|
+
return ref || void 0;
|
|
812
|
+
} catch {
|
|
813
|
+
return void 0;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Fetch the base ref's remote branch so the new worktree starts from the
|
|
818
|
+
* latest pushed state. Best-effort: offline / unknown remote is non-fatal.
|
|
819
|
+
*/
|
|
820
|
+
async fetchBaseRef(baseRef) {
|
|
821
|
+
const slash = baseRef.indexOf("/");
|
|
822
|
+
if (slash <= 0) {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const remote = baseRef.slice(0, slash);
|
|
826
|
+
const branch = baseRef.slice(slash + 1);
|
|
827
|
+
try {
|
|
828
|
+
await this.execGit(["-C", this.root, "fetch", remote, branch]);
|
|
829
|
+
} catch {
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Verify a base-ref candidate resolves on the remote (after fetching it).
|
|
834
|
+
* Returns the ref when it exists, else undefined so the caller falls back.
|
|
835
|
+
*/
|
|
836
|
+
async verifyRemoteRef(ref) {
|
|
837
|
+
await this.fetchBaseRef(ref);
|
|
838
|
+
try {
|
|
839
|
+
await this.execGit([
|
|
840
|
+
"-C",
|
|
841
|
+
this.root,
|
|
842
|
+
"rev-parse",
|
|
843
|
+
"--verify",
|
|
844
|
+
"--quiet",
|
|
845
|
+
`${ref}^{commit}`
|
|
846
|
+
]);
|
|
847
|
+
return ref;
|
|
848
|
+
} catch {
|
|
849
|
+
return void 0;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
async ensure(_identifier, branch, baseRefOverride) {
|
|
853
|
+
if (branch === void 0) {
|
|
854
|
+
throw new Error("herdr workspace requires a branch name");
|
|
855
|
+
}
|
|
856
|
+
validateBranch(branch);
|
|
857
|
+
const worktrees = parseWorktreeList(
|
|
858
|
+
await this.execHerdr(["worktree", "list", "--cwd", this.root, "--json"])
|
|
859
|
+
);
|
|
860
|
+
const existing = worktrees.find((w) => w.branch === branch);
|
|
861
|
+
if (existing) {
|
|
862
|
+
let path2 = existing.path;
|
|
863
|
+
if (!existing.open_workspace_id) {
|
|
864
|
+
path2 = parseWorktreePath(
|
|
865
|
+
await this.execHerdr([
|
|
866
|
+
"worktree",
|
|
867
|
+
"open",
|
|
868
|
+
"--cwd",
|
|
869
|
+
this.root,
|
|
870
|
+
"--branch",
|
|
871
|
+
branch,
|
|
872
|
+
"--no-focus",
|
|
873
|
+
"--json"
|
|
874
|
+
]),
|
|
875
|
+
"worktree open"
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
return { path: path2, instructions: loadInstructions(path2) };
|
|
879
|
+
}
|
|
880
|
+
let baseRef;
|
|
881
|
+
if (baseRefOverride !== void 0) {
|
|
882
|
+
baseRef = await this.verifyRemoteRef(baseRefOverride);
|
|
883
|
+
}
|
|
884
|
+
if (baseRef === void 0) {
|
|
885
|
+
baseRef = await this.resolveBaseRef();
|
|
886
|
+
if (baseRef !== void 0) {
|
|
887
|
+
await this.fetchBaseRef(baseRef);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
const path = parseWorktreePath(
|
|
891
|
+
await this.execHerdr([
|
|
892
|
+
"worktree",
|
|
893
|
+
"create",
|
|
894
|
+
"--cwd",
|
|
895
|
+
this.root,
|
|
896
|
+
"--branch",
|
|
897
|
+
branch,
|
|
898
|
+
...baseRef !== void 0 ? ["--base", baseRef] : [],
|
|
899
|
+
"--label",
|
|
900
|
+
branch,
|
|
901
|
+
"--path",
|
|
902
|
+
join2(this.worktreeDir, branch),
|
|
903
|
+
"--no-focus",
|
|
904
|
+
"--json"
|
|
905
|
+
]),
|
|
906
|
+
"worktree create"
|
|
907
|
+
);
|
|
908
|
+
if (this.options.hooks?.after_create) {
|
|
909
|
+
await this.runHook(this.options.hooks.after_create, path);
|
|
910
|
+
}
|
|
911
|
+
return { path, instructions: loadInstructions(path) };
|
|
912
|
+
}
|
|
913
|
+
async beforeRun(path) {
|
|
914
|
+
if (this.options.hooks?.before_run) {
|
|
915
|
+
await this.runHook(this.options.hooks.before_run, path);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
async afterRun(path) {
|
|
919
|
+
if (this.options.hooks?.after_run) {
|
|
920
|
+
await this.runHook(this.options.hooks.after_run, path);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
async afterDone(path) {
|
|
924
|
+
if (this.options.hooks?.after_done) {
|
|
925
|
+
await this.runHook(this.options.hooks.after_done, path);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
function herdrWorkspace(opts = {}) {
|
|
930
|
+
return {
|
|
931
|
+
kind: "workspace",
|
|
932
|
+
id: "herdr",
|
|
933
|
+
requiredModules: [],
|
|
934
|
+
async createWorkspace(config) {
|
|
935
|
+
const root = config.config_path ? dirname2(config.config_path) : process.cwd();
|
|
936
|
+
return new HerdrWorkspace({
|
|
937
|
+
root,
|
|
938
|
+
...opts.hooks ? { hooks: opts.hooks } : {},
|
|
939
|
+
...opts.worktreeDir ? { worktreeDir: opts.worktreeDir } : {},
|
|
940
|
+
...opts.baseBranch ? { baseBranch: opts.baseBranch } : {}
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// plugins/claude/src/index.ts
|
|
947
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
948
|
+
import { homedir } from "node:os";
|
|
949
|
+
import { join as join3 } from "node:path";
|
|
950
|
+
function shellSingleQuote(value) {
|
|
951
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
952
|
+
}
|
|
953
|
+
function encodeClaudeProjectDir(cwd) {
|
|
954
|
+
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
955
|
+
}
|
|
956
|
+
var ClaudeAgent = class {
|
|
957
|
+
constructor(options, home = homedir()) {
|
|
958
|
+
this.options = options;
|
|
959
|
+
this.home = home;
|
|
960
|
+
}
|
|
961
|
+
options;
|
|
962
|
+
home;
|
|
963
|
+
id = "claude";
|
|
964
|
+
launchCommand(prompt) {
|
|
965
|
+
if (prompt === "") {
|
|
966
|
+
return "claude";
|
|
967
|
+
}
|
|
968
|
+
const parts = ["claude", shellSingleQuote(prompt)];
|
|
969
|
+
if (this.options.model) {
|
|
970
|
+
parts.push("--model", this.options.model);
|
|
971
|
+
}
|
|
972
|
+
parts.push("--dangerously-skip-permissions");
|
|
973
|
+
return parts.join(" ");
|
|
974
|
+
}
|
|
975
|
+
async getRunLog(worktreePath) {
|
|
976
|
+
const dir = join3(
|
|
977
|
+
this.home,
|
|
978
|
+
".claude",
|
|
979
|
+
"projects",
|
|
980
|
+
encodeClaudeProjectDir(worktreePath)
|
|
981
|
+
);
|
|
982
|
+
let entries;
|
|
983
|
+
try {
|
|
984
|
+
entries = await readdir(dir);
|
|
985
|
+
} catch {
|
|
986
|
+
return "";
|
|
987
|
+
}
|
|
988
|
+
let latest = null;
|
|
989
|
+
for (const name of entries) {
|
|
990
|
+
if (!name.endsWith(".jsonl")) {
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
const full = join3(dir, name);
|
|
994
|
+
try {
|
|
995
|
+
const s = await stat(full);
|
|
996
|
+
if (!latest || s.mtimeMs > latest.mtimeMs) {
|
|
997
|
+
latest = { path: full, mtimeMs: s.mtimeMs };
|
|
998
|
+
}
|
|
999
|
+
} catch {
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if (!latest) {
|
|
1003
|
+
return "";
|
|
1004
|
+
}
|
|
1005
|
+
try {
|
|
1006
|
+
return await readFile(latest.path, "utf8");
|
|
1007
|
+
} catch {
|
|
1008
|
+
return "";
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
function claudeAgent(options = {}) {
|
|
1013
|
+
const agent = new ClaudeAgent(options);
|
|
1014
|
+
return {
|
|
1015
|
+
kind: "agent",
|
|
1016
|
+
id: "claude",
|
|
1017
|
+
requiredModules: [],
|
|
1018
|
+
async createAgent() {
|
|
1019
|
+
return agent;
|
|
1020
|
+
}
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// plugins/codex/src/index.ts
|
|
1025
|
+
function shellSingleQuote2(value) {
|
|
1026
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
1027
|
+
}
|
|
1028
|
+
var CodexAgent = class {
|
|
1029
|
+
constructor(options) {
|
|
1030
|
+
this.options = options;
|
|
1031
|
+
}
|
|
1032
|
+
options;
|
|
1033
|
+
id = "codex";
|
|
1034
|
+
launchCommand(prompt) {
|
|
1035
|
+
const parts = [
|
|
1036
|
+
"codex",
|
|
1037
|
+
"--sandbox danger-full-access",
|
|
1038
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
1039
|
+
"--dangerously-bypass-hook-trust"
|
|
1040
|
+
];
|
|
1041
|
+
if (this.options.model) {
|
|
1042
|
+
parts.push("--model", this.options.model);
|
|
1043
|
+
}
|
|
1044
|
+
if (prompt !== "") {
|
|
1045
|
+
parts.push(shellSingleQuote2(prompt));
|
|
1046
|
+
}
|
|
1047
|
+
return parts.join(" ");
|
|
1048
|
+
}
|
|
1049
|
+
async getRunLog() {
|
|
1050
|
+
return "";
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
function codexAgent(options = {}) {
|
|
1054
|
+
const agent = new CodexAgent(options);
|
|
1055
|
+
return {
|
|
1056
|
+
kind: "agent",
|
|
1057
|
+
id: "codex",
|
|
1058
|
+
requiredModules: [],
|
|
1059
|
+
async createAgent() {
|
|
1060
|
+
return agent;
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// plugins/fake/src/index.ts
|
|
1066
|
+
import { writeFileSync } from "node:fs";
|
|
1067
|
+
import { isAbsolute, join as join4, relative, resolve as resolve4, sep } from "node:path";
|
|
1068
|
+
var FakeExecutor = class {
|
|
1069
|
+
constructor(options) {
|
|
1070
|
+
this.options = options;
|
|
1071
|
+
}
|
|
1072
|
+
options;
|
|
1073
|
+
id = "fake";
|
|
1074
|
+
capabilities() {
|
|
1075
|
+
return { persistent: false };
|
|
1076
|
+
}
|
|
1077
|
+
async startRun(input) {
|
|
1078
|
+
const branch = input.ticket.branchName;
|
|
1079
|
+
if (this.options.markerFile) {
|
|
1080
|
+
writeFileSync(
|
|
1081
|
+
this.markerPath(input.workspacePath, this.options.markerFile),
|
|
1082
|
+
JSON.stringify({
|
|
1083
|
+
run_uuid: input.run.uuid,
|
|
1084
|
+
ticket_uuid: input.ticket.uuid,
|
|
1085
|
+
handler: input.run.handler,
|
|
1086
|
+
instructions_sha256: input.instructions?.sha256 ?? null
|
|
1087
|
+
})
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
return { sessionRef: branch };
|
|
1091
|
+
}
|
|
1092
|
+
markerPath(workspacePath, markerFile) {
|
|
1093
|
+
const workspace = resolve4(workspacePath);
|
|
1094
|
+
const marker = resolve4(join4(workspace, markerFile));
|
|
1095
|
+
const markerRelativePath = relative(workspace, marker);
|
|
1096
|
+
if (isAbsolute(markerFile) || markerRelativePath === ".." || markerRelativePath.startsWith(`..${sep}`)) {
|
|
1097
|
+
throw new Error(
|
|
1098
|
+
`markerFile must stay inside the workspace: ${markerFile}`
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
return marker;
|
|
1102
|
+
}
|
|
1103
|
+
async retire(_branch) {
|
|
1104
|
+
}
|
|
1105
|
+
async stopAgent(_branch) {
|
|
1106
|
+
}
|
|
1107
|
+
async removeWorktree(_branch, _worktreePath) {
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
function fakeExecutor(options = {}) {
|
|
1111
|
+
const executor = new FakeExecutor({
|
|
1112
|
+
outcome: options.outcome ?? "exited",
|
|
1113
|
+
...options.markerFile !== void 0 ? { markerFile: options.markerFile } : {}
|
|
1114
|
+
});
|
|
1115
|
+
return {
|
|
1116
|
+
kind: "executor",
|
|
1117
|
+
id: "fake",
|
|
1118
|
+
requiredModules: [],
|
|
1119
|
+
async createExecutor() {
|
|
1120
|
+
return executor;
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// plugins/herdr/src/index.ts
|
|
1126
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
1127
|
+
import { dirname as dirname3 } from "node:path";
|
|
1128
|
+
|
|
1129
|
+
// plugins/herdr/src/config.ts
|
|
1130
|
+
var BUILTIN_DEFAULTS = {
|
|
1131
|
+
tabLabel: "{identifier} \xB7 {titleSlug}",
|
|
1132
|
+
workspaceLabel: "{repoName}",
|
|
1133
|
+
panes: []
|
|
1134
|
+
};
|
|
1135
|
+
function resolveStateConfig(options, state) {
|
|
1136
|
+
const base = options.default ?? {};
|
|
1137
|
+
const stateCfg = options.states?.[state] ?? {};
|
|
1138
|
+
return {
|
|
1139
|
+
tabLabel: stateCfg.tabLabel ?? base.tabLabel ?? BUILTIN_DEFAULTS.tabLabel,
|
|
1140
|
+
workspaceLabel: stateCfg.workspaceLabel ?? base.workspaceLabel ?? BUILTIN_DEFAULTS.workspaceLabel,
|
|
1141
|
+
panes: stateCfg.panes ?? base.panes ?? BUILTIN_DEFAULTS.panes
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
function renderTemplate(tpl, vars) {
|
|
1145
|
+
return tpl.replace(/\{(\w+)\}/g, (whole, key) => {
|
|
1146
|
+
const value = vars[key];
|
|
1147
|
+
return value === void 0 ? whole : value;
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// plugins/herdr/src/fs.ts
|
|
1152
|
+
import { chmodSync, lstatSync, readdirSync, rmSync } from "node:fs";
|
|
1153
|
+
import { join as join5 } from "node:path";
|
|
1154
|
+
function restoreWritable(path) {
|
|
1155
|
+
let stat2;
|
|
1156
|
+
try {
|
|
1157
|
+
stat2 = lstatSync(path);
|
|
1158
|
+
} catch {
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
if (stat2.isSymbolicLink()) {
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
try {
|
|
1165
|
+
chmodSync(path, stat2.mode | 128);
|
|
1166
|
+
} catch {
|
|
1167
|
+
}
|
|
1168
|
+
if (stat2.isDirectory()) {
|
|
1169
|
+
let entries;
|
|
1170
|
+
try {
|
|
1171
|
+
entries = readdirSync(path);
|
|
1172
|
+
} catch {
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
for (const entry of entries) {
|
|
1176
|
+
restoreWritable(join5(path, entry));
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
function forceRemoveDir(path) {
|
|
1181
|
+
restoreWritable(path);
|
|
1182
|
+
rmSync(path, { recursive: true, force: true });
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// plugins/herdr/src/index.ts
|
|
1186
|
+
async function defaultExec2(args) {
|
|
1187
|
+
return exec("herdr", args);
|
|
1188
|
+
}
|
|
1189
|
+
function isRecord2(value) {
|
|
1190
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1191
|
+
}
|
|
1192
|
+
function parseJson2(output, command) {
|
|
1193
|
+
try {
|
|
1194
|
+
return JSON.parse(output);
|
|
1195
|
+
} catch {
|
|
1196
|
+
throw new Error(`herdr ${command} returned invalid JSON`);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
function parseWorktreeList2(output) {
|
|
1200
|
+
const parsed = parseJson2(output, "worktree list");
|
|
1201
|
+
const worktrees = isRecord2(parsed) ? parsed.result?.worktrees : void 0;
|
|
1202
|
+
if (!Array.isArray(worktrees)) {
|
|
1203
|
+
return [];
|
|
1204
|
+
}
|
|
1205
|
+
return worktrees.filter(isRecord2).filter((w) => typeof w.branch === "string" && typeof w.path === "string").map((w) => ({
|
|
1206
|
+
branch: w.branch,
|
|
1207
|
+
path: w.path,
|
|
1208
|
+
...typeof w.open_workspace_id === "string" && w.open_workspace_id ? { open_workspace_id: w.open_workspace_id } : {}
|
|
1209
|
+
}));
|
|
1210
|
+
}
|
|
1211
|
+
function parseTabList(output) {
|
|
1212
|
+
const parsed = parseJson2(output, "tab list");
|
|
1213
|
+
const tabs = isRecord2(parsed) ? parsed.result?.tabs : void 0;
|
|
1214
|
+
if (!Array.isArray(tabs)) {
|
|
1215
|
+
return [];
|
|
1216
|
+
}
|
|
1217
|
+
return tabs.filter(isRecord2).filter((t) => typeof t.tab_id === "string" && typeof t.label === "string").map((t) => ({
|
|
1218
|
+
tabId: t.tab_id,
|
|
1219
|
+
label: t.label
|
|
1220
|
+
}));
|
|
1221
|
+
}
|
|
1222
|
+
function parsePaneList(output) {
|
|
1223
|
+
const parsed = parseJson2(output, "pane list");
|
|
1224
|
+
const panes = isRecord2(parsed) ? parsed.result?.panes : void 0;
|
|
1225
|
+
if (!Array.isArray(panes)) {
|
|
1226
|
+
return [];
|
|
1227
|
+
}
|
|
1228
|
+
return panes.filter(isRecord2).filter(
|
|
1229
|
+
(p) => typeof p.pane_id === "string" && typeof p.tab_id === "string"
|
|
1230
|
+
).map((p) => ({
|
|
1231
|
+
paneId: p.pane_id,
|
|
1232
|
+
tabId: p.tab_id
|
|
1233
|
+
}));
|
|
1234
|
+
}
|
|
1235
|
+
function parseTabCreate(output) {
|
|
1236
|
+
const parsed = parseJson2(output, "tab create");
|
|
1237
|
+
const result = isRecord2(parsed) ? parsed.result : void 0;
|
|
1238
|
+
const paneId = result?.root_pane?.pane_id;
|
|
1239
|
+
const tabId = result?.tab?.tab_id;
|
|
1240
|
+
if (typeof paneId !== "string" || typeof tabId !== "string") {
|
|
1241
|
+
throw new Error("herdr tab create returned invalid schema");
|
|
1242
|
+
}
|
|
1243
|
+
return { paneId, tabId };
|
|
1244
|
+
}
|
|
1245
|
+
function parsePaneSplit(output) {
|
|
1246
|
+
const parsed = parseJson2(output, "pane split");
|
|
1247
|
+
const paneId = isRecord2(parsed) ? parsed.result?.pane?.pane_id : void 0;
|
|
1248
|
+
if (typeof paneId !== "string") {
|
|
1249
|
+
throw new Error("herdr pane split returned invalid schema");
|
|
1250
|
+
}
|
|
1251
|
+
return paneId;
|
|
1252
|
+
}
|
|
1253
|
+
function shellSingleQuote3(value) {
|
|
1254
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
1255
|
+
}
|
|
1256
|
+
function normPath(path) {
|
|
1257
|
+
return path.replace(/\/+$/, "");
|
|
1258
|
+
}
|
|
1259
|
+
function resolveWorktreeEntry(entries, branch, worktreePath) {
|
|
1260
|
+
if (worktreePath) {
|
|
1261
|
+
const want = normPath(worktreePath);
|
|
1262
|
+
const byPath = entries.find((e) => normPath(e.path) === want);
|
|
1263
|
+
if (byPath) {
|
|
1264
|
+
return byPath;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
return entries.find((e) => e.branch === branch) ?? null;
|
|
1268
|
+
}
|
|
1269
|
+
var HerdrExecutor = class {
|
|
1270
|
+
constructor(options, execHerdr = defaultExec2) {
|
|
1271
|
+
this.options = options;
|
|
1272
|
+
this.execHerdr = execHerdr;
|
|
1273
|
+
}
|
|
1274
|
+
options;
|
|
1275
|
+
execHerdr;
|
|
1276
|
+
id = "herdr";
|
|
1277
|
+
capabilities() {
|
|
1278
|
+
return { persistent: true };
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Find the worktree entry (path + open_workspace_id) for a branch via
|
|
1282
|
+
* worktree list. Returns null if the branch has no worktree.
|
|
1283
|
+
*/
|
|
1284
|
+
async findWorktreeByBranch(branch) {
|
|
1285
|
+
const output = await this.execHerdr(["worktree", "list", "--json"]);
|
|
1286
|
+
const entries = parseWorktreeList2(output);
|
|
1287
|
+
return entries.find((e) => e.branch === branch) ?? null;
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Find the open_workspace_id for a branch via worktree list.
|
|
1291
|
+
* Returns null if no workspace is open for that branch.
|
|
1292
|
+
*/
|
|
1293
|
+
async findWorkspaceByBranch(branch) {
|
|
1294
|
+
const entry = await this.findWorktreeByBranch(branch);
|
|
1295
|
+
return entry?.open_workspace_id ?? null;
|
|
1296
|
+
}
|
|
1297
|
+
/**
|
|
1298
|
+
* Tear down a ticket's entire worktree (git worktree + hosted workspace).
|
|
1299
|
+
* `herdr worktree remove` is keyed by an open workspace id, so resolve the
|
|
1300
|
+
* worktree first — by its stable `worktreePath`, falling back to `branch`
|
|
1301
|
+
* (see {@link resolveWorktreeEntry}): if its workspace is already open use
|
|
1302
|
+
* that id; otherwise open the on-disk worktree to obtain one (mirrors
|
|
1303
|
+
* startRun's open-if-needed).
|
|
1304
|
+
*
|
|
1305
|
+
* `herdr worktree list` is machine-global — it enumerates every repo's
|
|
1306
|
+
* worktrees on the host, most of which are not ours. So a no-match does NOT
|
|
1307
|
+
* mean "error"; it means herdr no longer tracks this ticket's worktree.
|
|
1308
|
+
* Teardown is idempotent, keyed off the stable on-disk `worktreePath`:
|
|
1309
|
+
* - path gone from disk → already torn down; quiet success.
|
|
1310
|
+
* - path still on disk → herdr lost track but the dir survives (an
|
|
1311
|
+
* orphan we own); reclaim it directly with {@link forceRemoveDir} so it
|
|
1312
|
+
* can't pile up — the concern the old throw was meant to surface.
|
|
1313
|
+
* Either way we never touch, list-dump, or fail over foreign worktrees.
|
|
1314
|
+
*/
|
|
1315
|
+
async removeWorktree(branch, worktreePath) {
|
|
1316
|
+
const entries = parseWorktreeList2(
|
|
1317
|
+
await this.execHerdr(["worktree", "list", "--json"])
|
|
1318
|
+
);
|
|
1319
|
+
const entry = resolveWorktreeEntry(entries, branch, worktreePath);
|
|
1320
|
+
if (!entry) {
|
|
1321
|
+
if (worktreePath && existsSync3(worktreePath)) {
|
|
1322
|
+
forceRemoveDir(worktreePath);
|
|
1323
|
+
}
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
let workspaceId = entry.open_workspace_id ?? null;
|
|
1327
|
+
if (!workspaceId) {
|
|
1328
|
+
await this.execHerdr([
|
|
1329
|
+
"worktree",
|
|
1330
|
+
"open",
|
|
1331
|
+
"--cwd",
|
|
1332
|
+
this.options.root ?? entry.path,
|
|
1333
|
+
"--branch",
|
|
1334
|
+
entry.branch,
|
|
1335
|
+
"--no-focus",
|
|
1336
|
+
"--json"
|
|
1337
|
+
]);
|
|
1338
|
+
const reopened = resolveWorktreeEntry(
|
|
1339
|
+
parseWorktreeList2(await this.execHerdr(["worktree", "list", "--json"])),
|
|
1340
|
+
branch,
|
|
1341
|
+
worktreePath
|
|
1342
|
+
);
|
|
1343
|
+
workspaceId = reopened?.open_workspace_id ?? null;
|
|
1344
|
+
if (!workspaceId) {
|
|
1345
|
+
throw new Error(
|
|
1346
|
+
`herdr worktree open did not yield a workspace id for path ${entry.path} (branch ${entry.branch})`
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
restoreWritable(entry.path);
|
|
1351
|
+
await this.execHerdr([
|
|
1352
|
+
"worktree",
|
|
1353
|
+
"remove",
|
|
1354
|
+
"--workspace",
|
|
1355
|
+
workspaceId,
|
|
1356
|
+
"--force",
|
|
1357
|
+
"--json"
|
|
1358
|
+
]);
|
|
1359
|
+
}
|
|
1360
|
+
async startRun(input) {
|
|
1361
|
+
const branchName = input.ticket.branchName;
|
|
1362
|
+
const cfg = resolveStateConfig(
|
|
1363
|
+
this.options,
|
|
1364
|
+
input.ticket.state
|
|
1365
|
+
);
|
|
1366
|
+
let workspaceId = await this.findWorkspaceByBranch(branchName);
|
|
1367
|
+
if (!workspaceId) {
|
|
1368
|
+
await this.execHerdr([
|
|
1369
|
+
"worktree",
|
|
1370
|
+
"open",
|
|
1371
|
+
"--cwd",
|
|
1372
|
+
this.options.root ?? input.workspacePath,
|
|
1373
|
+
"--branch",
|
|
1374
|
+
branchName,
|
|
1375
|
+
"--no-focus",
|
|
1376
|
+
"--json"
|
|
1377
|
+
]);
|
|
1378
|
+
workspaceId = await this.findWorkspaceByBranch(branchName);
|
|
1379
|
+
if (!workspaceId) {
|
|
1380
|
+
throw new Error(
|
|
1381
|
+
`herdr worktree open did not produce an open_workspace_id for branch ${branchName}`
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
const vars = {
|
|
1386
|
+
identifier: input.ticket.identifier,
|
|
1387
|
+
title: input.ticket.title,
|
|
1388
|
+
titleSlug: slugify(input.ticket.title),
|
|
1389
|
+
runUuid: input.run.uuid,
|
|
1390
|
+
workspacePath: input.workspacePath
|
|
1391
|
+
};
|
|
1392
|
+
const { paneId: rootPaneId, tabId } = parseTabCreate(
|
|
1393
|
+
await this.execHerdr([
|
|
1394
|
+
"tab",
|
|
1395
|
+
"create",
|
|
1396
|
+
"--workspace",
|
|
1397
|
+
workspaceId,
|
|
1398
|
+
"--label",
|
|
1399
|
+
`${input.ticket.identifier} \xB7 ${input.ticket.state} #${input.run.id}`,
|
|
1400
|
+
"--no-focus"
|
|
1401
|
+
])
|
|
1402
|
+
);
|
|
1403
|
+
try {
|
|
1404
|
+
await this.execHerdr(["pane", "run", rootPaneId, this.commandFor(input)]);
|
|
1405
|
+
for (const spec of cfg.panes) {
|
|
1406
|
+
const splitPaneId = parsePaneSplit(
|
|
1407
|
+
await this.execHerdr([
|
|
1408
|
+
"pane",
|
|
1409
|
+
"split",
|
|
1410
|
+
rootPaneId,
|
|
1411
|
+
"--direction",
|
|
1412
|
+
spec.direction,
|
|
1413
|
+
"--cwd",
|
|
1414
|
+
input.workspacePath,
|
|
1415
|
+
spec.focus ? "--focus" : "--no-focus"
|
|
1416
|
+
])
|
|
1417
|
+
);
|
|
1418
|
+
await this.execHerdr([
|
|
1419
|
+
"pane",
|
|
1420
|
+
"run",
|
|
1421
|
+
splitPaneId,
|
|
1422
|
+
renderTemplate(spec.command, vars)
|
|
1423
|
+
]);
|
|
1424
|
+
}
|
|
1425
|
+
return { sessionRef: branchName };
|
|
1426
|
+
} catch (err) {
|
|
1427
|
+
await this.rollback(tabId);
|
|
1428
|
+
throw err;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
/** Best-effort cleanup: close only the just-created tab. Swallows errors. */
|
|
1432
|
+
async rollback(tabId) {
|
|
1433
|
+
try {
|
|
1434
|
+
await this.execHerdr(["tab", "close", tabId]);
|
|
1435
|
+
} catch {
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
async retire(branch) {
|
|
1439
|
+
const workspaceId = await this.findWorkspaceByBranch(branch);
|
|
1440
|
+
if (!workspaceId) {
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
const tabs = parseTabList(
|
|
1444
|
+
await this.execHerdr(["tab", "list", "--workspace", workspaceId])
|
|
1445
|
+
);
|
|
1446
|
+
const panes = parsePaneList(
|
|
1447
|
+
await this.execHerdr(["pane", "list", "--workspace", workspaceId])
|
|
1448
|
+
);
|
|
1449
|
+
for (const tab of tabs) {
|
|
1450
|
+
try {
|
|
1451
|
+
if (tab.label.endsWith(" (done)")) {
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
const pane = panes.find((p) => p.tabId === tab.tabId);
|
|
1455
|
+
if (pane) {
|
|
1456
|
+
await this.execHerdr(["pane", "send-keys", pane.paneId, "C-c"]);
|
|
1457
|
+
}
|
|
1458
|
+
await this.execHerdr([
|
|
1459
|
+
"tab",
|
|
1460
|
+
"rename",
|
|
1461
|
+
tab.tabId,
|
|
1462
|
+
`${tab.label} (done)`
|
|
1463
|
+
]);
|
|
1464
|
+
} catch {
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Ask the agent in each open tab of the branch workspace to exit gracefully:
|
|
1470
|
+
* type `/exit` then send Enter into its pane. Unlike {@link retire} (C-c
|
|
1471
|
+
* SIGINT), this is the clean shutdown claude expects. Best-effort: no open
|
|
1472
|
+
* workspace, a `(done)` tab, or a tab with no matching pane are skipped, and
|
|
1473
|
+
* one failing tab never aborts the others.
|
|
1474
|
+
*/
|
|
1475
|
+
async stopAgent(branch) {
|
|
1476
|
+
const workspaceId = await this.findWorkspaceByBranch(branch);
|
|
1477
|
+
if (!workspaceId) {
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
const tabs = parseTabList(
|
|
1481
|
+
await this.execHerdr(["tab", "list", "--workspace", workspaceId])
|
|
1482
|
+
);
|
|
1483
|
+
const panes = parsePaneList(
|
|
1484
|
+
await this.execHerdr(["pane", "list", "--workspace", workspaceId])
|
|
1485
|
+
);
|
|
1486
|
+
for (const tab of tabs) {
|
|
1487
|
+
if (tab.label.endsWith(" (done)")) {
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
const pane = panes.find((p) => p.tabId === tab.tabId);
|
|
1491
|
+
if (!pane) {
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
try {
|
|
1495
|
+
await this.execHerdr(["pane", "send-text", pane.paneId, "/exit"]);
|
|
1496
|
+
await this.execHerdr(["pane", "send-keys", pane.paneId, "Enter"]);
|
|
1497
|
+
} catch {
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
commandFor(input) {
|
|
1502
|
+
const env = Object.entries(input.env ?? {}).map(([key, value]) => {
|
|
1503
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
1504
|
+
throw new Error(`invalid environment variable name for herdr: ${key}`);
|
|
1505
|
+
}
|
|
1506
|
+
return `${key}=${shellSingleQuote3(value)}`;
|
|
1507
|
+
});
|
|
1508
|
+
const command = input.command ?? this.options.command ?? "claude";
|
|
1509
|
+
return [...env, command].join(" ");
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
function herdrExecutor(options = {}) {
|
|
1513
|
+
return {
|
|
1514
|
+
kind: "executor",
|
|
1515
|
+
id: "herdr",
|
|
1516
|
+
requiredModules: [],
|
|
1517
|
+
async createExecutor(config) {
|
|
1518
|
+
const root = options.root ?? dirname3(config.config_path);
|
|
1519
|
+
return new HerdrExecutor({
|
|
1520
|
+
command: options.command ?? "claude",
|
|
1521
|
+
root,
|
|
1522
|
+
...options.default ? { default: options.default } : {},
|
|
1523
|
+
...options.states ? { states: options.states } : {}
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// scripts/plugins-entry.ts
|
|
1530
|
+
import { oauth2Plugin } from "@dropsh/plugin-oauth2";
|
|
1531
|
+
export {
|
|
1532
|
+
ClaudeAgent,
|
|
1533
|
+
CodexAgent,
|
|
1534
|
+
DrupalGaiaRemote,
|
|
1535
|
+
FakeExecutor,
|
|
1536
|
+
FakeGaiaRemote,
|
|
1537
|
+
FakeWorkspace,
|
|
1538
|
+
GitWorkspace,
|
|
1539
|
+
HerdrExecutor,
|
|
1540
|
+
HerdrWorkspace,
|
|
1541
|
+
basicAuthProvider,
|
|
1542
|
+
claudeAgent,
|
|
1543
|
+
codexAgent,
|
|
1544
|
+
drupalRemote,
|
|
1545
|
+
encodeClaudeProjectDir,
|
|
1546
|
+
fakeExecutor,
|
|
1547
|
+
fakeRemote,
|
|
1548
|
+
fakeWorkspace,
|
|
1549
|
+
gitWorkspace,
|
|
1550
|
+
herdrExecutor,
|
|
1551
|
+
herdrWorkspace,
|
|
1552
|
+
oauth2Plugin,
|
|
1553
|
+
selectAgent,
|
|
1554
|
+
selectExecutor,
|
|
1555
|
+
selectRemote,
|
|
1556
|
+
selectWorkspace
|
|
1557
|
+
};
|