@gaia-ai/addon-remote-drupal 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/dist/src/index.d.ts +40 -0
- package/dist/src/index.js +399 -0
- package/dist/src/preset.d.ts +2 -0
- package/dist/src/preset.js +2 -0
- package/package.json +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 keytec GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ActiveRun, ClaimedRun, ClaimOptions, ConductorRegistration, ConductorStatus, FinalizableRun, GaiaRemote, RemotePlugin, RunMetrics, RunWriteAttributes, Ticket, UncleanTicket } from '@gaia-ai/conductor/contract';
|
|
2
|
+
import type { JsonApiClient } from 'dropsh/plugin';
|
|
3
|
+
export declare class DrupalGaiaRemote implements GaiaRemote {
|
|
4
|
+
private readonly api;
|
|
5
|
+
constructor(api: JsonApiClient);
|
|
6
|
+
fetchActiveRuns(id: string): Promise<ActiveRun[]>;
|
|
7
|
+
activeRunCount(id: string): Promise<number>;
|
|
8
|
+
claimNext(c: ClaimOptions): Promise<ClaimedRun | null>;
|
|
9
|
+
getTicket(uuid: string): Promise<Ticket>;
|
|
10
|
+
getRunWorktree(uuid: string): Promise<string>;
|
|
11
|
+
getRunTicketIdentifier(uuid: string): Promise<string>;
|
|
12
|
+
getRunTicketBranchName(uuid: string): Promise<string>;
|
|
13
|
+
registerConductor(reg: ConductorRegistration): Promise<string>;
|
|
14
|
+
heartbeat(reg: ConductorRegistration, load: number, lease?: number): Promise<string>;
|
|
15
|
+
getConductorStatus(id: string): Promise<string | null>;
|
|
16
|
+
setConductorStatus(id: string, status: string): Promise<void>;
|
|
17
|
+
listConductors(owner?: 'me'): Promise<ConductorStatus[]>;
|
|
18
|
+
markRunning(uuid: string, attrs?: RunWriteAttributes): Promise<void>;
|
|
19
|
+
markFailed(uuid: string, errorLog: string): Promise<void>;
|
|
20
|
+
fetchFinalizableRuns(id: string): Promise<FinalizableRun[]>;
|
|
21
|
+
finalizeRun(uuid: string, log: string, metrics?: RunMetrics): Promise<void>;
|
|
22
|
+
fetchUncleanedTickets(id: string): Promise<UncleanTicket[]>;
|
|
23
|
+
closeTicket(uuid: string): Promise<void>;
|
|
24
|
+
markTicketCleanedUp(uuid: string): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Absolute worktree path of the ticket's latest run (highest run id with a
|
|
27
|
+
* non-empty worktree_path), or '' when none — the cwd the cleanup command
|
|
28
|
+
* runs in. The conductor persists worktree_path on markRunning, so a done
|
|
29
|
+
* ticket's run carries the path even after the run closed.
|
|
30
|
+
*/
|
|
31
|
+
private latestRunWorktree;
|
|
32
|
+
resolveTicketByIdentifier(project: string, identifier: string): Promise<{
|
|
33
|
+
uuid: string;
|
|
34
|
+
title: string;
|
|
35
|
+
} | null>;
|
|
36
|
+
private readonly projectUuidCache;
|
|
37
|
+
private projectUuid;
|
|
38
|
+
}
|
|
39
|
+
export declare function drupalRemote(): RemotePlugin;
|
|
40
|
+
export default drupalRemote;
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// GAIA-224 (Finding 6, decision 8): the Drupal control-plane remote — formerly
|
|
2
|
+
// `@gaia-ai/core`'s `plugins/remote/drupal.ts`, now its own conductor-surface
|
|
3
|
+
// addon. Core ships no plugin implementations; every mountable lives under
|
|
4
|
+
// `addons/*`. The surface contract comes from `@gaia-ai/conductor/contract`
|
|
5
|
+
// (runtime-light: types + the tiny selectors, no engine).
|
|
6
|
+
import { resolveAuth } from 'dropsh';
|
|
7
|
+
import { createHttpClient, createJsonApiClient } from 'dropsh/plugin';
|
|
8
|
+
const ACTIVE = ['claimed', 'running'];
|
|
9
|
+
export class DrupalGaiaRemote {
|
|
10
|
+
api;
|
|
11
|
+
constructor(api) {
|
|
12
|
+
this.api = api;
|
|
13
|
+
}
|
|
14
|
+
async fetchActiveRuns(id) {
|
|
15
|
+
// The conductor identity `id` is the machine_id (hash of hostname+path).
|
|
16
|
+
// gaia_run links to its owning conductor via the `conductor_id` entity-ref;
|
|
17
|
+
// filter on the related conductor's `machine_id` (nested relationship filter).
|
|
18
|
+
const rows = await this.api
|
|
19
|
+
.collection('gaia_run')
|
|
20
|
+
.where('conductor_id.machine_id', '=', id)
|
|
21
|
+
.whereIn('state', ACTIVE)
|
|
22
|
+
.fields(['state', 'state_at_start', 'worktree_path'])
|
|
23
|
+
.page(100)
|
|
24
|
+
.list();
|
|
25
|
+
return Promise.all(rows.map(async (r) => ({
|
|
26
|
+
runUuid: r.id,
|
|
27
|
+
ticketUuid: r.rel('ticket_id') ?? '',
|
|
28
|
+
ticketIdentifier: await this.getRunTicketIdentifier(r.id),
|
|
29
|
+
branchName: await this.getRunTicketBranchName(r.id),
|
|
30
|
+
state: r.attr('state') ?? '',
|
|
31
|
+
stateAtStart: r.attr('state_at_start') ?? '',
|
|
32
|
+
worktreePath: r.attr('worktree_path') ?? '',
|
|
33
|
+
})));
|
|
34
|
+
}
|
|
35
|
+
async activeRunCount(id) {
|
|
36
|
+
// Filter on the related conductor's `machine_id` (nested relationship filter).
|
|
37
|
+
return this.api
|
|
38
|
+
.collection('gaia_run')
|
|
39
|
+
.where('conductor_id.machine_id', '=', id)
|
|
40
|
+
.whereIn('state', ACTIVE)
|
|
41
|
+
.fields(['drupal_internal__id'])
|
|
42
|
+
.page(100)
|
|
43
|
+
.count();
|
|
44
|
+
}
|
|
45
|
+
async claimNext(c) {
|
|
46
|
+
const res = (await this.api.post('gaia/claim-next', {
|
|
47
|
+
data: {
|
|
48
|
+
attributes: {
|
|
49
|
+
lease_seconds: c.leaseSeconds,
|
|
50
|
+
// Server (`ConductorResolverTrait`) narrows to the caller's conductor
|
|
51
|
+
// by the `machine_id` attribute; `c.conductorId` IS the machine_id.
|
|
52
|
+
machine_id: c.conductorId,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
}));
|
|
56
|
+
if (!res?.data) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const d = res.data;
|
|
60
|
+
const rawId = d.attributes?.drupal_internal__id;
|
|
61
|
+
if (rawId === undefined || rawId === null) {
|
|
62
|
+
throw new Error(`claimNext: missing drupal_internal__id on run ${d.id}`);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
runUuid: d.id,
|
|
66
|
+
runId: Number(rawId),
|
|
67
|
+
ticketUuid: d.relationships?.ticket_id?.data?.id ?? '',
|
|
68
|
+
stateAtStart: String(d.attributes?.state_at_start ?? ''),
|
|
69
|
+
// handler_id was dropped; the handler is the work the run does, i.e. the
|
|
70
|
+
// ticket state the run started in.
|
|
71
|
+
handler: String(d.attributes?.state_at_start ?? ''),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async getTicket(uuid) {
|
|
75
|
+
// The agent self-reads the ticket + its comments at run start (GAIA-112),
|
|
76
|
+
// so getTicket needs no ?include=comments — but it DOES sideload labels +
|
|
77
|
+
// environments (GAIA-144) so a config-side priority(ticket) can read them
|
|
78
|
+
// without fetching (AC-5).
|
|
79
|
+
const doc = (await this.api.get(`gaia_ticket/gaia_ticket/${uuid}?include=labels,environments`));
|
|
80
|
+
const attrs = doc.data?.attributes ?? {};
|
|
81
|
+
const issueUrl = typeof attrs.origin === 'string' ? attrs.origin : undefined;
|
|
82
|
+
const included = Array.isArray(doc.included) ? doc.included : [];
|
|
83
|
+
const byId = new Map(included.map((r) => [`${r.type}:${r.id}`, r]));
|
|
84
|
+
const refs = (name) => {
|
|
85
|
+
const data = doc.data?.relationships?.[name]?.data;
|
|
86
|
+
return Array.isArray(data) ? data : [];
|
|
87
|
+
};
|
|
88
|
+
const labels = refs('labels')
|
|
89
|
+
.map((ref) => byId.get(`${ref.type}:${ref.id}`)?.attributes?.name)
|
|
90
|
+
.filter((n) => typeof n === 'string');
|
|
91
|
+
const environments = refs('environments')
|
|
92
|
+
.map((ref) => {
|
|
93
|
+
const a = byId.get(`${ref.type}:${ref.id}`)?.attributes ?? {};
|
|
94
|
+
return {
|
|
95
|
+
name: typeof a.name === 'string' ? a.name : '',
|
|
96
|
+
tier: typeof a.tier === 'string' ? a.tier : '',
|
|
97
|
+
};
|
|
98
|
+
})
|
|
99
|
+
.filter((e) => e.name !== '');
|
|
100
|
+
return {
|
|
101
|
+
uuid: doc.data?.id ?? uuid,
|
|
102
|
+
identifier: typeof attrs.identifier === 'string' ? attrs.identifier : uuid,
|
|
103
|
+
title: typeof attrs.title === 'string' ? attrs.title : '',
|
|
104
|
+
state: typeof attrs.state === 'string' ? attrs.state : '',
|
|
105
|
+
branchName: typeof attrs.branch_name === 'string' ? attrs.branch_name : '',
|
|
106
|
+
...(typeof attrs.base_branch === 'string' && attrs.base_branch !== ''
|
|
107
|
+
? { baseBranch: attrs.base_branch }
|
|
108
|
+
: {}),
|
|
109
|
+
...(typeof attrs.effective_env_vars === 'string' &&
|
|
110
|
+
attrs.effective_env_vars !== ''
|
|
111
|
+
? { effectiveEnvVars: attrs.effective_env_vars }
|
|
112
|
+
: {}),
|
|
113
|
+
...(issueUrl ? { issueUrl } : {}),
|
|
114
|
+
labels,
|
|
115
|
+
environments,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
async getRunWorktree(uuid) {
|
|
119
|
+
const r = await this.api.resource('gaia_run', uuid);
|
|
120
|
+
return r.attr('worktree_path') ?? '';
|
|
121
|
+
}
|
|
122
|
+
async getRunTicketIdentifier(uuid) {
|
|
123
|
+
const run = await this.api.resource('gaia_run', uuid);
|
|
124
|
+
const ticketUuid = run.rel('ticket_id');
|
|
125
|
+
if (!ticketUuid) {
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
const ticket = await this.api.resource('gaia_ticket', ticketUuid);
|
|
129
|
+
return ticket.attr('identifier') ?? '';
|
|
130
|
+
}
|
|
131
|
+
async getRunTicketBranchName(uuid) {
|
|
132
|
+
const run = await this.api.resource('gaia_run', uuid);
|
|
133
|
+
const ticketUuid = run.rel('ticket_id');
|
|
134
|
+
if (!ticketUuid) {
|
|
135
|
+
return '';
|
|
136
|
+
}
|
|
137
|
+
const ticket = await this.api.resource('gaia_ticket', ticketUuid);
|
|
138
|
+
return ticket.attr('branch_name') ?? '';
|
|
139
|
+
}
|
|
140
|
+
async registerConductor(reg) {
|
|
141
|
+
const r = await this.api.upsert('gaia_conductor', { path: 'machine_id', value: reg.id }, {
|
|
142
|
+
attributes: {
|
|
143
|
+
machine_id: reg.id,
|
|
144
|
+
status: 'online',
|
|
145
|
+
workspace_root: reg.workspace,
|
|
146
|
+
label: reg.label,
|
|
147
|
+
states: reg.states,
|
|
148
|
+
max_parallel: reg.max_parallel,
|
|
149
|
+
},
|
|
150
|
+
relationships: {
|
|
151
|
+
owner_user_id: {
|
|
152
|
+
data: { type: 'user--user', id: await this.api.me() },
|
|
153
|
+
},
|
|
154
|
+
project_id: {
|
|
155
|
+
data: {
|
|
156
|
+
type: 'gaia_project--gaia_project',
|
|
157
|
+
id: await this.projectUuid(reg.project),
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
return r.id; // drupal uuid
|
|
163
|
+
}
|
|
164
|
+
async heartbeat(reg, load, lease = 300) {
|
|
165
|
+
// Server-side upsert by machine_id (see HeartbeatResource): never 404s,
|
|
166
|
+
// heals a vanished registration, and returns the resulting status. Sending
|
|
167
|
+
// the full registration lets the server recreate the entity when missing.
|
|
168
|
+
const res = (await this.api.post('gaia/heartbeat', {
|
|
169
|
+
data: {
|
|
170
|
+
attributes: {
|
|
171
|
+
machine_id: reg.id,
|
|
172
|
+
project: reg.project,
|
|
173
|
+
states: reg.states,
|
|
174
|
+
workspace_root: reg.workspace,
|
|
175
|
+
label: reg.label,
|
|
176
|
+
max_parallel: reg.max_parallel,
|
|
177
|
+
current_load: load,
|
|
178
|
+
lease_seconds: lease,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
}));
|
|
182
|
+
const status = res?.data?.attributes?.status;
|
|
183
|
+
return typeof status === 'string' ? status : 'online';
|
|
184
|
+
}
|
|
185
|
+
async getConductorStatus(id) {
|
|
186
|
+
const c = await this.api
|
|
187
|
+
.collection('gaia_conductor')
|
|
188
|
+
.where('machine_id', '=', id)
|
|
189
|
+
.fields(['status'])
|
|
190
|
+
.first();
|
|
191
|
+
return c ? (c.attr('status') ?? null) : null;
|
|
192
|
+
}
|
|
193
|
+
async setConductorStatus(id, status) {
|
|
194
|
+
const c = await this.api
|
|
195
|
+
.collection('gaia_conductor')
|
|
196
|
+
.where('machine_id', '=', id)
|
|
197
|
+
.first();
|
|
198
|
+
if (c) {
|
|
199
|
+
await this.api.update('gaia_conductor', c.id, { attributes: { status } });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async listConductors(owner) {
|
|
203
|
+
let col = this.api.collection('gaia_conductor');
|
|
204
|
+
if (owner === 'me') {
|
|
205
|
+
col = col.where('owner_user_id.id', '=', await this.api.me());
|
|
206
|
+
}
|
|
207
|
+
const rows = await col.page(200).list();
|
|
208
|
+
return rows.map((r) => ({
|
|
209
|
+
id: r.attr('machine_id') ?? r.id,
|
|
210
|
+
project: '',
|
|
211
|
+
label: r.attr('label') ?? '',
|
|
212
|
+
status: r.attr('status') ?? '',
|
|
213
|
+
lastSeen: r.attr('last_seen') ?? 0,
|
|
214
|
+
load: r.attr('current_load') ?? 0,
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
async markRunning(uuid, attrs) {
|
|
218
|
+
const t = Math.floor(Date.now() / 1000);
|
|
219
|
+
await this.api.update('gaia_run', uuid, {
|
|
220
|
+
attributes: {
|
|
221
|
+
state: 'running',
|
|
222
|
+
heartbeat: t,
|
|
223
|
+
...(attrs?.worktree_path ? { worktree_path: attrs.worktree_path } : {}),
|
|
224
|
+
...(attrs?.agent ? { agent: attrs.agent } : {}),
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async markFailed(uuid, errorLog) {
|
|
229
|
+
const t = Math.floor(Date.now() / 1000);
|
|
230
|
+
await this.api.update('gaia_run', uuid, {
|
|
231
|
+
attributes: {
|
|
232
|
+
state: 'failed',
|
|
233
|
+
error_log: errorLog,
|
|
234
|
+
closed: true,
|
|
235
|
+
closed_date: t,
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async fetchFinalizableRuns(id) {
|
|
240
|
+
const rows = await this.api
|
|
241
|
+
.collection('gaia_run')
|
|
242
|
+
.where('conductor_id.machine_id', '=', id)
|
|
243
|
+
.where('state', '=', 'done')
|
|
244
|
+
.where('closed', '=', '0')
|
|
245
|
+
.fields(['worktree_path', 'agent', 'drupal_internal__id'])
|
|
246
|
+
.page(100)
|
|
247
|
+
.list();
|
|
248
|
+
// No `started_at` read: the run's duration_s is derived from the agent
|
|
249
|
+
// transcript at finalize (GAIA-151), so the conductor no longer round-trips
|
|
250
|
+
// the timestamp back through JSON:API.
|
|
251
|
+
return rows.map((r) => ({
|
|
252
|
+
runUuid: r.id,
|
|
253
|
+
// The `#<runId>` tab-label token: scopes the run's tab-close (GAIA-183).
|
|
254
|
+
runId: Number(r.attr('drupal_internal__id')),
|
|
255
|
+
worktreePath: r.attr('worktree_path') ?? '',
|
|
256
|
+
agent: r.attr('agent') ?? '',
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
async finalizeRun(uuid, log, metrics) {
|
|
260
|
+
const t = Math.floor(Date.now() / 1000);
|
|
261
|
+
await this.api.update('gaia_run', uuid, {
|
|
262
|
+
attributes: {
|
|
263
|
+
closed: true,
|
|
264
|
+
closed_date: t,
|
|
265
|
+
...(log ? { log } : {}),
|
|
266
|
+
...(metrics ?? {}),
|
|
267
|
+
},
|
|
268
|
+
}); // NO state — the run is already done.
|
|
269
|
+
}
|
|
270
|
+
async fetchUncleanedTickets(id) {
|
|
271
|
+
// Finished tickets whose worktree is not yet torn down (cleaned_up=0),
|
|
272
|
+
// driven by the durable `cleaned_up` flag (GAIA-89) AND scoped to this
|
|
273
|
+
// conductor (GAIA-121): only tickets assigned to `id` are loaded, so a
|
|
274
|
+
// ticket owned by another conductor never reaches the reaper loop. "Finished"
|
|
275
|
+
// = reached `done` OR already `closed`; the collection builder has no OR, so
|
|
276
|
+
// this is two AND-queries merged + de-duped by uuid. The `conductor_id`
|
|
277
|
+
// relationship carries the assigned conductor's `machine_id` — the same
|
|
278
|
+
// nested filter the sibling reaper queries (`fetchFinalizableRuns` etc.) use.
|
|
279
|
+
const fields = ['branch_name', 'state', 'closed'];
|
|
280
|
+
const done = await this.api
|
|
281
|
+
.collection('gaia_ticket')
|
|
282
|
+
.where('conductor_id.machine_id', '=', id)
|
|
283
|
+
.where('state', '=', 'done')
|
|
284
|
+
.where('cleaned_up', '=', '0')
|
|
285
|
+
.fields(fields)
|
|
286
|
+
.page(100)
|
|
287
|
+
.list();
|
|
288
|
+
const closed = await this.api
|
|
289
|
+
.collection('gaia_ticket')
|
|
290
|
+
.where('conductor_id.machine_id', '=', id)
|
|
291
|
+
.where('closed', '=', '1')
|
|
292
|
+
.where('cleaned_up', '=', '0')
|
|
293
|
+
.fields(fields)
|
|
294
|
+
.page(100)
|
|
295
|
+
.list();
|
|
296
|
+
const byUuid = new Map();
|
|
297
|
+
for (const r of [...done, ...closed]) {
|
|
298
|
+
byUuid.set(r.id, r);
|
|
299
|
+
}
|
|
300
|
+
return Promise.all([...byUuid.values()].map(async (r) => ({
|
|
301
|
+
ticketUuid: r.id,
|
|
302
|
+
branchName: r.attr('branch_name') ?? '',
|
|
303
|
+
state: r.attr('state') ?? '',
|
|
304
|
+
closed: r.attr('closed') ?? false,
|
|
305
|
+
worktreePath: await this.latestRunWorktree(r.id),
|
|
306
|
+
})));
|
|
307
|
+
}
|
|
308
|
+
async closeTicket(uuid) {
|
|
309
|
+
const t = Math.floor(Date.now() / 1000);
|
|
310
|
+
await this.api.update('gaia_ticket', uuid, {
|
|
311
|
+
attributes: { closed: true, closed_date: t },
|
|
312
|
+
}); // NO state — the ticket is already done.
|
|
313
|
+
}
|
|
314
|
+
async markTicketCleanedUp(uuid) {
|
|
315
|
+
await this.api.update('gaia_ticket', uuid, {
|
|
316
|
+
attributes: { cleaned_up: true },
|
|
317
|
+
}); // NO state/closed — teardown flag only, decoupled from the lifecycle.
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Absolute worktree path of the ticket's latest run (highest run id with a
|
|
321
|
+
* non-empty worktree_path), or '' when none — the cwd the cleanup command
|
|
322
|
+
* runs in. The conductor persists worktree_path on markRunning, so a done
|
|
323
|
+
* ticket's run carries the path even after the run closed.
|
|
324
|
+
*/
|
|
325
|
+
async latestRunWorktree(ticketUuid) {
|
|
326
|
+
const rows = await this.api
|
|
327
|
+
.collection('gaia_run')
|
|
328
|
+
.where('ticket_id.id', '=', ticketUuid)
|
|
329
|
+
.fields(['worktree_path', 'drupal_internal__id'])
|
|
330
|
+
.sort('-drupal_internal__id')
|
|
331
|
+
.page(100)
|
|
332
|
+
.list();
|
|
333
|
+
for (const r of rows) {
|
|
334
|
+
const path = r.attr('worktree_path');
|
|
335
|
+
if (path) {
|
|
336
|
+
return path;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return '';
|
|
340
|
+
}
|
|
341
|
+
async resolveTicketByIdentifier(project, identifier) {
|
|
342
|
+
// Scope by the project uuid: identifiers (GAIA-nnn) are unique per project,
|
|
343
|
+
// not globally, so a bare identifier filter could match another project's
|
|
344
|
+
// ticket in a multi-project instance.
|
|
345
|
+
const projectId = await this.projectUuid(project);
|
|
346
|
+
const t = await this.api
|
|
347
|
+
.collection('gaia_ticket')
|
|
348
|
+
.where('identifier', '=', identifier)
|
|
349
|
+
.where('project_id.id', '=', projectId)
|
|
350
|
+
.first();
|
|
351
|
+
if (!t) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
return { uuid: t.id, title: t.attr('title') ?? '' };
|
|
355
|
+
}
|
|
356
|
+
projectUuidCache = new Map();
|
|
357
|
+
async projectUuid(name) {
|
|
358
|
+
// A project's name→uuid never changes, so cache it: gatherBatch resolves
|
|
359
|
+
// many identifiers per `gaia deployment tickets` call, each of which would
|
|
360
|
+
// otherwise re-fetch the same project.
|
|
361
|
+
const cached = this.projectUuidCache.get(name);
|
|
362
|
+
if (cached) {
|
|
363
|
+
return cached;
|
|
364
|
+
}
|
|
365
|
+
const p = await this.api
|
|
366
|
+
.collection('gaia_project')
|
|
367
|
+
.where('name', '=', name)
|
|
368
|
+
.first();
|
|
369
|
+
if (!p) {
|
|
370
|
+
throw new Error(`gaia project "${name}" not found`);
|
|
371
|
+
}
|
|
372
|
+
this.projectUuidCache.set(name, p.id);
|
|
373
|
+
return p.id;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
export function drupalRemote() {
|
|
377
|
+
return {
|
|
378
|
+
kind: 'remote',
|
|
379
|
+
id: 'drupal',
|
|
380
|
+
requiredModules: [],
|
|
381
|
+
async createRemote(config) {
|
|
382
|
+
const http = createHttpClient();
|
|
383
|
+
const auth = await resolveAuth({
|
|
384
|
+
baseUrl: config.site.base_url,
|
|
385
|
+
plugins: config.plugins ?? [],
|
|
386
|
+
http,
|
|
387
|
+
now: Date.now,
|
|
388
|
+
// stateDir omitted → dropsh defaultStateDir() (~/.config/dropsh)
|
|
389
|
+
});
|
|
390
|
+
return new DrupalGaiaRemote(createJsonApiClient({
|
|
391
|
+
baseUrl: config.site.base_url,
|
|
392
|
+
prefix: config.site.jsonapi_prefix,
|
|
393
|
+
http,
|
|
394
|
+
auth: auth,
|
|
395
|
+
}));
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
export default drupalRemote;
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gaia-ai/addon-remote-drupal",
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "GAIA conductor remote addon: the Drupal/JSON:API control-plane remote.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/src/index.js",
|
|
9
|
+
"./preset": "./dist/src/preset.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist/src"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://git.key-tec.de/keytec/gaia.git",
|
|
20
|
+
"directory": "gaia-cli/addons/remote-drupal"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"dropsh": "^0.5.8"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@gaia-ai/conductor": "^0.6.1",
|
|
27
|
+
"@gaia-ai/core": "^0.6.1"
|
|
28
|
+
}
|
|
29
|
+
}
|