@gaia-ai/addon-gaia-ui 0.14.1 → 0.14.2

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.
Files changed (64) hide show
  1. package/dist/src/Entity/collection.d.ts +15 -0
  2. package/dist/src/Entity/collection.js +26 -5
  3. package/dist/src/Entity/conductor/Data/detail.d.ts +39 -0
  4. package/dist/src/Entity/conductor/Data/detail.js +69 -0
  5. package/dist/src/Entity/conductor/Data/log-block.d.ts +18 -0
  6. package/dist/src/Entity/conductor/Data/log-block.js +32 -0
  7. package/dist/src/Entity/conductor/Screen/conductor-log.d.ts +36 -0
  8. package/dist/src/Entity/conductor/Screen/conductor-log.js +46 -0
  9. package/dist/src/Entity/conductor/Screen/conductor-overview.d.ts +40 -0
  10. package/dist/src/Entity/conductor/Screen/conductor-overview.js +61 -0
  11. package/dist/src/Entity/conductor/Screen/conductor-runs.d.ts +90 -0
  12. package/dist/src/Entity/conductor/Screen/conductor-runs.js +128 -0
  13. package/dist/src/Entity/conductor/Screen/conductors.d.ts +6 -13
  14. package/dist/src/Entity/conductor/Screen/conductors.js +9 -60
  15. package/dist/src/Entity/conductor/Screen/project-conductors.d.ts +6 -13
  16. package/dist/src/Entity/conductor/Screen/project-conductors.js +9 -57
  17. package/dist/src/Entity/project/Data/with-tickets.js +1 -0
  18. package/dist/src/Entity/run/Data/query.d.ts +12 -0
  19. package/dist/src/Entity/run/Data/query.js +22 -1
  20. package/dist/src/Entity/run/Data/runs.d.ts +99 -0
  21. package/dist/src/Entity/run/Data/runs.js +37 -0
  22. package/dist/src/Entity/run/Form/edit.d.ts +29 -0
  23. package/dist/src/Entity/run/Form/edit.js +23 -0
  24. package/dist/src/Entity/run/Screen/edit-run-form.d.ts +26 -0
  25. package/dist/src/Entity/run/Screen/edit-run-form.js +117 -0
  26. package/dist/src/Entity/run/Screen/run-detail.d.ts +24 -3
  27. package/dist/src/Entity/run/Screen/run-detail.js +55 -3
  28. package/dist/src/Entity/ticket/Component/ticket-content/model.js +9 -1
  29. package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +6 -3
  30. package/dist/src/Entity/ticket/Component/ticket-detail/index.js +8 -3
  31. package/dist/src/Entity/ticket/Data/parents.js +2 -0
  32. package/dist/src/Entity/ticket/Data/query.d.ts +17 -0
  33. package/dist/src/Entity/ticket/Data/query.js +29 -1
  34. package/dist/src/Entity/ticket/Data/store.js +16 -0
  35. package/dist/src/Entity/ticket/Data/tickets.d.ts +115 -1
  36. package/dist/src/Entity/ticket/Data/tickets.js +38 -0
  37. package/dist/src/Entity/ticket/Form/who-filter.d.ts +8 -0
  38. package/dist/src/Entity/ticket/Screen/conductor-tickets.d.ts +85 -0
  39. package/dist/src/Entity/ticket/Screen/conductor-tickets.js +111 -0
  40. package/dist/src/Entity/ticket/Screen/ticket-runs.js +9 -0
  41. package/dist/src/Entity/ticket/Screen/tickets.d.ts +8 -0
  42. package/dist/src/Entity/ticket/Screen/tickets.js +27 -0
  43. package/dist/src/Entity/ticket/Screen/user-tickets.d.ts +8 -0
  44. package/dist/src/Entity/ticket/Screen/who-filter-form.d.ts +7 -0
  45. package/dist/src/Entity/ticket/Screen/who-filter-form.js +20 -6
  46. package/dist/src/Kernel/braid.d.ts +4 -0
  47. package/dist/src/Kernel/braid.js +10 -0
  48. package/dist/src/Kernel/chrome.d.ts +1 -1
  49. package/dist/src/Kernel/chrome.js +4 -4
  50. package/dist/src/Kernel/splash.js +10 -3
  51. package/dist/src/Routing/registry.js +1 -0
  52. package/dist/src/Routing/routes.js +10 -0
  53. package/dist/src/Screen/async-react-screen.d.ts +4 -2
  54. package/dist/src/Screen/async-react-screen.js +26 -1
  55. package/dist/src/Statistics/figures.d.ts +1 -2
  56. package/dist/src/Statistics/figures.js +72 -29
  57. package/dist/src/Statistics/presentation.d.ts +8 -0
  58. package/dist/src/Statistics/presentation.js +23 -0
  59. package/dist/src/Statistics/screen.d.ts +21 -14
  60. package/dist/src/Statistics/screen.js +126 -27
  61. package/dist/src/Vendor/termcn/ui/dither-chart.js +1 -2
  62. package/dist/src/lib/theme.d.ts +5 -0
  63. package/dist/src/lib/theme.js +5 -0
  64. package/package.json +2 -2
@@ -162,6 +162,21 @@ export interface ListColumn<Row, Ctx = void> {
162
162
  readonly min?: number;
163
163
  readonly max?: number;
164
164
  readonly grow?: number;
165
+ /**
166
+ * Size this column to the longest value in the rows being rendered, never
167
+ * below `width` and never below the header's own label.
168
+ *
169
+ * The other columns of this tree are DECLARED widths and deliberately so — a
170
+ * domain constant like STATE's 13 is the length of `qualification`. An
171
+ * identifier has no such constant: its length is the project's prefix plus a
172
+ * number, so a fixed column cuts exactly the number that distinguishes one
173
+ * ticket from the next (GAIA-549). One width per render, shared by the header
174
+ * and every row, is what keeps the column aligned.
175
+ *
176
+ * Deliberately uncapped: a cap puts the ellipsis back on the number. The
177
+ * surplus demand is paid by the row's grower, which is the title.
178
+ */
179
+ readonly fit?: boolean;
165
180
  get(row: Row, ctx: Ctx): unknown;
166
181
  }
167
182
  /**
@@ -96,9 +96,11 @@ const BLANKS = ' '.repeat(GROWN_CELL_FILL);
96
96
  * argument for it: the target is a number the COLUMN DECLARES, never a width
97
97
  * measured off the page (spec D4), and the cell resolves to exactly the columns
98
98
  * it resolved to before — Yoga is handed the same budget either way. `cellText`
99
- * and `elideLeft` are the two sanctioned `displayWidth` callers because they
100
- * mark a cut against a declared width; this is the third and it is the same
101
- * shape, one step further: it FILLS to a declared width.
99
+ * and `elideLeft` are sanctioned `displayWidth` callers because they mark a cut
100
+ * against a declared width; this is the same shape, one step further: it FILLS
101
+ * to a declared width. {@link fitColumns} is the one caller that measures a
102
+ * value instead, and it does so to PRODUCE the declared width before any cell
103
+ * exists, so the rule this paragraph states still holds below it.
102
104
  *
103
105
  * The two branches are not a style choice, they are what the renderer allows:
104
106
  *
@@ -261,6 +263,24 @@ const tableRow = (ids, columns, value, { selected, bold }) => seamlessRowNode(id
261
263
  * `rowNodeId`. Beside {@link listTableTree}, which keys the rows under it, and
262
264
  * reading the SAME `noun` off the same configuration (GAIA-345 D2). */
263
265
  export const listRowIds = (table, rows) => rows.map((row) => rowNodeId(table.noun, row.id));
266
+ /**
267
+ * The column set as RENDERED: a `fit` column resolved against the rows this
268
+ * frame carries, every other column untouched.
269
+ *
270
+ * Measured with `displayWidth`, like every other measurement in this tree, so a
271
+ * wide glyph costs the columns it paints rather than the characters it has.
272
+ * That makes this the one caller that measures a VALUE rather than marking a cut
273
+ * against a width already declared (see {@link tableRow}) — which is the point:
274
+ * it turns the longest value of this frame INTO the declared width, once, before
275
+ * a cell is built, so every cell below still resolves against a number the
276
+ * column declares.
277
+ */
278
+ const fitColumns = (columns, rows, ctx) => columns.map((column) => column.fit !== true
279
+ ? column
280
+ : {
281
+ ...column,
282
+ width: rows.reduce((widest, row) => Math.max(widest, displayWidth(String(column.get(row, ctx) ?? ''))), Math.max(column.width, displayWidth(column.label))),
283
+ });
264
284
  /**
265
285
  * GAIA-340 D1 — a column-driven list as the renderable tree a screen hands over.
266
286
  *
@@ -301,6 +321,7 @@ export const listRowIds = (table, rows) => rows.map((row) => rowNodeId(table.nou
301
321
  */
302
322
  export function listTableTree(table, rows, ctx, { selectedIndex }) {
303
323
  const { noun, columns, emptyMessage, header = true } = table;
324
+ const rendered = fitColumns(columns, rows, ctx);
304
325
  return columnNode(`${noun}-table`,
305
326
  // GAIA-345 D5/D6 — the header is BOLD, which is what distinguishes it from a
306
327
  // data row, and it is optional because one list is not a record set: the
@@ -310,14 +331,14 @@ export function listTableTree(table, rows, ctx, { selectedIndex }) {
310
331
  tableRow({
311
332
  row: `${noun}-header`,
312
333
  cell: (key) => `${noun}-header-${key}`,
313
- }, columns, (column) => column.label, { selected: false, bold: true }),
334
+ }, rendered, (column) => column.label, { selected: false, bold: true }),
314
335
  ]
315
336
  : []), ...(rows.length === 0
316
337
  ? [textNode({ id: `${noun}-empty`, content: emptyMessage })]
317
338
  : rows.map((row, index) => tableRow({
318
339
  row: rowNodeId(noun, row.id),
319
340
  cell: (key) => `${noun}-cell-${row.id}-${key}`,
320
- }, columns, (column) => column.get(row, ctx), { selected: index === selectedIndex, bold: false }))));
341
+ }, rendered, (column) => column.get(row, ctx), { selected: index === selectedIndex, bold: false }))));
321
342
  }
322
343
  /**
323
344
  * GAIA-345 D7 — the SAME list for the surface that has no layout:
@@ -0,0 +1,39 @@
1
+ import { type SessionStore } from '../../../Data/session-store.js';
2
+ import type { DropshClient } from '../../../types.js';
3
+ import { type ConductorStatus } from './liveness.js';
4
+ /** The conductor detail scope's shared header data. */
5
+ export interface ConductorDetail {
6
+ id: string;
7
+ /** Never empty — the uuid is the last fallback. */
8
+ label: string;
9
+ /** The `--conductor <name>` stem it was started from, if reported. */
10
+ conductorName: string | undefined;
11
+ /** The sideloaded machine's own label, or its host_key, or ''. */
12
+ machineLabel: string;
13
+ /** The linked project's uuid, or '' when unlinked — no project read here. */
14
+ projectLabel: string;
15
+ /** The `.gaia` dir it last registered from; '' when it never reported one. */
16
+ workspaceRoot: string;
17
+ lastPid: number | undefined;
18
+ status: ConductorStatus;
19
+ lastSeen: number;
20
+ currentLoad: number;
21
+ maxParallel: number;
22
+ /** THIS record's machine host_key, for the `mine` derivation. */
23
+ hostKey: string | undefined;
24
+ }
25
+ /** Read one conductor with its sideloaded machine, mapped to {@link ConductorDetail}. */
26
+ export declare function loadConductorDetail(client: DropshClient, conductorId: string, nowSeconds: number): Promise<ConductorDetail>;
27
+ /** What the store-cached read needs from the outside world. */
28
+ interface ConductorDetailContext {
29
+ readonly store?: SessionStore;
30
+ readonly services?: {
31
+ client: DropshClient;
32
+ };
33
+ }
34
+ /**
35
+ * The store-cached single-entity read every tab of the conductor scope
36
+ * shares — one request per conductor per session, not one per tab.
37
+ */
38
+ export declare function readConductorDetail(context: ConductorDetailContext, conductorId: string, nowSeconds?: number): Promise<ConductorDetail>;
39
+ export {};
@@ -0,0 +1,69 @@
1
+ // The shared conductor read for the conductor detail scope. ONE cached
2
+ // single-entity read (`include=machine`), so all four tabs share exactly one
3
+ // header instead of each re-deriving it.
4
+ import { toUnixSeconds } from '@gaia-ai/core';
5
+ import { dataKey } from '../../../Data/session-store.js';
6
+ import { deriveConductorStatus } from './liveness.js';
7
+ const str = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
8
+ const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
9
+ function singleRelId(raw, key) {
10
+ const data = raw.relationships?.[key]?.data;
11
+ if (!data || Array.isArray(data))
12
+ return null;
13
+ return data.id ?? null;
14
+ }
15
+ /** Read one conductor with its sideloaded machine, mapped to {@link ConductorDetail}. */
16
+ export async function loadConductorDetail(client, conductorId, nowSeconds) {
17
+ const doc = (await client.get(`gaia_conductor/gaia_conductor/${conductorId}?include=machine`));
18
+ const raw = doc?.data ?? { id: conductorId };
19
+ const attrs = raw.attributes ?? {};
20
+ const machineId = singleRelId(raw, 'machine');
21
+ const machine = machineId
22
+ ? (doc.included ?? []).find((r) => r.id === machineId)
23
+ : undefined;
24
+ const machineAttrs = machine?.attributes ?? {};
25
+ const lastSeen = toUnixSeconds(attrs.last_seen) ?? 0;
26
+ const leaseExpiresAt = toUnixSeconds(attrs.lease_expires_at) ?? 0;
27
+ const hostKey = str(machineAttrs.host_key);
28
+ return {
29
+ id: raw.id ?? conductorId,
30
+ label: str(attrs.label) ??
31
+ str(attrs.conductor_id) ??
32
+ str(attrs.machine_id) ??
33
+ raw.id ??
34
+ conductorId,
35
+ conductorName: str(attrs.conductor_name),
36
+ machineLabel: str(machineAttrs.label) ?? hostKey ?? '',
37
+ projectLabel: singleRelId(raw, 'project_id') ?? '',
38
+ workspaceRoot: str(attrs.workspace_root) ?? '',
39
+ lastPid: num(attrs.last_pid),
40
+ status: deriveConductorStatus({
41
+ present: true,
42
+ status: str(attrs.status) ?? null,
43
+ lastSeen: lastSeen === 0 ? null : lastSeen,
44
+ leaseExpiresAt: leaseExpiresAt === 0 ? null : leaseExpiresAt,
45
+ }, nowSeconds),
46
+ lastSeen,
47
+ currentLoad: num(machineAttrs.current_load) ?? num(attrs.current_load) ?? 0,
48
+ maxParallel: num(machineAttrs.max_parallel) ?? num(attrs.max_parallel) ?? 0,
49
+ hostKey,
50
+ };
51
+ }
52
+ const conductorDetailKey = (id) => dataKey(`conductor/${id}`);
53
+ /**
54
+ * The store-cached single-entity read every tab of the conductor scope
55
+ * shares — one request per conductor per session, not one per tab.
56
+ */
57
+ export async function readConductorDetail(context, conductorId, nowSeconds = Math.floor(Date.now() / 1000)) {
58
+ if (!context.services)
59
+ throw new Error('Conductor detail requires services');
60
+ const key = conductorDetailKey(conductorId);
61
+ if (!context.store)
62
+ return loadConductorDetail(context.services.client, conductorId, nowSeconds);
63
+ const cached = context.store.get(key);
64
+ if (cached)
65
+ return cached;
66
+ const detail = await loadConductorDetail(context.services.client, conductorId, nowSeconds);
67
+ context.store.set(key, detail, ['detail']);
68
+ return detail;
69
+ }
@@ -0,0 +1,18 @@
1
+ import type { ConductorDetail } from './detail.js';
2
+ export interface ConductorLog {
3
+ readonly heading: string;
4
+ readonly lines: readonly string[];
5
+ }
6
+ /**
7
+ * Whether the log block should tail a real file — true only when the
8
+ * detail's own machine host_key matches THIS process's. An absent key on
9
+ * either side is never waved through as a match: an unproven identity is
10
+ * refused, the same rule the Conductors list's `mine` uses.
11
+ */
12
+ export declare function isMine(detail: ConductorDetail, thisHostKey: string | undefined): boolean;
13
+ /**
14
+ * The open row's log block: where it is, and its tail. A conductor on
15
+ * another machine has a workspace this machine cannot read, so the block
16
+ * states that instead of showing an empty tail.
17
+ */
18
+ export declare function logBlockFor(detail: ConductorDetail, mine: boolean): ConductorLog;
@@ -0,0 +1,32 @@
1
+ // The conductor log block: where it is, and its tail — the single home for
2
+ // what was duplicated character for character in both Conductors screens.
3
+ import { configHomeOf } from '@gaia-ai/core';
4
+ import { tailConductorLog } from './log-tail.js';
5
+ /**
6
+ * Whether the log block should tail a real file — true only when the
7
+ * detail's own machine host_key matches THIS process's. An absent key on
8
+ * either side is never waved through as a match: an unproven identity is
9
+ * refused, the same rule the Conductors list's `mine` uses.
10
+ */
11
+ export function isMine(detail, thisHostKey) {
12
+ return (detail.hostKey !== undefined &&
13
+ thisHostKey !== undefined &&
14
+ detail.hostKey === thisHostKey);
15
+ }
16
+ /**
17
+ * The open row's log block: where it is, and its tail. A conductor on
18
+ * another machine has a workspace this machine cannot read, so the block
19
+ * states that instead of showing an empty tail.
20
+ */
21
+ export function logBlockFor(detail, mine) {
22
+ const logPath = `${configHomeOf(detail.workspaceRoot)}/log.txt`;
23
+ return mine
24
+ ? {
25
+ heading: `log — ${logPath}`,
26
+ lines: tailConductorLog(logPath, { maxLines: 40 }).lines,
27
+ }
28
+ : {
29
+ heading: `log — ${detail.label} runs on another machine; no log here`,
30
+ lines: [],
31
+ };
32
+ }
@@ -0,0 +1,36 @@
1
+ import type { VNode } from '@opentui/core';
2
+ import { AsyncScreen } from '../../../Screen/async-screen.js';
3
+ import type { FullScreenChrome } from '../../../Screen/full-screen.js';
4
+ import type { ScreenContext } from '../../../Screen/screen.js';
5
+ interface LogView {
6
+ readonly label: string;
7
+ readonly heading: string;
8
+ readonly lines: readonly string[];
9
+ }
10
+ /** The tail of the selected conductor's log, or why there is none here. */
11
+ export declare class ConductorLogScreen extends AsyncScreen<LogView> {
12
+ static readonly route: {
13
+ id: string;
14
+ path: string;
15
+ parent: string;
16
+ tab: {
17
+ id: string;
18
+ label: string;
19
+ scope: string;
20
+ weight: number;
21
+ };
22
+ fallback: {
23
+ routeId: string;
24
+ };
25
+ };
26
+ readonly id: string;
27
+ readonly frameId = "conductor";
28
+ protected load(context: ScreenContext): Promise<LogView>;
29
+ protected renderLoading(): string[];
30
+ protected renderReady(view: LogView): VNode;
31
+ protected chrome(): FullScreenChrome;
32
+ /** Nothing on this tab to act on. */
33
+ protected actions(): never[];
34
+ renderNonInteractive(): string[];
35
+ }
36
+ export {};
@@ -0,0 +1,46 @@
1
+ import { columnNode, textNode } from '../../../lib/vnode.js';
2
+ import { AsyncScreen } from '../../../Screen/async-screen.js';
3
+ import { readConductorDetail } from '../Data/detail.js';
4
+ import { isMine, logBlockFor } from '../Data/log-block.js';
5
+ import { createConductorProcessHost } from '../Data/process.js';
6
+ import { conductorTitle } from './conductor-overview.js';
7
+ /** The tail of the selected conductor's log, or why there is none here. */
8
+ export class ConductorLogScreen extends AsyncScreen {
9
+ static route = {
10
+ id: 'conductor.log',
11
+ path: '/conductors/:conductorId/log',
12
+ parent: 'conductors',
13
+ tab: { id: 'log', label: 'Log', scope: 'conductor', weight: 40 },
14
+ fallback: { routeId: 'conductors' },
15
+ };
16
+ id = ConductorLogScreen.route.id;
17
+ frameId = 'conductor';
18
+ async load(context) {
19
+ const conductorId = context.params?.conductorId ?? '';
20
+ const detail = await readConductorDetail(context, conductorId);
21
+ const host = context.services?.conductorProcess ?? createConductorProcessHost();
22
+ const thisHostKey = await host.thisHostKey();
23
+ const block = logBlockFor(detail, isMine(detail, thisHostKey));
24
+ return { label: detail.label, ...block };
25
+ }
26
+ renderLoading() {
27
+ return ['Loading log…'];
28
+ }
29
+ renderReady(view) {
30
+ return columnNode('conductor-log', textNode({ id: 'conductor-log-heading', content: view.heading }), ...view.lines.map((line, index) => textNode({ id: `conductor-log-${index}`, content: line })));
31
+ }
32
+ chrome() {
33
+ return {
34
+ title: conductorTitle(this.readyValue ? { label: this.readyValue.label } : undefined),
35
+ };
36
+ }
37
+ /** Nothing on this tab to act on. */
38
+ actions() {
39
+ return [];
40
+ }
41
+ renderNonInteractive() {
42
+ return this.readyValue
43
+ ? [this.readyValue.heading, ...this.readyValue.lines]
44
+ : [];
45
+ }
46
+ }
@@ -0,0 +1,40 @@
1
+ import type { VNode } from '@opentui/core';
2
+ import { AsyncScreen } from '../../../Screen/async-screen.js';
3
+ import type { FullScreenChrome } from '../../../Screen/full-screen.js';
4
+ import type { ScreenContext } from '../../../Screen/screen.js';
5
+ import { type ConductorDetail } from '../Data/detail.js';
6
+ /** `GAIA conductor — <label>` — the tab bar title every tab of the scope shares. */
7
+ export declare function conductorTitle(detail: Pick<ConductorDetail, 'label'> | undefined): string;
8
+ /**
9
+ * The label/value rows of the Overview tab — the single source both
10
+ * `renderReady` and `renderNonInteractive` draw from, so the piped output
11
+ * cannot drift from the drawn tree.
12
+ */
13
+ export declare function conductorOverviewRows(detail: ConductorDetail, nowSeconds?: number): string[];
14
+ /** Overview — the conductor detail scope's landing tab. */
15
+ export declare class ConductorOverviewScreen extends AsyncScreen<ConductorDetail> {
16
+ static readonly route: {
17
+ id: string;
18
+ path: string;
19
+ pipe: "scope";
20
+ parent: string;
21
+ tab: {
22
+ id: string;
23
+ label: string;
24
+ scope: string;
25
+ weight: number;
26
+ };
27
+ fallback: {
28
+ routeId: string;
29
+ };
30
+ };
31
+ readonly id: string;
32
+ readonly frameId = "conductor";
33
+ protected load(context: ScreenContext): Promise<ConductorDetail>;
34
+ protected renderLoading(): string[];
35
+ protected renderReady(detail: ConductorDetail, context: ScreenContext): VNode;
36
+ protected chrome(): FullScreenChrome;
37
+ /** Read-only — nothing on this tab to act on. */
38
+ protected actions(): never[];
39
+ renderNonInteractive(context: ScreenContext): string[];
40
+ }
@@ -0,0 +1,61 @@
1
+ import { humanizeAge } from '../../../lib/format.js';
2
+ import { columnNode, textNode } from '../../../lib/vnode.js';
3
+ import { AsyncScreen } from '../../../Screen/async-screen.js';
4
+ import { readConductorDetail } from '../Data/detail.js';
5
+ /** `GAIA conductor — <label>` — the tab bar title every tab of the scope shares. */
6
+ export function conductorTitle(detail) {
7
+ return `GAIA conductor — ${detail?.label ?? ''}`;
8
+ }
9
+ /**
10
+ * The label/value rows of the Overview tab — the single source both
11
+ * `renderReady` and `renderNonInteractive` draw from, so the piped output
12
+ * cannot drift from the drawn tree.
13
+ */
14
+ export function conductorOverviewRows(detail, nowSeconds = Math.floor(Date.now() / 1000)) {
15
+ return [
16
+ `Status: ${detail.status} (${humanizeAge(detail.lastSeen || null, nowSeconds)})`,
17
+ `Machine: ${detail.machineLabel || '—'}`,
18
+ `Project: ${detail.projectLabel || '—'}`,
19
+ `Workspace: ${detail.workspaceRoot || '—'}`,
20
+ `PID: ${detail.lastPid ?? '-'}`,
21
+ // Stated explicitly as the machine's own budget, not the conductor's:
22
+ // several conductors can share one host's capacity.
23
+ `Load: ${detail.currentLoad}/${detail.maxParallel} (machine budget)`,
24
+ ];
25
+ }
26
+ /** Overview — the conductor detail scope's landing tab. */
27
+ export class ConductorOverviewScreen extends AsyncScreen {
28
+ static route = {
29
+ id: 'conductor.overview',
30
+ path: '/conductors/:conductorId',
31
+ pipe: 'scope',
32
+ parent: 'conductors',
33
+ tab: { id: 'overview', label: 'Overview', scope: 'conductor', weight: 10 },
34
+ fallback: { routeId: 'conductors' },
35
+ };
36
+ id = ConductorOverviewScreen.route.id;
37
+ frameId = 'conductor';
38
+ load(context) {
39
+ return readConductorDetail(context, context.params?.conductorId ?? '', context.nowSeconds?.() ?? Math.floor(Date.now() / 1000));
40
+ }
41
+ renderLoading() {
42
+ return ['Loading conductor…'];
43
+ }
44
+ renderReady(detail, context) {
45
+ const nowSeconds = context.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
46
+ return columnNode('conductor-overview', ...conductorOverviewRows(detail, nowSeconds).map((line, index) => textNode({ id: `conductor-overview-${index}`, content: line })));
47
+ }
48
+ chrome() {
49
+ return { title: conductorTitle(this.readyValue) };
50
+ }
51
+ /** Read-only — nothing on this tab to act on. */
52
+ actions() {
53
+ return [];
54
+ }
55
+ renderNonInteractive(context) {
56
+ if (!this.readyValue)
57
+ return [];
58
+ const nowSeconds = context.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
59
+ return conductorOverviewRows(this.readyValue, nowSeconds);
60
+ }
61
+ }
@@ -0,0 +1,90 @@
1
+ import type { FullScreenChrome } from '../../../Screen/full-screen.js';
2
+ import { ListScreen } from '../../../Screen/list-screen.js';
3
+ import type { ScreenContext } from '../../../Screen/screen.js';
4
+ import { type Page } from '../../collection.js';
5
+ import { type RunRow } from '../../run/Data/runs.js';
6
+ /** Held work — the runs one conductor currently holds (claimed/running). */
7
+ export declare class ConductorRunsScreen extends ListScreen<RunRow, number> {
8
+ static readonly route: {
9
+ id: string;
10
+ path: string;
11
+ parent: string;
12
+ tab: {
13
+ id: string;
14
+ label: string;
15
+ scope: string;
16
+ weight: number;
17
+ };
18
+ fallback: {
19
+ routeId: string;
20
+ };
21
+ };
22
+ readonly id: string;
23
+ readonly frameId = "conductor";
24
+ protected readonly table: {
25
+ readonly noun: "run";
26
+ readonly columns: readonly [{
27
+ readonly key: "run";
28
+ readonly label: "RUN";
29
+ readonly width: 8;
30
+ readonly fields: readonly ["drupal_internal__id"];
31
+ readonly get: (r: RunRow) => string;
32
+ }, {
33
+ readonly key: "ticket";
34
+ readonly label: "TICKET";
35
+ readonly width: 10;
36
+ readonly fields: readonly ["ticket_id"];
37
+ readonly get: (r: RunRow) => string;
38
+ }, {
39
+ readonly key: "title";
40
+ readonly label: "TITLE";
41
+ readonly width: 0;
42
+ readonly grow: 2;
43
+ readonly min: 24;
44
+ readonly fields: readonly ["ticket_id"];
45
+ readonly get: (r: RunRow) => string;
46
+ }, {
47
+ readonly key: "state";
48
+ readonly label: "STATE";
49
+ readonly width: 8;
50
+ readonly fields: readonly ["state", "heartbeat", "claim_expires_at"];
51
+ readonly get: (r: RunRow, nowSeconds: number) => string;
52
+ }, {
53
+ readonly key: "phase";
54
+ readonly label: "PHASE";
55
+ readonly width: 13;
56
+ readonly fields: readonly ["state_at_start"];
57
+ readonly get: (r: RunRow) => string;
58
+ }, {
59
+ readonly key: "started";
60
+ readonly label: "STARTED";
61
+ readonly width: 0;
62
+ readonly grow: 1;
63
+ readonly fields: readonly ["started_at", "created"];
64
+ readonly get: (r: RunRow, nowSeconds: number) => string;
65
+ }];
66
+ readonly emptyMessage: "This conductor holds no run.";
67
+ };
68
+ protected tableContext(context: ScreenContext): number;
69
+ protected loadRows(context: ScreenContext, page: number): Promise<Page<RunRow>>;
70
+ protected renderLoading(): string[];
71
+ protected chrome(context: ScreenContext): FullScreenChrome;
72
+ protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
73
+ activate(index: number, context: ScreenContext): boolean;
74
+ select(index: number, context: ScreenContext): boolean;
75
+ handleInput(input: string, context: ScreenContext): boolean;
76
+ /**
77
+ * The header title needs the shared conductor label. Loaded once here (the
78
+ * store-cached read is cheap on a repeat) rather than threading it through
79
+ * `loadRows`'s row type.
80
+ */
81
+ private headerDetail;
82
+ protected didLoad(_data: Page<RunRow>, context: ScreenContext): void;
83
+ reset(): void;
84
+ /**
85
+ * The pipe's own text — the SAME table + rows the drawn tree uses, through
86
+ * `listTableRows` rather than a second description (Task 8's anti-drift
87
+ * pattern).
88
+ */
89
+ renderNonInteractive(context: ScreenContext): string[];
90
+ }
@@ -0,0 +1,128 @@
1
+ import { actionItem } from '../../../Component/footer-actions/index.js';
2
+ import { flattenSections } from '../../../Component/section-list/index.js';
3
+ import { ListScreen } from '../../../Screen/list-screen.js';
4
+ import { listTableRows, loadPage } from '../../collection.js';
5
+ import { LIST_PAGE_SIZE } from '../../cycle.js';
6
+ import { buildConductorRunQueryParams } from '../../run/Data/query.js';
7
+ import { CONDUCTOR_RUN_TABLE, mapRunData, } from '../../run/Data/runs.js';
8
+ import { deriveRunStatus } from '../../run/Data/status.js';
9
+ import { readConductorDetail } from '../Data/detail.js';
10
+ import { conductorTitle } from './conductor-overview.js';
11
+ /** Held work — the runs one conductor currently holds (claimed/running). */
12
+ export class ConductorRunsScreen extends ListScreen {
13
+ static route = {
14
+ id: 'conductor.runs',
15
+ path: '/conductors/:conductorId/runs',
16
+ parent: 'conductors',
17
+ tab: { id: 'runs', label: 'Held work', scope: 'conductor', weight: 20 },
18
+ fallback: { routeId: 'conductors' },
19
+ };
20
+ id = ConductorRunsScreen.route.id;
21
+ frameId = 'conductor';
22
+ table = CONDUCTOR_RUN_TABLE;
23
+ tableContext(context) {
24
+ return context.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
25
+ }
26
+ loadRows(context, page) {
27
+ if (!context.services)
28
+ throw new Error('Held work requires services');
29
+ const conductorId = context.params?.conductorId ?? '';
30
+ const window = { offset: page * LIST_PAGE_SIZE, limit: LIST_PAGE_SIZE };
31
+ const params = buildConductorRunQueryParams(conductorId, {
32
+ size: window.limit,
33
+ index: page,
34
+ });
35
+ return loadPage(context.services.client, 'gaia_run/gaia_run', params, (data, included) => {
36
+ const ticketsById = new Map(included.filter((r) => r.id).map((r) => [r.id, r]));
37
+ return data.map((item) => mapRunData(item, ticketsById));
38
+ }, page, window);
39
+ }
40
+ renderLoading() {
41
+ return ['Loading held work…'];
42
+ }
43
+ chrome(context) {
44
+ const nowSeconds = this.tableContext(context);
45
+ const stale = this.rows.filter((row) => deriveRunStatus(row, nowSeconds) === 'stale').length;
46
+ return {
47
+ selectable: this.rows.length,
48
+ selectedIndex: this.selectedIndex,
49
+ rowIds: this.rowNodeIds,
50
+ title: conductorTitle(this.headerDetail),
51
+ meta: [
52
+ this.countLabel(),
53
+ ...(stale > 0 ? [`${stale} stale`] : []),
54
+ ...this.pagingSegments(),
55
+ ]
56
+ .filter(Boolean)
57
+ .join(' · '),
58
+ };
59
+ }
60
+ actions() {
61
+ return [
62
+ ...(this.rows.length > 0
63
+ ? [actionItem('conductor-runs-enter', 'Enter', 'ticket')]
64
+ : []),
65
+ actionItem('conductor-runs-tab', 'Tab', 'tabs'),
66
+ ...this.pagingActionItems(),
67
+ ];
68
+ }
69
+ activate(index, context) {
70
+ const ticketId = this.rows[index]?.ticketId;
71
+ if (!ticketId || !context.navigate)
72
+ return false;
73
+ context.navigate('ticket.runs', { ticketId });
74
+ return true;
75
+ }
76
+ select(index, context) {
77
+ if (index < 0 || index >= this.rows.length)
78
+ return false;
79
+ this.selectedIndex = index;
80
+ context.requestDraw();
81
+ return true;
82
+ }
83
+ handleInput(input, context) {
84
+ if (super.handleInput(input, context))
85
+ return true;
86
+ if (this.handlePagingInput(input, context))
87
+ return true;
88
+ if (this.handleCursorInput(input, context))
89
+ return true;
90
+ if (input === 'enter')
91
+ return this.activate(this.selectedIndex, context);
92
+ return false;
93
+ }
94
+ /**
95
+ * The header title needs the shared conductor label. Loaded once here (the
96
+ * store-cached read is cheap on a repeat) rather than threading it through
97
+ * `loadRows`'s row type.
98
+ */
99
+ headerDetail;
100
+ didLoad(_data, context) {
101
+ const conductorId = context.params?.conductorId ?? '';
102
+ if (!conductorId || !context.services || this.headerDetail)
103
+ return;
104
+ void readConductorDetail(context, conductorId).then((detail) => {
105
+ this.headerDetail = detail;
106
+ context.requestDraw();
107
+ });
108
+ }
109
+ reset() {
110
+ this.resetList();
111
+ this.headerDetail = undefined;
112
+ super.reset();
113
+ }
114
+ /**
115
+ * The pipe's own text — the SAME table + rows the drawn tree uses, through
116
+ * `listTableRows` rather than a second description (Task 8's anti-drift
117
+ * pattern).
118
+ */
119
+ renderNonInteractive(context) {
120
+ const nowSeconds = this.tableContext(context);
121
+ return flattenSections([
122
+ {
123
+ heading: 'Held work',
124
+ lines: listTableRows(CONDUCTOR_RUN_TABLE, this.rows, nowSeconds),
125
+ },
126
+ ]).lines;
127
+ }
128
+ }