@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
@@ -0,0 +1,25 @@
1
+ export interface LogTail {
2
+ /** Whether the log file exists at all. */
3
+ present: boolean;
4
+ /** Formatted lines, oldest first. */
5
+ lines: string[];
6
+ /** Whether earlier content was cut off by the byte window. */
7
+ truncated: boolean;
8
+ }
9
+ export interface LogTailOptions {
10
+ maxLines?: number;
11
+ }
12
+ /**
13
+ * The tail of a conductor's `log.txt`, formatted for reading.
14
+ *
15
+ * The file sink writes raw pino NDJSON (`pino-pretty` serves only the stdout
16
+ * sink), so a dump would be a wall of JSON. Each record becomes
17
+ * `HH:MM:SS LEVEL msg (k=v …)`; anything that is not a pino record — a stack
18
+ * trace, a stray `console.log`, a line the window cut — is passed through
19
+ * verbatim rather than hidden, because a log view that silently drops what it
20
+ * cannot parse is worse than one that shows it.
21
+ *
22
+ * Never throws: this runs inside a TUI refresh, where an unreadable log must
23
+ * leave the screen navigable.
24
+ */
25
+ export declare function tailConductorLog(path: string, options?: LogTailOptions): LogTail;
@@ -0,0 +1,133 @@
1
+ import { closeSync, openSync, readSync, statSync } from 'node:fs';
2
+ /**
3
+ * How much of the log's tail is read per refresh.
4
+ *
5
+ * The live log on a working machine is megabytes; this view re-reads on every
6
+ * 5 s tick, so it reads a bounded window from the END rather than the file.
7
+ */
8
+ const WINDOW_BYTES = 64 * 1024;
9
+ /** Default number of formatted lines handed to the renderer. */
10
+ const DEFAULT_MAX_LINES = 500;
11
+ /** pino's numeric levels, as labels. */
12
+ const LEVELS = {
13
+ 10: 'TRACE',
14
+ 20: 'DEBUG',
15
+ 30: 'INFO',
16
+ 40: 'WARN',
17
+ 50: 'ERROR',
18
+ 60: 'FATAL',
19
+ };
20
+ /** Fields consumed by the formatted prefix, not repeated in the k=v tail. */
21
+ const CONSUMED = new Set(['level', 'time', 'msg', 'pid', 'hostname']);
22
+ /**
23
+ * The tail of a conductor's `log.txt`, formatted for reading.
24
+ *
25
+ * The file sink writes raw pino NDJSON (`pino-pretty` serves only the stdout
26
+ * sink), so a dump would be a wall of JSON. Each record becomes
27
+ * `HH:MM:SS LEVEL msg (k=v …)`; anything that is not a pino record — a stack
28
+ * trace, a stray `console.log`, a line the window cut — is passed through
29
+ * verbatim rather than hidden, because a log view that silently drops what it
30
+ * cannot parse is worse than one that shows it.
31
+ *
32
+ * Never throws: this runs inside a TUI refresh, where an unreadable log must
33
+ * leave the screen navigable.
34
+ */
35
+ export function tailConductorLog(path, options = {}) {
36
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
37
+ let size;
38
+ try {
39
+ size = statSync(path).size;
40
+ }
41
+ catch {
42
+ return {
43
+ present: false,
44
+ lines: [`no log at ${path}`],
45
+ truncated: false,
46
+ };
47
+ }
48
+ const start = Math.max(0, size - WINDOW_BYTES);
49
+ const truncated = start > 0;
50
+ let raw = '';
51
+ try {
52
+ const fd = openSync(path, 'r');
53
+ try {
54
+ const length = size - start;
55
+ const buf = Buffer.alloc(length);
56
+ readSync(fd, buf, 0, length, start);
57
+ raw = buf.toString('utf8');
58
+ }
59
+ finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+ catch (err) {
64
+ return {
65
+ present: true,
66
+ lines: [`could not read ${path}: ${String(err)}`],
67
+ truncated: false,
68
+ };
69
+ }
70
+ const all = raw.split('\n');
71
+ // The window almost certainly cut through a record; that fragment is not a
72
+ // line the conductor ever wrote, so it is dropped rather than shown broken.
73
+ if (truncated)
74
+ all.shift();
75
+ const lines = all
76
+ .filter((l) => l.trim() !== '')
77
+ .map(formatLine)
78
+ .slice(-maxLines);
79
+ return { present: true, lines, truncated };
80
+ }
81
+ function formatLine(line) {
82
+ const record = parseRecord(line);
83
+ if (!record)
84
+ return line;
85
+ const level = LEVELS[Number(record.level)] ?? String(record.level ?? '');
86
+ const time = formatTime(record.time);
87
+ const msg = typeof record.msg === 'string' ? record.msg : '';
88
+ const context = Object.entries(record)
89
+ .filter(([k, v]) => !CONSUMED.has(k) && v !== undefined)
90
+ .map(([k, v]) => `${k}=${renderValue(v)}`)
91
+ .join(' ');
92
+ const head = [time, level.padEnd(5), msg].filter(Boolean).join(' ');
93
+ return context === '' ? head : `${head} (${context})`;
94
+ }
95
+ function parseRecord(line) {
96
+ const trimmed = line.trim();
97
+ if (!trimmed.startsWith('{'))
98
+ return undefined;
99
+ try {
100
+ const parsed = JSON.parse(trimmed);
101
+ // A pino record is an object with a numeric level; anything else that
102
+ // happens to be JSON is not ours to reformat.
103
+ if (typeof parsed === 'object' &&
104
+ parsed !== null &&
105
+ typeof parsed.level === 'number') {
106
+ return parsed;
107
+ }
108
+ return undefined;
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ }
114
+ function formatTime(value) {
115
+ if (typeof value !== 'number' || !Number.isFinite(value))
116
+ return '';
117
+ const d = new Date(value);
118
+ const pad = (n) => String(n).padStart(2, '0');
119
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
120
+ }
121
+ function renderValue(value) {
122
+ if (typeof value === 'string')
123
+ return value;
124
+ if (typeof value === 'number' || typeof value === 'boolean') {
125
+ return String(value);
126
+ }
127
+ try {
128
+ return JSON.stringify(value) ?? '';
129
+ }
130
+ catch {
131
+ return '';
132
+ }
133
+ }
@@ -0,0 +1,30 @@
1
+ import type { DropshClient } from '../../../types.js';
2
+ import type { JsonApiCollectionDoc } from '../../collection.js';
3
+ /** What this checkout's conductor record supplies to the cockpit's defaults. */
4
+ export interface OwnConductor {
5
+ /** This machine's conductor id — preselected in the new-ticket form. */
6
+ machineId: string | undefined;
7
+ /** The project name it serves — the new-ticket form's default project. */
8
+ project: string | undefined;
9
+ }
10
+ /**
11
+ * Pick this checkout's conductor out of a `gaia_conductor` collection.
12
+ *
13
+ * A repo may host several conductor configs, and they share one
14
+ * `workspace_root` — the dir alone cannot say which of them the cockpit belongs
15
+ * to. The first in server order is taken, and that is a documented guess rather
16
+ * than a resolution: both values only PRESELECT fields in the new-ticket form,
17
+ * which the writer sees and can change.
18
+ */
19
+ export declare function selectOwnConductor(doc: JsonApiCollectionDoc, gaiaDir: string): OwnConductor;
20
+ /**
21
+ * Read the conductor collection and resolve this checkout's own record.
22
+ *
23
+ * One request with the project sideloaded, so the project NAME costs no second
24
+ * round trip. The read is owner-gated, so it is already this user's conductors.
25
+ *
26
+ * A failure yields nothing rather than rejecting: this runs on the boot path,
27
+ * and an unreachable control plane must leave the cockpit openable — the two
28
+ * values it produces are form defaults, not preconditions.
29
+ */
30
+ export declare function readOwnConductor(client: DropshClient, gaiaDir: string): Promise<OwnConductor>;
@@ -0,0 +1,73 @@
1
+ // GAIA-232 (spec Decision D9) — "which conductor is THIS checkout's?", answered
2
+ // from the control plane instead of the deleted `~/.gaia/conductors.json`.
3
+ //
4
+ // The `gaia ui` entry used to read two values straight out of the registry file:
5
+ // the project this checkout belongs to, and this machine's conductor
6
+ // `machine_id`. Both preselect fields in the new-ticket form. The entry cannot
7
+ // resolve them itself — it holds no JSON:API client (GAIA-223 spec Finding 1) —
8
+ // so it hands over the `.gaia` dir it is standing in and the renderer matches it
9
+ // against each conductor's `workspace_root`, which is the same directory the
10
+ // registry's `path` held.
11
+ //
12
+ // The matching rule is deliberately unchanged from the registry lookup it
13
+ // replaces (`resolve(entry.path) === resolve(gaiaDir)`): an exact, normalised
14
+ // path comparison, never a longest-prefix walk. So a worktree — which has its
15
+ // own `.gaia` — resolves to no conductor rather than quietly inheriting its
16
+ // parent checkout's project, exactly as before.
17
+ import { resolve } from 'node:path';
18
+ import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
19
+ const NOTHING = { machineId: undefined, project: undefined };
20
+ /** Id of a single-valued relationship linkage (`data` object), or null. */
21
+ function singleRelId(rel) {
22
+ const data = rel?.data;
23
+ if (!data || Array.isArray(data))
24
+ return null;
25
+ return data.id ?? null;
26
+ }
27
+ const str = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
28
+ /**
29
+ * Pick this checkout's conductor out of a `gaia_conductor` collection.
30
+ *
31
+ * A repo may host several conductor configs, and they share one
32
+ * `workspace_root` — the dir alone cannot say which of them the cockpit belongs
33
+ * to. The first in server order is taken, and that is a documented guess rather
34
+ * than a resolution: both values only PRESELECT fields in the new-ticket form,
35
+ * which the writer sees and can change.
36
+ */
37
+ export function selectOwnConductor(doc, gaiaDir) {
38
+ const target = resolve(gaiaDir);
39
+ const own = (doc.data ?? []).find((c) => {
40
+ const root = str(c.attributes?.workspace_root);
41
+ // A conductor that never reported a workspace matches no directory,
42
+ // whatever `resolve('')` would make of an empty path.
43
+ return root !== undefined && resolve(root) === target;
44
+ });
45
+ if (own === undefined)
46
+ return NOTHING;
47
+ const projectId = singleRelId(own.relationships?.project_id);
48
+ const project = projectId
49
+ ? str((doc.included ?? []).find((inc) => inc.id === projectId)?.attributes
50
+ ?.name)
51
+ : undefined;
52
+ return { machineId: str(own.attributes?.machine_id), project };
53
+ }
54
+ /**
55
+ * Read the conductor collection and resolve this checkout's own record.
56
+ *
57
+ * One request with the project sideloaded, so the project NAME costs no second
58
+ * round trip. The read is owner-gated, so it is already this user's conductors.
59
+ *
60
+ * A failure yields nothing rather than rejecting: this runs on the boot path,
61
+ * and an unreachable control plane must leave the cockpit openable — the two
62
+ * values it produces are form defaults, not preconditions.
63
+ */
64
+ export async function readOwnConductor(client, gaiaDir) {
65
+ const params = new DrupalJsonApiParams().addInclude(['project_id']);
66
+ try {
67
+ const doc = (await client.get('gaia_conductor/gaia_conductor', params));
68
+ return selectOwnConductor(doc, gaiaDir);
69
+ }
70
+ catch {
71
+ return NOTHING;
72
+ }
73
+ }
@@ -0,0 +1,78 @@
1
+ import { type SignalOutcome } from '@gaia-ai/core';
2
+ /** What a cockpit action reports back to its row. */
3
+ export interface ConductorActionResult {
4
+ ok: boolean;
5
+ message: string;
6
+ }
7
+ /** A detached child launch, injected so tests spawn nothing. */
8
+ export interface SpawnRequest {
9
+ command: string;
10
+ args: string[];
11
+ cwd: string;
12
+ env: Record<string, string | undefined>;
13
+ }
14
+ /**
15
+ * The platform seams the cockpit's conductor actions stand on.
16
+ *
17
+ * `spawn` settles when the child has really been forked, or rejects when it
18
+ * could not be. It is not `void`, because the failure that actually happens —
19
+ * `ENOENT`, `EACCES` — arrives on an `'error'` EVENT a tick after the call
20
+ * returns, and a caller with nothing to await can neither report it nor even
21
+ * survive it (GAIA-232 review finding F2).
22
+ */
23
+ export interface ConductorProcessDeps {
24
+ spawn?: (request: SpawnRequest) => void | Promise<void>;
25
+ signal?: (input: {
26
+ pid: number | undefined;
27
+ workspaceRoot: string;
28
+ signal: NodeJS.Signals;
29
+ }) => SignalOutcome;
30
+ }
31
+ export interface ConductorProcessHost {
32
+ start(input: {
33
+ workspaceRoot: string;
34
+ conductorName: string | undefined;
35
+ }): Promise<ConductorActionResult>;
36
+ stop(input: {
37
+ pid: number | undefined;
38
+ workspaceRoot: string;
39
+ }): Promise<ConductorActionResult>;
40
+ }
41
+ /**
42
+ * Start and stop a conductor from the cockpit.
43
+ *
44
+ * Start runs the same `gaia conductor start` a human runs, in the conductor's
45
+ * own workspace — rather than reimplementing the lifecycle in the TUI process.
46
+ * How that command backgrounds itself (herdr, or a bare foreground child) is
47
+ * not this screen's business, and keeping one implementation means the cockpit
48
+ * cannot drift from the command line.
49
+ *
50
+ * Stop is a verified signal, never a spawn: the process already exists, and
51
+ * asking a child to find it would only add a way to get it wrong.
52
+ */
53
+ export declare function createConductorProcessHost(deps?: ConductorProcessDeps): ConductorProcessHost;
54
+ /**
55
+ * The real launch: detached and fully disowned.
56
+ *
57
+ * `gaia ui` must not become the conductor's parent — the operator quits the
58
+ * cockpit far more often than they stop a conductor, and a child tied to this
59
+ * process would die with it. The streams are ignored for the same reason a
60
+ * separate logger exists: anything the child printed would land on the TUI's
61
+ * frame.
62
+ *
63
+ * The `'error'` listener is not defensive tidiness, it is the whole point
64
+ * (GAIA-232 review finding F2). `child_process.spawn` does not throw on a
65
+ * missing or unexecutable binary: it returns a child object and emits `'error'`
66
+ * asynchronously. An EventEmitter with no `'error'` listener THROWS that event,
67
+ * out of a tick nobody is in — so `gaia` missing from the TUI's `PATH`, or a
68
+ * `workspaceRoot` deleted since the last 5 s read, took the whole cockpit down.
69
+ *
70
+ * Node guarantees exactly one of `'spawn'` / `'error'`, so the promise always
71
+ * settles. The listener deliberately stays attached afterwards: a post-spawn
72
+ * `'error'` on a settled promise is a harmless no-op, and removing it would
73
+ * restore the crash for the rarer case.
74
+ *
75
+ * Exported so the real child is covered by a test — with a binary that cannot
76
+ * exist, so the suite never starts a conductor.
77
+ */
78
+ export declare function defaultSpawn(request: SpawnRequest): Promise<void>;
@@ -0,0 +1,101 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { signalVerifiedProcess } from '@gaia-ai/core';
3
+ /**
4
+ * Start and stop a conductor from the cockpit.
5
+ *
6
+ * Start runs the same `gaia conductor start` a human runs, in the conductor's
7
+ * own workspace — rather than reimplementing the lifecycle in the TUI process.
8
+ * How that command backgrounds itself (herdr, or a bare foreground child) is
9
+ * not this screen's business, and keeping one implementation means the cockpit
10
+ * cannot drift from the command line.
11
+ *
12
+ * Stop is a verified signal, never a spawn: the process already exists, and
13
+ * asking a child to find it would only add a way to get it wrong.
14
+ */
15
+ export function createConductorProcessHost(deps = {}) {
16
+ const spawn = deps.spawn ?? defaultSpawn;
17
+ const signal = deps.signal ?? signalVerifiedProcess;
18
+ return {
19
+ async start({ workspaceRoot, conductorName }) {
20
+ try {
21
+ // Awaited, so "starting …" is claimed only once the fork has really
22
+ // happened — and so an `'error'` event becomes a reported failure
23
+ // instead of an unhandled one.
24
+ await spawn({
25
+ command: 'gaia',
26
+ args: ['conductor', 'start'],
27
+ cwd: workspaceRoot,
28
+ env: {
29
+ ...process.env,
30
+ // Target by NAME and let core resolve it — the stem→file mapping
31
+ // has exactly one owner, and it is not this screen.
32
+ ...(conductorName ? { GAIA_CONDUCTOR: conductorName } : {}),
33
+ // Cleared, not merely unset: an inherited value beats
34
+ // $GAIA_CONDUCTOR in core's resolution, so a shell that exported it
35
+ // would make this button start a DIFFERENT conductor — silently,
36
+ // since both are valid configs and neither errors.
37
+ GAIA_CONDUCTOR_CONFIG: '',
38
+ },
39
+ });
40
+ return {
41
+ ok: true,
42
+ message: `starting ${conductorName ?? 'conductor'} in ${workspaceRoot}`,
43
+ };
44
+ }
45
+ catch (err) {
46
+ return {
47
+ ok: false,
48
+ message: `could not start: ${err instanceof Error ? err.message : String(err)}`,
49
+ };
50
+ }
51
+ },
52
+ async stop({ pid, workspaceRoot }) {
53
+ const outcome = signal({ pid, workspaceRoot, signal: 'SIGTERM' });
54
+ return outcome.ok
55
+ ? {
56
+ ok: true,
57
+ message: `stopped pid ${outcome.pid}; it writes its own offline as it exits`,
58
+ }
59
+ : { ok: false, message: outcome.message };
60
+ },
61
+ };
62
+ }
63
+ /**
64
+ * The real launch: detached and fully disowned.
65
+ *
66
+ * `gaia ui` must not become the conductor's parent — the operator quits the
67
+ * cockpit far more often than they stop a conductor, and a child tied to this
68
+ * process would die with it. The streams are ignored for the same reason a
69
+ * separate logger exists: anything the child printed would land on the TUI's
70
+ * frame.
71
+ *
72
+ * The `'error'` listener is not defensive tidiness, it is the whole point
73
+ * (GAIA-232 review finding F2). `child_process.spawn` does not throw on a
74
+ * missing or unexecutable binary: it returns a child object and emits `'error'`
75
+ * asynchronously. An EventEmitter with no `'error'` listener THROWS that event,
76
+ * out of a tick nobody is in — so `gaia` missing from the TUI's `PATH`, or a
77
+ * `workspaceRoot` deleted since the last 5 s read, took the whole cockpit down.
78
+ *
79
+ * Node guarantees exactly one of `'spawn'` / `'error'`, so the promise always
80
+ * settles. The listener deliberately stays attached afterwards: a post-spawn
81
+ * `'error'` on a settled promise is a harmless no-op, and removing it would
82
+ * restore the crash for the rarer case.
83
+ *
84
+ * Exported so the real child is covered by a test — with a binary that cannot
85
+ * exist, so the suite never starts a conductor.
86
+ */
87
+ export function defaultSpawn(request) {
88
+ return new Promise((resolve, reject) => {
89
+ const child = nodeSpawn(request.command, request.args, {
90
+ cwd: request.cwd,
91
+ env: request.env,
92
+ detached: true,
93
+ stdio: 'ignore',
94
+ });
95
+ child.on('error', reject);
96
+ child.once('spawn', () => {
97
+ child.unref();
98
+ resolve();
99
+ });
100
+ });
101
+ }
@@ -0,0 +1,42 @@
1
+ import { BaseScreen, type ScreenContext } from '../../../Screen/screen.js';
2
+ export declare class ConductorsScreen extends BaseScreen {
3
+ static readonly route: {
4
+ id: string;
5
+ path: string;
6
+ tab: {
7
+ id: string;
8
+ label: string;
9
+ scope: string;
10
+ order: number;
11
+ };
12
+ };
13
+ readonly id: string;
14
+ readonly frameId = "conductors";
15
+ private rows;
16
+ private projectNames;
17
+ private selectedIndex;
18
+ private logFor;
19
+ private status;
20
+ private reading;
21
+ private timer;
22
+ private readonly busyIds;
23
+ private activation;
24
+ topTab(): {
25
+ readonly level: "root";
26
+ readonly tab: "conductors";
27
+ };
28
+ private quietRead;
29
+ get busy(): boolean;
30
+ reload(context: ScreenContext): void;
31
+ enter(context: ScreenContext): void;
32
+ leave(): void;
33
+ render(context: ScreenContext): string[];
34
+ present(context: ScreenContext): import("../../../Screen/screen.js").ScreenPresentation;
35
+ select(index: number, context: ScreenContext): boolean;
36
+ activate(index: number, context: ScreenContext): boolean;
37
+ handleInput(input: string, context: ScreenContext): boolean;
38
+ reset(): void;
39
+ private refresh;
40
+ private act;
41
+ private stopTimer;
42
+ }
@@ -0,0 +1,200 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { humanizeAge } from '../../../lib/format.js';
3
+ import { BaseScreen, presentation, } from '../../../Screen/screen.js';
4
+ import { conductorRows } from '../Component/conductor-list/index.js';
5
+ import { listConductorRows, listProjectNames, } from '../Data/conductors.js';
6
+ import { tailConductorLog } from '../Data/log-tail.js';
7
+ import { createConductorProcessHost } from '../Data/process.js';
8
+ const REFRESH_MS = 5_000;
9
+ export class ConductorsScreen extends BaseScreen {
10
+ static route = {
11
+ id: 'conductors',
12
+ path: '/conductors',
13
+ tab: { id: 'conductors', label: 'Conductors', scope: 'root', order: 30 },
14
+ };
15
+ id = ConductorsScreen.route.id;
16
+ frameId = 'conductors';
17
+ rows = [];
18
+ projectNames = new Map();
19
+ selectedIndex = 0;
20
+ logFor = null;
21
+ status = '';
22
+ reading = false;
23
+ timer = null;
24
+ busyIds = new Set();
25
+ activation = 0;
26
+ topTab() {
27
+ return { level: 'root', tab: 'conductors' };
28
+ }
29
+ // GAIA-285 spec D5/D6 — the shared `R` lands here; the 5 s tick passes
30
+ // `quiet` so only an EXPLICIT reload states its loading in the footer.
31
+ quietRead = false;
32
+ get busy() {
33
+ return this.reading && !this.quietRead;
34
+ }
35
+ reload(context) {
36
+ void this.refresh(context);
37
+ }
38
+ enter(context) {
39
+ const activation = ++this.activation;
40
+ void this.refresh(context, true, activation);
41
+ this.stopTimer();
42
+ this.timer = setInterval(() => void this.refresh(context, false, undefined, true), REFRESH_MS);
43
+ this.timer.unref?.();
44
+ }
45
+ leave() {
46
+ this.activation++;
47
+ this.stopTimer();
48
+ }
49
+ render(context) {
50
+ const lines = conductorRows(this.rows.map((row) => ({
51
+ label: row.label,
52
+ machineId: row.machineId,
53
+ project: row.projectId
54
+ ? (this.projectNames.get(row.projectId) ?? '')
55
+ : '',
56
+ derivedStatus: row.derivedStatus,
57
+ ageLabel: humanizeAge(row.lastSeen || null, context.nowSeconds?.() ?? Math.floor(Date.now() / 1000)),
58
+ workspaceRoot: row.workspaceRoot,
59
+ lastPid: row.lastPid,
60
+ isLocal: row.isLocal,
61
+ busy: this.busyIds.has(row.id),
62
+ })));
63
+ const open = this.rows.find((row) => row.id === this.logFor);
64
+ if (!open)
65
+ return lines;
66
+ return [
67
+ ...lines,
68
+ '',
69
+ open.isLocal
70
+ ? `log — ${open.workspaceRoot}/log.txt`
71
+ : `log — ${open.label} runs on another machine; no log here`,
72
+ ...(open.isLocal
73
+ ? tailConductorLog(`${open.workspaceRoot}/log.txt`, { maxLines: 40 })
74
+ .lines
75
+ : []),
76
+ ];
77
+ }
78
+ present(context) {
79
+ return presentation(this.render(context), {
80
+ selectable: this.rows.length,
81
+ selectedIndex: this.selectedIndex,
82
+ selectedRow: this.rows.length ? this.selectedIndex : -1,
83
+ title: 'GAIA conductors',
84
+ meta: `${this.rows.length} conductor(s) · s start · x stop${this.status ? ` · ${this.status}` : ''}`,
85
+ status: ' ↑↓ select · Enter log · s start · x stop · Tab tabs · q quit ',
86
+ });
87
+ }
88
+ select(index, context) {
89
+ if (index < 0 || index >= this.rows.length)
90
+ return false;
91
+ this.selectedIndex = index;
92
+ context.requestDraw();
93
+ return true;
94
+ }
95
+ activate(index, context) {
96
+ const row = this.rows[index];
97
+ if (!row)
98
+ return false;
99
+ this.logFor = this.logFor === row.id ? null : row.id;
100
+ context.requestDraw();
101
+ return true;
102
+ }
103
+ handleInput(input, context) {
104
+ if (input === 's' || input === 'x') {
105
+ void this.act(input === 's' ? 'start' : 'stop', context);
106
+ return true;
107
+ }
108
+ if (input === '\r' || input === '\n')
109
+ return this.activate(this.selectedIndex, context);
110
+ if (input === 'j' || input === '\x1b[B')
111
+ return this.select(Math.min(this.selectedIndex + 1, this.rows.length - 1), context);
112
+ if (input === 'k' || input === '\x1b[A')
113
+ return this.select(Math.max(this.selectedIndex - 1, 0), context);
114
+ return false;
115
+ }
116
+ reset() {
117
+ this.leave();
118
+ this.rows = [];
119
+ this.projectNames.clear();
120
+ this.selectedIndex = 0;
121
+ this.logFor = null;
122
+ this.status = '';
123
+ this.busyIds.clear();
124
+ }
125
+ async refresh(context, includeProjects = false, activation = this.activation, quiet = false) {
126
+ if (this.reading || !context.services)
127
+ return;
128
+ this.reading = true;
129
+ this.quietRead = quiet;
130
+ try {
131
+ if (includeProjects && this.projectNames.size === 0) {
132
+ const names = await listProjectNames(context.services.client);
133
+ if (activation !== this.activation)
134
+ return;
135
+ this.projectNames = names;
136
+ }
137
+ const rows = await listConductorRows(context.services.client, {
138
+ now: context.nowSeconds?.() ?? Math.floor(Date.now() / 1000),
139
+ exists: existsSync,
140
+ });
141
+ if (activation !== this.activation)
142
+ return;
143
+ this.rows = rows;
144
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.rows.length - 1));
145
+ }
146
+ finally {
147
+ this.reading = false;
148
+ this.quietRead = false;
149
+ }
150
+ // After the finally, so the frame this triggers already reads busy=false —
151
+ // drawn from inside the try it would restate `Loading…` one last time.
152
+ if (context.isActive())
153
+ context.requestDraw();
154
+ }
155
+ async act(action, context) {
156
+ const row = this.rows[this.selectedIndex];
157
+ if (!row || this.busyIds.has(row.id))
158
+ return;
159
+ if (!row.isLocal) {
160
+ this.status = `${row.label} runs on another machine — no action from here`;
161
+ context.requestDraw();
162
+ return;
163
+ }
164
+ if (action === 'start' && row.derivedStatus === 'running') {
165
+ this.status = `${row.label} is already running`;
166
+ context.requestDraw();
167
+ return;
168
+ }
169
+ this.busyIds.add(row.id);
170
+ this.status = `${action}…`;
171
+ context.requestDraw();
172
+ const host = context.services?.conductorProcess ?? createConductorProcessHost();
173
+ try {
174
+ const result = action === 'start'
175
+ ? await host.start({
176
+ workspaceRoot: row.workspaceRoot,
177
+ conductorName: row.conductorName,
178
+ })
179
+ : await host.stop({
180
+ pid: row.lastPid,
181
+ workspaceRoot: row.workspaceRoot,
182
+ });
183
+ this.status = result.message;
184
+ }
185
+ catch (caught) {
186
+ this.status = caught instanceof Error ? caught.message : String(caught);
187
+ }
188
+ finally {
189
+ this.busyIds.delete(row.id);
190
+ await this.refresh(context);
191
+ if (context.isActive())
192
+ context.requestDraw();
193
+ }
194
+ }
195
+ stopTimer() {
196
+ if (this.timer)
197
+ clearInterval(this.timer);
198
+ this.timer = null;
199
+ }
200
+ }