@gaia-ai/addon-gaia-ui 0.11.2 → 0.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/Component/top-tabs/index.d.ts +18 -1
- package/dist/src/Component/top-tabs/index.js +15 -2
- package/dist/src/Entity/conductor/Data/conductors.d.ts +174 -15
- package/dist/src/Entity/conductor/Data/conductors.js +74 -16
- package/dist/src/Entity/conductor/Data/own-conductor.js +17 -3
- package/dist/src/Entity/conductor/Data/process.d.ts +29 -2
- package/dist/src/Entity/conductor/Data/process.js +40 -4
- package/dist/src/Entity/conductor/Screen/conductors.d.ts +43 -0
- package/dist/src/Entity/conductor/Screen/conductors.js +113 -32
- package/dist/src/Entity/conductor/Screen/project-conductors.d.ts +87 -0
- package/dist/src/Entity/conductor/Screen/project-conductors.js +245 -0
- package/dist/src/Entity/project/Data/projects.d.ts +7 -5
- package/dist/src/Entity/project/Data/projects.js +8 -6
- package/dist/src/Kernel/splash.d.ts +8 -0
- package/dist/src/Kernel/splash.js +11 -1
- package/dist/src/Routing/routes.js +2 -0
- package/package.json +2 -2
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
-
import { signalVerifiedProcess } from '@gaia-ai/core';
|
|
2
|
+
import { machineContextPath, readMachineContext, signalVerifiedProcess, } from '@gaia-ai/core';
|
|
3
|
+
/**
|
|
4
|
+
* This machine's host_key, from the same MachineContext the CLI reads.
|
|
5
|
+
*
|
|
6
|
+
* Exported so the Conductors screens (GAIA-450 B10) can key their view-only
|
|
7
|
+
* marking and their `actions()` gate on IDENTITY — this row's `host_key`
|
|
8
|
+
* against this machine's own — rather than on `isLocal`'s filesystem check,
|
|
9
|
+
* which two machines whose checkouts share a path can both satisfy.
|
|
10
|
+
*/
|
|
11
|
+
export async function defaultThisHostKey() {
|
|
12
|
+
const ctx = await readMachineContext(machineContextPath());
|
|
13
|
+
return ctx.machine_id ?? '';
|
|
14
|
+
}
|
|
3
15
|
/**
|
|
4
16
|
* Start and stop a conductor from the cockpit.
|
|
5
17
|
*
|
|
@@ -15,8 +27,27 @@ import { signalVerifiedProcess } from '@gaia-ai/core';
|
|
|
15
27
|
export function createConductorProcessHost(deps = {}) {
|
|
16
28
|
const spawn = deps.spawn ?? defaultSpawn;
|
|
17
29
|
const signal = deps.signal ?? signalVerifiedProcess;
|
|
30
|
+
const thisHostKey = deps.thisHostKey ?? defaultThisHostKey;
|
|
18
31
|
return {
|
|
19
|
-
|
|
32
|
+
thisHostKey,
|
|
33
|
+
async start({ workspaceRoot, conductorName, hostKey }) {
|
|
34
|
+
// GAIA-450 review B5: the only gate this used to have was `isLocal` — a
|
|
35
|
+
// filesystem check on `workspaceRoot`, which answers "does this path
|
|
36
|
+
// exist on THIS disk", not "is this THIS machine's conductor". Two
|
|
37
|
+
// machines whose checkouts share a path (the ordinary case for one
|
|
38
|
+
// operator with several) make that a false positive: pressing `s` on a
|
|
39
|
+
// ROW naming another machine's conductor silently started this
|
|
40
|
+
// machine's OWN one instead, composed from `workspaceRoot` alone with no
|
|
41
|
+
// identity at all. `stop` never had this hole — it already runs through
|
|
42
|
+
// `signalVerifiedProcess`'s `host_key` check downstream — so `start`
|
|
43
|
+
// gets the same verification here, the one place both actions now share.
|
|
44
|
+
const expected = await thisHostKey();
|
|
45
|
+
if (!hostKey || hostKey !== expected) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
message: `conductor's host_key (${hostKey ?? '(none)'}) is not this machine's (${expected}) — refusing to start a conductor on another host`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
20
51
|
try {
|
|
21
52
|
// Awaited, so "starting …" is claimed only once the fork has really
|
|
22
53
|
// happened — and so an `'error'` event becomes a reported failure
|
|
@@ -49,8 +80,13 @@ export function createConductorProcessHost(deps = {}) {
|
|
|
49
80
|
};
|
|
50
81
|
}
|
|
51
82
|
},
|
|
52
|
-
async stop({ pid,
|
|
53
|
-
const outcome = signal({
|
|
83
|
+
async stop({ pid, hostKey }) {
|
|
84
|
+
const outcome = signal({
|
|
85
|
+
pid,
|
|
86
|
+
hostKey,
|
|
87
|
+
thisHostKey: await thisHostKey(),
|
|
88
|
+
signal: 'SIGTERM',
|
|
89
|
+
});
|
|
54
90
|
return outcome.ok
|
|
55
91
|
? {
|
|
56
92
|
ok: true,
|
|
@@ -31,6 +31,14 @@ export declare class ConductorsScreen extends AsyncScreen<ConductorsData> {
|
|
|
31
31
|
private selectedIndex;
|
|
32
32
|
private logFor;
|
|
33
33
|
private readonly busyIds;
|
|
34
|
+
/**
|
|
35
|
+
* GAIA-450 review B6 — every `act()` outcome (success, refusal, thrown
|
|
36
|
+
* failure) lands here so the footer states it instead of staying silent.
|
|
37
|
+
* Cleared on selection change so a stale outcome does not linger under a
|
|
38
|
+
* different row; falls back to {@link viewOnlyNotice} so a row this screen
|
|
39
|
+
* cannot act on is marked BEFORE a key is pressed, not only after.
|
|
40
|
+
*/
|
|
41
|
+
private status;
|
|
34
42
|
topTab(): {
|
|
35
43
|
readonly level: "root";
|
|
36
44
|
readonly tab: "conductors";
|
|
@@ -42,11 +50,46 @@ export declare class ConductorsScreen extends AsyncScreen<ConductorsData> {
|
|
|
42
50
|
protected renderReady(data: ConductorsData, context: ScreenContext): VNode;
|
|
43
51
|
protected didLoad(data: ConductorsData): void;
|
|
44
52
|
protected chrome(_context: ScreenContext): FullScreenChrome;
|
|
53
|
+
/**
|
|
54
|
+
* GAIA-450 review B9 — `s`/`x` are advertised only when the SELECTED row can
|
|
55
|
+
* actually be acted on: `mine` is the identity gate (B10), not `isLocal`'s
|
|
56
|
+
* filesystem check, so a foreign conductor whose workspace happens to exist
|
|
57
|
+
* on this disk (GAIA-281 Finding 2) is correctly excluded too.
|
|
58
|
+
*
|
|
59
|
+
* GAIA-450 review B12 — `Enter :log` joins the same gate. A row that is not
|
|
60
|
+
* ours has no readable log on this machine (see {@link logFor}), so
|
|
61
|
+
* advertising the key made a promise the footer could not keep.
|
|
62
|
+
*/
|
|
45
63
|
protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
|
|
64
|
+
/**
|
|
65
|
+
* GAIA-450 review B6/B10 — a row this screen cannot act on states so,
|
|
66
|
+
* before any key is pressed: `!mine` is the identity gate `act()` uses too.
|
|
67
|
+
* The row's own MARKS cell already carries `elsewhere`; this is the
|
|
68
|
+
* footer's copy of the same fact for the row currently selected.
|
|
69
|
+
*/
|
|
70
|
+
private viewOnlyNotice;
|
|
71
|
+
protected notice(): string | undefined;
|
|
46
72
|
select(index: number, context: ScreenContext): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* GAIA-450 review B12 — gated here, not in `handleInput`: the kernel's own
|
|
75
|
+
* fallback (`tui-kernel.ts` — an unhandled `enter` calls `active()?.activate?.()`
|
|
76
|
+
* unconditionally) and a mouse click on the selected row
|
|
77
|
+
* (`Kernel/regions.ts`'s `rows.activate(index)`) both reach this method
|
|
78
|
+
* directly, bypassing `handleInput` entirely. Refusing here is therefore the
|
|
79
|
+
* one place that actually withholds the log on a row that is not ours,
|
|
80
|
+
* whichever path invoked it.
|
|
81
|
+
*/
|
|
47
82
|
activate(index: number, context: ScreenContext): boolean;
|
|
48
83
|
handleInput(input: string, context: ScreenContext): boolean;
|
|
49
84
|
reset(): void;
|
|
85
|
+
/**
|
|
86
|
+
* GAIA-450 review B6 — every outcome is now stated in the footer: the two
|
|
87
|
+
* refusals this screen can decide on its own (foreign row, already-online),
|
|
88
|
+
* the `{ok, message}` `host.start()`/`host.stop()` returns (previously
|
|
89
|
+
* discarded at the call site — the host-mismatch refusal `start` gained in
|
|
90
|
+
* the prior rework was composed only to be dropped here), and a thrown
|
|
91
|
+
* failure.
|
|
92
|
+
*/
|
|
50
93
|
private act;
|
|
51
94
|
}
|
|
52
95
|
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
+
import { configHomeOf } from '@gaia-ai/core';
|
|
2
3
|
import { actionItem } from '../../../Component/footer-actions/index.js';
|
|
3
4
|
import { columnNode, textNode } from '../../../lib/vnode.js';
|
|
4
5
|
import { AsyncScreen } from '../../../Screen/async-screen.js';
|
|
@@ -12,18 +13,30 @@ import { createConductorProcessHost } from '../Data/process.js';
|
|
|
12
13
|
* A conductor on another machine has a workspace this machine cannot read, so
|
|
13
14
|
* the block states that instead of showing an empty tail — the same rule the
|
|
14
15
|
* row's `elsewhere` mark states for its actions.
|
|
16
|
+
*
|
|
17
|
+
* GAIA-450 review B12: keyed on `mine`, not `isLocal`. `isLocal` is a
|
|
18
|
+
* filesystem existence check on `workspace_root`, so a FOREIGN conductor that
|
|
19
|
+
* happens to share this machine's directory layout (GAIA-281 Finding 2 — two
|
|
20
|
+
* `gaia_conductor` records naming one checkout under different machines) still
|
|
21
|
+
* has `isLocal === true`. Keying the log on it tailed THIS machine's own
|
|
22
|
+
* `log.txt` under the foreign conductor's heading, while the footer
|
|
23
|
+
* simultaneously read "view-only". `mine` is the identity gate `actions()` and
|
|
24
|
+
* `act()` already use; the log now agrees with them.
|
|
15
25
|
*/
|
|
16
|
-
const logFor = (row) =>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
const logFor = (row) => {
|
|
27
|
+
// GAIA-450 D4: workspace_root is the checkout root; the log lives under its
|
|
28
|
+
// own .gaia dir, not directly at the root.
|
|
29
|
+
const logPath = `${configHomeOf(row.workspaceRoot)}/log.txt`;
|
|
30
|
+
return row.mine
|
|
31
|
+
? {
|
|
32
|
+
heading: `log — ${logPath}`,
|
|
33
|
+
lines: tailConductorLog(logPath, { maxLines: 40 }).lines,
|
|
34
|
+
}
|
|
35
|
+
: {
|
|
36
|
+
heading: `log — ${row.label} runs on another machine; no log here`,
|
|
37
|
+
lines: [],
|
|
38
|
+
};
|
|
39
|
+
};
|
|
27
40
|
/**
|
|
28
41
|
* Conductors list — a normal {@link AsyncScreen}.
|
|
29
42
|
*
|
|
@@ -42,6 +55,14 @@ export class ConductorsScreen extends AsyncScreen {
|
|
|
42
55
|
selectedIndex = 0;
|
|
43
56
|
logFor = null;
|
|
44
57
|
busyIds = new Set();
|
|
58
|
+
/**
|
|
59
|
+
* GAIA-450 review B6 — every `act()` outcome (success, refusal, thrown
|
|
60
|
+
* failure) lands here so the footer states it instead of staying silent.
|
|
61
|
+
* Cleared on selection change so a stale outcome does not linger under a
|
|
62
|
+
* different row; falls back to {@link viewOnlyNotice} so a row this screen
|
|
63
|
+
* cannot act on is marked BEFORE a key is pressed, not only after.
|
|
64
|
+
*/
|
|
65
|
+
status = '';
|
|
45
66
|
topTab() {
|
|
46
67
|
return { level: 'root', tab: 'conductors' };
|
|
47
68
|
}
|
|
@@ -55,13 +76,19 @@ export class ConductorsScreen extends AsyncScreen {
|
|
|
55
76
|
if (!context.services)
|
|
56
77
|
throw new Error('Conductors requires services');
|
|
57
78
|
const client = context.services.client;
|
|
58
|
-
const
|
|
79
|
+
const host = context.services.conductorProcess ?? createConductorProcessHost();
|
|
80
|
+
// GAIA-450 B10: `mine` needs THIS machine's own host_key resolved before a
|
|
81
|
+
// row can be projected, so it is read alongside the project-name lookup
|
|
82
|
+
// rather than after — the same seam `start`/`stop` already verify against.
|
|
83
|
+
const [projectNames, thisHostKey] = await Promise.all([
|
|
59
84
|
listProjectNames(client),
|
|
60
|
-
|
|
61
|
-
now: context.nowSeconds?.() ?? Math.floor(Date.now() / 1000),
|
|
62
|
-
exists: existsSync,
|
|
63
|
-
}),
|
|
85
|
+
host.thisHostKey(),
|
|
64
86
|
]);
|
|
87
|
+
const rows = await listConductorRows(client, {
|
|
88
|
+
now: context.nowSeconds?.() ?? Math.floor(Date.now() / 1000),
|
|
89
|
+
exists: existsSync,
|
|
90
|
+
thisHostKey,
|
|
91
|
+
});
|
|
65
92
|
return { rows, projectNames };
|
|
66
93
|
}
|
|
67
94
|
renderLoading() {
|
|
@@ -95,24 +122,64 @@ export class ConductorsScreen extends AsyncScreen {
|
|
|
95
122
|
meta: `${rows.length} conductor(s)`,
|
|
96
123
|
};
|
|
97
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* GAIA-450 review B9 — `s`/`x` are advertised only when the SELECTED row can
|
|
127
|
+
* actually be acted on: `mine` is the identity gate (B10), not `isLocal`'s
|
|
128
|
+
* filesystem check, so a foreign conductor whose workspace happens to exist
|
|
129
|
+
* on this disk (GAIA-281 Finding 2) is correctly excluded too.
|
|
130
|
+
*
|
|
131
|
+
* GAIA-450 review B12 — `Enter :log` joins the same gate. A row that is not
|
|
132
|
+
* ours has no readable log on this machine (see {@link logFor}), so
|
|
133
|
+
* advertising the key made a promise the footer could not keep.
|
|
134
|
+
*/
|
|
98
135
|
actions() {
|
|
136
|
+
const canAct = this.rows[this.selectedIndex]?.mine ?? false;
|
|
99
137
|
return [
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
138
|
+
...(canAct
|
|
139
|
+
? [
|
|
140
|
+
actionItem('conductors-start', 's', 'start'),
|
|
141
|
+
actionItem('conductors-stop', 'x', 'stop'),
|
|
142
|
+
actionItem('conductors-enter', 'Enter', 'log'),
|
|
143
|
+
]
|
|
144
|
+
: []),
|
|
103
145
|
actionItem('conductors-tab', 'Tab', 'tabs'),
|
|
104
146
|
];
|
|
105
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* GAIA-450 review B6/B10 — a row this screen cannot act on states so,
|
|
150
|
+
* before any key is pressed: `!mine` is the identity gate `act()` uses too.
|
|
151
|
+
* The row's own MARKS cell already carries `elsewhere`; this is the
|
|
152
|
+
* footer's copy of the same fact for the row currently selected.
|
|
153
|
+
*/
|
|
154
|
+
viewOnlyNotice() {
|
|
155
|
+
const row = this.rows[this.selectedIndex];
|
|
156
|
+
return row && !row.mine
|
|
157
|
+
? `${row.label} runs on another machine — view-only`
|
|
158
|
+
: undefined;
|
|
159
|
+
}
|
|
160
|
+
notice() {
|
|
161
|
+
return this.status || this.viewOnlyNotice();
|
|
162
|
+
}
|
|
106
163
|
select(index, context) {
|
|
107
164
|
if (index < 0 || index >= this.rows.length)
|
|
108
165
|
return false;
|
|
109
166
|
this.selectedIndex = index;
|
|
167
|
+
this.status = '';
|
|
110
168
|
context.requestDraw();
|
|
111
169
|
return true;
|
|
112
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* GAIA-450 review B12 — gated here, not in `handleInput`: the kernel's own
|
|
173
|
+
* fallback (`tui-kernel.ts` — an unhandled `enter` calls `active()?.activate?.()`
|
|
174
|
+
* unconditionally) and a mouse click on the selected row
|
|
175
|
+
* (`Kernel/regions.ts`'s `rows.activate(index)`) both reach this method
|
|
176
|
+
* directly, bypassing `handleInput` entirely. Refusing here is therefore the
|
|
177
|
+
* one place that actually withholds the log on a row that is not ours,
|
|
178
|
+
* whichever path invoked it.
|
|
179
|
+
*/
|
|
113
180
|
activate(index, context) {
|
|
114
181
|
const row = this.rows[index];
|
|
115
|
-
if (!row)
|
|
182
|
+
if (!row?.mine)
|
|
116
183
|
return false;
|
|
117
184
|
this.logFor = this.logFor === row.id ? null : row.id;
|
|
118
185
|
context.requestDraw();
|
|
@@ -137,35 +204,49 @@ export class ConductorsScreen extends AsyncScreen {
|
|
|
137
204
|
this.selectedIndex = 0;
|
|
138
205
|
this.logFor = null;
|
|
139
206
|
this.busyIds.clear();
|
|
207
|
+
this.status = '';
|
|
140
208
|
super.reset();
|
|
141
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* GAIA-450 review B6 — every outcome is now stated in the footer: the two
|
|
212
|
+
* refusals this screen can decide on its own (foreign row, already-online),
|
|
213
|
+
* the `{ok, message}` `host.start()`/`host.stop()` returns (previously
|
|
214
|
+
* discarded at the call site — the host-mismatch refusal `start` gained in
|
|
215
|
+
* the prior rework was composed only to be dropped here), and a thrown
|
|
216
|
+
* failure.
|
|
217
|
+
*/
|
|
142
218
|
async act(action, context) {
|
|
143
219
|
const row = this.rows[this.selectedIndex];
|
|
144
220
|
if (!row || this.busyIds.has(row.id))
|
|
145
221
|
return;
|
|
146
|
-
if (!row.
|
|
222
|
+
if (!row.mine) {
|
|
223
|
+
this.status = `${row.label} runs on another machine — view-only`;
|
|
224
|
+
context.requestDraw();
|
|
147
225
|
return;
|
|
148
|
-
|
|
226
|
+
}
|
|
227
|
+
if (action === 'start' && row.derivedStatus === 'online') {
|
|
228
|
+
this.status = `${row.label} is already online`;
|
|
229
|
+
context.requestDraw();
|
|
149
230
|
return;
|
|
231
|
+
}
|
|
150
232
|
this.busyIds.add(row.id);
|
|
151
233
|
context.requestDraw();
|
|
152
234
|
const host = context.services?.conductorProcess ?? createConductorProcessHost();
|
|
153
235
|
try {
|
|
154
|
-
|
|
155
|
-
await host.start({
|
|
236
|
+
const result = action === 'start'
|
|
237
|
+
? await host.start({
|
|
156
238
|
workspaceRoot: row.workspaceRoot,
|
|
157
239
|
conductorName: row.conductorName,
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
await host.stop({
|
|
240
|
+
hostKey: row.hostKey,
|
|
241
|
+
})
|
|
242
|
+
: await host.stop({
|
|
162
243
|
pid: row.lastPid,
|
|
163
|
-
|
|
244
|
+
hostKey: row.hostKey,
|
|
164
245
|
});
|
|
165
|
-
|
|
246
|
+
this.status = result.message;
|
|
166
247
|
}
|
|
167
|
-
catch {
|
|
168
|
-
|
|
248
|
+
catch (err) {
|
|
249
|
+
this.status = `${action} failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
169
250
|
}
|
|
170
251
|
finally {
|
|
171
252
|
this.busyIds.delete(row.id);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { VNode } from '@opentui/core';
|
|
2
|
+
import { AsyncScreen } from '../../../Screen/async-screen.js';
|
|
3
|
+
import type { FullScreenChrome } from '../../../Screen/full-screen.js';
|
|
4
|
+
import type { ScreenContext } from '../../../Screen/screen.js';
|
|
5
|
+
import { type ConductorRow } from '../Data/conductors.js';
|
|
6
|
+
/**
|
|
7
|
+
* The project-scoped Conductors tab (GAIA-450 D13) — the conductors bound to
|
|
8
|
+
* THIS project, beside Tickets, Ticket-Agents and Runs. Same interaction as
|
|
9
|
+
* the root-level Conductors screen (start/stop keys, the log toggle, through
|
|
10
|
+
* the same {@link createConductorProcessHost}) over the same row shape, minus
|
|
11
|
+
* the PROJECT column every row would otherwise repeat.
|
|
12
|
+
*
|
|
13
|
+
* Refresh is the shared mechanism (GAIA-376 `ui-refresh` file + `R`), not a
|
|
14
|
+
* screen-private timer, matching every other list on this level.
|
|
15
|
+
*/
|
|
16
|
+
export declare class ProjectConductorsScreen extends AsyncScreen<readonly ConductorRow[]> {
|
|
17
|
+
static readonly route: {
|
|
18
|
+
id: string;
|
|
19
|
+
path: string;
|
|
20
|
+
parent: string;
|
|
21
|
+
tab: {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
scope: string;
|
|
25
|
+
order: number;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly frameId = "project-conductors";
|
|
30
|
+
private selectedIndex;
|
|
31
|
+
private logFor;
|
|
32
|
+
private readonly busyIds;
|
|
33
|
+
/**
|
|
34
|
+
* GAIA-450 review B6 — every `act()` outcome (success, refusal, thrown
|
|
35
|
+
* failure) lands here so the footer states it instead of staying silent.
|
|
36
|
+
* Cleared on selection change so a stale outcome does not linger under a
|
|
37
|
+
* different row; falls back to {@link viewOnlyNotice} so a row this screen
|
|
38
|
+
* cannot act on is marked BEFORE a key is pressed, not only after.
|
|
39
|
+
*/
|
|
40
|
+
private status;
|
|
41
|
+
topTab(): {
|
|
42
|
+
readonly level: "project";
|
|
43
|
+
readonly tab: "conductors";
|
|
44
|
+
};
|
|
45
|
+
reload(context: ScreenContext): void;
|
|
46
|
+
get rows(): readonly ConductorRow[];
|
|
47
|
+
protected load(context: ScreenContext): Promise<readonly ConductorRow[]>;
|
|
48
|
+
protected renderLoading(): string[];
|
|
49
|
+
protected renderReady(rows: readonly ConductorRow[], context: ScreenContext): VNode;
|
|
50
|
+
protected didLoad(rows: readonly ConductorRow[]): void;
|
|
51
|
+
protected chrome(context: ScreenContext): FullScreenChrome;
|
|
52
|
+
/**
|
|
53
|
+
* GAIA-450 review B9 — `s`/`x` are advertised only when the SELECTED row can
|
|
54
|
+
* actually be acted on: `mine` is the identity gate (B10), not `isLocal`'s
|
|
55
|
+
* filesystem check.
|
|
56
|
+
*
|
|
57
|
+
* GAIA-450 review B12 — `Enter :log` joins the same gate; see the root
|
|
58
|
+
* screen's copy of this comment.
|
|
59
|
+
*/
|
|
60
|
+
protected actions(): import("../../../Component/footer-actions/index.js").ActionItem[];
|
|
61
|
+
/**
|
|
62
|
+
* GAIA-450 review B6/B10 — a row this screen cannot act on states so,
|
|
63
|
+
* before any key is pressed: `!mine` is the identity gate `act()` uses too.
|
|
64
|
+
* The row's own MARKS cell already carries `elsewhere`; this is the
|
|
65
|
+
* footer's copy of the same fact for the row currently selected.
|
|
66
|
+
*/
|
|
67
|
+
private viewOnlyNotice;
|
|
68
|
+
protected notice(): string | undefined;
|
|
69
|
+
select(index: number, context: ScreenContext): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* GAIA-450 review B12 — gated here, not in `handleInput`; see the root
|
|
72
|
+
* screen's copy of this comment for why (the kernel's unhandled-`enter`
|
|
73
|
+
* fallback and a mouse click both call this directly).
|
|
74
|
+
*/
|
|
75
|
+
activate(index: number, context: ScreenContext): boolean;
|
|
76
|
+
handleInput(input: string, context: ScreenContext): boolean;
|
|
77
|
+
reset(): void;
|
|
78
|
+
/**
|
|
79
|
+
* GAIA-450 review B6 — every outcome is now stated in the footer: the two
|
|
80
|
+
* refusals this screen can decide on its own (foreign row, already-online),
|
|
81
|
+
* the `{ok, message}` `host.start()`/`host.stop()` returns (previously
|
|
82
|
+
* discarded at the call site — the host-mismatch refusal `start` gained in
|
|
83
|
+
* the prior rework was composed only to be dropped here), and a thrown
|
|
84
|
+
* failure.
|
|
85
|
+
*/
|
|
86
|
+
private act;
|
|
87
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { configHomeOf } from '@gaia-ai/core';
|
|
3
|
+
import { actionItem } from '../../../Component/footer-actions/index.js';
|
|
4
|
+
import { columnNode, textNode } from '../../../lib/vnode.js';
|
|
5
|
+
import { AsyncScreen } from '../../../Screen/async-screen.js';
|
|
6
|
+
import { listRowIds, listTableTree } from '../../collection.js';
|
|
7
|
+
import { listConductorRows, PROJECT_CONDUCTOR_TABLE, } from '../Data/conductors.js';
|
|
8
|
+
import { tailConductorLog } from '../Data/log-tail.js';
|
|
9
|
+
import { createConductorProcessHost } from '../Data/process.js';
|
|
10
|
+
/**
|
|
11
|
+
* The open row's log block: where it is, and its tail.
|
|
12
|
+
*
|
|
13
|
+
* Identical to the root Conductors screen's own — see its comment for why a
|
|
14
|
+
* non-local row shows a stated reason rather than an empty tail. Not shared
|
|
15
|
+
* as an import: a two-line function duplicated once is cheaper than a shared
|
|
16
|
+
* module two screens would otherwise have to agree on evolving together.
|
|
17
|
+
*
|
|
18
|
+
* GAIA-450 review B12: keyed on `mine`, not `isLocal` — see the root screen's
|
|
19
|
+
* copy of this comment for the GAIA-281 Finding 2 shape this closes.
|
|
20
|
+
*/
|
|
21
|
+
const logFor = (row) => {
|
|
22
|
+
const logPath = `${configHomeOf(row.workspaceRoot)}/log.txt`;
|
|
23
|
+
return row.mine
|
|
24
|
+
? {
|
|
25
|
+
heading: `log — ${logPath}`,
|
|
26
|
+
lines: tailConductorLog(logPath, { maxLines: 40 }).lines,
|
|
27
|
+
}
|
|
28
|
+
: {
|
|
29
|
+
heading: `log — ${row.label} runs on another machine; no log here`,
|
|
30
|
+
lines: [],
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The project-scoped Conductors tab (GAIA-450 D13) — the conductors bound to
|
|
35
|
+
* THIS project, beside Tickets, Ticket-Agents and Runs. Same interaction as
|
|
36
|
+
* the root-level Conductors screen (start/stop keys, the log toggle, through
|
|
37
|
+
* the same {@link createConductorProcessHost}) over the same row shape, minus
|
|
38
|
+
* the PROJECT column every row would otherwise repeat.
|
|
39
|
+
*
|
|
40
|
+
* Refresh is the shared mechanism (GAIA-376 `ui-refresh` file + `R`), not a
|
|
41
|
+
* screen-private timer, matching every other list on this level.
|
|
42
|
+
*/
|
|
43
|
+
export class ProjectConductorsScreen extends AsyncScreen {
|
|
44
|
+
static route = {
|
|
45
|
+
id: 'project.conductors',
|
|
46
|
+
path: '/projects/:projectId/conductors',
|
|
47
|
+
parent: 'project.dashboard',
|
|
48
|
+
tab: { id: 'conductors', label: 'Conductors', scope: 'project', order: 50 },
|
|
49
|
+
};
|
|
50
|
+
id = ProjectConductorsScreen.route.id;
|
|
51
|
+
frameId = 'project-conductors';
|
|
52
|
+
selectedIndex = 0;
|
|
53
|
+
logFor = null;
|
|
54
|
+
busyIds = new Set();
|
|
55
|
+
/**
|
|
56
|
+
* GAIA-450 review B6 — every `act()` outcome (success, refusal, thrown
|
|
57
|
+
* failure) lands here so the footer states it instead of staying silent.
|
|
58
|
+
* Cleared on selection change so a stale outcome does not linger under a
|
|
59
|
+
* different row; falls back to {@link viewOnlyNotice} so a row this screen
|
|
60
|
+
* cannot act on is marked BEFORE a key is pressed, not only after.
|
|
61
|
+
*/
|
|
62
|
+
status = '';
|
|
63
|
+
topTab() {
|
|
64
|
+
return { level: 'project', tab: 'conductors' };
|
|
65
|
+
}
|
|
66
|
+
reload(context) {
|
|
67
|
+
this.retry(context);
|
|
68
|
+
}
|
|
69
|
+
get rows() {
|
|
70
|
+
return this.readyValue ?? [];
|
|
71
|
+
}
|
|
72
|
+
async load(context) {
|
|
73
|
+
if (!context.services || !context.project) {
|
|
74
|
+
throw new Error('Project conductors requires project context');
|
|
75
|
+
}
|
|
76
|
+
const host = context.services.conductorProcess ?? createConductorProcessHost();
|
|
77
|
+
// GAIA-450 B10: `mine` needs THIS machine's own host_key, the same seam
|
|
78
|
+
// `start`/`stop` already verify against.
|
|
79
|
+
const thisHostKey = await host.thisHostKey();
|
|
80
|
+
return listConductorRows(context.services.client, {
|
|
81
|
+
now: context.nowSeconds?.() ?? Math.floor(Date.now() / 1000),
|
|
82
|
+
exists: existsSync,
|
|
83
|
+
thisHostKey,
|
|
84
|
+
}, context.project.id);
|
|
85
|
+
}
|
|
86
|
+
renderLoading() {
|
|
87
|
+
return ['Loading conductors…'];
|
|
88
|
+
}
|
|
89
|
+
renderReady(rows, context) {
|
|
90
|
+
const open = rows.find((row) => row.id === this.logFor);
|
|
91
|
+
const log = open ? logFor(open) : undefined;
|
|
92
|
+
return columnNode('project-conductor-list', listTableTree(PROJECT_CONDUCTOR_TABLE, rows, {
|
|
93
|
+
nowSeconds: context.nowSeconds?.() ?? Math.floor(Date.now() / 1000),
|
|
94
|
+
busyIds: this.busyIds,
|
|
95
|
+
projectNames: new Map(),
|
|
96
|
+
}, { selectedIndex: this.selectedIndex }), ...(log
|
|
97
|
+
? [
|
|
98
|
+
textNode({ id: 'project-conductor-log-gap', content: '' }),
|
|
99
|
+
textNode({
|
|
100
|
+
id: 'project-conductor-log-heading',
|
|
101
|
+
content: log.heading,
|
|
102
|
+
}),
|
|
103
|
+
...log.lines.map((line, index) => textNode({ id: `project-conductor-log-${index}`, content: line })),
|
|
104
|
+
]
|
|
105
|
+
: []));
|
|
106
|
+
}
|
|
107
|
+
didLoad(rows) {
|
|
108
|
+
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, rows.length - 1));
|
|
109
|
+
}
|
|
110
|
+
chrome(context) {
|
|
111
|
+
const rows = this.rows;
|
|
112
|
+
return {
|
|
113
|
+
selectable: rows.length,
|
|
114
|
+
selectedIndex: this.selectedIndex,
|
|
115
|
+
rowIds: listRowIds(PROJECT_CONDUCTOR_TABLE, rows),
|
|
116
|
+
title: `${context.project?.name ?? context.project?.slug ?? ''} · conductors`,
|
|
117
|
+
meta: `${rows.length} conductor(s)`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* GAIA-450 review B9 — `s`/`x` are advertised only when the SELECTED row can
|
|
122
|
+
* actually be acted on: `mine` is the identity gate (B10), not `isLocal`'s
|
|
123
|
+
* filesystem check.
|
|
124
|
+
*
|
|
125
|
+
* GAIA-450 review B12 — `Enter :log` joins the same gate; see the root
|
|
126
|
+
* screen's copy of this comment.
|
|
127
|
+
*/
|
|
128
|
+
actions() {
|
|
129
|
+
const canAct = this.rows[this.selectedIndex]?.mine ?? false;
|
|
130
|
+
return [
|
|
131
|
+
...(canAct
|
|
132
|
+
? [
|
|
133
|
+
actionItem('project-conductors-start', 's', 'start'),
|
|
134
|
+
actionItem('project-conductors-stop', 'x', 'stop'),
|
|
135
|
+
actionItem('project-conductors-enter', 'Enter', 'log'),
|
|
136
|
+
]
|
|
137
|
+
: []),
|
|
138
|
+
actionItem('project-conductors-tab', 'Tab', 'tabs'),
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* GAIA-450 review B6/B10 — a row this screen cannot act on states so,
|
|
143
|
+
* before any key is pressed: `!mine` is the identity gate `act()` uses too.
|
|
144
|
+
* The row's own MARKS cell already carries `elsewhere`; this is the
|
|
145
|
+
* footer's copy of the same fact for the row currently selected.
|
|
146
|
+
*/
|
|
147
|
+
viewOnlyNotice() {
|
|
148
|
+
const row = this.rows[this.selectedIndex];
|
|
149
|
+
return row && !row.mine
|
|
150
|
+
? `${row.label} runs on another machine — view-only`
|
|
151
|
+
: undefined;
|
|
152
|
+
}
|
|
153
|
+
notice() {
|
|
154
|
+
return this.status || this.viewOnlyNotice();
|
|
155
|
+
}
|
|
156
|
+
select(index, context) {
|
|
157
|
+
if (index < 0 || index >= this.rows.length)
|
|
158
|
+
return false;
|
|
159
|
+
this.selectedIndex = index;
|
|
160
|
+
this.status = '';
|
|
161
|
+
context.requestDraw();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* GAIA-450 review B12 — gated here, not in `handleInput`; see the root
|
|
166
|
+
* screen's copy of this comment for why (the kernel's unhandled-`enter`
|
|
167
|
+
* fallback and a mouse click both call this directly).
|
|
168
|
+
*/
|
|
169
|
+
activate(index, context) {
|
|
170
|
+
const row = this.rows[index];
|
|
171
|
+
if (!row?.mine)
|
|
172
|
+
return false;
|
|
173
|
+
this.logFor = this.logFor === row.id ? null : row.id;
|
|
174
|
+
context.requestDraw();
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
handleInput(input, context) {
|
|
178
|
+
if (super.handleInput(input, context))
|
|
179
|
+
return true;
|
|
180
|
+
if (input === 's' || input === 'x') {
|
|
181
|
+
void this.act(input === 's' ? 'start' : 'stop', context);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
if (input === 'enter')
|
|
185
|
+
return this.activate(this.selectedIndex, context);
|
|
186
|
+
if (input === 'j' || input === 'down')
|
|
187
|
+
return this.select(Math.min(this.selectedIndex + 1, this.rows.length - 1), context);
|
|
188
|
+
if (input === 'k' || input === 'up')
|
|
189
|
+
return this.select(Math.max(this.selectedIndex - 1, 0), context);
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
reset() {
|
|
193
|
+
this.selectedIndex = 0;
|
|
194
|
+
this.logFor = null;
|
|
195
|
+
this.busyIds.clear();
|
|
196
|
+
this.status = '';
|
|
197
|
+
super.reset();
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* GAIA-450 review B6 — every outcome is now stated in the footer: the two
|
|
201
|
+
* refusals this screen can decide on its own (foreign row, already-online),
|
|
202
|
+
* the `{ok, message}` `host.start()`/`host.stop()` returns (previously
|
|
203
|
+
* discarded at the call site — the host-mismatch refusal `start` gained in
|
|
204
|
+
* the prior rework was composed only to be dropped here), and a thrown
|
|
205
|
+
* failure.
|
|
206
|
+
*/
|
|
207
|
+
async act(action, context) {
|
|
208
|
+
const row = this.rows[this.selectedIndex];
|
|
209
|
+
if (!row || this.busyIds.has(row.id))
|
|
210
|
+
return;
|
|
211
|
+
if (!row.mine) {
|
|
212
|
+
this.status = `${row.label} runs on another machine — view-only`;
|
|
213
|
+
context.requestDraw();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (action === 'start' && row.derivedStatus === 'online') {
|
|
217
|
+
this.status = `${row.label} is already online`;
|
|
218
|
+
context.requestDraw();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.busyIds.add(row.id);
|
|
222
|
+
context.requestDraw();
|
|
223
|
+
const host = context.services?.conductorProcess ?? createConductorProcessHost();
|
|
224
|
+
try {
|
|
225
|
+
const result = action === 'start'
|
|
226
|
+
? await host.start({
|
|
227
|
+
workspaceRoot: row.workspaceRoot,
|
|
228
|
+
conductorName: row.conductorName,
|
|
229
|
+
hostKey: row.hostKey,
|
|
230
|
+
})
|
|
231
|
+
: await host.stop({
|
|
232
|
+
pid: row.lastPid,
|
|
233
|
+
hostKey: row.hostKey,
|
|
234
|
+
});
|
|
235
|
+
this.status = result.message;
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
this.status = `${action} failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
this.busyIds.delete(row.id);
|
|
242
|
+
this.retry(context);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|