@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.
- package/dist/src/Agent/launch.d.ts +4 -4
- package/dist/src/Agent/launch.js +14 -7
- package/dist/src/Component/stats-panel/index.d.ts +13 -2
- package/dist/src/Component/stats-panel/index.js +28 -18
- package/dist/src/Component/top-tabs/index.d.ts +6 -0
- package/dist/src/Component/top-tabs/index.js +15 -6
- package/dist/src/Entity/collection.d.ts +98 -0
- package/dist/src/Entity/collection.js +46 -0
- package/dist/src/Entity/conductor/Component/conductor-list/index.d.ts +25 -0
- package/dist/src/Entity/conductor/Component/conductor-list/index.js +60 -0
- package/dist/src/Entity/conductor/Data/conductors.d.ts +57 -0
- package/dist/src/Entity/conductor/Data/conductors.js +67 -0
- package/dist/src/Entity/conductor/Data/liveness.d.ts +2 -1
- package/dist/src/Entity/conductor/Data/liveness.js +12 -16
- package/dist/src/Entity/conductor/Data/log-tail.d.ts +25 -0
- package/dist/src/Entity/conductor/Data/log-tail.js +133 -0
- package/dist/src/Entity/conductor/Data/own-conductor.d.ts +30 -0
- package/dist/src/Entity/conductor/Data/own-conductor.js +73 -0
- package/dist/src/Entity/conductor/Data/process.d.ts +78 -0
- package/dist/src/Entity/conductor/Data/process.js +101 -0
- package/dist/src/Entity/conductor/Screen/conductors.d.ts +42 -0
- package/dist/src/Entity/conductor/Screen/conductors.js +200 -0
- package/dist/src/Entity/cycle.d.ts +18 -0
- package/dist/src/Entity/cycle.js +30 -0
- package/dist/src/Entity/project/Data/project.d.ts +8 -0
- package/dist/src/Entity/project/Data/project.js +12 -0
- package/dist/src/Entity/project/Data/projects.d.ts +3 -0
- package/dist/src/Entity/project/Data/projects.js +3 -0
- package/dist/src/Entity/project/Data/with-tickets.d.ts +5 -0
- package/dist/src/Entity/project/Data/with-tickets.js +15 -13
- package/dist/src/Entity/project/Screen/project-picker.d.ts +36 -0
- package/dist/src/Entity/project/Screen/project-picker.js +90 -0
- package/dist/src/Entity/project/Screen/projects-screen.d.ts +40 -0
- package/dist/src/Entity/project/Screen/projects-screen.js +103 -0
- package/dist/src/Entity/run/Component/run-table/index.d.ts +5 -7
- package/dist/src/Entity/run/Component/run-table/index.js +9 -18
- package/dist/src/Entity/run/Data/query.d.ts +22 -0
- package/dist/src/Entity/run/Data/query.js +41 -0
- package/dist/src/Entity/run/Data/runs.d.ts +85 -4
- package/dist/src/Entity/run/Data/runs.js +143 -27
- package/dist/src/Entity/run/Screen/run-detail.d.ts +25 -0
- package/dist/src/Entity/run/Screen/run-detail.js +51 -0
- package/dist/src/Entity/run/Screen/runs.d.ts +82 -0
- package/dist/src/Entity/run/Screen/runs.js +169 -0
- package/dist/src/Entity/statistics/Screen/dashboard.d.ts +42 -0
- package/dist/src/Entity/statistics/Screen/dashboard.js +177 -0
- package/dist/src/Entity/ticket/Component/create-form/index.d.ts +10 -0
- package/dist/src/Entity/ticket/Component/create-form/index.js +4 -0
- package/dist/src/Entity/ticket/Component/edit-form/index.d.ts +3 -2
- package/dist/src/Entity/ticket/Component/edit-form/index.js +30 -8
- package/dist/src/Entity/ticket/Component/tab-bar/index.js +6 -0
- package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +1 -1
- package/dist/src/Entity/ticket/Component/ticket-detail/index.js +33 -2
- package/dist/src/Entity/ticket/Component/ticket-table/index.d.ts +4 -2
- package/dist/src/Entity/ticket/Component/ticket-table/index.js +8 -12
- package/dist/src/Entity/ticket/Data/parents.d.ts +13 -1
- package/dist/src/Entity/ticket/Data/parents.js +64 -12
- package/dist/src/Entity/ticket/Data/query.d.ts +44 -7
- package/dist/src/Entity/ticket/Data/query.js +60 -26
- package/dist/src/Entity/ticket/Data/tickets.d.ts +105 -4
- package/dist/src/Entity/ticket/Data/tickets.js +164 -12
- package/dist/src/Entity/ticket/Data/workflow.d.ts +1 -5
- package/dist/src/Entity/ticket/Data/workflow.js +5 -13
- package/dist/src/Entity/ticket/Data/write.d.ts +32 -0
- package/dist/src/Entity/ticket/Data/write.js +80 -0
- package/dist/src/Entity/ticket/Form/create-data.d.ts +1 -11
- package/dist/src/Entity/ticket/Form/create-data.js +7 -36
- package/dist/src/Entity/ticket/Form/create.d.ts +45 -5
- package/dist/src/Entity/ticket/Form/create.js +45 -8
- package/dist/src/Entity/ticket/Form/edit-data.d.ts +1 -1
- package/dist/src/Entity/ticket/Form/edit-data.js +4 -0
- package/dist/src/Entity/ticket/Form/edit.d.ts +79 -12
- package/dist/src/Entity/ticket/Form/edit.js +165 -18
- package/dist/src/Entity/ticket/Form/keys.d.ts +7 -0
- package/dist/src/Entity/ticket/Form/keys.js +7 -0
- package/dist/src/Entity/ticket/Form/parent-filter.d.ts +36 -0
- package/dist/src/Entity/ticket/Form/parent-filter.js +37 -0
- package/dist/src/Entity/ticket/Form/parent-source.d.ts +27 -0
- package/dist/src/Entity/ticket/Form/parent-source.js +56 -0
- package/dist/src/Entity/ticket/Form/who-filter.d.ts +33 -0
- package/dist/src/Entity/ticket/Form/who-filter.js +33 -0
- package/dist/src/Entity/ticket/Form/who-source.d.ts +18 -0
- package/dist/src/Entity/ticket/Form/who-source.js +38 -0
- package/dist/src/Entity/ticket/Screen/create-ticket-form.d.ts +68 -0
- package/dist/src/Entity/ticket/Screen/create-ticket-form.js +336 -0
- package/dist/src/Entity/ticket/Screen/edit-assignment-form.d.ts +81 -0
- package/dist/src/Entity/ticket/Screen/edit-assignment-form.js +200 -0
- package/dist/src/Entity/ticket/Screen/parent-filter-form.d.ts +27 -0
- package/dist/src/Entity/ticket/Screen/parent-filter-form.js +177 -0
- package/dist/src/Entity/ticket/Screen/ticket-agents.d.ts +45 -0
- package/dist/src/Entity/ticket/Screen/ticket-agents.js +135 -0
- package/dist/src/Entity/ticket/Screen/ticket-detail.d.ts +61 -0
- package/dist/src/Entity/ticket/Screen/ticket-detail.js +300 -0
- package/dist/src/Entity/ticket/Screen/tickets.d.ts +129 -0
- package/dist/src/Entity/ticket/Screen/tickets.js +507 -0
- package/dist/src/Entity/ticket/Screen/who-filter-form.d.ts +27 -0
- package/dist/src/Entity/ticket/Screen/who-filter-form.js +154 -0
- package/dist/src/Entity/user/Data/users.d.ts +17 -0
- package/dist/src/Entity/user/Data/users.js +66 -6
- package/dist/src/Form/field.d.ts +3 -1
- package/dist/src/Form/field.js +1 -0
- package/dist/src/Form/form.d.ts +1 -1
- package/dist/src/Form/form.js +61 -9
- package/dist/src/Form/keys.d.ts +5 -1
- package/dist/src/Form/keys.js +38 -5
- package/dist/src/Kernel/tui-kernel.d.ts +0 -10
- package/dist/src/Kernel/tui-kernel.js +250 -2384
- package/dist/src/Routing/context.d.ts +31 -0
- package/dist/src/Routing/context.js +52 -0
- package/dist/src/Routing/registry.d.ts +19 -0
- package/dist/src/Routing/registry.js +97 -0
- package/dist/src/Routing/route.d.ts +39 -0
- package/dist/src/Routing/route.js +1 -0
- package/dist/src/Routing/router.d.ts +33 -0
- package/dist/src/Routing/router.js +102 -0
- package/dist/src/Routing/routes.d.ts +17 -0
- package/dist/src/Routing/routes.js +57 -0
- package/dist/src/Screen/async-screen.d.ts +50 -0
- package/dist/src/Screen/async-screen.js +164 -0
- package/dist/src/Screen/list-screen.d.ts +158 -0
- package/dist/src/Screen/list-screen.js +237 -0
- package/dist/src/Screen/screen.d.ts +106 -0
- package/dist/src/Screen/screen.js +18 -0
- package/dist/src/Tui/controller.d.ts +13 -0
- package/dist/src/Tui/controller.js +1794 -0
- package/dist/src/Widget/text.js +49 -4
- package/dist/src/Widget/types.d.ts +2 -1
- package/dist/src/launcher.d.ts +9 -2
- package/dist/src/launcher.js +2 -1
- package/dist/src/lib/ansi.js +13 -5
- package/dist/src/lib/cursor.d.ts +31 -0
- package/dist/src/lib/cursor.js +45 -0
- package/dist/src/lib/format.d.ts +21 -0
- package/dist/src/lib/format.js +59 -0
- package/dist/src/lib/table.d.ts +37 -0
- package/dist/src/lib/table.js +20 -0
- package/dist/src/lib/width.d.ts +33 -1
- package/dist/src/lib/width.js +107 -12
- package/dist/src/plugin.js +19 -0
- package/dist/src/types.d.ts +26 -0
- package/dist/src/types.js +0 -4
- 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
|
+
}
|