@gaia-ai/addon-gaia-ui 0.11.2 → 0.11.3

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.
@@ -17,7 +17,18 @@ export declare const ROOT_TABS: readonly [{
17
17
  readonly id: "teams";
18
18
  readonly label: "Teams";
19
19
  }];
20
- /** The project level: the tabs of an opened project. */
20
+ /**
21
+ * The project level: the tabs of an opened project.
22
+ *
23
+ * GAIA-450 added `conductors` (`ProjectConductorsScreen`, `project.conductors`,
24
+ * order 50) — LAST, by the same "which project it serves is a COLUMN of that
25
+ * screen" argument GAIA-232 made for the root level, applied the other way:
26
+ * once a project IS chosen, seeing who serves it is a natural final stop
27
+ * beside Tickets, Ticket-Agents and Runs. Missing here — as it was for one
28
+ * `coding` pass, review B11 — makes the screen and its route entirely
29
+ * unreachable by Tab or click: `tabsOfLocation` reads this list, not the
30
+ * route registry's `tab` metadata, for what a level cycles through.
31
+ */
21
32
  export declare const PROJECT_TABS: readonly [{
22
33
  readonly id: "dashboard";
23
34
  readonly label: "Dashboard";
@@ -30,6 +41,9 @@ export declare const PROJECT_TABS: readonly [{
30
41
  }, {
31
42
  readonly id: "runs";
32
43
  readonly label: "Runs";
44
+ }, {
45
+ readonly id: "conductors";
46
+ readonly label: "Conductors";
33
47
  }];
34
48
  /**
35
49
  * The ticket level: the sections of ONE OPEN TICKET — GAIA-346.
@@ -139,6 +153,9 @@ export declare const TAB_LEVELS: {
139
153
  }, {
140
154
  readonly id: "runs";
141
155
  readonly label: "Runs";
156
+ }, {
157
+ readonly id: "conductors";
158
+ readonly label: "Conductors";
142
159
  }];
143
160
  readonly ticket: readonly [{
144
161
  readonly id: "overview";
@@ -5,7 +5,8 @@
5
5
  // root dashboard, projects, project-picker → Dashboard · Projects ·
6
6
  // Conductors · Teams
7
7
  // project dashboard, project, ticket-agents, → Dashboard · Tickets ·
8
- // project-runs Ticket-Agents · Runs
8
+ // project-runs, project-conductors Ticket-Agents · Runs ·
9
+ // Conductors
9
10
  // ticket overview, comments, related, runs → the sections of one open
10
11
  // ticket, in the bar row every
11
12
  // other level uses (GAIA-346)
@@ -82,12 +83,24 @@ export const ROOT_TABS = [
82
83
  // rows still open one person directly.
83
84
  { id: 'teams', label: 'Teams' },
84
85
  ];
85
- /** The project level: the tabs of an opened project. */
86
+ /**
87
+ * The project level: the tabs of an opened project.
88
+ *
89
+ * GAIA-450 added `conductors` (`ProjectConductorsScreen`, `project.conductors`,
90
+ * order 50) — LAST, by the same "which project it serves is a COLUMN of that
91
+ * screen" argument GAIA-232 made for the root level, applied the other way:
92
+ * once a project IS chosen, seeing who serves it is a natural final stop
93
+ * beside Tickets, Ticket-Agents and Runs. Missing here — as it was for one
94
+ * `coding` pass, review B11 — makes the screen and its route entirely
95
+ * unreachable by Tab or click: `tabsOfLocation` reads this list, not the
96
+ * route registry's `tab` metadata, for what a level cycles through.
97
+ */
86
98
  export const PROJECT_TABS = [
87
99
  { id: 'dashboard', label: 'Dashboard' },
88
100
  { id: 'tickets', label: 'Tickets' },
89
101
  { id: 'ticket-agents', label: 'Ticket-Agents' },
90
102
  { id: 'runs', label: 'Runs' },
103
+ { id: 'conductors', label: 'Conductors' },
91
104
  ];
92
105
  /**
93
106
  * The ticket level: the sections of ONE OPEN TICKET — GAIA-346.
@@ -41,18 +41,39 @@ export interface ConductorRow extends Conductor {
41
41
  lastPid: number | undefined;
42
42
  /** The config stem it was started from, as `--conductor <name>` takes it. */
43
43
  conductorName: string | undefined;
44
+ /**
45
+ * The MachineContext host stem this record last heartbeat with (GAIA-450
46
+ * AC-4/B7) — what a stop compares against this machine's own before
47
+ * signalling. Resolved from the related `gaia_machine` (`host_key` is that
48
+ * entity's field, never `gaia_conductor`'s), never from the conductor row
49
+ * itself.
50
+ */
51
+ hostKey: string | undefined;
44
52
  /** Liveness, derived — never the raw `status`. */
45
53
  derivedStatus: ConductorStatus;
46
54
  /**
47
55
  * Whether this conductor's workspace exists on THIS machine.
48
56
  *
49
57
  * The control plane is user-scoped, not machine-scoped, so a user's other
50
- * machines' conductors appear in this list. They are shown rather than hidden
51
- * — filtering by machine_id would mean substring-matching a composed string —
52
- * but only a local row can be started or stopped from here, because a pid
53
- * from another machine means nothing on this one.
58
+ * machines' conductors appear in this list. They are shown rather than
59
+ * hidden — filtering by machine_id would mean substring-matching a composed
60
+ * string. This is a FILESYSTEM fact only (used to decide whether the log
61
+ * tail can read a local file); it is not an identity gate — see {@link mine}
62
+ * for that.
54
63
  */
55
64
  isLocal: boolean;
65
+ /**
66
+ * Whether this row's `hostKey` matches THIS machine's own (GAIA-450 B10) —
67
+ * the identity gate for starting/stopping and for the view-only marking.
68
+ *
69
+ * Deliberately distinct from {@link isLocal}: two `gaia_conductor` records
70
+ * can name the SAME `workspace_root` under different machines (GAIA-281
71
+ * Finding 2), which makes `isLocal` true for both — a path is not proof of
72
+ * identity, which is the whole thesis this ticket is built on. `false` when
73
+ * `hostKey` is absent (a record from before GAIA-450, or an unreadable
74
+ * machine): an unproven identity is never treated as this machine's own.
75
+ */
76
+ mine: boolean;
56
77
  }
57
78
  /** What a conductor ROW cannot know on its own, handed to the columns per frame. */
58
79
  export interface ConductorListContext {
@@ -65,12 +86,12 @@ export interface ConductorListContext {
65
86
  readonly projectNames: ReadonlyMap<string, string>;
66
87
  }
67
88
  /**
68
- * The conductors list, declared once (GAIA-345 D2/D10) — its eight columns, the
89
+ * The conductors list, declared once (GAIA-345 D2/D10) — its nine columns, the
69
90
  * noun every one of its node ids is prefixed with, and what it says when it is
70
91
  * empty.
71
92
  *
72
93
  * It GAINED A HEADER with this ticket (D6). This is the list a header earns its
73
- * keep in most: eight columns of ids, paths and numbers are not self-describing
94
+ * keep in most: nine columns of ids, paths and numbers are not self-describing
74
95
  * the way three named ones were. The consequence is stated because it is a
75
96
  * behaviour change — the list grows one row at the top and its selection
76
97
  * highlight moves down one physical row. Nothing computes that offset:
@@ -94,7 +115,7 @@ export interface ConductorListContext {
94
115
  * than as a bound, so a narrow terminal leaves it exactly the columns it had
95
116
  * before this ticket and a wide one hands it a share.
96
117
  *
97
- * The other six do not grow, and each for its own reason. `MACHINE` and
118
+ * The other seven do not grow, and each for its own reason. `MACHINE` and
98
119
  * `WORKSPACE` are free text too, and are the ONE exception to the rule: they
99
120
  * are elided from the LEFT (see {@link elideLeft}), which needs a declared
100
121
  * width to cut against and keeps the distinguishing tail — a grower would hand
@@ -102,7 +123,8 @@ export interface ConductorListContext {
102
123
  * elision preserves. `STATUS`, `AGE` and `PID` are closed vocabularies that
103
124
  * cannot use a surplus at all. `MARKS` is a closed vocabulary too and keeps a
104
125
  * real budget for a measured reason: `width: 0, grow: 0` collapses it to zero
105
- * columns and the mark vanishes.
126
+ * columns and the mark vanishes. `LOAD` (GAIA-450 AC-7) is a fixed `<current>/
127
+ * <max>` ratio, not free text, so it takes a budget rather than a share too.
106
128
  */
107
129
  export declare const CONDUCTOR_TABLE: {
108
130
  readonly noun: "conductor";
@@ -162,7 +184,7 @@ export declare const CONDUCTOR_TABLE: {
162
184
  readonly get: (row: ConductorRow, ctx: ConductorListContext) => string;
163
185
  }, {
164
186
  /** A CLOSED VOCABULARY. `ConductorStatus`'s reachable members here are
165
- * `running | stale | offline` — this screen can never produce
187
+ * `online | stale | offline` — this screen can never produce
166
188
  * `registry-only`, because every row IS a control-plane record — whose
167
189
  * longest is 7, so 9 shows each of them whole. */
168
190
  readonly key: "status";
@@ -181,7 +203,7 @@ export declare const CONDUCTOR_TABLE: {
181
203
  }, {
182
204
  readonly key: "workspace";
183
205
  readonly label: "WORKSPACE";
184
- readonly width: 24;
206
+ readonly width: 18;
185
207
  readonly fields: readonly ["workspace_root"];
186
208
  readonly get: (row: ConductorRow) => string;
187
209
  }, {
@@ -199,26 +221,159 @@ export declare const CONDUCTOR_TABLE: {
199
221
  * trailing slot it used to hold: everything it can say is `…`,
200
222
  * `elsewhere` or both, and 11 is `… elsewhere`.
201
223
  *
202
- * A non-local conductor is MARKED rather than hidden. The control plane
203
- * is user-scoped, not machine-scoped, so a user's other machines'
224
+ * A conductor that is not MINE is MARKED rather than hidden. The control
225
+ * plane is user-scoped, not machine-scoped, so a user's other machines'
204
226
  * conductors are legitimately in this list — and silently dropping them
205
227
  * would make the cockpit disagree with `gaia conductor ls` for no visible
206
- * reason. The mark says why their actions are inert here: a pid from
207
- * another machine means nothing on this one.
228
+ * reason. The mark says why their actions are inert here: an identity
229
+ * that is not this machine's own (GAIA-450 B10 — `mine`, not `isLocal`'s
230
+ * path check, since two records can share one `workspace_root` under
231
+ * different machines).
208
232
  */
209
233
  readonly key: "marks";
210
234
  readonly label: "MARKS";
211
235
  readonly width: 11;
212
236
  readonly fields: readonly ["workspace_root"];
213
237
  readonly get: (row: ConductorRow, ctx: ConductorListContext) => string;
238
+ }, {
239
+ /**
240
+ * `<current>/<max>` (GAIA-450 AC-7) — the utilization `capacityOf()`
241
+ * already computes from the sideloaded machine, reaching the row but no
242
+ * column until this one. 5 fits every realistic pair (`max_parallel` is
243
+ * a small hand-set integer); a triple-digit load would overflow it, and
244
+ * that is a capacity-planning problem this list only reports, not one
245
+ * this column need budget for.
246
+ */
247
+ readonly key: "load";
248
+ readonly label: "LOAD";
249
+ readonly width: 5;
250
+ readonly fields: readonly ["current_load", "max_parallel"];
251
+ readonly get: (row: ConductorRow) => string;
214
252
  }];
215
253
  };
254
+ /**
255
+ * The project-scoped Conductors tab's table (GAIA-450 D13) — the SAME column
256
+ * definitions as {@link CONDUCTOR_TABLE}, minus `project`: every row is
257
+ * already this one project's, so the column would repeat one value down the
258
+ * whole list. No new component or column is declared; this reuses the exact
259
+ * objects, so the two lists cannot drift out of step with each other.
260
+ */
261
+ export declare const PROJECT_CONDUCTOR_TABLE: {
262
+ readonly columns: ({
263
+ /**
264
+ * A GROWER — free text, right-truncated, and the column an operator reads
265
+ * first. Declared FIRST and growing all the same: `flexGrow` is a
266
+ * property of the cell, not of its position in the row (measured, spec
267
+ * R2). Its `grow: 2` against `PROJECT`'s 1 is the identity column's larger
268
+ * claim on the surplus, argued above.
269
+ *
270
+ * `min: 20` is the ONE floor on this list, and it is preservation rather
271
+ * than invention: 20 is the budget this column has carried since
272
+ * GAIA-232, and without it a narrow terminal squeezes the grower to
273
+ * nothing while seven fixed budgets keep theirs — measured at 80 columns,
274
+ * NAME came out ONE column wide and the label read `c…`. The other seven
275
+ * columns need 106 columns between them, so below ~126 the row runs past
276
+ * the right edge and the renderer clips it there. That is exactly what
277
+ * this list did before this ticket, at every width; the floor keeps the
278
+ * narrow case as it was and lets the wide case improve.
279
+ */
280
+ readonly key: "name";
281
+ readonly label: "NAME";
282
+ readonly width: 0;
283
+ readonly min: 20;
284
+ readonly grow: 2;
285
+ readonly fields: readonly ["label", "machine_id"];
286
+ readonly get: (row: ConductorRow) => string;
287
+ } | {
288
+ readonly key: "machine";
289
+ readonly label: "MACHINE";
290
+ readonly width: 20;
291
+ readonly fields: readonly ["machine_id"];
292
+ readonly get: (row: ConductorRow) => string;
293
+ } | {
294
+ /** A CLOSED VOCABULARY. `ConductorStatus`'s reachable members here are
295
+ * `online | stale | offline` — this screen can never produce
296
+ * `registry-only`, because every row IS a control-plane record — whose
297
+ * longest is 7, so 9 shows each of them whole. */
298
+ readonly key: "status";
299
+ readonly label: "STATUS";
300
+ readonly width: 9;
301
+ readonly fields: readonly ["status", "lease_expires_at", "last_seen"];
302
+ readonly get: (row: ConductorRow) => ConductorStatus;
303
+ } | {
304
+ /** `humanizeAge`'s own vocabulary, exactly as on the projects list: `—`,
305
+ * `just now`, `<n>m|h|d ago`. 8 is the longest of them. */
306
+ readonly key: "age";
307
+ readonly label: "AGE";
308
+ readonly width: 8;
309
+ readonly fields: readonly ["last_seen"];
310
+ readonly get: (row: ConductorRow, ctx: ConductorListContext) => string;
311
+ } | {
312
+ readonly key: "workspace";
313
+ readonly label: "WORKSPACE";
314
+ readonly width: 18;
315
+ readonly fields: readonly ["workspace_root"];
316
+ readonly get: (row: ConductorRow) => string;
317
+ } | {
318
+ /** A pid is at most 7 digits — Linux's `pid_max` ceiling is 4 194 304 —
319
+ * and a conductor that never reported one shows `-` rather than a
320
+ * fabricated number. */
321
+ readonly key: "pid";
322
+ readonly label: "PID";
323
+ readonly width: 7;
324
+ readonly fields: readonly ["last_pid"];
325
+ readonly get: (row: ConductorRow) => string;
326
+ } | {
327
+ /**
328
+ * A closed vocabulary too, which is why it takes a budget instead of the
329
+ * trailing slot it used to hold: everything it can say is `…`,
330
+ * `elsewhere` or both, and 11 is `… elsewhere`.
331
+ *
332
+ * A conductor that is not MINE is MARKED rather than hidden. The control
333
+ * plane is user-scoped, not machine-scoped, so a user's other machines'
334
+ * conductors are legitimately in this list — and silently dropping them
335
+ * would make the cockpit disagree with `gaia conductor ls` for no visible
336
+ * reason. The mark says why their actions are inert here: an identity
337
+ * that is not this machine's own (GAIA-450 B10 — `mine`, not `isLocal`'s
338
+ * path check, since two records can share one `workspace_root` under
339
+ * different machines).
340
+ */
341
+ readonly key: "marks";
342
+ readonly label: "MARKS";
343
+ readonly width: 11;
344
+ readonly fields: readonly ["workspace_root"];
345
+ readonly get: (row: ConductorRow, ctx: ConductorListContext) => string;
346
+ } | {
347
+ /**
348
+ * `<current>/<max>` (GAIA-450 AC-7) — the utilization `capacityOf()`
349
+ * already computes from the sideloaded machine, reaching the row but no
350
+ * column until this one. 5 fits every realistic pair (`max_parallel` is
351
+ * a small hand-set integer); a triple-digit load would overflow it, and
352
+ * that is a capacity-planning problem this list only reports, not one
353
+ * this column need budget for.
354
+ */
355
+ readonly key: "load";
356
+ readonly label: "LOAD";
357
+ readonly width: 5;
358
+ readonly fields: readonly ["current_load", "max_parallel"];
359
+ readonly get: (row: ConductorRow) => string;
360
+ })[];
361
+ readonly noun: "conductor";
362
+ readonly emptyMessage: "No conductors on this control plane.";
363
+ };
216
364
  /** What the row projection needs from the outside world, injected for tests. */
217
365
  export interface ConductorRowContext {
218
366
  /** Unix seconds; the liveness derivation's clock. */
219
367
  now: number;
220
368
  /** Whether a path exists on this machine. */
221
369
  exists: (path: string) => boolean;
370
+ /**
371
+ * THIS machine's own `host_key` (GAIA-450 B10), resolved once by the caller
372
+ * before the read — the same MachineContext value `signalVerifiedProcess`
373
+ * and the CLI's `hostKeyOf(config)` compare against. Compared against each
374
+ * row's own `hostKey` to derive {@link ConductorRow.mine}.
375
+ */
376
+ thisHostKey: string;
222
377
  }
223
378
  /**
224
379
  * The conductor collection as cockpit rows, in server order.
@@ -227,8 +382,12 @@ export interface ConductorRowContext {
227
382
  * not removed conductor fields. Swallows a read failure into an empty list on
228
383
  * purpose: the Conductors screen re-reads every 5 s, so one failed poll must
229
384
  * leave the screen navigable rather than replace it with an error.
385
+ *
386
+ * GAIA-450 D13: an optional `projectId` narrows the read to the project's own
387
+ * conductors — the project-scoped Conductors tab's whole filter, applied at
388
+ * the query rather than client-side so the read stays one round trip.
230
389
  */
231
- export declare const listConductorRows: (client: DropshClient, ctx: ConductorRowContext) => Promise<ConductorRow[]>;
390
+ export declare const listConductorRows: (client: DropshClient, ctx: ConductorRowContext, projectId?: string) => Promise<ConductorRow[]>;
232
391
  /**
233
392
  * Project uuid → name, for the Conductors screen's project column.
234
393
  *
@@ -65,31 +65,37 @@ const MACHINE_ID_WIDTH = 20;
65
65
  * STATUS 9, AGE 8, WORKSPACE 30, PID 7, MARKS 11, plus seven separators) while
66
66
  * `gaia ui` opens at 120, so the terminal cut the LAST column: measured on a
67
67
  * real pty, the header ended `PID MAR` and a non-local conductor's
68
- * `elsewhere` came out `elsew`. 24 brings the row to exactly 120.
68
+ * `elsewhere` came out `elsew`. GAIA-345 brought it to 24, fitting exactly 120.
69
+ *
70
+ * GAIA-450 AC-7 spent six of those back: adding `LOAD` (its own 5-wide budget
71
+ * plus the separator every column past the first pays) reopened the same
72
+ * 126-column overrun this constant exists to close, so `WORKSPACE` pays the
73
+ * same six columns again — 18, not 24 — to keep the row at exactly 120.
69
74
  *
70
75
  * WHY THIS COLUMN GIVES THE SIX. `WORKSPACE` is the only column in the row whose
71
76
  * cut is free: it is elided from the LEFT, so it loses six columns of the HEAD —
72
77
  * the `/home/<user>/projects/…` prefix every conductor workspace on a machine
73
78
  * shares — and keeps the distinguishing tail either way. Every other column
74
79
  * would pay in information: `MACHINE` elides left too but is already the
75
- * narrower of the pair, the three closed vocabularies are sized to their own
76
- * longest member, and `NAME`/`PROJECT` are the growers this ticket widened.
80
+ * narrower of the pair, the three closed vocabularies (and `LOAD`'s fixed
81
+ * ratio) are sized to their own longest member, and `NAME`/`PROJECT` are the
82
+ * growers this ticket widened.
77
83
  *
78
- * A DECLARED BUDGET, never a measurement (spec D4). 24 is not the width of
84
+ * A DECLARED BUDGET, never a measurement (spec D4). 18 is not the width of
79
85
  * anyone's path — no page's data is consulted to arrive at it, so the columns do
80
86
  * not move when the list does. It is shared by the column's `width` and by the
81
87
  * {@link elideLeft} its accessor applies for exactly that reason: two numbers
82
88
  * could drift, and a marker cut at one width inside a cell laid out at another
83
89
  * is the defect the single constant makes unspellable.
84
90
  */
85
- const PATH_WIDTH = 24;
91
+ const PATH_WIDTH = 18;
86
92
  /**
87
- * The conductors list, declared once (GAIA-345 D2/D10) — its eight columns, the
93
+ * The conductors list, declared once (GAIA-345 D2/D10) — its nine columns, the
88
94
  * noun every one of its node ids is prefixed with, and what it says when it is
89
95
  * empty.
90
96
  *
91
97
  * It GAINED A HEADER with this ticket (D6). This is the list a header earns its
92
- * keep in most: eight columns of ids, paths and numbers are not self-describing
98
+ * keep in most: nine columns of ids, paths and numbers are not self-describing
93
99
  * the way three named ones were. The consequence is stated because it is a
94
100
  * behaviour change — the list grows one row at the top and its selection
95
101
  * highlight moves down one physical row. Nothing computes that offset:
@@ -113,7 +119,7 @@ const PATH_WIDTH = 24;
113
119
  * than as a bound, so a narrow terminal leaves it exactly the columns it had
114
120
  * before this ticket and a wide one hands it a share.
115
121
  *
116
- * The other six do not grow, and each for its own reason. `MACHINE` and
122
+ * The other seven do not grow, and each for its own reason. `MACHINE` and
117
123
  * `WORKSPACE` are free text too, and are the ONE exception to the rule: they
118
124
  * are elided from the LEFT (see {@link elideLeft}), which needs a declared
119
125
  * width to cut against and keeps the distinguishing tail — a grower would hand
@@ -121,7 +127,8 @@ const PATH_WIDTH = 24;
121
127
  * elision preserves. `STATUS`, `AGE` and `PID` are closed vocabularies that
122
128
  * cannot use a surplus at all. `MARKS` is a closed vocabulary too and keeps a
123
129
  * real budget for a measured reason: `width: 0, grow: 0` collapses it to zero
124
- * columns and the mark vanishes.
130
+ * columns and the mark vanishes. `LOAD` (GAIA-450 AC-7) is a fixed `<current>/
131
+ * <max>` ratio, not free text, so it takes a budget rather than a share too.
125
132
  */
126
133
  export const CONDUCTOR_TABLE = {
127
134
  noun: 'conductor',
@@ -188,7 +195,7 @@ export const CONDUCTOR_TABLE = {
188
195
  },
189
196
  {
190
197
  /** A CLOSED VOCABULARY. `ConductorStatus`'s reachable members here are
191
- * `running | stale | offline` — this screen can never produce
198
+ * `online | stale | offline` — this screen can never produce
192
199
  * `registry-only`, because every row IS a control-plane record — whose
193
200
  * longest is 7, so 9 shows each of them whole. */
194
201
  key: 'status',
@@ -229,23 +236,51 @@ export const CONDUCTOR_TABLE = {
229
236
  * trailing slot it used to hold: everything it can say is `…`,
230
237
  * `elsewhere` or both, and 11 is `… elsewhere`.
231
238
  *
232
- * A non-local conductor is MARKED rather than hidden. The control plane
233
- * is user-scoped, not machine-scoped, so a user's other machines'
239
+ * A conductor that is not MINE is MARKED rather than hidden. The control
240
+ * plane is user-scoped, not machine-scoped, so a user's other machines'
234
241
  * conductors are legitimately in this list — and silently dropping them
235
242
  * would make the cockpit disagree with `gaia conductor ls` for no visible
236
- * reason. The mark says why their actions are inert here: a pid from
237
- * another machine means nothing on this one.
243
+ * reason. The mark says why their actions are inert here: an identity
244
+ * that is not this machine's own (GAIA-450 B10 — `mine`, not `isLocal`'s
245
+ * path check, since two records can share one `workspace_root` under
246
+ * different machines).
238
247
  */
239
248
  key: 'marks',
240
249
  label: 'MARKS',
241
250
  width: 11,
242
251
  fields: ['workspace_root'],
243
- get: (row, ctx) => [ctx.busyIds.has(row.id) ? '…' : '', row.isLocal ? '' : 'elsewhere']
252
+ get: (row, ctx) => [ctx.busyIds.has(row.id) ? '…' : '', row.mine ? '' : 'elsewhere']
244
253
  .filter((mark) => mark !== '')
245
254
  .join(' '),
246
255
  },
256
+ {
257
+ /**
258
+ * `<current>/<max>` (GAIA-450 AC-7) — the utilization `capacityOf()`
259
+ * already computes from the sideloaded machine, reaching the row but no
260
+ * column until this one. 5 fits every realistic pair (`max_parallel` is
261
+ * a small hand-set integer); a triple-digit load would overflow it, and
262
+ * that is a capacity-planning problem this list only reports, not one
263
+ * this column need budget for.
264
+ */
265
+ key: 'load',
266
+ label: 'LOAD',
267
+ width: 5,
268
+ fields: ['current_load', 'max_parallel'],
269
+ get: (row) => `${row.currentLoad}/${row.maxParallel}`,
270
+ },
247
271
  ],
248
272
  };
273
+ /**
274
+ * The project-scoped Conductors tab's table (GAIA-450 D13) — the SAME column
275
+ * definitions as {@link CONDUCTOR_TABLE}, minus `project`: every row is
276
+ * already this one project's, so the column would repeat one value down the
277
+ * whole list. No new component or column is declared; this reuses the exact
278
+ * objects, so the two lists cannot drift out of step with each other.
279
+ */
280
+ export const PROJECT_CONDUCTOR_TABLE = {
281
+ ...CONDUCTOR_TABLE,
282
+ columns: CONDUCTOR_TABLE.columns.filter((column) => column.key !== 'project'),
283
+ };
249
284
  const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
250
285
  /** Id of a single-valued relationship linkage (`data` object), or null. */
251
286
  function singleRelId(rel) {
@@ -294,6 +329,18 @@ function capacityOf(raw, machinesById) {
294
329
  maxParallel: num(machine?.attributes?.max_parallel) ?? num(attrs.max_parallel) ?? 0,
295
330
  };
296
331
  }
332
+ /**
333
+ * `host_key` from the sideloaded machine (GAIA-450 B7) — `gaia_conductor`
334
+ * carries no such field at all; `filter[host_key]` against it is a
335
+ * server-side 400. Reading `raw.attributes.host_key` here would repeat the
336
+ * exact defect the CLI's `listConductors()` had: always `undefined` in
337
+ * reality, present only in a fixture that hand-writes it on the wrong entity.
338
+ */
339
+ function hostKeyOf(raw, machinesById) {
340
+ const machineId = singleRelId(raw.relationships?.machine);
341
+ const machine = machineId ? machinesById.get(machineId) : undefined;
342
+ return str(machine?.attributes?.host_key);
343
+ }
297
344
  const toConductorRow = (raw, ctx, machinesById) => {
298
345
  const r = asResource(raw);
299
346
  const workspaceRoot = str(r.attr('workspace_root')) ?? '';
@@ -302,6 +349,7 @@ const toConductorRow = (raw, ctx, machinesById) => {
302
349
  const lastSeen = toUnixSeconds(r.attr('last_seen')) ?? 0;
303
350
  const leaseExpiresAt = toUnixSeconds(r.attr('lease_expires_at')) ?? 0;
304
351
  const { currentLoad, maxParallel } = capacityOf(raw, machinesById);
352
+ const hostKey = hostKeyOf(raw, machinesById);
305
353
  return {
306
354
  ...toConductor(r),
307
355
  workspaceRoot,
@@ -311,6 +359,7 @@ const toConductorRow = (raw, ctx, machinesById) => {
311
359
  maxParallel,
312
360
  lastPid: num(r.attr('last_pid')),
313
361
  conductorName: str(r.attr('conductor_name')),
362
+ hostKey,
314
363
  // `present: true` because the row IS a control-plane record — which is why
315
364
  // `registry-only` can never come out of this screen.
316
365
  derivedStatus: deriveConductorStatus({
@@ -322,6 +371,9 @@ const toConductorRow = (raw, ctx, machinesById) => {
322
371
  // A conductor that never reported a workspace cannot be local, whatever the
323
372
  // filesystem would say about an empty path.
324
373
  isLocal: workspaceRoot !== '' && ctx.exists(workspaceRoot),
374
+ // GAIA-450 B10: an absent hostKey is never waved through as "mine" — an
375
+ // unproven identity is refused, the same rule signalVerifiedProcess uses.
376
+ mine: hostKey !== undefined && hostKey === ctx.thisHostKey,
325
377
  };
326
378
  };
327
379
  /**
@@ -331,9 +383,15 @@ const toConductorRow = (raw, ctx, machinesById) => {
331
383
  * not removed conductor fields. Swallows a read failure into an empty list on
332
384
  * purpose: the Conductors screen re-reads every 5 s, so one failed poll must
333
385
  * leave the screen navigable rather than replace it with an error.
386
+ *
387
+ * GAIA-450 D13: an optional `projectId` narrows the read to the project's own
388
+ * conductors — the project-scoped Conductors tab's whole filter, applied at
389
+ * the query rather than client-side so the read stays one round trip.
334
390
  */
335
- export const listConductorRows = async (client, ctx) => {
391
+ export const listConductorRows = async (client, ctx, projectId) => {
336
392
  const params = new DrupalJsonApiParams().addInclude(['machine']);
393
+ if (projectId)
394
+ params.addFilter('project_id.id', projectId);
337
395
  try {
338
396
  const doc = (await client.get('gaia_conductor/gaia_conductor', params));
339
397
  const machinesById = new Map();
@@ -14,7 +14,14 @@
14
14
  // path comparison, never a longest-prefix walk. So a worktree — which has its
15
15
  // own `.gaia` — resolves to no conductor rather than quietly inheriting its
16
16
  // parent checkout's project, exactly as before.
17
- import { resolve } from 'node:path';
17
+ //
18
+ // GAIA-450 D3/D15: `workspace_root` now holds the checkout root, not this
19
+ // `.gaia` config directory, because every heartbeat rewrites the field going
20
+ // forward. A record written before that change still carries the legacy
21
+ // `.gaia` value until its next heartbeat, so the match accepts EITHER form —
22
+ // the derived checkout root (`dirname(gaiaDir)`) or the legacy `gaiaDir`
23
+ // itself — so a cockpit opened against an un-healed record still resolves.
24
+ import { dirname, resolve } from 'node:path';
18
25
  import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
19
26
  const NOTHING = { machineId: undefined, project: undefined };
20
27
  /** Id of a single-valued relationship linkage (`data` object), or null. */
@@ -35,12 +42,19 @@ const str = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
35
42
  * which the writer sees and can change.
36
43
  */
37
44
  export function selectOwnConductor(doc, gaiaDir) {
38
- const target = resolve(gaiaDir);
45
+ // The current form (GAIA-450 D3): workspace_root is the checkout root.
46
+ const checkoutRoot = resolve(dirname(gaiaDir));
47
+ // The legacy form: a record whose heartbeat has not yet rewritten the
48
+ // field still carries the `.gaia` config directory itself.
49
+ const legacyTarget = resolve(gaiaDir);
39
50
  const own = (doc.data ?? []).find((c) => {
40
51
  const root = str(c.attributes?.workspace_root);
41
52
  // A conductor that never reported a workspace matches no directory,
42
53
  // whatever `resolve('')` would make of an empty path.
43
- return root !== undefined && resolve(root) === target;
54
+ if (root === undefined)
55
+ return false;
56
+ const resolved = resolve(root);
57
+ return resolved === checkoutRoot || resolved === legacyTarget;
44
58
  });
45
59
  if (own === undefined)
46
60
  return NOTHING;
@@ -24,20 +24,47 @@ export interface ConductorProcessDeps {
24
24
  spawn?: (request: SpawnRequest) => void | Promise<void>;
25
25
  signal?: (input: {
26
26
  pid: number | undefined;
27
- workspaceRoot: string;
27
+ hostKey: string | undefined;
28
+ thisHostKey: string;
28
29
  signal: NodeJS.Signals;
29
30
  }) => SignalOutcome;
31
+ /**
32
+ * This machine's own host_key (MachineContext.machine_id), read once per
33
+ * stop. Injected for tests; defaults to the real `~/.gaia/machine.config.js`
34
+ * (GAIA-450 D1 — `signalVerifiedProcess` refuses a `host_key` mismatch, so a
35
+ * stop needs this machine's own value to compare against).
36
+ */
37
+ thisHostKey?: () => Promise<string>;
30
38
  }
31
39
  export interface ConductorProcessHost {
32
40
  start(input: {
33
41
  workspaceRoot: string;
34
42
  conductorName: string | undefined;
43
+ /** The row's `host_key` (GAIA-450 review B5) — verified against this
44
+ * machine's own before anything is spawned. */
45
+ hostKey: string | undefined;
35
46
  }): Promise<ConductorActionResult>;
36
47
  stop(input: {
37
48
  pid: number | undefined;
38
- workspaceRoot: string;
49
+ hostKey: string | undefined;
39
50
  }): Promise<ConductorActionResult>;
51
+ /**
52
+ * THIS machine's own `host_key` (GAIA-450 B10) — the same value `start`/
53
+ * `stop` already verify against, exposed so the Conductors screens can key
54
+ * their `mine` row projection and their `actions()` gate on the identical
55
+ * seam rather than resolving it a second, divergent way.
56
+ */
57
+ thisHostKey(): Promise<string>;
40
58
  }
59
+ /**
60
+ * This machine's host_key, from the same MachineContext the CLI reads.
61
+ *
62
+ * Exported so the Conductors screens (GAIA-450 B10) can key their view-only
63
+ * marking and their `actions()` gate on IDENTITY — this row's `host_key`
64
+ * against this machine's own — rather than on `isLocal`'s filesystem check,
65
+ * which two machines whose checkouts share a path can both satisfy.
66
+ */
67
+ export declare function defaultThisHostKey(): Promise<string>;
41
68
  /**
42
69
  * Start and stop a conductor from the cockpit.
43
70
  *