@gaia-ai/addon-gaia-ui 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.
Files changed (142) hide show
  1. package/dist/src/Agent/launch.d.ts +4 -4
  2. package/dist/src/Agent/launch.js +14 -7
  3. package/dist/src/Component/stats-panel/index.d.ts +13 -2
  4. package/dist/src/Component/stats-panel/index.js +28 -18
  5. package/dist/src/Component/top-tabs/index.d.ts +6 -0
  6. package/dist/src/Component/top-tabs/index.js +15 -6
  7. package/dist/src/Entity/collection.d.ts +98 -0
  8. package/dist/src/Entity/collection.js +46 -0
  9. package/dist/src/Entity/conductor/Component/conductor-list/index.d.ts +25 -0
  10. package/dist/src/Entity/conductor/Component/conductor-list/index.js +60 -0
  11. package/dist/src/Entity/conductor/Data/conductors.d.ts +57 -0
  12. package/dist/src/Entity/conductor/Data/conductors.js +67 -0
  13. package/dist/src/Entity/conductor/Data/liveness.d.ts +2 -1
  14. package/dist/src/Entity/conductor/Data/liveness.js +12 -16
  15. package/dist/src/Entity/conductor/Data/log-tail.d.ts +25 -0
  16. package/dist/src/Entity/conductor/Data/log-tail.js +133 -0
  17. package/dist/src/Entity/conductor/Data/own-conductor.d.ts +30 -0
  18. package/dist/src/Entity/conductor/Data/own-conductor.js +73 -0
  19. package/dist/src/Entity/conductor/Data/process.d.ts +78 -0
  20. package/dist/src/Entity/conductor/Data/process.js +101 -0
  21. package/dist/src/Entity/conductor/Screen/conductors.d.ts +42 -0
  22. package/dist/src/Entity/conductor/Screen/conductors.js +200 -0
  23. package/dist/src/Entity/cycle.d.ts +18 -0
  24. package/dist/src/Entity/cycle.js +30 -0
  25. package/dist/src/Entity/project/Data/project.d.ts +8 -0
  26. package/dist/src/Entity/project/Data/project.js +12 -0
  27. package/dist/src/Entity/project/Data/projects.d.ts +3 -0
  28. package/dist/src/Entity/project/Data/projects.js +3 -0
  29. package/dist/src/Entity/project/Data/with-tickets.d.ts +5 -0
  30. package/dist/src/Entity/project/Data/with-tickets.js +15 -13
  31. package/dist/src/Entity/project/Screen/project-picker.d.ts +36 -0
  32. package/dist/src/Entity/project/Screen/project-picker.js +90 -0
  33. package/dist/src/Entity/project/Screen/projects-screen.d.ts +40 -0
  34. package/dist/src/Entity/project/Screen/projects-screen.js +103 -0
  35. package/dist/src/Entity/run/Component/run-table/index.d.ts +5 -7
  36. package/dist/src/Entity/run/Component/run-table/index.js +9 -18
  37. package/dist/src/Entity/run/Data/query.d.ts +22 -0
  38. package/dist/src/Entity/run/Data/query.js +41 -0
  39. package/dist/src/Entity/run/Data/runs.d.ts +85 -4
  40. package/dist/src/Entity/run/Data/runs.js +143 -27
  41. package/dist/src/Entity/run/Screen/run-detail.d.ts +25 -0
  42. package/dist/src/Entity/run/Screen/run-detail.js +51 -0
  43. package/dist/src/Entity/run/Screen/runs.d.ts +82 -0
  44. package/dist/src/Entity/run/Screen/runs.js +169 -0
  45. package/dist/src/Entity/statistics/Screen/dashboard.d.ts +42 -0
  46. package/dist/src/Entity/statistics/Screen/dashboard.js +177 -0
  47. package/dist/src/Entity/ticket/Component/create-form/index.d.ts +10 -0
  48. package/dist/src/Entity/ticket/Component/create-form/index.js +4 -0
  49. package/dist/src/Entity/ticket/Component/edit-form/index.d.ts +3 -2
  50. package/dist/src/Entity/ticket/Component/edit-form/index.js +30 -8
  51. package/dist/src/Entity/ticket/Component/tab-bar/index.js +6 -0
  52. package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +1 -1
  53. package/dist/src/Entity/ticket/Component/ticket-detail/index.js +33 -2
  54. package/dist/src/Entity/ticket/Component/ticket-table/index.d.ts +4 -2
  55. package/dist/src/Entity/ticket/Component/ticket-table/index.js +8 -12
  56. package/dist/src/Entity/ticket/Data/parents.d.ts +13 -1
  57. package/dist/src/Entity/ticket/Data/parents.js +64 -12
  58. package/dist/src/Entity/ticket/Data/query.d.ts +44 -7
  59. package/dist/src/Entity/ticket/Data/query.js +60 -26
  60. package/dist/src/Entity/ticket/Data/tickets.d.ts +105 -4
  61. package/dist/src/Entity/ticket/Data/tickets.js +164 -12
  62. package/dist/src/Entity/ticket/Data/workflow.d.ts +1 -5
  63. package/dist/src/Entity/ticket/Data/workflow.js +5 -13
  64. package/dist/src/Entity/ticket/Data/write.d.ts +32 -0
  65. package/dist/src/Entity/ticket/Data/write.js +80 -0
  66. package/dist/src/Entity/ticket/Form/create-data.d.ts +1 -11
  67. package/dist/src/Entity/ticket/Form/create-data.js +7 -36
  68. package/dist/src/Entity/ticket/Form/create.d.ts +45 -5
  69. package/dist/src/Entity/ticket/Form/create.js +45 -8
  70. package/dist/src/Entity/ticket/Form/edit-data.d.ts +1 -1
  71. package/dist/src/Entity/ticket/Form/edit-data.js +4 -0
  72. package/dist/src/Entity/ticket/Form/edit.d.ts +79 -12
  73. package/dist/src/Entity/ticket/Form/edit.js +165 -18
  74. package/dist/src/Entity/ticket/Form/keys.d.ts +7 -0
  75. package/dist/src/Entity/ticket/Form/keys.js +7 -0
  76. package/dist/src/Entity/ticket/Form/parent-filter.d.ts +36 -0
  77. package/dist/src/Entity/ticket/Form/parent-filter.js +37 -0
  78. package/dist/src/Entity/ticket/Form/parent-source.d.ts +27 -0
  79. package/dist/src/Entity/ticket/Form/parent-source.js +56 -0
  80. package/dist/src/Entity/ticket/Form/who-filter.d.ts +33 -0
  81. package/dist/src/Entity/ticket/Form/who-filter.js +33 -0
  82. package/dist/src/Entity/ticket/Form/who-source.d.ts +18 -0
  83. package/dist/src/Entity/ticket/Form/who-source.js +38 -0
  84. package/dist/src/Entity/ticket/Screen/create-ticket-form.d.ts +68 -0
  85. package/dist/src/Entity/ticket/Screen/create-ticket-form.js +336 -0
  86. package/dist/src/Entity/ticket/Screen/edit-assignment-form.d.ts +81 -0
  87. package/dist/src/Entity/ticket/Screen/edit-assignment-form.js +200 -0
  88. package/dist/src/Entity/ticket/Screen/parent-filter-form.d.ts +27 -0
  89. package/dist/src/Entity/ticket/Screen/parent-filter-form.js +177 -0
  90. package/dist/src/Entity/ticket/Screen/ticket-agents.d.ts +45 -0
  91. package/dist/src/Entity/ticket/Screen/ticket-agents.js +135 -0
  92. package/dist/src/Entity/ticket/Screen/ticket-detail.d.ts +61 -0
  93. package/dist/src/Entity/ticket/Screen/ticket-detail.js +300 -0
  94. package/dist/src/Entity/ticket/Screen/tickets.d.ts +129 -0
  95. package/dist/src/Entity/ticket/Screen/tickets.js +507 -0
  96. package/dist/src/Entity/ticket/Screen/who-filter-form.d.ts +27 -0
  97. package/dist/src/Entity/ticket/Screen/who-filter-form.js +154 -0
  98. package/dist/src/Entity/user/Data/users.d.ts +17 -0
  99. package/dist/src/Entity/user/Data/users.js +66 -6
  100. package/dist/src/Form/field.d.ts +3 -1
  101. package/dist/src/Form/field.js +1 -0
  102. package/dist/src/Form/form.d.ts +1 -1
  103. package/dist/src/Form/form.js +61 -9
  104. package/dist/src/Form/keys.d.ts +5 -1
  105. package/dist/src/Form/keys.js +38 -5
  106. package/dist/src/Kernel/tui-kernel.d.ts +0 -10
  107. package/dist/src/Kernel/tui-kernel.js +250 -2384
  108. package/dist/src/Routing/context.d.ts +31 -0
  109. package/dist/src/Routing/context.js +52 -0
  110. package/dist/src/Routing/registry.d.ts +19 -0
  111. package/dist/src/Routing/registry.js +97 -0
  112. package/dist/src/Routing/route.d.ts +39 -0
  113. package/dist/src/Routing/route.js +1 -0
  114. package/dist/src/Routing/router.d.ts +33 -0
  115. package/dist/src/Routing/router.js +102 -0
  116. package/dist/src/Routing/routes.d.ts +17 -0
  117. package/dist/src/Routing/routes.js +57 -0
  118. package/dist/src/Screen/async-screen.d.ts +50 -0
  119. package/dist/src/Screen/async-screen.js +164 -0
  120. package/dist/src/Screen/list-screen.d.ts +158 -0
  121. package/dist/src/Screen/list-screen.js +237 -0
  122. package/dist/src/Screen/screen.d.ts +106 -0
  123. package/dist/src/Screen/screen.js +18 -0
  124. package/dist/src/Tui/controller.d.ts +13 -0
  125. package/dist/src/Tui/controller.js +1794 -0
  126. package/dist/src/Widget/text.js +49 -4
  127. package/dist/src/Widget/types.d.ts +2 -1
  128. package/dist/src/launcher.d.ts +9 -2
  129. package/dist/src/launcher.js +2 -1
  130. package/dist/src/lib/ansi.js +13 -5
  131. package/dist/src/lib/cursor.d.ts +31 -0
  132. package/dist/src/lib/cursor.js +45 -0
  133. package/dist/src/lib/format.d.ts +21 -0
  134. package/dist/src/lib/format.js +59 -0
  135. package/dist/src/lib/table.d.ts +37 -0
  136. package/dist/src/lib/table.js +20 -0
  137. package/dist/src/lib/width.d.ts +33 -1
  138. package/dist/src/lib/width.js +107 -12
  139. package/dist/src/plugin.js +19 -0
  140. package/dist/src/types.d.ts +26 -0
  141. package/dist/src/types.js +0 -4
  142. package/package.json +2 -2
@@ -17,9 +17,9 @@ export declare function buildCreateLaunch(opts: {
17
17
  label?: string | undefined;
18
18
  }): CreateLaunch;
19
19
  /**
20
- * A short human label for the agent list: the description's first line (capped to 40
21
- * incl. the ellipsis), else "New <workflow>". GAIA-236 AC-8: it builds the `label`
22
- * `buildCreateLaunch` takes, so it belongs to the module that builds the launch
23
- * intent moved from `Service/create-form.ts` unchanged.
20
+ * A short human label for the agent list: the ticket's title, else the
21
+ * description's first line (both capped to 40 incl. the ellipsis), else
22
+ * "New <workflow>". GAIA-236 AC-8: it builds the `label` `buildCreateLaunch`
23
+ * takes, so it belongs to the module that builds the launch intent.
24
24
  */
25
25
  export declare function shortName(payload: CreateFormPayload): string;
@@ -32,18 +32,25 @@ export function buildCreateLaunch(opts) {
32
32
  };
33
33
  }
34
34
  /**
35
- * A short human label for the agent list: the description's first line (capped to 40
36
- * incl. the ellipsis), else "New <workflow>". GAIA-236 AC-8: it builds the `label`
37
- * `buildCreateLaunch` takes, so it belongs to the module that builds the launch
38
- * intent moved from `Service/create-form.ts` unchanged.
35
+ * A short human label for the agent list: the ticket's title, else the
36
+ * description's first line (both capped to 40 incl. the ellipsis), else
37
+ * "New <workflow>". GAIA-236 AC-8: it builds the `label` `buildCreateLaunch`
38
+ * takes, so it belongs to the module that builds the launch intent.
39
39
  */
40
40
  export function shortName(payload) {
41
+ // GAIA-309 D9: the title is what the operator NAMED the ticket, so it is the
42
+ // better tab label; the description's first line stays the fallback for a
43
+ // payload that has no title yet. This touches the launch intent's `label`
44
+ // ONLY — never `prompt`, never `env`, which is what AC-6 pins.
45
+ const cap = (line) => line.length > 40 ? `${line.slice(0, 39)}…` : line;
46
+ const title = payload.title.trim();
47
+ if (title)
48
+ return cap(title);
41
49
  const firstLine = payload.description
42
50
  .split('\n')
43
51
  .map((l) => l.trim())
44
52
  .find((l) => l.length > 0);
45
- if (firstLine) {
46
- return firstLine.length > 40 ? `${firstLine.slice(0, 39)}…` : firstLine;
47
- }
53
+ if (firstLine)
54
+ return cap(firstLine);
48
55
  return `New ${payload.workflow?.label ?? 'ticket'}`;
49
56
  }
@@ -36,14 +36,25 @@ export interface StatsPanelOptions {
36
36
  * components keep their boundary.
37
37
  */
38
38
  columns?: readonly string[] | undefined;
39
+ /**
40
+ * The group's name, printed in the header row's already-empty label field
41
+ * (GAIA-324 D4). A caller that renders several groups under one heading uses
42
+ * it to say which group each block of bars is.
43
+ *
44
+ * The component stays ENTITY-NEUTRAL: this is a string, and nothing here knows
45
+ * a ticket from a run. It is padded exactly like a data label, so the title,
46
+ * the labels beneath it and the column titles keep one geometry.
47
+ */
48
+ title?: string | undefined;
39
49
  /** What to render instead of rows when there are none (spec R7). */
40
50
  emptyMessage?: string | undefined;
41
51
  }
42
52
  /**
43
- * One block's body rows: an optional column header, then one row per entry.
53
+ * One block's body rows: an optional header row (the group's title, the column
54
+ * titles, or both), then one row per entry.
44
55
  *
45
56
  * Every column is measured with `displayWidth` (through `pad`/`padNumber`), so a
46
57
  * wide-character label cannot shift the numbers out of alignment — the failure
47
58
  * that makes a table of counts unreadable exactly when the data is interesting.
48
59
  */
49
- export declare function statsPanelRows(rows: readonly StatsPanelRow[], { columns, emptyMessage }?: StatsPanelOptions): string[];
60
+ export declare function statsPanelRows(rows: readonly StatsPanelRow[], { columns, title, emptyMessage }?: StatsPanelOptions): string[];
@@ -31,12 +31,16 @@ export const BAR_WIDTH = 20;
31
31
  /**
32
32
  * The label column, in display columns: 26 for the text plus {@link LABEL_GAP}.
33
33
  *
34
- * The text budget is wide enough for the longest label the Dashboard actually
35
- * builds: a breakdown row suffixes its entity type (`gaia · tickets`), the
36
- * unattributed bucket's `— unattributed · tickets` is 24 columns and a real
37
- * project name reaches 26 (`gaia-e2e-project · tickets`). At 18 that suffix was
38
- * cut to `— unattributed · t…`, which silently made the two entity types
39
- * indistinguishable in exactly the row where attribution is already unclear.
34
+ * The text budget is what the field has to hold, and after GAIA-324 that is two
35
+ * different kinds of text sharing one column: a breakdown row's bare entity name
36
+ * (a real project name reaches 26 columns on its own — `gaia-e2e-project` plus a
37
+ * suffix used to; a long one still does), and the group TITLE printed in the
38
+ * header row's label field above it. Both are padded to the same field, which is
39
+ * what keeps a title, the labels it captions and the column titles on one
40
+ * geometry. Kept at 26 rather than narrowed with the `· tickets` suffix GAIA-324
41
+ * deleted: the budget was never sized for the suffix alone, and a project name
42
+ * cut to `a-very-long-project-…` is the same unreadable row the width exists to
43
+ * prevent.
40
44
  */
41
45
  const LABEL_WIDTH = 27;
42
46
  /**
@@ -44,16 +48,16 @@ const LABEL_WIDTH = 27;
44
48
  *
45
49
  * Load-bearing, not decoration. `pad` fills a label to exactly `LABEL_WIDTH`, so
46
50
  * a label of precisely that width left NO separator and the row rendered
47
- * `gaia-e2e-project · tickets████████` — a real project name, at exactly 26
48
- * columns. A truncated label collided the same way, ending in `…` against the
49
- * bar. Every fixture in the tests was shorter, which is why the suite was green
50
- * while the screen was wrong; the gap is part of the geometry now so the label
51
- * length cannot decide whether it exists.
51
+ * `gaia-e2e-project-name██████` — a real project name, at exactly 26 columns. A
52
+ * truncated label collided the same way, ending in `…` against the bar. Every
53
+ * fixture in the tests was shorter, which is why the suite was green while the
54
+ * screen was wrong; the gap is part of the geometry now so the label length
55
+ * cannot decide whether it exists.
52
56
  *
53
57
  * It is ADDED to the field rather than taken out of it: the 26 columns above are
54
58
  * a content budget with a stated reason, and spending one of them on the gap
55
- * would cut `gaia-e2e-project · tickets` to `gaia-e2e-project · ticke…`losing
56
- * exactly the entity-type suffix that tells the two rows of a project apart.
59
+ * would truncate one more column off the longest namesthe labels that are
60
+ * already hardest to tell apart.
57
61
  */
58
62
  const LABEL_GAP = 1;
59
63
  /** One number column, in display columns. */
@@ -81,24 +85,30 @@ function bar(value, max) {
81
85
  return BAR_FULL.repeat(filled) + BAR_EMPTY.repeat(BAR_WIDTH - filled);
82
86
  }
83
87
  /**
84
- * One block's body rows: an optional column header, then one row per entry.
88
+ * One block's body rows: an optional header row (the group's title, the column
89
+ * titles, or both), then one row per entry.
85
90
  *
86
91
  * Every column is measured with `displayWidth` (through `pad`/`padNumber`), so a
87
92
  * wide-character label cannot shift the numbers out of alignment — the failure
88
93
  * that makes a table of counts unreadable exactly when the data is interesting.
89
94
  */
90
- export function statsPanelRows(rows, { columns, emptyMessage } = {}) {
95
+ export function statsPanelRows(rows, { columns, title, emptyMessage } = {}) {
91
96
  if (rows.length === 0) {
92
97
  return emptyMessage === undefined ? [] : [`${INDENT}${emptyMessage}`];
93
98
  }
94
99
  const barValue = (row) => row.numbers.at(-1) ?? 0;
95
100
  const max = Math.max(...rows.map(barValue));
96
101
  const out = [];
97
- if (columns && columns.length > 0) {
102
+ if ((columns && columns.length > 0) || title !== undefined) {
98
103
  out.push(INDENT +
99
- pad('', LABEL_WIDTH) +
104
+ // The SAME label geometry as a data row below (padded to the field width
105
+ // minus the gap, then the gap), so a group title sits exactly over the
106
+ // labels it captions. With no title this is the blank label field the
107
+ // header row always had, byte for byte.
108
+ pad(title ?? '', LABEL_WIDTH - LABEL_GAP) +
109
+ ' '.repeat(LABEL_GAP) +
100
110
  ' '.repeat(BAR_WIDTH) +
101
- columns.map((c) => padNumber(c, NUMBER_WIDTH)).join(''));
111
+ (columns ?? []).map((c) => padNumber(c, NUMBER_WIDTH)).join(''));
102
112
  }
103
113
  for (const row of rows) {
104
114
  out.push(INDENT +
@@ -10,6 +10,9 @@ export declare const ROOT_TABS: readonly [{
10
10
  }, {
11
11
  readonly id: "projects";
12
12
  readonly label: "Projects";
13
+ }, {
14
+ readonly id: "conductors";
15
+ readonly label: "Conductors";
13
16
  }];
14
17
  /** The project level: the tabs of an opened project. */
15
18
  export declare const PROJECT_TABS: readonly [{
@@ -33,6 +36,9 @@ export declare const TAB_LEVELS: {
33
36
  }, {
34
37
  readonly id: "projects";
35
38
  readonly label: "Projects";
39
+ }, {
40
+ readonly id: "conductors";
41
+ readonly label: "Conductors";
36
42
  }];
37
43
  readonly project: readonly [{
38
44
  readonly id: "dashboard";
@@ -26,15 +26,23 @@ import { displayWidth } from '../../lib/width.js';
26
26
  // `topTabSpans(level, id)`, `tabBarFor(location)`, `TabIdOf<L>`). It was a
27
27
  // property of ONE function, the level-blind `locateTab`, which walked the levels
28
28
  // in key order and would now resolve the project level's `dashboard` to the ROOT
29
- // location. `gotoTopTab` was its only caller, and it only ever moves within one
29
+ // location. Top-tab navigation was its only caller, and it only moves within one
30
30
  // level — the bar you Tab through or click IS the current level's bar — so it
31
31
  // pairs the target with `here.level` directly and `locateTab` is deleted.
32
32
  //
33
- // Only tabs that are actually navigable are listed. A planned screen (`Users` on
34
- // the root level, `Conductors` on the project level) arrives with its own ticket
35
- // and adds its entry here then a tab drawn before its screen exists would
36
- // advertise navigation the operator cannot take, which is the very defect
37
- // GAIA-239 removes.
33
+ // Only tabs that are actually navigable are listed. A planned screen (`Users`)
34
+ // arrives with its own ticket and adds its entry here then a tab drawn before
35
+ // its screen exists would advertise navigation the operator cannot take, which
36
+ // is the very defect GAIA-239 removes.
37
+ //
38
+ // GAIA-232 added `conductors`, and put it on the ROOT level although the note
39
+ // above once reserved it for the project level. A conductor is an operator
40
+ // concern: "is my conductor alive?" has to be answerable without first choosing
41
+ // a project, and one conductor commonly serves work across a checkout rather
42
+ // than sitting inside one project's screen. Which project it serves is a COLUMN
43
+ // of that screen, not a navigation level above it. Being an ordinary entry here
44
+ // is also what keeps it inside the derived id union and gives it GAIA-241's
45
+ // click spans for free.
38
46
  //
39
47
  // `as const satisfies` is load-bearing on both halves: `satisfies` still
40
48
  // shape-checks every entry against `TopTab`, while `as const` keeps the literal
@@ -45,6 +53,7 @@ import { displayWidth } from '../../lib/width.js';
45
53
  export const ROOT_TABS = [
46
54
  { id: 'dashboard', label: 'Dashboard' },
47
55
  { id: 'projects', label: 'Projects' },
56
+ { id: 'conductors', label: 'Conductors' },
48
57
  ];
49
58
  /** The project level: the tabs of an opened project. */
50
59
  export const PROJECT_TABS = [
@@ -0,0 +1,98 @@
1
+ import type { DropshClient, JsonApiResource } from '../types.js';
2
+ /** A JSON:API collection document (the raw shape `client.get` returns). */
3
+ export interface JsonApiCollectionDoc {
4
+ data?: JsonApiResource[];
5
+ included?: JsonApiResource[];
6
+ /**
7
+ * The document-level `meta`. GAIA-305 makes every `gaia_*` collection report its
8
+ * total here (`GaiaResourceType::includeCount()` -> TRUE, applied by
9
+ * `GaiaResourceTypeRepository::countsCollections()`); see {@link Page.total}.
10
+ *
11
+ * Optional and loosely typed on purpose: a control plane that predates GAIA-305
12
+ * answers either with no `meta` at all or with a `meta` carrying only `omitted`
13
+ * — both measured this session — and neither is an error.
14
+ */
15
+ meta?: {
16
+ count?: unknown;
17
+ };
18
+ /**
19
+ * JSON:API's own paging links. `next` is the ONLY valid end-of-list signal, and
20
+ * it stays so after GAIA-305: a page can simply BE the last one, and `next` is
21
+ * what says whether it is. `rows.length === limit` cannot answer that — a full
22
+ * page is exactly as consistent with "more follows" as with "that was all", and
23
+ * the run collection's last page is short for the ordinary reason that
24
+ * collections do not divide evenly.
25
+ *
26
+ * (GAIA-305 does retire the older reason given here — that access filtering
27
+ * emptied pages after `page[limit]` had bounded the query, so a SHORT page said
28
+ * nothing at all. Its query-level access filtering makes pages full again. The
29
+ * conclusion is unchanged; only the argument for it is.)
30
+ *
31
+ * Optional, because the three non-paged readers (the parent picker, the projects
32
+ * screen, the own-conductor lookup) read one bounded bag and never page.
33
+ */
34
+ links?: {
35
+ next?: unknown;
36
+ };
37
+ }
38
+ /**
39
+ * The window a paged read ASKED the server for: the request's own `page[offset]`
40
+ * and `page[limit]` (GAIA-312 D3).
41
+ *
42
+ * A fact about the REQUEST, never an inference about the pages before it. Both
43
+ * list queries send `page[limit]=size` and `page[offset]=index * size`
44
+ * (`Entity/*​/Data/query.ts`), and since GAIA-305 the server skips and counts the
45
+ * same access-filtered collection — which is what lets a screen turn an offset
46
+ * into a position at all.
47
+ */
48
+ export interface PageWindow {
49
+ /** Rows of the collection the request told the server to skip. */
50
+ offset: number;
51
+ /** Rows the request told the server to return at most. */
52
+ limit: number;
53
+ }
54
+ /**
55
+ * One page of rows plus everything the screen states about it.
56
+ *
57
+ * Carried together so a `hasNext`, a page number or a total cannot go stale beside
58
+ * fresh rows (GAIA-292 D4/D4a).
59
+ */
60
+ export interface Page<Row> {
61
+ rows: Row[];
62
+ hasNext: boolean;
63
+ /** The page these rows CAME FROM, not the one most recently requested. */
64
+ page: number;
65
+ /**
66
+ * The size of the filtered collection as the SERVER reported it, or null when it
67
+ * reported none (GAIA-292 D6e).
68
+ *
69
+ * It rides the page response's `meta.count`, so it costs no read of its own and
70
+ * cannot describe a different collection than the rows beside it: the server
71
+ * applied one query and answered both from it. That is why the earlier background
72
+ * walk — 28 sequential reads for the open-run collection — is gone rather than
73
+ * kept as a fallback.
74
+ *
75
+ * A number renders as `20 of 107 ticket(s)`; `null` renders as `20 of ?
76
+ * ticket(s)` (D6d), which is a visible statement that this control plane reports
77
+ * no total — never a number nobody established.
78
+ */
79
+ total: number | null;
80
+ /**
81
+ * The window these rows were read with, or `null` when they no longer fill a
82
+ * window that positions them (GAIA-312 D3/D4).
83
+ *
84
+ * Required and explicitly nullable, exactly like {@link Page.total}: "not
85
+ * established" occupies the field's own place instead of being spelled by an
86
+ * absent key. `null` means the screen states a row COUNT; a window means it may
87
+ * state a RANGE, under the test in `ListScreen.countLabel`.
88
+ */
89
+ window: PageWindow | null;
90
+ }
91
+ /**
92
+ * Read one page of a collection and map it to rows.
93
+ *
94
+ * `mapRows` receives `data` AND `included` so each slice keeps its own sideload
95
+ * indexing — the ticket list indexes every include by id, the run list builds a
96
+ * ticket-only map — without this function knowing either.
97
+ */
98
+ export declare function loadPage<Row>(client: DropshClient, path: string, params: unknown, mapRows: (data: JsonApiResource[], included: JsonApiResource[]) => Row[], page: number, window: PageWindow): Promise<Page<Row>>;
@@ -0,0 +1,46 @@
1
+ // The ONE JSON:API collection-document shape and the ONE page read (GAIA-294).
2
+ //
3
+ // Slice-less on purpose, directly under `Entity/` beside `transport.ts`,
4
+ // `count.ts` and `cycle.ts`. That is the only placement every slice's `Data/` may
5
+ // import: `tests/boundary.test.ts` G2 forbids a data module from reaching another
6
+ // slice, and its `sliceOf` returns undefined for a file sitting here, so these
7
+ // edges are not cross-slice ones.
8
+ //
9
+ // Before this file the document was declared FIVE times — in ticket/Data/tickets,
10
+ // run/Data/runs, ticket/Data/parents, project/Data/projects and
11
+ // conductor/Data/own-conductor — and only the first two carried `links.next`. Same
12
+ // name, different shapes: the drift GAIA-292's "mirroring Task 3 exactly so the
13
+ // two lists cannot drift" was meant to prevent had already happened.
14
+ /**
15
+ * The collection total a document reports, or null when it reports none.
16
+ *
17
+ * Defensive by contract rather than by suspicion: this value is rendered to the
18
+ * operator as a total, so anything that is not a usable count must become the
19
+ * explicit unknown instead of a guess (AC 8.5). A count that is absent, not a
20
+ * number, not finite, or negative is no count.
21
+ */
22
+ function readTotal(doc) {
23
+ const count = doc?.meta?.count;
24
+ if (typeof count !== 'number')
25
+ return null;
26
+ if (!Number.isFinite(count) || count < 0)
27
+ return null;
28
+ return count;
29
+ }
30
+ /**
31
+ * Read one page of a collection and map it to rows.
32
+ *
33
+ * `mapRows` receives `data` AND `included` so each slice keeps its own sideload
34
+ * indexing — the ticket list indexes every include by id, the run list builds a
35
+ * ticket-only map — without this function knowing either.
36
+ */
37
+ export async function loadPage(client, path, params, mapRows, page, window) {
38
+ const doc = (await client.get(path, params));
39
+ return {
40
+ rows: mapRows(doc?.data ?? [], doc?.included ?? []),
41
+ hasNext: Boolean(doc?.links?.next),
42
+ page,
43
+ total: readTotal(doc),
44
+ window,
45
+ };
46
+ }
@@ -0,0 +1,25 @@
1
+ /** The minimum shape a conductor row needs to render. */
2
+ export interface ConductorRowInput {
3
+ label: string;
4
+ machineId: string | undefined;
5
+ project: string;
6
+ derivedStatus: string;
7
+ ageLabel: string;
8
+ workspaceRoot: string;
9
+ lastPid: number | undefined;
10
+ /** Whether this conductor's workspace exists on THIS machine. */
11
+ isLocal: boolean;
12
+ /** Whether an action on this row is in flight. */
13
+ busy: boolean;
14
+ }
15
+ /**
16
+ * One line per conductor: who, where, alive, since when, and its process.
17
+ *
18
+ * A non-local row is marked rather than hidden. The control plane is
19
+ * user-scoped, not machine-scoped, so a user's other machines' conductors are
20
+ * legitimately in this list — and silently dropping them would make the cockpit
21
+ * disagree with `gaia conductor ls` for no visible reason. Marking them says
22
+ * why their actions are inert here: a pid from another machine means nothing on
23
+ * this one.
24
+ */
25
+ export declare function conductorRows(conductors: ConductorRowInput[]): string[];
@@ -0,0 +1,60 @@
1
+ import { pad } from '../../../../lib/format.js';
2
+ /** How much width the workspace column gets before it is elided. */
3
+ const PATH_WIDTH = 30;
4
+ /**
5
+ * How much width the machine_id column gets before it is elided.
6
+ *
7
+ * A `machine_id` is COMPOSED (`${user_id}-${machine_id}-${project}`), so on a
8
+ * user-scoped control plane every row repeats the same uuid at the head. It is
9
+ * therefore elided from the left like the path: the tail is the only part that
10
+ * tells one conductor from another.
11
+ */
12
+ const MACHINE_ID_WIDTH = 20;
13
+ /**
14
+ * One line per conductor: who, where, alive, since when, and its process.
15
+ *
16
+ * A non-local row is marked rather than hidden. The control plane is
17
+ * user-scoped, not machine-scoped, so a user's other machines' conductors are
18
+ * legitimately in this list — and silently dropping them would make the cockpit
19
+ * disagree with `gaia conductor ls` for no visible reason. Marking them says
20
+ * why their actions are inert here: a pid from another machine means nothing on
21
+ * this one.
22
+ */
23
+ export function conductorRows(conductors) {
24
+ if (conductors.length === 0)
25
+ return ['No conductors on this control plane.'];
26
+ return conductors.map((c) => {
27
+ const name = c.label !== '' ? c.label : (c.machineId ?? '');
28
+ const pid = c.lastPid === undefined ? '-' : String(c.lastPid);
29
+ const marks = [c.busy ? '…' : '', c.isLocal ? '' : 'elsewhere']
30
+ .filter((m) => m !== '')
31
+ .join(' ');
32
+ return [
33
+ pad(name, 20),
34
+ // Its OWN column, not merely the label's fallback (AC-1, spec D2): two
35
+ // conductors of one repo share a project and a workspace_root, and the
36
+ // machine_id is the only thing that separates them.
37
+ pad(elideLeft(c.machineId ?? '', MACHINE_ID_WIDTH), MACHINE_ID_WIDTH),
38
+ pad(c.project, 14),
39
+ pad(c.derivedStatus, 9),
40
+ pad(c.ageLabel, 8),
41
+ pad(elideLeft(c.workspaceRoot, PATH_WIDTH), PATH_WIDTH),
42
+ pad(pid, 7),
43
+ marks,
44
+ ]
45
+ .join(' ')
46
+ .trimEnd();
47
+ });
48
+ }
49
+ /**
50
+ * Shorten a path from the LEFT, keeping its end.
51
+ *
52
+ * Conductor workspaces share a long common head (`/home/<user>/projects/…`) and
53
+ * differ at the tail, which is also the part that identifies the checkout.
54
+ * Eliding the right would leave every row looking identical.
55
+ */
56
+ function elideLeft(path, width) {
57
+ if (path.length <= width)
58
+ return path;
59
+ return `…${path.slice(path.length - (width - 1))}`;
60
+ }
@@ -1,5 +1,6 @@
1
1
  import type { DropshClient } from '../../../types.js';
2
2
  import { type ListOutcome, type OptionClients } from '../../transport.js';
3
+ import { type ConductorStatus } from './liveness.js';
3
4
  /** One conductor of the control plane, as this renderer needs it. */
4
5
  export interface Conductor {
5
6
  id: string;
@@ -26,3 +27,59 @@ export declare const listConductors: (client: DropshClient) => Promise<ListOutco
26
27
  * and `pm` sees the FULL conductor set where `session` sees a subset.
27
28
  */
28
29
  export declare const listConductorsPreferringWrite: (clients: OptionClients) => Promise<Conductor[]>;
30
+ /** A conductor as the Conductors screen renders it. */
31
+ export interface ConductorRow extends Conductor {
32
+ /** The `.gaia` dir it last registered from; '' when it never reported one. */
33
+ workspaceRoot: string;
34
+ /** Unix seconds of the last heartbeat; 0 when it never reported one. */
35
+ lastSeen: number;
36
+ /** Unix seconds the lease runs out; 0 when unset. */
37
+ leaseExpiresAt: number;
38
+ currentLoad: number;
39
+ maxParallel: number;
40
+ /** The pid its serve loop last reported, if any. */
41
+ lastPid: number | undefined;
42
+ /** The config stem it was started from, as `--conductor <name>` takes it. */
43
+ conductorName: string | undefined;
44
+ /** Liveness, derived — never the raw `status`. */
45
+ derivedStatus: ConductorStatus;
46
+ /**
47
+ * Whether this conductor's workspace exists on THIS machine.
48
+ *
49
+ * 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.
54
+ */
55
+ isLocal: boolean;
56
+ }
57
+ /** What the row projection needs from the outside world, injected for tests. */
58
+ export interface ConductorRowContext {
59
+ /** Unix seconds; the liveness derivation's clock. */
60
+ now: number;
61
+ /** Whether a path exists on this machine. */
62
+ exists: (path: string) => boolean;
63
+ }
64
+ /**
65
+ * The conductor collection as cockpit rows, in server order.
66
+ *
67
+ * Swallows a read failure into an empty list on purpose: the Conductors screen
68
+ * re-reads every 5 s, so one failed poll must leave the screen navigable rather
69
+ * than replace it with an error.
70
+ */
71
+ export declare const listConductorRows: (client: DropshClient, ctx: ConductorRowContext) => Promise<ConductorRow[]>;
72
+ /**
73
+ * Project uuid → name, for the Conductors screen's project column.
74
+ *
75
+ * A conductor references its project by uuid; the screen shows the name. This
76
+ * is a separate small read rather than a widened `ProjectRow`, because
77
+ * `loadProjectRows` builds the PROJECTS screen (one row per project, derived
78
+ * from conductors) and adding an id to it would change a shape four screens
79
+ * depend on to serve one column.
80
+ *
81
+ * A failed read yields an empty map: an unnamed project renders blank, which is
82
+ * better than a screen that refuses to list conductors because their projects
83
+ * could not be resolved.
84
+ */
85
+ export declare const listProjectNames: (client: DropshClient) => Promise<Map<string, string>>;
@@ -1,4 +1,11 @@
1
+ // GAIA-236 — the CONDUCTOR data service. It returns CONDUCTORS and knows nothing
2
+ // about forms: no `Option`, no project filter, no `(status)` in a label. Those are
3
+ // one popup's rules and live with that popup's adapter (`edit-form-data.ts`); a
4
+ // service that applied them would be unusable for any other purpose. The two read
5
+ // strategies are both just reading, so both live here.
6
+ import { toUnixSeconds } from '@gaia-ai/core';
1
7
  import { listOutcome, listPreferringWrite, } from '../../transport.js';
8
+ import { deriveConductorStatus } from './liveness.js';
2
9
  const str = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
3
10
  const toConductor = (r) => ({
4
11
  id: r.id,
@@ -21,3 +28,63 @@ export const listConductors = (client) => listOutcome(client, 'gaia_conductor',
21
28
  * and `pm` sees the FULL conductor set where `session` sees a subset.
22
29
  */
23
30
  export const listConductorsPreferringWrite = (clients) => listPreferringWrite(clients, 'gaia_conductor', toConductor);
31
+ const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
32
+ const toConductorRow = (r, ctx) => {
33
+ const workspaceRoot = str(r.attr('workspace_root')) ?? '';
34
+ // ISO-8601 over the wire, not the unix integer the field name implies — a
35
+ // `typeof === 'number'` guard here is what made a live conductor read `stale`.
36
+ const lastSeen = toUnixSeconds(r.attr('last_seen')) ?? 0;
37
+ const leaseExpiresAt = toUnixSeconds(r.attr('lease_expires_at')) ?? 0;
38
+ return {
39
+ ...toConductor(r),
40
+ workspaceRoot,
41
+ lastSeen,
42
+ leaseExpiresAt,
43
+ currentLoad: num(r.attr('current_load')) ?? 0,
44
+ maxParallel: num(r.attr('max_parallel')) ?? 0,
45
+ lastPid: num(r.attr('last_pid')),
46
+ conductorName: str(r.attr('conductor_name')),
47
+ // `present: true` because the row IS a control-plane record — which is why
48
+ // `registry-only` can never come out of this screen.
49
+ derivedStatus: deriveConductorStatus({
50
+ present: true,
51
+ status: str(r.attr('status')) ?? null,
52
+ lastSeen: lastSeen === 0 ? null : lastSeen,
53
+ leaseExpiresAt: leaseExpiresAt === 0 ? null : leaseExpiresAt,
54
+ }, ctx.now),
55
+ // A conductor that never reported a workspace cannot be local, whatever the
56
+ // filesystem would say about an empty path.
57
+ isLocal: workspaceRoot !== '' && ctx.exists(workspaceRoot),
58
+ };
59
+ };
60
+ /**
61
+ * The conductor collection as cockpit rows, in server order.
62
+ *
63
+ * Swallows a read failure into an empty list on purpose: the Conductors screen
64
+ * re-reads every 5 s, so one failed poll must leave the screen navigable rather
65
+ * than replace it with an error.
66
+ */
67
+ export const listConductorRows = async (client, ctx) => {
68
+ const outcome = await listOutcome(client, 'gaia_conductor', (r) => toConductorRow(r, ctx));
69
+ return outcome.rows;
70
+ };
71
+ /**
72
+ * Project uuid → name, for the Conductors screen's project column.
73
+ *
74
+ * A conductor references its project by uuid; the screen shows the name. This
75
+ * is a separate small read rather than a widened `ProjectRow`, because
76
+ * `loadProjectRows` builds the PROJECTS screen (one row per project, derived
77
+ * from conductors) and adding an id to it would change a shape four screens
78
+ * depend on to serve one column.
79
+ *
80
+ * A failed read yields an empty map: an unnamed project renders blank, which is
81
+ * better than a screen that refuses to list conductors because their projects
82
+ * could not be resolved.
83
+ */
84
+ export const listProjectNames = async (client) => {
85
+ const outcome = await listOutcome(client, 'gaia_project', (r) => ({
86
+ id: r.id,
87
+ name: str(r.attr('name')) ?? str(r.attr('label')) ?? '',
88
+ }));
89
+ return new Map(outcome.rows.map((p) => [p.id, p.name]));
90
+ };
@@ -1,5 +1,6 @@
1
+ import { type ConductorLiveness } from '@gaia-ai/core';
1
2
  /** The derived liveness state of a conductor. */
2
- export type ConductorStatus = 'running' | 'stale' | 'offline' | 'registry-only';
3
+ export type ConductorStatus = ConductorLiveness | 'registry-only';
3
4
  /** The raw liveness inputs derived from a `gaia_conductor` record. */
4
5
  export interface ConductorInput {
5
6
  present?: boolean;
@@ -1,23 +1,19 @@
1
- // Conductor liveness derivation. Mirrors the server-side ConductorDeriver
1
+ // Conductor liveness derivation.
2
+ //
3
+ // The rule itself lives in `@gaia-ai/core` (`deriveConductorLiveness`) so the
4
+ // CLI listing, this cockpit and the server-side ConductorDeriver
2
5
  // (web/modules/custom/gaia_core/modules/gaia_dashboard/src/Dashboard/ConductorDeriver.php)
3
- // and the CLI `classify()` in gaia-cli/conductor/src/commands/conductor.ts:
4
- // no gaia_conductor record registry-only; status 'offline' offline; else
5
- // stale when last_seen older than 600s OR the lease has expired; else running.
6
- const STALE_WINDOW_SECONDS = 600;
6
+ // cannot drift into describing one conductor three ways. What stays here is the
7
+ // one thing core cannot answer: whether a gaia_conductor record exists at all.
8
+ import { deriveConductorLiveness } from '@gaia-ai/core';
7
9
  export function deriveConductorStatus(conductor, nowSeconds) {
10
+ // `registry-only` means "something names this conductor but the control plane
11
+ // has no record of it". The Conductors screen reads the control plane itself,
12
+ // so every row it builds is present by construction and this branch cannot
13
+ // fire there; it remains for callers that join a record against another list.
8
14
  if (!conductor?.present)
9
15
  return 'registry-only';
10
- if (conductor.status === 'offline')
11
- return 'offline';
12
- if (conductor.lastSeen == null)
13
- return 'stale';
14
- if (nowSeconds - conductor.lastSeen > STALE_WINDOW_SECONDS)
15
- return 'stale';
16
- if (conductor.leaseExpiresAt != null &&
17
- conductor.leaseExpiresAt < nowSeconds) {
18
- return 'stale';
19
- }
20
- return 'running';
16
+ return deriveConductorLiveness(conductor, nowSeconds);
21
17
  }
22
18
  // `humanizeAge` moved to `lib/format.ts` (GAIA-236, entity-slice Phase 0): it is a
23
19
  // generic relative-time formatter with no conductor vocabulary, and keeping it here