@gaia-ai/addon-gaia-ui 0.11.5 → 0.13.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/Entity/conductor/Data/conductors.d.ts +44 -9
- package/dist/src/Entity/conductor/Data/conductors.js +86 -18
- package/dist/src/Entity/conductor/Data/own-conductor.d.ts +5 -1
- package/dist/src/Entity/conductor/Data/own-conductor.js +5 -1
- package/dist/src/Entity/conductor/Data/own-rows.d.ts +59 -0
- package/dist/src/Entity/conductor/Data/own-rows.js +87 -0
- package/dist/src/Entity/conductor/Data/process.d.ts +20 -0
- package/dist/src/Entity/conductor/Data/process.js +30 -1
- package/dist/src/Entity/conductor/Screen/conductors.d.ts +92 -16
- package/dist/src/Entity/conductor/Screen/conductors.js +106 -38
- package/dist/src/Entity/conductor/Screen/project-conductors.d.ts +99 -21
- package/dist/src/Entity/conductor/Screen/project-conductors.js +122 -51
- package/dist/src/Entity/project/Data/project.d.ts +8 -0
- package/dist/src/Entity/project/Data/project.js +18 -2
- package/dist/src/Entity/run/Data/query.d.ts +3 -1
- package/dist/src/Entity/run/Data/query.js +3 -1
- package/dist/src/Entity/statistics/Data/statistics.d.ts +99 -146
- package/dist/src/Entity/statistics/Data/statistics.js +118 -163
- package/dist/src/Entity/statistics/Screen/dashboard.d.ts +24 -40
- package/dist/src/Entity/statistics/Screen/dashboard.js +111 -214
- package/dist/src/Entity/statistics/render.d.ts +6 -0
- package/dist/src/Entity/statistics/render.js +69 -0
- package/dist/src/Entity/team/Screen/team-users.js +28 -10
- package/dist/src/Entity/team/Screen/teams-screen.d.ts +4 -4
- package/dist/src/Entity/team/Screen/teams-screen.js +29 -80
- package/dist/src/Entity/ticket/Component/ticket-detail/index.d.ts +42 -3
- package/dist/src/Entity/ticket/Component/ticket-detail/index.js +48 -1
- package/dist/src/Entity/ticket/Form/create-data.d.ts +8 -0
- package/dist/src/Entity/ticket/Form/create-data.js +11 -4
- package/dist/src/Entity/ticket/Form/edit-data.d.ts +2 -0
- package/dist/src/Entity/ticket/Form/edit-data.js +6 -4
- package/dist/src/Entity/ticket/Screen/create-ticket-form.d.ts +7 -0
- package/dist/src/Entity/ticket/Screen/create-ticket-form.js +18 -0
- package/dist/src/Entity/ticket/Screen/edit-assignment-form.d.ts +7 -0
- package/dist/src/Entity/ticket/Screen/edit-assignment-form.js +21 -1
- package/dist/src/Entity/ticket/Screen/ticket-detail.d.ts +83 -3
- package/dist/src/Entity/ticket/Screen/ticket-detail.js +300 -45
- package/dist/src/Entity/user/Screen/user-overview.d.ts +4 -4
- package/dist/src/Entity/user/Screen/user-overview.js +20 -99
- package/dist/src/Entity/workspace/Data/workspaces.d.ts +20 -0
- package/dist/src/Entity/workspace/Data/workspaces.js +20 -0
- package/dist/src/Entity/workspace/Screen/workspace-picker.d.ts +34 -0
- package/dist/src/Entity/workspace/Screen/workspace-picker.js +167 -0
- package/dist/src/Extension/contract.d.ts +133 -3
- package/dist/src/Extension/store.js +10 -2
- package/dist/src/Kernel/chrome.d.ts +10 -0
- package/dist/src/Kernel/chrome.js +26 -5
- package/dist/src/Kernel/tui-kernel.js +22 -1
- package/dist/src/Routing/routes.d.ts +6 -0
- package/dist/src/Routing/routes.js +8 -0
- package/dist/src/launcher.d.ts +9 -0
- package/dist/src/launcher.js +7 -0
- package/dist/src/plugin.js +13 -0
- package/dist/src/types.d.ts +36 -0
- package/dist/src/ui.d.ts +1 -1
- package/package.json +3 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DropshClient } from '../../../types.js';
|
|
2
|
+
import { type Page } from '../../collection.js';
|
|
2
3
|
import { type ListOutcome, type OptionClients } from '../../transport.js';
|
|
3
4
|
import { type ConductorStatus } from './liveness.js';
|
|
4
5
|
/** One conductor of the control plane, as this renderer needs it. */
|
|
@@ -12,6 +13,8 @@ export interface Conductor {
|
|
|
12
13
|
status: string | undefined;
|
|
13
14
|
/** The conductor's project, `null` when unlinked. */
|
|
14
15
|
projectId: string | null;
|
|
16
|
+
/** The user who owns this record, `null` when unowned (GAIA-460 D8/D9). */
|
|
17
|
+
ownerUserId: string | null;
|
|
15
18
|
}
|
|
16
19
|
/**
|
|
17
20
|
* The conductor collection through one client, in server order.
|
|
@@ -375,19 +378,51 @@ export interface ConductorRowContext {
|
|
|
375
378
|
*/
|
|
376
379
|
thisHostKey: string;
|
|
377
380
|
}
|
|
381
|
+
/** What narrows a conductor read — both filters AND together in one query. */
|
|
382
|
+
export interface ConductorQuery {
|
|
383
|
+
/** The project's own conductors (GAIA-450 D13). */
|
|
384
|
+
projectId?: string | undefined;
|
|
385
|
+
/** The conductors this user OWNS (GAIA-460 D3) — by owner, never by machine. */
|
|
386
|
+
ownerUserId?: string | undefined;
|
|
387
|
+
}
|
|
378
388
|
/**
|
|
379
|
-
*
|
|
389
|
+
* One PAGE of the conductor collection as cockpit rows (GAIA-460 D6).
|
|
390
|
+
*
|
|
391
|
+
* Sideloads `machine` so capacity (GAIA-353 D5/D10) comes from the host budget.
|
|
392
|
+
* The owner filter rides this same query rather than the returned rows (D3):
|
|
393
|
+
* applied to an already-bounded page it would hide GAIA-458's truncation instead
|
|
394
|
+
* of fixing it.
|
|
380
395
|
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
396
|
+
* A FAILED READ PROPAGATES, and that is the change D6 buys rather than an
|
|
397
|
+
* oversight. The read this replaced swallowed a failure into an empty list, so
|
|
398
|
+
* one failed 5 s poll left the screen navigable; on the shared `ListScreen`
|
|
399
|
+
* base a rejected load is the error state every other GAIA list already shows,
|
|
400
|
+
* and one collection may not keep a second spelling of what a failure looks
|
|
401
|
+
* like. The cost is real — a single bad poll now replaces the screen — and it
|
|
402
|
+
* is the convention's cost, not this read's decision to make.
|
|
403
|
+
*/
|
|
404
|
+
export declare const listConductorRowPage: (client: DropshClient, ctx: ConductorRowContext, query: ConductorQuery, page?: number) => Promise<Page<ConductorRow>>;
|
|
405
|
+
/**
|
|
406
|
+
* A single conductor's own `last_seen`, or `undefined` when the record has
|
|
407
|
+
* none or the read fails.
|
|
408
|
+
*
|
|
409
|
+
* GAIA-477 AC-5: the cockpit's `act('start', …)` polls this after a spawn,
|
|
410
|
+
* comparing against the row's `lastSeen` captured before the start, to tell
|
|
411
|
+
* a genuine new heartbeat apart from a stale one still in the window — the
|
|
412
|
+
* same distinction the CLI's `waitForFreshHeartbeat` makes server-side.
|
|
413
|
+
*/
|
|
414
|
+
export declare const fetchConductorLastSeen: (client: DropshClient, id: string) => Promise<number | undefined>;
|
|
415
|
+
/**
|
|
416
|
+
* How large the collection under `query` is, or `null` when that is not
|
|
417
|
+
* established (GAIA-460 D4).
|
|
385
418
|
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
419
|
+
* One row is requested because no row is wanted: the answer is `meta.count`, and
|
|
420
|
+
* asking for a page of them to count them is the walk GAIA-292 removed. `null`
|
|
421
|
+
* covers both a plane that reports no count (R1) and a failed probe — an
|
|
422
|
+
* explanation is never a precondition, so its failure may not disturb the list
|
|
423
|
+
* it was meant to explain.
|
|
389
424
|
*/
|
|
390
|
-
export declare const
|
|
425
|
+
export declare const countConductors: (client: DropshClient, query: ConductorQuery) => Promise<number | null>;
|
|
391
426
|
/**
|
|
392
427
|
* Project uuid → name, for the Conductors screen's project column.
|
|
393
428
|
*
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { toUnixSeconds } from '@gaia-ai/core';
|
|
7
7
|
import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
|
|
8
8
|
import { humanizeAge } from '../../../lib/format.js';
|
|
9
|
+
import { loadPage, } from '../../collection.js';
|
|
10
|
+
import { LIST_PAGE_SIZE } from '../../cycle.js';
|
|
9
11
|
import { listOutcome, listPreferringWrite, } from '../../transport.js';
|
|
10
12
|
import { deriveConductorStatus } from './liveness.js';
|
|
11
13
|
const str = (v) => typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
@@ -19,6 +21,7 @@ const toConductor = (r) => ({
|
|
|
19
21
|
machineId: str(r.attr('conductor_id')) ?? str(r.attr('machine_id')),
|
|
20
22
|
status: str(r.attr('status')),
|
|
21
23
|
projectId: r.rel('project_id'),
|
|
24
|
+
ownerUserId: r.rel('owner_user_id'),
|
|
22
25
|
});
|
|
23
26
|
/**
|
|
24
27
|
* The conductor collection through one client, in server order.
|
|
@@ -377,32 +380,97 @@ const toConductorRow = (raw, ctx, machinesById) => {
|
|
|
377
380
|
};
|
|
378
381
|
};
|
|
379
382
|
/**
|
|
380
|
-
* The
|
|
383
|
+
* The query both reads share: the narrowing, the declared order, the sideload.
|
|
381
384
|
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
* GAIA-450 D13: an optional `projectId` narrows the read to the project's own
|
|
388
|
-
* conductors — the project-scoped Conductors tab's whole filter, applied at
|
|
389
|
-
* the query rather than client-side so the read stays one round trip.
|
|
385
|
+
* `sort=-last_seen` is not cosmetic (GAIA-460 D7, GAIA-458 AC-2). Paging over a
|
|
386
|
+
* collection with no declared order lets a row repeat on two pages while another
|
|
387
|
+
* is skipped entirely, so the order is a correctness property of the paging
|
|
388
|
+
* before it is a convenience for the reader.
|
|
390
389
|
*/
|
|
391
|
-
|
|
390
|
+
const conductorParams = (query) => {
|
|
392
391
|
const params = new DrupalJsonApiParams().addInclude(['machine']);
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
392
|
+
params.addSort('last_seen', 'DESC');
|
|
393
|
+
if (query.projectId)
|
|
394
|
+
params.addFilter('project_id.id', query.projectId);
|
|
395
|
+
if (query.ownerUserId)
|
|
396
|
+
params.addFilter('owner_user_id.id', query.ownerUserId);
|
|
397
|
+
return params;
|
|
398
|
+
};
|
|
399
|
+
/**
|
|
400
|
+
* One PAGE of the conductor collection as cockpit rows (GAIA-460 D6).
|
|
401
|
+
*
|
|
402
|
+
* Sideloads `machine` so capacity (GAIA-353 D5/D10) comes from the host budget.
|
|
403
|
+
* The owner filter rides this same query rather than the returned rows (D3):
|
|
404
|
+
* applied to an already-bounded page it would hide GAIA-458's truncation instead
|
|
405
|
+
* of fixing it.
|
|
406
|
+
*
|
|
407
|
+
* A FAILED READ PROPAGATES, and that is the change D6 buys rather than an
|
|
408
|
+
* oversight. The read this replaced swallowed a failure into an empty list, so
|
|
409
|
+
* one failed 5 s poll left the screen navigable; on the shared `ListScreen`
|
|
410
|
+
* base a rejected load is the error state every other GAIA list already shows,
|
|
411
|
+
* and one collection may not keep a second spelling of what a failure looks
|
|
412
|
+
* like. The cost is real — a single bad poll now replaces the screen — and it
|
|
413
|
+
* is the convention's cost, not this read's decision to make.
|
|
414
|
+
*/
|
|
415
|
+
export const listConductorRowPage = async (client, ctx, query, page = 0) => {
|
|
416
|
+
const window = {
|
|
417
|
+
offset: page * LIST_PAGE_SIZE,
|
|
418
|
+
limit: LIST_PAGE_SIZE,
|
|
419
|
+
};
|
|
420
|
+
const params = conductorParams(query);
|
|
421
|
+
params.addPageLimit(window.limit);
|
|
422
|
+
params.addPageOffset(window.offset);
|
|
423
|
+
return loadPage(client, 'gaia_conductor/gaia_conductor', params, (data, included) => {
|
|
397
424
|
const machinesById = new Map();
|
|
398
|
-
for (const inc of
|
|
425
|
+
for (const inc of included)
|
|
399
426
|
if (inc.id)
|
|
400
427
|
machinesById.set(inc.id, inc);
|
|
401
|
-
|
|
402
|
-
|
|
428
|
+
return data.map((raw) => toConductorRow(raw, ctx, machinesById));
|
|
429
|
+
}, page, window);
|
|
430
|
+
};
|
|
431
|
+
/**
|
|
432
|
+
* A single conductor's own `last_seen`, or `undefined` when the record has
|
|
433
|
+
* none or the read fails.
|
|
434
|
+
*
|
|
435
|
+
* GAIA-477 AC-5: the cockpit's `act('start', …)` polls this after a spawn,
|
|
436
|
+
* comparing against the row's `lastSeen` captured before the start, to tell
|
|
437
|
+
* a genuine new heartbeat apart from a stale one still in the window — the
|
|
438
|
+
* same distinction the CLI's `waitForFreshHeartbeat` makes server-side.
|
|
439
|
+
*/
|
|
440
|
+
export const fetchConductorLastSeen = async (client, id) => {
|
|
441
|
+
try {
|
|
442
|
+
const doc = (await client.get(`gaia_conductor/gaia_conductor/${id}`));
|
|
443
|
+
const raw = doc?.data;
|
|
444
|
+
if (!raw)
|
|
445
|
+
return undefined;
|
|
446
|
+
return toUnixSeconds(asResource(raw).attr('last_seen')) ?? undefined;
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
/**
|
|
453
|
+
* How large the collection under `query` is, or `null` when that is not
|
|
454
|
+
* established (GAIA-460 D4).
|
|
455
|
+
*
|
|
456
|
+
* One row is requested because no row is wanted: the answer is `meta.count`, and
|
|
457
|
+
* asking for a page of them to count them is the walk GAIA-292 removed. `null`
|
|
458
|
+
* covers both a plane that reports no count (R1) and a failed probe — an
|
|
459
|
+
* explanation is never a precondition, so its failure may not disturb the list
|
|
460
|
+
* it was meant to explain.
|
|
461
|
+
*/
|
|
462
|
+
export const countConductors = async (client, query) => {
|
|
463
|
+
const params = conductorParams(query);
|
|
464
|
+
params.addPageLimit(1);
|
|
465
|
+
try {
|
|
466
|
+
const doc = (await client.get('gaia_conductor/gaia_conductor', params));
|
|
467
|
+
const count = doc?.meta?.count;
|
|
468
|
+
return typeof count === 'number' && Number.isFinite(count) && count >= 0
|
|
469
|
+
? count
|
|
470
|
+
: null;
|
|
403
471
|
}
|
|
404
472
|
catch {
|
|
405
|
-
return
|
|
473
|
+
return null;
|
|
406
474
|
}
|
|
407
475
|
};
|
|
408
476
|
/**
|
|
@@ -21,7 +21,11 @@ export declare function selectOwnConductor(doc: JsonApiCollectionDoc, gaiaDir: s
|
|
|
21
21
|
* Read the conductor collection and resolve this checkout's own record.
|
|
22
22
|
*
|
|
23
23
|
* One request with the project sideloaded, so the project NAME costs no second
|
|
24
|
-
* round trip. The read is owner-gated
|
|
24
|
+
* round trip. The read is NOT owner-gated: GAIA-391 granted `view gaia_conductor` to every
|
|
25
|
+
* `gaia:session` token, so this reads the whole collection. That is harmless
|
|
26
|
+
* here — the match is on `workspace_root`, a path on THIS machine, which no
|
|
27
|
+
* other user's record can hold. GAIA-460 filters the conductor LIST by owner;
|
|
28
|
+
* this lookup needs no filter because its predicate is already stricter.
|
|
25
29
|
*
|
|
26
30
|
* A failure yields nothing rather than rejecting: this runs on the boot path,
|
|
27
31
|
* and an unreachable control plane must leave the cockpit openable — the two
|
|
@@ -75,7 +75,11 @@ export function selectOwnConductor(doc, gaiaDir) {
|
|
|
75
75
|
* Read the conductor collection and resolve this checkout's own record.
|
|
76
76
|
*
|
|
77
77
|
* One request with the project sideloaded, so the project NAME costs no second
|
|
78
|
-
* round trip. The read is owner-gated
|
|
78
|
+
* round trip. The read is NOT owner-gated: GAIA-391 granted `view gaia_conductor` to every
|
|
79
|
+
* `gaia:session` token, so this reads the whole collection. That is harmless
|
|
80
|
+
* here — the match is on `workspace_root`, a path on THIS machine, which no
|
|
81
|
+
* other user's record can hold. GAIA-460 filters the conductor LIST by owner;
|
|
82
|
+
* this lookup needs no filter because its predicate is already stricter.
|
|
79
83
|
*
|
|
80
84
|
* A failure yields nothing rather than rejecting: this runs on the boot path,
|
|
81
85
|
* and an unreachable control plane must leave the cockpit openable — the two
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ListFilterBadge } from '../../../Component/list-chrome/index.js';
|
|
2
|
+
import type { DropshClient } from '../../../types.js';
|
|
3
|
+
/** Who the operator is, or why the screen could not establish it. */
|
|
4
|
+
export type OwnerOutcome = {
|
|
5
|
+
kind: 'resolved';
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'unavailable';
|
|
10
|
+
reason: string;
|
|
11
|
+
};
|
|
12
|
+
/** The read this identity needs — `resolveWhoCandidate`'s shape, injected. */
|
|
13
|
+
export type OwnerResolver = (client: DropshClient, name: string) => Promise<{
|
|
14
|
+
rows: {
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
}[];
|
|
18
|
+
error?: string;
|
|
19
|
+
}>;
|
|
20
|
+
/**
|
|
21
|
+
* The operator's user uuid, resolved at most once per screen lifetime.
|
|
22
|
+
*
|
|
23
|
+
* WHAT IS CACHED, AND WHAT IS NOT. A success is cached, and so is a definitive
|
|
24
|
+
* miss — a name the collection does not hold will not start holding it inside one
|
|
25
|
+
* session, and re-asking on every refresh would spend a request per poll to learn
|
|
26
|
+
* the same "no". A READ ERROR is cached by neither: it says nothing about the
|
|
27
|
+
* collection, only that this attempt failed, so the next load tries again
|
|
28
|
+
* (GAIA-287 D5).
|
|
29
|
+
*/
|
|
30
|
+
export declare class OwnerIdentity {
|
|
31
|
+
private readonly resolveOwner;
|
|
32
|
+
private cached;
|
|
33
|
+
constructor(resolveOwner: OwnerResolver);
|
|
34
|
+
resolve(client: DropshClient, currentUser: string | undefined): Promise<OwnerOutcome>;
|
|
35
|
+
/** A new project or session means a new context; forget the answer. */
|
|
36
|
+
reset(): void;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The shipped default (GAIA-460 D1) — the list opens showing MINE.
|
|
40
|
+
*
|
|
41
|
+
* A product decision, and deliberately not an inheritance from the access layer:
|
|
42
|
+
* GAIA-391 granted `view gaia_conductor` to every session token on purpose, and
|
|
43
|
+
* this default restores what operators had before it without touching that grant.
|
|
44
|
+
*/
|
|
45
|
+
export declare const DEFAULT_CONDUCTOR_MINE = true;
|
|
46
|
+
/** The filter strip both conductor screens show, so neither can word it its own way. */
|
|
47
|
+
export declare const mineBadges: (mine: boolean) => ListFilterBadge[];
|
|
48
|
+
/**
|
|
49
|
+
* `5 hidden by mine — press m`, or '' when there is nothing to say (AC-2).
|
|
50
|
+
*
|
|
51
|
+
* Three cases collapse to the empty string and each for its own reason. `null` is
|
|
52
|
+
* a total nobody established — a plane that reports no `meta.count` (R1) or a
|
|
53
|
+
* failed probe — and a number nobody measured must not be printed. `0` is a
|
|
54
|
+
* measured nothing: an operator sent to press `m` for rows that do not exist
|
|
55
|
+
* learns only that the screen is guessing. Anything else is a fact, and it names
|
|
56
|
+
* the key that releases it, because a count without a remedy is just a smaller
|
|
57
|
+
* mystery.
|
|
58
|
+
*/
|
|
59
|
+
export declare const hiddenNotice: (hidden: number | null) => string;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// GAIA-460 — "which conductors are MINE?", resolved once per screen.
|
|
2
|
+
//
|
|
3
|
+
// The Conductors list opens FILTERED (D1), so the operator's uuid must be known
|
|
4
|
+
// before the first read rather than on the first keypress the way GAIA-287's
|
|
5
|
+
// ticket list resolves it. That is the whole reason this is a small object with
|
|
6
|
+
// a cache instead of a function: the resolution happens on the load path, may
|
|
7
|
+
// fail there, and must not be retried on every 5 s refresh once it has been
|
|
8
|
+
// definitively answered.
|
|
9
|
+
//
|
|
10
|
+
// It takes the RESOLVER as an argument rather than importing it.
|
|
11
|
+
// `tests/boundary.test.ts` G2 forbids a `Data/` module reaching another slice,
|
|
12
|
+
// and `resolveWhoCandidate` lives in the user slice; the screens inject it, as
|
|
13
|
+
// they already inject `exists` and `thisHostKey` into `ConductorRowContext`.
|
|
14
|
+
const messageOf = (caught) => caught instanceof Error ? caught.message : String(caught);
|
|
15
|
+
/**
|
|
16
|
+
* The operator's user uuid, resolved at most once per screen lifetime.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IS CACHED, AND WHAT IS NOT. A success is cached, and so is a definitive
|
|
19
|
+
* miss — a name the collection does not hold will not start holding it inside one
|
|
20
|
+
* session, and re-asking on every refresh would spend a request per poll to learn
|
|
21
|
+
* the same "no". A READ ERROR is cached by neither: it says nothing about the
|
|
22
|
+
* collection, only that this attempt failed, so the next load tries again
|
|
23
|
+
* (GAIA-287 D5).
|
|
24
|
+
*/
|
|
25
|
+
export class OwnerIdentity {
|
|
26
|
+
resolveOwner;
|
|
27
|
+
cached = null;
|
|
28
|
+
constructor(resolveOwner) {
|
|
29
|
+
this.resolveOwner = resolveOwner;
|
|
30
|
+
}
|
|
31
|
+
async resolve(client, currentUser) {
|
|
32
|
+
if (this.cached)
|
|
33
|
+
return this.cached;
|
|
34
|
+
if (currentUser === undefined) {
|
|
35
|
+
// Not cached: the launcher supplies this, and a screen re-entered under a
|
|
36
|
+
// different context may well have one.
|
|
37
|
+
return { kind: 'unavailable', reason: 'mine: no current user' };
|
|
38
|
+
}
|
|
39
|
+
let outcome;
|
|
40
|
+
try {
|
|
41
|
+
outcome = await this.resolveOwner(client, currentUser);
|
|
42
|
+
}
|
|
43
|
+
catch (caught) {
|
|
44
|
+
return { kind: 'unavailable', reason: `mine: ${messageOf(caught)}` };
|
|
45
|
+
}
|
|
46
|
+
if (outcome.error !== undefined) {
|
|
47
|
+
return { kind: 'unavailable', reason: `mine: ${outcome.error}` };
|
|
48
|
+
}
|
|
49
|
+
const row = outcome.rows[0];
|
|
50
|
+
this.cached =
|
|
51
|
+
row === undefined
|
|
52
|
+
? { kind: 'unavailable', reason: `mine: '${currentUser}' not found` }
|
|
53
|
+
: { kind: 'resolved', id: row.id, label: row.label };
|
|
54
|
+
return this.cached;
|
|
55
|
+
}
|
|
56
|
+
/** A new project or session means a new context; forget the answer. */
|
|
57
|
+
reset() {
|
|
58
|
+
this.cached = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The shipped default (GAIA-460 D1) — the list opens showing MINE.
|
|
63
|
+
*
|
|
64
|
+
* A product decision, and deliberately not an inheritance from the access layer:
|
|
65
|
+
* GAIA-391 granted `view gaia_conductor` to every session token on purpose, and
|
|
66
|
+
* this default restores what operators had before it without touching that grant.
|
|
67
|
+
*/
|
|
68
|
+
export const DEFAULT_CONDUCTOR_MINE = true;
|
|
69
|
+
/** The filter strip both conductor screens show, so neither can word it its own way. */
|
|
70
|
+
export const mineBadges = (mine) => [
|
|
71
|
+
mine
|
|
72
|
+
? { id: 'mine', label: 'm mine', value: 'mine' }
|
|
73
|
+
: { id: 'mine', label: 'm mine' },
|
|
74
|
+
{ id: 'clear', label: 'c clear' },
|
|
75
|
+
];
|
|
76
|
+
/**
|
|
77
|
+
* `5 hidden by mine — press m`, or '' when there is nothing to say (AC-2).
|
|
78
|
+
*
|
|
79
|
+
* Three cases collapse to the empty string and each for its own reason. `null` is
|
|
80
|
+
* a total nobody established — a plane that reports no `meta.count` (R1) or a
|
|
81
|
+
* failed probe — and a number nobody measured must not be printed. `0` is a
|
|
82
|
+
* measured nothing: an operator sent to press `m` for rows that do not exist
|
|
83
|
+
* learns only that the screen is guessing. Anything else is a fact, and it names
|
|
84
|
+
* the key that releases it, because a count without a remedy is just a smaller
|
|
85
|
+
* mystery.
|
|
86
|
+
*/
|
|
87
|
+
export const hiddenNotice = (hidden) => hidden === null || hidden <= 0 ? '' : `${hidden} hidden by mine — press m`;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type SignalOutcome } from '@gaia-ai/core';
|
|
2
|
+
import type { DropshClient } from '../../../types.js';
|
|
2
3
|
/** What a cockpit action reports back to its row. */
|
|
3
4
|
export interface ConductorActionResult {
|
|
4
5
|
ok: boolean;
|
|
@@ -35,6 +36,16 @@ export interface ConductorProcessDeps {
|
|
|
35
36
|
* stop needs this machine's own value to compare against).
|
|
36
37
|
*/
|
|
37
38
|
thisHostKey?: () => Promise<string>;
|
|
39
|
+
/**
|
|
40
|
+
* GAIA-477 AC-5: how `start` learns whether the spawn actually came online,
|
|
41
|
+
* injected so tests need no timers or network. Defaults to
|
|
42
|
+
* {@link fetchConductorLastSeen} against the real control plane.
|
|
43
|
+
*/
|
|
44
|
+
fetchLastSeen?: (client: DropshClient, id: string) => Promise<number | undefined>;
|
|
45
|
+
sleep?: (ms: number) => Promise<void>;
|
|
46
|
+
/** Bounded poll budget; small defaults so a real failure resolves quickly. */
|
|
47
|
+
heartbeatPollAttempts?: number;
|
|
48
|
+
heartbeatPollIntervalMs?: number;
|
|
38
49
|
}
|
|
39
50
|
export interface ConductorProcessHost {
|
|
40
51
|
start(input: {
|
|
@@ -43,6 +54,15 @@ export interface ConductorProcessHost {
|
|
|
43
54
|
/** The row's `host_key` (GAIA-450 review B5) — verified against this
|
|
44
55
|
* machine's own before anything is spawned. */
|
|
45
56
|
hostKey: string | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* GAIA-477 AC-5: the conductor's own record id and its `last_seen`
|
|
59
|
+
* captured before the spawn, and the client to poll with. Present only
|
|
60
|
+
* when the caller can observe the outcome — absent, `start` reports the
|
|
61
|
+
* spawn as before and never claims to have seen it come online.
|
|
62
|
+
*/
|
|
63
|
+
id?: string | undefined;
|
|
64
|
+
priorLastSeen?: number | undefined;
|
|
65
|
+
client?: DropshClient | undefined;
|
|
46
66
|
}): Promise<ConductorActionResult>;
|
|
47
67
|
stop(input: {
|
|
48
68
|
pid: number | undefined;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
2
|
import { machineContextPath, readMachineContext, signalVerifiedProcess, } from '@gaia-ai/core';
|
|
3
|
+
import { fetchConductorLastSeen } from './conductors.js';
|
|
3
4
|
/**
|
|
4
5
|
* This machine's host_key, from the same MachineContext the CLI reads.
|
|
5
6
|
*
|
|
@@ -28,9 +29,13 @@ export function createConductorProcessHost(deps = {}) {
|
|
|
28
29
|
const spawn = deps.spawn ?? defaultSpawn;
|
|
29
30
|
const signal = deps.signal ?? signalVerifiedProcess;
|
|
30
31
|
const thisHostKey = deps.thisHostKey ?? defaultThisHostKey;
|
|
32
|
+
const fetchLastSeen = deps.fetchLastSeen ?? fetchConductorLastSeen;
|
|
33
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
34
|
+
const heartbeatPollAttempts = deps.heartbeatPollAttempts ?? 5;
|
|
35
|
+
const heartbeatPollIntervalMs = deps.heartbeatPollIntervalMs ?? 1000;
|
|
31
36
|
return {
|
|
32
37
|
thisHostKey,
|
|
33
|
-
async start({ workspaceRoot, conductorName, hostKey }) {
|
|
38
|
+
async start({ workspaceRoot, conductorName, hostKey, id, priorLastSeen, client, }) {
|
|
34
39
|
// GAIA-450 review B5: the only gate this used to have was `isLocal` — a
|
|
35
40
|
// filesystem check on `workspaceRoot`, which answers "does this path
|
|
36
41
|
// exist on THIS disk", not "is this THIS machine's conductor". Two
|
|
@@ -68,6 +73,30 @@ export function createConductorProcessHost(deps = {}) {
|
|
|
68
73
|
GAIA_CONDUCTOR_CONFIG: '',
|
|
69
74
|
},
|
|
70
75
|
});
|
|
76
|
+
// GAIA-477 AC-5: `spawn` settling only proves the fork happened, not
|
|
77
|
+
// that a foreground conductor is running under it — the herdr pane
|
|
78
|
+
// boundary this ticket's other fix crosses can still leave a row
|
|
79
|
+
// silently offline. Watch for a genuinely NEW heartbeat (never the
|
|
80
|
+
// stale one already on the row) over a bounded budget before telling
|
|
81
|
+
// the operator this succeeded, the same distinction the CLI's own
|
|
82
|
+
// `waitForFreshHeartbeat` makes.
|
|
83
|
+
if (id && client) {
|
|
84
|
+
for (let attempt = 0; attempt < heartbeatPollAttempts; attempt += 1) {
|
|
85
|
+
await sleep(heartbeatPollIntervalMs);
|
|
86
|
+
const lastSeen = await fetchLastSeen(client, id);
|
|
87
|
+
if (lastSeen !== undefined &&
|
|
88
|
+
(priorLastSeen === undefined || lastSeen > priorLastSeen)) {
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
message: `starting ${conductorName ?? 'conductor'} in ${workspaceRoot}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
message: `${conductorName ?? 'conductor'} did not come online — spawned in ${workspaceRoot} but no fresh heartbeat arrived`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
71
100
|
return {
|
|
72
101
|
ok: true,
|
|
73
102
|
message: `starting ${conductorName ?? 'conductor'} in ${workspaceRoot}`,
|
|
@@ -1,21 +1,19 @@
|
|
|
1
1
|
import type { VNode } from '@opentui/core';
|
|
2
|
-
import {
|
|
2
|
+
import type { ListFilterBadge } from '../../../Component/list-chrome/index.js';
|
|
3
3
|
import type { FullScreenChrome } from '../../../Screen/full-screen.js';
|
|
4
|
+
import { ListScreen } from '../../../Screen/list-screen.js';
|
|
4
5
|
import type { ScreenContext } from '../../../Screen/screen.js';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
interface ConductorsData {
|
|
8
|
-
readonly rows: readonly ConductorRow[];
|
|
9
|
-
readonly projectNames: ReadonlyMap<string, string>;
|
|
10
|
-
}
|
|
6
|
+
import type { Page } from '../../collection.js';
|
|
7
|
+
import { type ConductorListContext, type ConductorRow } from '../Data/conductors.js';
|
|
11
8
|
/**
|
|
12
|
-
* Conductors list —
|
|
9
|
+
* Conductors list — an ordinary {@link ListScreen}, paged and filtered to the
|
|
10
|
+
* operator's own conductors by default (GAIA-460 D1).
|
|
13
11
|
*
|
|
14
12
|
* Refresh is the shared mechanism (GAIA-376 `ui-refresh` file + `R`), not a
|
|
15
13
|
* screen-private timer. Enter loads; navigation away cancels in-flight reads
|
|
16
14
|
* via the async lifecycle.
|
|
17
15
|
*/
|
|
18
|
-
export declare class ConductorsScreen extends
|
|
16
|
+
export declare class ConductorsScreen extends ListScreen<ConductorRow, ConductorListContext> {
|
|
19
17
|
static readonly route: {
|
|
20
18
|
id: string;
|
|
21
19
|
path: string;
|
|
@@ -28,7 +26,68 @@ export declare class ConductorsScreen extends AsyncScreen<ConductorsData> {
|
|
|
28
26
|
};
|
|
29
27
|
readonly id: string;
|
|
30
28
|
readonly frameId = "conductors";
|
|
31
|
-
|
|
29
|
+
protected readonly table: {
|
|
30
|
+
readonly noun: "conductor";
|
|
31
|
+
readonly emptyMessage: "No conductors on this control plane.";
|
|
32
|
+
readonly columns: readonly [{
|
|
33
|
+
readonly key: "name";
|
|
34
|
+
readonly label: "NAME";
|
|
35
|
+
readonly width: 0;
|
|
36
|
+
readonly min: 20;
|
|
37
|
+
readonly grow: 2;
|
|
38
|
+
readonly fields: readonly ["label", "machine_id"];
|
|
39
|
+
readonly get: (row: ConductorRow) => string;
|
|
40
|
+
}, {
|
|
41
|
+
readonly key: "machine";
|
|
42
|
+
readonly label: "MACHINE";
|
|
43
|
+
readonly width: 20;
|
|
44
|
+
readonly fields: readonly ["machine_id"];
|
|
45
|
+
readonly get: (row: ConductorRow) => string;
|
|
46
|
+
}, {
|
|
47
|
+
readonly key: "project";
|
|
48
|
+
readonly label: "PROJECT";
|
|
49
|
+
readonly width: 14;
|
|
50
|
+
readonly grow: 1;
|
|
51
|
+
readonly fields: readonly ["project_id"];
|
|
52
|
+
readonly get: (row: ConductorRow, ctx: ConductorListContext) => string;
|
|
53
|
+
}, {
|
|
54
|
+
readonly key: "status";
|
|
55
|
+
readonly label: "STATUS";
|
|
56
|
+
readonly width: 9;
|
|
57
|
+
readonly fields: readonly ["status", "lease_expires_at", "last_seen"];
|
|
58
|
+
readonly get: (row: ConductorRow) => import("../Data/liveness.js").ConductorStatus;
|
|
59
|
+
}, {
|
|
60
|
+
readonly key: "age";
|
|
61
|
+
readonly label: "AGE";
|
|
62
|
+
readonly width: 8;
|
|
63
|
+
readonly fields: readonly ["last_seen"];
|
|
64
|
+
readonly get: (row: ConductorRow, ctx: ConductorListContext) => string;
|
|
65
|
+
}, {
|
|
66
|
+
readonly key: "workspace";
|
|
67
|
+
readonly label: "WORKSPACE";
|
|
68
|
+
readonly width: 18;
|
|
69
|
+
readonly fields: readonly ["workspace_root"];
|
|
70
|
+
readonly get: (row: ConductorRow) => string;
|
|
71
|
+
}, {
|
|
72
|
+
readonly key: "pid";
|
|
73
|
+
readonly label: "PID";
|
|
74
|
+
readonly width: 7;
|
|
75
|
+
readonly fields: readonly ["last_pid"];
|
|
76
|
+
readonly get: (row: ConductorRow) => string;
|
|
77
|
+
}, {
|
|
78
|
+
readonly key: "marks";
|
|
79
|
+
readonly label: "MARKS";
|
|
80
|
+
readonly width: 11;
|
|
81
|
+
readonly fields: readonly ["workspace_root"];
|
|
82
|
+
readonly get: (row: ConductorRow, ctx: ConductorListContext) => string;
|
|
83
|
+
}, {
|
|
84
|
+
readonly key: "load";
|
|
85
|
+
readonly label: "LOAD";
|
|
86
|
+
readonly width: 5;
|
|
87
|
+
readonly fields: readonly ["current_load", "max_parallel"];
|
|
88
|
+
readonly get: (row: ConductorRow) => string;
|
|
89
|
+
}];
|
|
90
|
+
};
|
|
32
91
|
private logFor;
|
|
33
92
|
private readonly busyIds;
|
|
34
93
|
/**
|
|
@@ -39,16 +98,34 @@ export declare class ConductorsScreen extends AsyncScreen<ConductorsData> {
|
|
|
39
98
|
* cannot act on is marked BEFORE a key is pressed, not only after.
|
|
40
99
|
*/
|
|
41
100
|
private status;
|
|
101
|
+
/**
|
|
102
|
+
* GAIA-460 review — separate from {@link status}: the identity reason is
|
|
103
|
+
* how `mine` failed to resolve, not an action's outcome. Sharing one field
|
|
104
|
+
* let `loadRows()` blank a start/stop result on every refresh and let a
|
|
105
|
+
* standing identity reason permanently block {@link viewOnlyNotice}; kept
|
|
106
|
+
* apart, each notice source has its own lifecycle.
|
|
107
|
+
*/
|
|
108
|
+
private identityReason;
|
|
109
|
+
private mine;
|
|
110
|
+
private readonly identity;
|
|
111
|
+
private ownerId;
|
|
112
|
+
private projectNames;
|
|
113
|
+
private hidden;
|
|
42
114
|
topTab(): {
|
|
43
115
|
readonly level: "root";
|
|
44
116
|
readonly tab: "conductors";
|
|
45
117
|
};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
118
|
+
protected tableContext(context: ScreenContext): ConductorListContext;
|
|
119
|
+
/**
|
|
120
|
+
* GAIA-460 D5 — the identity is resolved BEFORE the first read, because the
|
|
121
|
+
* filter is on by default. Every way of failing to establish it loads the
|
|
122
|
+
* FULL list and states the reason: a cockpit that cannot prove who you are
|
|
123
|
+
* shows you everything, never an empty list it cannot explain.
|
|
124
|
+
*/
|
|
125
|
+
protected loadRows(context: ScreenContext, page: number): Promise<Page<ConductorRow>>;
|
|
126
|
+
protected badges(): readonly ListFilterBadge[];
|
|
49
127
|
protected renderLoading(): string[];
|
|
50
|
-
protected renderReady(
|
|
51
|
-
protected didLoad(data: ConductorsData): void;
|
|
128
|
+
protected renderReady(value: Page<ConductorRow>, context: ScreenContext): VNode;
|
|
52
129
|
protected chrome(_context: ScreenContext): FullScreenChrome;
|
|
53
130
|
/**
|
|
54
131
|
* GAIA-450 review B9 — `s`/`x` are advertised only when the SELECTED row can
|
|
@@ -92,4 +169,3 @@ export declare class ConductorsScreen extends AsyncScreen<ConductorsData> {
|
|
|
92
169
|
*/
|
|
93
170
|
private act;
|
|
94
171
|
}
|
|
95
|
-
export {};
|