@gaia-ai/addon-remote-drupal 0.7.0 → 0.8.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.
@@ -23,17 +23,30 @@ export declare class DrupalGaiaRemote implements GaiaRemote {
23
23
  closeTicket(uuid: string): Promise<void>;
24
24
  markTicketCleanedUp(uuid: string): Promise<void>;
25
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
26
+ * UUID + absolute worktree path of the ticket's latest run (highest run id
27
+ * with a non-empty worktree_path), or empty strings when none — the cwd the
28
+ * cleanup command runs in, plus the record its teardown is noted on
29
+ * (GAIA-293). The conductor persists worktree_path on markRunning, so a done
29
30
  * ticket's run carries the path even after the run closed.
31
+ *
32
+ * The uuid rides along on the query that already resolves the path, so the
33
+ * reaper learns which run to write to at no extra round-trip.
30
34
  */
31
- private latestRunWorktree;
35
+ private latestRun;
36
+ appendRunNote(runUuid: string, note: string): Promise<void>;
32
37
  resolveTicketByIdentifier(project: string, identifier: string): Promise<{
33
38
  uuid: string;
34
39
  title: string;
35
40
  } | null>;
36
41
  private readonly projectUuidCache;
42
+ /**
43
+ * The project *name* behind a project uuid — the inverse of projectUuid.
44
+ *
45
+ * A conductor row references its project; the CLI and the cockpit want to
46
+ * show the name. Best-effort: an unreadable project must not fail a listing,
47
+ * so it degrades to '' and the row still renders.
48
+ */
49
+ private projectName;
37
50
  private projectUuid;
38
51
  }
39
52
  export declare function drupalRemote(): RemotePlugin;
package/dist/src/index.js CHANGED
@@ -1,8 +1,4 @@
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).
1
+ import { toUnixSeconds } from '@gaia-ai/core';
6
2
  import { resolveAuth } from 'dropsh';
7
3
  import { createHttpClient, createJsonApiClient } from 'dropsh/plugin';
8
4
  const ACTIVE = ['claimed', 'running'];
@@ -16,6 +12,22 @@ const ACTIVE = ['claimed', 'running'];
16
12
  * change, since it asks the workflow definition directly.
17
13
  */
18
14
  const TERMINAL = ['done', 'cancelled'];
15
+ /**
16
+ * The process-identity attributes a registration carries, if any (GAIA-232).
17
+ *
18
+ * Spread rather than assigned so an absent value sends no key at all: the
19
+ * server treats a missing key as "leave the stored value alone", which is what
20
+ * lets `poll` and `reap` heartbeat past a running conductor without wiping the
21
+ * pid it needs to be stoppable.
22
+ */
23
+ function processIdentity(reg) {
24
+ return {
25
+ ...(reg.last_pid === undefined ? {} : { last_pid: reg.last_pid }),
26
+ ...(reg.conductor_name === undefined
27
+ ? {}
28
+ : { conductor_name: reg.conductor_name }),
29
+ };
30
+ }
19
31
  export class DrupalGaiaRemote {
20
32
  api;
21
33
  constructor(api) {
@@ -156,6 +168,7 @@ export class DrupalGaiaRemote {
156
168
  label: reg.label,
157
169
  states: reg.states,
158
170
  max_parallel: reg.max_parallel,
171
+ ...processIdentity(reg),
159
172
  },
160
173
  relationships: {
161
174
  owner_user_id: {
@@ -186,6 +199,7 @@ export class DrupalGaiaRemote {
186
199
  max_parallel: reg.max_parallel,
187
200
  current_load: load,
188
201
  lease_seconds: lease,
202
+ ...processIdentity(reg),
189
203
  },
190
204
  },
191
205
  }));
@@ -215,14 +229,38 @@ export class DrupalGaiaRemote {
215
229
  col = col.where('owner_user_id.id', '=', await this.api.me());
216
230
  }
217
231
  const rows = await col.page(200).list();
218
- return rows.map((r) => ({
219
- id: r.attr('machine_id') ?? r.id,
220
- project: '',
221
- label: r.attr('label') ?? '',
222
- status: r.attr('status') ?? '',
223
- lastSeen: r.attr('last_seen') ?? 0,
224
- load: r.attr('current_load') ?? 0,
225
- }));
232
+ // Resolve each distinct project once rather than per row: a machine
233
+ // typically runs several conductors of the same project.
234
+ const projectNames = new Map();
235
+ for (const r of rows) {
236
+ const uuid = r.rel('project_id');
237
+ if (uuid && !projectNames.has(uuid)) {
238
+ projectNames.set(uuid, await this.projectName(uuid));
239
+ }
240
+ }
241
+ return rows.map((r) => {
242
+ // Spread the optionals conditionally: an absent field must be absent,
243
+ // not present-and-undefined (exactOptionalPropertyTypes), and a row
244
+ // written before GAIA-232 legitimately carries neither.
245
+ const pid = r.attr('last_pid');
246
+ const name = r.attr('conductor_name');
247
+ return {
248
+ id: r.attr('machine_id') ?? r.id,
249
+ project: projectNames.get(r.rel('project_id') ?? '') ?? '',
250
+ label: r.attr('label') ?? '',
251
+ status: r.attr('status') ?? '',
252
+ // ISO-8601 over the wire, not the unix integer the field name implies.
253
+ lastSeen: toUnixSeconds(r.attr('last_seen')) ?? 0,
254
+ load: r.attr('current_load') ?? 0,
255
+ workspaceRoot: r.attr('workspace_root') ?? '',
256
+ leaseExpiresAt: toUnixSeconds(r.attr('lease_expires_at')) ?? 0,
257
+ maxParallel: r.attr('max_parallel') ?? 0,
258
+ ...(typeof pid === 'number' ? { lastPid: pid } : {}),
259
+ ...(typeof name === 'string' && name !== ''
260
+ ? { conductorName: name }
261
+ : {}),
262
+ };
263
+ });
226
264
  }
227
265
  async markRunning(uuid, attrs) {
228
266
  const t = Math.floor(Date.now() / 1000);
@@ -317,7 +355,7 @@ export class DrupalGaiaRemote {
317
355
  branchName: r.attr('branch_name') ?? '',
318
356
  state: r.attr('state') ?? '',
319
357
  closed: r.attr('closed') ?? false,
320
- worktreePath: await this.latestRunWorktree(r.id),
358
+ ...(await this.latestRun(r.id)),
321
359
  })));
322
360
  }
323
361
  async closeTicket(uuid) {
@@ -332,12 +370,16 @@ export class DrupalGaiaRemote {
332
370
  }); // NO state/closed — teardown flag only, decoupled from the lifecycle.
333
371
  }
334
372
  /**
335
- * Absolute worktree path of the ticket's latest run (highest run id with a
336
- * non-empty worktree_path), or '' when none — the cwd the cleanup command
337
- * runs in. The conductor persists worktree_path on markRunning, so a done
373
+ * UUID + absolute worktree path of the ticket's latest run (highest run id
374
+ * with a non-empty worktree_path), or empty strings when none — the cwd the
375
+ * cleanup command runs in, plus the record its teardown is noted on
376
+ * (GAIA-293). The conductor persists worktree_path on markRunning, so a done
338
377
  * ticket's run carries the path even after the run closed.
378
+ *
379
+ * The uuid rides along on the query that already resolves the path, so the
380
+ * reaper learns which run to write to at no extra round-trip.
339
381
  */
340
- async latestRunWorktree(ticketUuid) {
382
+ async latestRun(ticketUuid) {
341
383
  const rows = await this.api
342
384
  .collection('gaia_run')
343
385
  .where('ticket_id.id', '=', ticketUuid)
@@ -348,10 +390,45 @@ export class DrupalGaiaRemote {
348
390
  for (const r of rows) {
349
391
  const path = r.attr('worktree_path');
350
392
  if (path) {
351
- return path;
393
+ return { runUuid: r.id, worktreePath: path };
352
394
  }
353
395
  }
354
- return '';
396
+ return { runUuid: '', worktreePath: '' };
397
+ }
398
+ async appendRunNote(runUuid, note) {
399
+ if (!runUuid) {
400
+ return; // no run to record anything on
401
+ }
402
+ // Read `log` ALONE, through the sparse fieldset every neighbouring query in
403
+ // this file uses. A note append has no business pulling the run's
404
+ // relationships and its dozen other attributes, and `log` is already the
405
+ // heaviest field on the row.
406
+ const run = await this.api
407
+ .collection('gaia_run')
408
+ .where('id', '=', runUuid)
409
+ .fields(['log'])
410
+ .first();
411
+ if (!run) {
412
+ // Refuse rather than guess. With no row read, `existing` below would be ''
413
+ // and the PATCH would REPLACE the transcript with this one diagnostic
414
+ // line — destroying exactly what the note exists to preserve. An
415
+ // unreadable run is the reaper's best-effort catch (it warns and still
416
+ // flags the ticket), never a silent truncation.
417
+ throw new Error(`gaia_run ${runUuid} could not be read — run note not written`);
418
+ }
419
+ // `log` is a `text_long` field, so JSON:API sends it as
420
+ // `{ value, format, processed }` — not a bare string. Reading it through an
421
+ // `attr<string>` cast cannot fail at runtime, it just yields the object, and
422
+ // interpolating that wrote the literal `[object Object]` over the run's
423
+ // transcript. Read the main property, and keep accepting a plain string so a
424
+ // backend (or a double) handing one back still appends correctly.
425
+ const raw = run.attr('log');
426
+ const existing = typeof raw === 'string'
427
+ ? raw
428
+ : (raw?.value ?? '');
429
+ await this.api.update('gaia_run', runUuid, {
430
+ attributes: { log: existing ? `${existing}\n${note}` : note },
431
+ }); // NO state/closed — the run is already closed.
355
432
  }
356
433
  async resolveTicketByIdentifier(project, identifier) {
357
434
  // Scope by the project uuid: identifiers (GAIA-nnn) are unique per project,
@@ -369,6 +446,22 @@ export class DrupalGaiaRemote {
369
446
  return { uuid: t.id, title: t.attr('title') ?? '' };
370
447
  }
371
448
  projectUuidCache = new Map();
449
+ /**
450
+ * The project *name* behind a project uuid — the inverse of projectUuid.
451
+ *
452
+ * A conductor row references its project; the CLI and the cockpit want to
453
+ * show the name. Best-effort: an unreadable project must not fail a listing,
454
+ * so it degrades to '' and the row still renders.
455
+ */
456
+ async projectName(uuid) {
457
+ try {
458
+ const p = await this.api.resource('gaia_project', uuid);
459
+ return p.attr('name') ?? '';
460
+ }
461
+ catch {
462
+ return '';
463
+ }
464
+ }
372
465
  async projectUuid(name) {
373
466
  // A project's name→uuid never changes, so cache it: gatherBatch resolves
374
467
  // many identifiers per `gaia deployment tickets` call, each of which would
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaia-ai/addon-remote-drupal",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "GAIA conductor remote addon: the Drupal/JSON:API control-plane remote.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,7 +23,7 @@
23
23
  "dropsh": "^0.5.8"
24
24
  },
25
25
  "peerDependencies": {
26
- "@gaia-ai/conductor": "^0.7.0",
27
- "@gaia-ai/core": "^0.7.0"
26
+ "@gaia-ai/conductor": "^0.8.0",
27
+ "@gaia-ai/core": "^0.8.0"
28
28
  }
29
29
  }