@gaia-ai/core 0.4.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/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/src/core/conductor-id.d.ts +1 -0
- package/dist/src/core/conductor-id.js +8 -0
- package/dist/src/core/exec.d.ts +41 -0
- package/dist/src/core/exec.js +75 -0
- package/dist/src/core/logger.d.ts +31 -0
- package/dist/src/core/logger.js +36 -0
- package/dist/src/core/shell.d.ts +21 -0
- package/dist/src/core/shell.js +46 -0
- package/dist/src/core/slug.d.ts +9 -0
- package/dist/src/core/slug.js +18 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.js +6 -0
- package/dist/src/plugins/agent/agent.d.ts +20 -0
- package/dist/src/plugins/agent/agent.js +1 -0
- package/dist/src/plugins/auth/basic.d.ts +11 -0
- package/dist/src/plugins/auth/basic.js +35 -0
- package/dist/src/plugins/executor/executor.d.ts +92 -0
- package/dist/src/plugins/executor/executor.js +1 -0
- package/dist/src/plugins/plugins.d.ts +44 -0
- package/dist/src/plugins/plugins.js +16 -0
- package/dist/src/plugins/registry-exports.d.ts +6 -0
- package/dist/src/plugins/registry-exports.js +6 -0
- package/dist/src/plugins/remote/drupal.d.ts +48 -0
- package/dist/src/plugins/remote/drupal.js +363 -0
- package/dist/src/plugins/remote/fake.d.ts +87 -0
- package/dist/src/plugins/remote/fake.js +214 -0
- package/dist/src/plugins/remote/remote.d.ts +169 -0
- package/dist/src/plugins/remote/remote.js +1 -0
- package/dist/src/plugins/workspace/fake.d.ts +6 -0
- package/dist/src/plugins/workspace/fake.js +16 -0
- package/dist/src/plugins/workspace/git.d.ts +37 -0
- package/dist/src/plugins/workspace/git.js +89 -0
- package/dist/src/plugins/workspace/instructions.d.ts +6 -0
- package/dist/src/plugins/workspace/instructions.js +16 -0
- package/dist/src/plugins/workspace/workspace.d.ts +35 -0
- package/dist/src/plugins/workspace/workspace.js +1 -0
- package/dist/src/plugins-index.d.ts +1 -0
- package/dist/src/plugins-index.js +1 -0
- package/dist/src/types.d.ts +51 -0
- package/dist/src/types.js +1 -0
- package/package.json +33 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { resolveAuth } from 'dropsh';
|
|
2
|
+
import { createHttpClient, createJsonApiClient } from 'dropsh/plugin';
|
|
3
|
+
const ACTIVE = ['claimed', 'running'];
|
|
4
|
+
/**
|
|
5
|
+
* Extracts gaia_comment resources from a JSON:API `included` array into
|
|
6
|
+
* conductor-facing TicketComments. JSON:API renames the comment entity's `type`
|
|
7
|
+
* field to `gaia_comment_type` (the bare `type` is the reserved resource type),
|
|
8
|
+
* and serialises the text_long `body` as `{value, format, processed}`. Resources
|
|
9
|
+
* of other types are ignored; the server already returns them created-ASC.
|
|
10
|
+
*/
|
|
11
|
+
export function parseTicketComments(included) {
|
|
12
|
+
if (!Array.isArray(included)) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
const comments = [];
|
|
16
|
+
for (const res of included) {
|
|
17
|
+
if (res?.type !== 'gaia_comment--gaia_comment') {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const attrs = res.attributes ?? {};
|
|
21
|
+
const body = attrs.body;
|
|
22
|
+
const value = typeof body === 'string'
|
|
23
|
+
? body
|
|
24
|
+
: typeof body?.value === 'string'
|
|
25
|
+
? body.value
|
|
26
|
+
: '';
|
|
27
|
+
comments.push({
|
|
28
|
+
type: typeof attrs.gaia_comment_type === 'string'
|
|
29
|
+
? attrs.gaia_comment_type
|
|
30
|
+
: 'comment',
|
|
31
|
+
body: value,
|
|
32
|
+
created: typeof attrs.created === 'string' ? attrs.created : '',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return comments;
|
|
36
|
+
}
|
|
37
|
+
export class DrupalGaiaRemote {
|
|
38
|
+
api;
|
|
39
|
+
constructor(api) {
|
|
40
|
+
this.api = api;
|
|
41
|
+
}
|
|
42
|
+
async fetchActiveRuns(id) {
|
|
43
|
+
// The conductor identity `id` is the machine_id (hash of hostname+path).
|
|
44
|
+
// gaia_run links to its owning conductor via the `conductor_id` entity-ref;
|
|
45
|
+
// filter on the related conductor's `machine_id` (nested relationship filter).
|
|
46
|
+
const rows = await this.api
|
|
47
|
+
.collection('gaia_run')
|
|
48
|
+
.where('conductor_id.machine_id', '=', id)
|
|
49
|
+
.whereIn('state', ACTIVE)
|
|
50
|
+
.fields(['state', 'state_at_start', 'worktree_path'])
|
|
51
|
+
.page(100)
|
|
52
|
+
.list();
|
|
53
|
+
return Promise.all(rows.map(async (r) => ({
|
|
54
|
+
runUuid: r.id,
|
|
55
|
+
ticketUuid: r.rel('ticket_id') ?? '',
|
|
56
|
+
ticketIdentifier: await this.getRunTicketIdentifier(r.id),
|
|
57
|
+
branchName: await this.getRunTicketBranchName(r.id),
|
|
58
|
+
state: r.attr('state') ?? '',
|
|
59
|
+
stateAtStart: r.attr('state_at_start') ?? '',
|
|
60
|
+
worktreePath: r.attr('worktree_path') ?? '',
|
|
61
|
+
})));
|
|
62
|
+
}
|
|
63
|
+
async activeRunCount(id) {
|
|
64
|
+
// Filter on the related conductor's `machine_id` (nested relationship filter).
|
|
65
|
+
return this.api
|
|
66
|
+
.collection('gaia_run')
|
|
67
|
+
.where('conductor_id.machine_id', '=', id)
|
|
68
|
+
.whereIn('state', ACTIVE)
|
|
69
|
+
.fields(['drupal_internal__id'])
|
|
70
|
+
.page(100)
|
|
71
|
+
.count();
|
|
72
|
+
}
|
|
73
|
+
async claimNext(c) {
|
|
74
|
+
const res = (await this.api.post('gaia/claim-next', {
|
|
75
|
+
data: {
|
|
76
|
+
attributes: {
|
|
77
|
+
lease_seconds: c.leaseSeconds,
|
|
78
|
+
// Server (`ConductorResolverTrait`) narrows to the caller's conductor
|
|
79
|
+
// by the `machine_id` attribute; `c.conductorId` IS the machine_id.
|
|
80
|
+
machine_id: c.conductorId,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
}));
|
|
84
|
+
if (!res?.data) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const d = res.data;
|
|
88
|
+
const rawId = d.attributes?.drupal_internal__id;
|
|
89
|
+
if (rawId === undefined || rawId === null) {
|
|
90
|
+
throw new Error(`claimNext: missing drupal_internal__id on run ${d.id}`);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
runUuid: d.id,
|
|
94
|
+
runId: Number(rawId),
|
|
95
|
+
ticketUuid: d.relationships?.ticket_id?.data?.id ?? '',
|
|
96
|
+
stateAtStart: String(d.attributes?.state_at_start ?? ''),
|
|
97
|
+
// handler_id was dropped; the handler is the work the run does, i.e. the
|
|
98
|
+
// ticket state the run started in.
|
|
99
|
+
handler: String(d.attributes?.state_at_start ?? ''),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
async getTicket(uuid) {
|
|
103
|
+
// `?include=comments` sideloads the ticket's comments in one request so the
|
|
104
|
+
// conductor can render them into the dispatch prompt (review→coding bounce
|
|
105
|
+
// must carry the review summary to the coder). `comments` is a computed
|
|
106
|
+
// entity_reference field (CommentsItemList, ordered created ASC), so the
|
|
107
|
+
// included resources arrive oldest-first. We use the raw client here rather
|
|
108
|
+
// than `resource()` because `resource()` discards the `included` array.
|
|
109
|
+
const doc = (await this.api.get(`gaia_ticket/gaia_ticket/${uuid}?include=comments`));
|
|
110
|
+
const attrs = doc.data?.attributes ?? {};
|
|
111
|
+
const issueUrl = typeof attrs.origin === 'string' ? attrs.origin : undefined;
|
|
112
|
+
return {
|
|
113
|
+
uuid: doc.data?.id ?? uuid,
|
|
114
|
+
identifier: typeof attrs.identifier === 'string' ? attrs.identifier : uuid,
|
|
115
|
+
title: typeof attrs.title === 'string' ? attrs.title : '',
|
|
116
|
+
state: typeof attrs.state === 'string' ? attrs.state : '',
|
|
117
|
+
branchName: typeof attrs.branch_name === 'string' ? attrs.branch_name : '',
|
|
118
|
+
...(typeof attrs.base_branch === 'string' && attrs.base_branch !== ''
|
|
119
|
+
? { baseBranch: attrs.base_branch }
|
|
120
|
+
: {}),
|
|
121
|
+
...(typeof attrs.effective_env_vars === 'string' &&
|
|
122
|
+
attrs.effective_env_vars !== ''
|
|
123
|
+
? { effectiveEnvVars: attrs.effective_env_vars }
|
|
124
|
+
: {}),
|
|
125
|
+
comments: parseTicketComments(doc.included),
|
|
126
|
+
...(issueUrl ? { issueUrl } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async getRunWorktree(uuid) {
|
|
130
|
+
const r = await this.api.resource('gaia_run', uuid);
|
|
131
|
+
return r.attr('worktree_path') ?? '';
|
|
132
|
+
}
|
|
133
|
+
async getRunTicketIdentifier(uuid) {
|
|
134
|
+
const run = await this.api.resource('gaia_run', uuid);
|
|
135
|
+
const ticketUuid = run.rel('ticket_id');
|
|
136
|
+
if (!ticketUuid) {
|
|
137
|
+
return '';
|
|
138
|
+
}
|
|
139
|
+
const ticket = await this.api.resource('gaia_ticket', ticketUuid);
|
|
140
|
+
return ticket.attr('identifier') ?? '';
|
|
141
|
+
}
|
|
142
|
+
async getRunTicketBranchName(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('branch_name') ?? '';
|
|
150
|
+
}
|
|
151
|
+
async registerConductor(reg) {
|
|
152
|
+
const r = await this.api.upsert('gaia_conductor', { path: 'machine_id', value: reg.id }, {
|
|
153
|
+
attributes: {
|
|
154
|
+
machine_id: reg.id,
|
|
155
|
+
status: 'online',
|
|
156
|
+
workspace_root: reg.workspace,
|
|
157
|
+
label: reg.label,
|
|
158
|
+
states: reg.states,
|
|
159
|
+
max_parallel: reg.max_parallel,
|
|
160
|
+
},
|
|
161
|
+
relationships: {
|
|
162
|
+
owner_user_id: {
|
|
163
|
+
data: { type: 'user--user', id: await this.api.me() },
|
|
164
|
+
},
|
|
165
|
+
project_id: {
|
|
166
|
+
data: {
|
|
167
|
+
type: 'gaia_project--gaia_project',
|
|
168
|
+
id: await this.projectUuid(reg.project),
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
return r.id; // drupal uuid
|
|
174
|
+
}
|
|
175
|
+
async heartbeat(reg, load, lease = 300) {
|
|
176
|
+
// Server-side upsert by machine_id (see HeartbeatResource): never 404s,
|
|
177
|
+
// heals a vanished registration, and returns the resulting status. Sending
|
|
178
|
+
// the full registration lets the server recreate the entity when missing.
|
|
179
|
+
const res = (await this.api.post('gaia/heartbeat', {
|
|
180
|
+
data: {
|
|
181
|
+
attributes: {
|
|
182
|
+
machine_id: reg.id,
|
|
183
|
+
project: reg.project,
|
|
184
|
+
states: reg.states,
|
|
185
|
+
workspace_root: reg.workspace,
|
|
186
|
+
label: reg.label,
|
|
187
|
+
max_parallel: reg.max_parallel,
|
|
188
|
+
current_load: load,
|
|
189
|
+
lease_seconds: lease,
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
}));
|
|
193
|
+
const status = res?.data?.attributes?.status;
|
|
194
|
+
return typeof status === 'string' ? status : 'online';
|
|
195
|
+
}
|
|
196
|
+
async getConductorStatus(id) {
|
|
197
|
+
const c = await this.api
|
|
198
|
+
.collection('gaia_conductor')
|
|
199
|
+
.where('machine_id', '=', id)
|
|
200
|
+
.fields(['status'])
|
|
201
|
+
.first();
|
|
202
|
+
return c ? (c.attr('status') ?? null) : null;
|
|
203
|
+
}
|
|
204
|
+
async setConductorStatus(id, status) {
|
|
205
|
+
const c = await this.api
|
|
206
|
+
.collection('gaia_conductor')
|
|
207
|
+
.where('machine_id', '=', id)
|
|
208
|
+
.first();
|
|
209
|
+
if (c) {
|
|
210
|
+
await this.api.update('gaia_conductor', c.id, { attributes: { status } });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async listConductors(owner) {
|
|
214
|
+
let col = this.api.collection('gaia_conductor');
|
|
215
|
+
if (owner === 'me') {
|
|
216
|
+
col = col.where('owner_user_id.id', '=', await this.api.me());
|
|
217
|
+
}
|
|
218
|
+
const rows = await col.page(200).list();
|
|
219
|
+
return rows.map((r) => ({
|
|
220
|
+
id: r.attr('machine_id') ?? r.id,
|
|
221
|
+
project: '',
|
|
222
|
+
label: r.attr('label') ?? '',
|
|
223
|
+
status: r.attr('status') ?? '',
|
|
224
|
+
lastSeen: r.attr('last_seen') ?? 0,
|
|
225
|
+
load: r.attr('current_load') ?? 0,
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
async markRunning(uuid, attrs) {
|
|
229
|
+
const t = Math.floor(Date.now() / 1000);
|
|
230
|
+
await this.api.update('gaia_run', uuid, {
|
|
231
|
+
attributes: {
|
|
232
|
+
state: 'running',
|
|
233
|
+
heartbeat: t,
|
|
234
|
+
...(attrs?.worktree_path ? { worktree_path: attrs.worktree_path } : {}),
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
async fetchFinalizableRuns(id) {
|
|
239
|
+
const rows = await this.api
|
|
240
|
+
.collection('gaia_run')
|
|
241
|
+
.where('conductor_id.machine_id', '=', id)
|
|
242
|
+
.where('state', '=', 'done')
|
|
243
|
+
.where('closed', '=', '0')
|
|
244
|
+
.fields(['worktree_path'])
|
|
245
|
+
.page(100)
|
|
246
|
+
.list();
|
|
247
|
+
return rows.map((r) => ({
|
|
248
|
+
runUuid: r.id,
|
|
249
|
+
worktreePath: r.attr('worktree_path') ?? '',
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
async finalizeRun(uuid, log) {
|
|
253
|
+
const t = Math.floor(Date.now() / 1000);
|
|
254
|
+
await this.api.update('gaia_run', uuid, {
|
|
255
|
+
attributes: {
|
|
256
|
+
closed: true,
|
|
257
|
+
closed_date: t,
|
|
258
|
+
...(log ? { log } : {}),
|
|
259
|
+
},
|
|
260
|
+
}); // NO state — the run is already done.
|
|
261
|
+
}
|
|
262
|
+
async fetchUncleanedTickets() {
|
|
263
|
+
// Finished tickets whose worktree is not yet torn down (cleaned_up=0),
|
|
264
|
+
// driven by the durable `cleaned_up` flag (GAIA-89). "Finished" = reached
|
|
265
|
+
// `done` OR already `closed`; the collection builder has no OR, so this is
|
|
266
|
+
// two AND-queries merged + de-duped by uuid. NOT machine-scoped, so the
|
|
267
|
+
// same query backs both the tick and the standalone `gaia conductor reap`
|
|
268
|
+
// and catches tickets orphaned while a conductor was down / after a restart
|
|
269
|
+
// / after a `conductor_id` reassignment (RC2/RC3).
|
|
270
|
+
const fields = ['branch_name', 'state', 'closed'];
|
|
271
|
+
const done = await this.api
|
|
272
|
+
.collection('gaia_ticket')
|
|
273
|
+
.where('state', '=', 'done')
|
|
274
|
+
.where('cleaned_up', '=', '0')
|
|
275
|
+
.fields(fields)
|
|
276
|
+
.page(100)
|
|
277
|
+
.list();
|
|
278
|
+
const closed = await this.api
|
|
279
|
+
.collection('gaia_ticket')
|
|
280
|
+
.where('closed', '=', '1')
|
|
281
|
+
.where('cleaned_up', '=', '0')
|
|
282
|
+
.fields(fields)
|
|
283
|
+
.page(100)
|
|
284
|
+
.list();
|
|
285
|
+
const byUuid = new Map();
|
|
286
|
+
for (const r of [...done, ...closed]) {
|
|
287
|
+
byUuid.set(r.id, r);
|
|
288
|
+
}
|
|
289
|
+
return Promise.all([...byUuid.values()].map(async (r) => ({
|
|
290
|
+
ticketUuid: r.id,
|
|
291
|
+
branchName: r.attr('branch_name') ?? '',
|
|
292
|
+
state: r.attr('state') ?? '',
|
|
293
|
+
closed: r.attr('closed') ?? false,
|
|
294
|
+
worktreePath: await this.latestRunWorktree(r.id),
|
|
295
|
+
})));
|
|
296
|
+
}
|
|
297
|
+
async closeTicket(uuid) {
|
|
298
|
+
const t = Math.floor(Date.now() / 1000);
|
|
299
|
+
await this.api.update('gaia_ticket', uuid, {
|
|
300
|
+
attributes: { closed: true, closed_date: t },
|
|
301
|
+
}); // NO state — the ticket is already done.
|
|
302
|
+
}
|
|
303
|
+
async markTicketCleanedUp(uuid) {
|
|
304
|
+
await this.api.update('gaia_ticket', uuid, {
|
|
305
|
+
attributes: { cleaned_up: true },
|
|
306
|
+
}); // NO state/closed — teardown flag only, decoupled from the lifecycle.
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Absolute worktree path of the ticket's latest run (highest run id with a
|
|
310
|
+
* non-empty worktree_path), or '' when none — the cwd the cleanup command
|
|
311
|
+
* runs in. The conductor persists worktree_path on markRunning, so a done
|
|
312
|
+
* ticket's run carries the path even after the run closed.
|
|
313
|
+
*/
|
|
314
|
+
async latestRunWorktree(ticketUuid) {
|
|
315
|
+
const rows = await this.api
|
|
316
|
+
.collection('gaia_run')
|
|
317
|
+
.where('ticket_id.id', '=', ticketUuid)
|
|
318
|
+
.fields(['worktree_path', 'drupal_internal__id'])
|
|
319
|
+
.sort('-drupal_internal__id')
|
|
320
|
+
.page(100)
|
|
321
|
+
.list();
|
|
322
|
+
for (const r of rows) {
|
|
323
|
+
const path = r.attr('worktree_path');
|
|
324
|
+
if (path) {
|
|
325
|
+
return path;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return '';
|
|
329
|
+
}
|
|
330
|
+
async projectUuid(name) {
|
|
331
|
+
const p = await this.api
|
|
332
|
+
.collection('gaia_project')
|
|
333
|
+
.where('name', '=', name)
|
|
334
|
+
.first();
|
|
335
|
+
if (!p) {
|
|
336
|
+
throw new Error(`gaia project "${name}" not found`);
|
|
337
|
+
}
|
|
338
|
+
return p.id;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
export function drupalRemote() {
|
|
342
|
+
return {
|
|
343
|
+
kind: 'remote',
|
|
344
|
+
id: 'drupal',
|
|
345
|
+
requiredModules: [],
|
|
346
|
+
async createRemote(config) {
|
|
347
|
+
const http = createHttpClient();
|
|
348
|
+
const auth = await resolveAuth({
|
|
349
|
+
baseUrl: config.site.base_url,
|
|
350
|
+
plugins: config.plugins ?? [],
|
|
351
|
+
http,
|
|
352
|
+
now: Date.now,
|
|
353
|
+
// stateDir omitted → dropsh defaultStateDir() (~/.config/dropsh)
|
|
354
|
+
});
|
|
355
|
+
return new DrupalGaiaRemote(createJsonApiClient({
|
|
356
|
+
baseUrl: config.site.base_url,
|
|
357
|
+
prefix: config.site.jsonapi_prefix,
|
|
358
|
+
http,
|
|
359
|
+
auth: auth,
|
|
360
|
+
}));
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { RemotePlugin } from '../plugins.js';
|
|
2
|
+
import type { ActiveRun, ClaimedRun, ClaimOptions, ConductorRegistration, ConductorStatus, FinalizableRun, GaiaRemote, RunWriteAttributes, Ticket, TicketComment, UncleanTicket } from './remote.js';
|
|
3
|
+
export interface FakeSeedRun {
|
|
4
|
+
runUuid: string;
|
|
5
|
+
/** Numeric (drupal_internal__id) run id. Defaults to a stable 1-based counter when omitted. */
|
|
6
|
+
id?: number;
|
|
7
|
+
ticketUuid: string;
|
|
8
|
+
stateAtStart: string;
|
|
9
|
+
/** Override the initial state (defaults to stateAtStart). */
|
|
10
|
+
state?: string;
|
|
11
|
+
handler?: string;
|
|
12
|
+
worktreePath?: string;
|
|
13
|
+
closed?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface FakeSeedTicket {
|
|
16
|
+
identifier: string;
|
|
17
|
+
title: string;
|
|
18
|
+
state: string;
|
|
19
|
+
branchName?: string;
|
|
20
|
+
/** Computed base branch (parent branch / project default / 'main'). */
|
|
21
|
+
baseBranch?: string;
|
|
22
|
+
/** Effective env vars (parent-chain resolved) as `.env`-style lines. */
|
|
23
|
+
effectiveEnvVars?: string;
|
|
24
|
+
url?: string;
|
|
25
|
+
workflow?: string;
|
|
26
|
+
comments?: TicketComment[];
|
|
27
|
+
/** Whether the ticket is already closed (its lifecycle wound up). */
|
|
28
|
+
closed?: boolean;
|
|
29
|
+
/** Whether the ticket's worktree is already torn down (cleaned_up flag). */
|
|
30
|
+
cleanedUp?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface FakeRemoteSeed {
|
|
33
|
+
runs?: FakeSeedRun[];
|
|
34
|
+
tickets?: Record<string, FakeSeedTicket>;
|
|
35
|
+
}
|
|
36
|
+
export interface MarkRunningCall {
|
|
37
|
+
runUuid: string;
|
|
38
|
+
attrs?: {
|
|
39
|
+
worktree_path?: string;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export interface FinalizeRunCall {
|
|
43
|
+
runUuid: string;
|
|
44
|
+
log: string;
|
|
45
|
+
}
|
|
46
|
+
export declare class FakeGaiaRemote implements GaiaRemote {
|
|
47
|
+
readonly calls: {
|
|
48
|
+
markRunning: MarkRunningCall[];
|
|
49
|
+
finalizeRun: FinalizeRunCall[];
|
|
50
|
+
closeTicket: string[];
|
|
51
|
+
markCleanedUp: string[];
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Override for reconcile tests: when set, `fetchActiveRuns` returns this
|
|
55
|
+
* list verbatim instead of deriving it from the internal runs map.
|
|
56
|
+
*/
|
|
57
|
+
activeRuns: ActiveRun[] | null;
|
|
58
|
+
private readonly runs;
|
|
59
|
+
private readonly queue;
|
|
60
|
+
private readonly tickets;
|
|
61
|
+
private conductorStatus;
|
|
62
|
+
/** Stable counter for assigning numeric ids to unseeded runs. */
|
|
63
|
+
private runIdCounter;
|
|
64
|
+
constructor(seed?: FakeRemoteSeed);
|
|
65
|
+
registerConductor(_reg: ConductorRegistration): Promise<string>;
|
|
66
|
+
heartbeat(): Promise<string>;
|
|
67
|
+
getConductorStatus(_conductorId: string): Promise<string | null>;
|
|
68
|
+
setConductorStatus(_conductorId: string, status: 'offline' | 'online'): Promise<void>;
|
|
69
|
+
listConductors(_owner?: 'me'): Promise<ConductorStatus[]>;
|
|
70
|
+
activeRunCount(_conductorId: string): Promise<number>;
|
|
71
|
+
fetchActiveRuns(_conductorId: string): Promise<ActiveRun[]>;
|
|
72
|
+
claimNext(_claim: ClaimOptions): Promise<ClaimedRun | null>;
|
|
73
|
+
getTicket(ticketUuid: string): Promise<Ticket>;
|
|
74
|
+
getRunWorktree(runUuid: string): Promise<string>;
|
|
75
|
+
getRunTicketIdentifier(runUuid: string): Promise<string>;
|
|
76
|
+
getRunTicketBranchName(runUuid: string): Promise<string>;
|
|
77
|
+
markRunning(runUuid: string, attrs?: RunWriteAttributes): Promise<void>;
|
|
78
|
+
fetchFinalizableRuns(_conductorId: string): Promise<FinalizableRun[]>;
|
|
79
|
+
finalizeRun(runUuid: string, log: string): Promise<void>;
|
|
80
|
+
fetchUncleanedTickets(): Promise<UncleanTicket[]>;
|
|
81
|
+
closeTicket(uuid: string): Promise<void>;
|
|
82
|
+
markTicketCleanedUp(uuid: string): Promise<void>;
|
|
83
|
+
/** Worktree path of the ticket's most recently seeded run, or '' when none. */
|
|
84
|
+
private latestRunWorktree;
|
|
85
|
+
private internalActiveRuns;
|
|
86
|
+
}
|
|
87
|
+
export declare function fakeRemote(seed?: FakeRemoteSeed): RemotePlugin;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
const ACTIVE = ['claimed', 'running'];
|
|
2
|
+
export class FakeGaiaRemote {
|
|
3
|
+
calls = {
|
|
4
|
+
markRunning: [],
|
|
5
|
+
finalizeRun: [],
|
|
6
|
+
closeTicket: [],
|
|
7
|
+
markCleanedUp: [],
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Override for reconcile tests: when set, `fetchActiveRuns` returns this
|
|
11
|
+
* list verbatim instead of deriving it from the internal runs map.
|
|
12
|
+
*/
|
|
13
|
+
activeRuns = null;
|
|
14
|
+
runs = new Map();
|
|
15
|
+
queue = [];
|
|
16
|
+
tickets;
|
|
17
|
+
conductorStatus = 'online';
|
|
18
|
+
/** Stable counter for assigning numeric ids to unseeded runs. */
|
|
19
|
+
runIdCounter = 0;
|
|
20
|
+
constructor(seed = {}) {
|
|
21
|
+
this.tickets = seed.tickets ?? {};
|
|
22
|
+
for (const r of seed.runs ?? []) {
|
|
23
|
+
const handler = r.handler ?? 'code';
|
|
24
|
+
// Use the seeded id when provided; otherwise assign a deterministic counter.
|
|
25
|
+
const runId = r.id ?? ++this.runIdCounter;
|
|
26
|
+
this.runs.set(r.runUuid, {
|
|
27
|
+
runUuid: r.runUuid,
|
|
28
|
+
runId,
|
|
29
|
+
ticketUuid: r.ticketUuid,
|
|
30
|
+
handler,
|
|
31
|
+
state: r.state ?? r.stateAtStart,
|
|
32
|
+
stateAtStart: r.stateAtStart,
|
|
33
|
+
...(r.worktreePath !== undefined
|
|
34
|
+
? { worktreePath: r.worktreePath }
|
|
35
|
+
: {}),
|
|
36
|
+
...(r.closed !== undefined ? { closed: r.closed } : {}),
|
|
37
|
+
});
|
|
38
|
+
this.queue.push({
|
|
39
|
+
runUuid: r.runUuid,
|
|
40
|
+
runId,
|
|
41
|
+
ticketUuid: r.ticketUuid,
|
|
42
|
+
stateAtStart: r.stateAtStart,
|
|
43
|
+
handler,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async registerConductor(_reg) {
|
|
48
|
+
this.conductorStatus = 'online';
|
|
49
|
+
return 'fake-conductor-uuid';
|
|
50
|
+
}
|
|
51
|
+
async heartbeat() {
|
|
52
|
+
// Mirrors the server: a heartbeat always brings the conductor online.
|
|
53
|
+
this.conductorStatus = 'online';
|
|
54
|
+
return this.conductorStatus;
|
|
55
|
+
}
|
|
56
|
+
async getConductorStatus(_conductorId) {
|
|
57
|
+
return this.conductorStatus;
|
|
58
|
+
}
|
|
59
|
+
async setConductorStatus(_conductorId, status) {
|
|
60
|
+
this.conductorStatus = status;
|
|
61
|
+
}
|
|
62
|
+
async listConductors(_owner) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
async activeRunCount(_conductorId) {
|
|
66
|
+
return this.internalActiveRuns().length;
|
|
67
|
+
}
|
|
68
|
+
async fetchActiveRuns(_conductorId) {
|
|
69
|
+
if (this.activeRuns !== null) {
|
|
70
|
+
return this.activeRuns;
|
|
71
|
+
}
|
|
72
|
+
return this.internalActiveRuns().map((r) => {
|
|
73
|
+
const ticket = this.tickets[r.ticketUuid];
|
|
74
|
+
const identifier = ticket?.identifier ?? '';
|
|
75
|
+
const branchName = ticket?.branchName ??
|
|
76
|
+
(identifier ? `gaia/${identifier.toLowerCase()}` : '');
|
|
77
|
+
return {
|
|
78
|
+
runUuid: r.runUuid,
|
|
79
|
+
ticketUuid: r.ticketUuid,
|
|
80
|
+
ticketIdentifier: identifier,
|
|
81
|
+
branchName,
|
|
82
|
+
state: r.state,
|
|
83
|
+
stateAtStart: r.stateAtStart,
|
|
84
|
+
worktreePath: r.worktreePath ?? '',
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
async claimNext(_claim) {
|
|
89
|
+
return this.queue.shift() ?? null;
|
|
90
|
+
}
|
|
91
|
+
async getTicket(ticketUuid) {
|
|
92
|
+
const t = this.tickets[ticketUuid];
|
|
93
|
+
if (!t) {
|
|
94
|
+
throw new Error(`fake remote: ticket ${ticketUuid} not seeded`);
|
|
95
|
+
}
|
|
96
|
+
const branchName = t.branchName ?? `gaia/${t.identifier.toLowerCase()}`;
|
|
97
|
+
return {
|
|
98
|
+
uuid: ticketUuid,
|
|
99
|
+
identifier: t.identifier,
|
|
100
|
+
title: t.title,
|
|
101
|
+
state: t.state,
|
|
102
|
+
branchName,
|
|
103
|
+
...(t.baseBranch ? { baseBranch: t.baseBranch } : {}),
|
|
104
|
+
...(t.effectiveEnvVars ? { effectiveEnvVars: t.effectiveEnvVars } : {}),
|
|
105
|
+
comments: t.comments ?? [],
|
|
106
|
+
...(t.url ? { issueUrl: t.url } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
async getRunWorktree(runUuid) {
|
|
110
|
+
return this.runs.get(runUuid)?.worktreePath ?? '';
|
|
111
|
+
}
|
|
112
|
+
async getRunTicketIdentifier(runUuid) {
|
|
113
|
+
const ticketUuid = this.runs.get(runUuid)?.ticketUuid;
|
|
114
|
+
if (!ticketUuid) {
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
return this.tickets[ticketUuid]?.identifier ?? '';
|
|
118
|
+
}
|
|
119
|
+
async getRunTicketBranchName(runUuid) {
|
|
120
|
+
const ticketUuid = this.runs.get(runUuid)?.ticketUuid;
|
|
121
|
+
if (!ticketUuid) {
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
const ticket = this.tickets[ticketUuid];
|
|
125
|
+
if (!ticket) {
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
return ticket.branchName ?? `gaia/${ticket.identifier.toLowerCase()}`;
|
|
129
|
+
}
|
|
130
|
+
async markRunning(runUuid, attrs) {
|
|
131
|
+
this.calls.markRunning.push({
|
|
132
|
+
runUuid,
|
|
133
|
+
...(attrs
|
|
134
|
+
? {
|
|
135
|
+
attrs: {
|
|
136
|
+
...(attrs.worktree_path
|
|
137
|
+
? { worktree_path: attrs.worktree_path }
|
|
138
|
+
: {}),
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
142
|
+
});
|
|
143
|
+
const run = this.runs.get(runUuid);
|
|
144
|
+
if (run) {
|
|
145
|
+
run.state = 'running';
|
|
146
|
+
if (attrs?.worktree_path) {
|
|
147
|
+
run.worktreePath = attrs.worktree_path;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async fetchFinalizableRuns(_conductorId) {
|
|
152
|
+
return [...this.runs.values()]
|
|
153
|
+
.filter((r) => r.state === 'done' && !r.closed)
|
|
154
|
+
.map((r) => ({ runUuid: r.runUuid, worktreePath: r.worktreePath ?? '' }));
|
|
155
|
+
}
|
|
156
|
+
async finalizeRun(runUuid, log) {
|
|
157
|
+
this.calls.finalizeRun.push({ runUuid, log });
|
|
158
|
+
const run = this.runs.get(runUuid);
|
|
159
|
+
if (run) {
|
|
160
|
+
run.closed = true;
|
|
161
|
+
if (log)
|
|
162
|
+
run.log = log;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async fetchUncleanedTickets() {
|
|
166
|
+
return Object.entries(this.tickets)
|
|
167
|
+
.filter(([, t]) => (t.state === 'done' || t.closed) && !t.cleanedUp)
|
|
168
|
+
.map(([ticketUuid, t]) => ({
|
|
169
|
+
ticketUuid,
|
|
170
|
+
branchName: t.branchName ?? `gaia/${t.identifier.toLowerCase()}`,
|
|
171
|
+
worktreePath: this.latestRunWorktree(ticketUuid),
|
|
172
|
+
state: t.state,
|
|
173
|
+
closed: t.closed ?? false,
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
async closeTicket(uuid) {
|
|
177
|
+
this.calls.closeTicket.push(uuid);
|
|
178
|
+
const t = this.tickets[uuid];
|
|
179
|
+
if (t) {
|
|
180
|
+
t.closed = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async markTicketCleanedUp(uuid) {
|
|
184
|
+
this.calls.markCleanedUp.push(uuid);
|
|
185
|
+
const t = this.tickets[uuid];
|
|
186
|
+
if (t) {
|
|
187
|
+
t.cleanedUp = true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** Worktree path of the ticket's most recently seeded run, or '' when none. */
|
|
191
|
+
latestRunWorktree(ticketUuid) {
|
|
192
|
+
let worktree = '';
|
|
193
|
+
for (const r of this.runs.values()) {
|
|
194
|
+
if (r.ticketUuid === ticketUuid && r.worktreePath) {
|
|
195
|
+
worktree = r.worktreePath;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return worktree;
|
|
199
|
+
}
|
|
200
|
+
internalActiveRuns() {
|
|
201
|
+
return [...this.runs.values()].filter((r) => ACTIVE.includes(r.state));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export function fakeRemote(seed = {}) {
|
|
205
|
+
const remote = new FakeGaiaRemote(seed);
|
|
206
|
+
return {
|
|
207
|
+
kind: 'remote',
|
|
208
|
+
id: 'fake',
|
|
209
|
+
requiredModules: [],
|
|
210
|
+
async createRemote(_config) {
|
|
211
|
+
return remote;
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|