@gaia-ai/core 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/cli/gaia-dir.d.ts +13 -0
- package/dist/src/cli/gaia-dir.js +22 -5
- package/dist/src/cli/version-check.d.ts +17 -0
- package/dist/src/cli/version-check.js +100 -0
- package/dist/src/core/liveness.d.ts +48 -0
- package/dist/src/core/liveness.js +56 -0
- package/dist/src/core/logger.d.ts +2 -0
- package/dist/src/core/logger.js +12 -4
- package/dist/src/core/proc.d.ts +65 -0
- package/dist/src/core/proc.js +92 -0
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +4 -2
- package/package.json +2 -1
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conductor stem a config file name denotes, or `undefined` when the file
|
|
3
|
+
* is not a conductor config at all.
|
|
4
|
+
*
|
|
5
|
+
* The inverse of {@link fileForStem}, exported because the naming rule has to
|
|
6
|
+
* stay in one place: a conductor reporting which config it was started from
|
|
7
|
+
* would otherwise slice the filename itself and quietly disagree with the
|
|
8
|
+
* loader about the near-miss cases this rule exists to exclude
|
|
9
|
+
* (`myconductor.config.js`, `vite.config.js`, `gaia.config.js`).
|
|
10
|
+
*
|
|
11
|
+
* Takes a bare file name, not a path — callers pass `basename(config_path)`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function stemForConfigFile(fileName: string): string | undefined;
|
|
1
14
|
/**
|
|
2
15
|
* The user-global `~/.gaia` dir. Core owns where the split-config files live,
|
|
3
16
|
* so a consumer that must recognise the home dir (e.g. to exclude it from a
|
package/dist/src/cli/gaia-dir.js
CHANGED
|
@@ -39,14 +39,31 @@ const GAIA_CONFIG = 'gaia.config.js';
|
|
|
39
39
|
*/
|
|
40
40
|
function configStems(gaiaDir) {
|
|
41
41
|
return readdirSync(gaiaDir)
|
|
42
|
-
.map(
|
|
43
|
-
? 'conductor'
|
|
44
|
-
: f.endsWith(VARIANT_SUFFIX)
|
|
45
|
-
? f.slice(0, -VARIANT_SUFFIX.length)
|
|
46
|
-
: undefined)
|
|
42
|
+
.map(stemForConfigFile)
|
|
47
43
|
.filter((s) => s !== undefined)
|
|
48
44
|
.sort();
|
|
49
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* The conductor stem a config file name denotes, or `undefined` when the file
|
|
48
|
+
* is not a conductor config at all.
|
|
49
|
+
*
|
|
50
|
+
* The inverse of {@link fileForStem}, exported because the naming rule has to
|
|
51
|
+
* stay in one place: a conductor reporting which config it was started from
|
|
52
|
+
* would otherwise slice the filename itself and quietly disagree with the
|
|
53
|
+
* loader about the near-miss cases this rule exists to exclude
|
|
54
|
+
* (`myconductor.config.js`, `vite.config.js`, `gaia.config.js`).
|
|
55
|
+
*
|
|
56
|
+
* Takes a bare file name, not a path — callers pass `basename(config_path)`.
|
|
57
|
+
*/
|
|
58
|
+
export function stemForConfigFile(fileName) {
|
|
59
|
+
if (fileName === DEFAULT_CONFIG)
|
|
60
|
+
return 'conductor';
|
|
61
|
+
if (fileName.endsWith(VARIANT_SUFFIX)) {
|
|
62
|
+
const stem = fileName.slice(0, -VARIANT_SUFFIX.length);
|
|
63
|
+
return stem === '' ? undefined : stem;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
50
67
|
/** Map a conductor stem back to its file name. */
|
|
51
68
|
function fileForStem(stem) {
|
|
52
69
|
return stem === 'conductor' ? DEFAULT_CONFIG : `${stem}${VARIANT_SUFFIX}`;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const GAIA_PACKAGE = "@gaia-ai/gaia";
|
|
2
|
+
export declare const REGISTRY_LATEST_URL = "https://registry.npmjs.org/@gaia-ai/gaia/latest";
|
|
3
|
+
export declare const UNKNOWN_VERSION = "0.0.0";
|
|
4
|
+
export declare const UPDATE_CHECK_TTL_MS: number;
|
|
5
|
+
export declare const DEFAULT_UPDATE_CACHE_PATH: string;
|
|
6
|
+
export declare function resolveCliVersion(): string;
|
|
7
|
+
export declare function fetchLatestVersion(fetchImpl?: typeof fetch, timeoutMs?: number): Promise<string | null>;
|
|
8
|
+
export declare function updateNotice(current: string, latest: string): string | null;
|
|
9
|
+
export interface UpdateCheckOptions {
|
|
10
|
+
cachePath?: string;
|
|
11
|
+
now?: () => Date;
|
|
12
|
+
fetchImpl?: typeof fetch;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
ttlMs?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function fetchUpdateNotice(current: string, options?: UpdateCheckOptions): Promise<string | null>;
|
|
17
|
+
export declare function printVersionLine(out?: (line: string) => void): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import semver from 'semver';
|
|
7
|
+
export const GAIA_PACKAGE = '@gaia-ai/gaia';
|
|
8
|
+
export const REGISTRY_LATEST_URL = 'https://registry.npmjs.org/@gaia-ai/gaia/latest';
|
|
9
|
+
export const UNKNOWN_VERSION = '0.0.0';
|
|
10
|
+
export const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
export const DEFAULT_UPDATE_CACHE_PATH = join(homedir(), '.gaia', 'update-check.json');
|
|
12
|
+
let cachedCliVersion;
|
|
13
|
+
export function resolveCliVersion() {
|
|
14
|
+
if (cachedCliVersion)
|
|
15
|
+
return cachedCliVersion;
|
|
16
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
for (let i = 0; i < 7; i++) {
|
|
18
|
+
try {
|
|
19
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
20
|
+
if (pkg.name === '@gaia-ai/core') {
|
|
21
|
+
cachedCliVersion = pkg.version ?? UNKNOWN_VERSION;
|
|
22
|
+
return cachedCliVersion;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
dir = dirname(dir);
|
|
27
|
+
}
|
|
28
|
+
cachedCliVersion = UNKNOWN_VERSION;
|
|
29
|
+
return cachedCliVersion;
|
|
30
|
+
}
|
|
31
|
+
export async function fetchLatestVersion(fetchImpl = globalThis.fetch, timeoutMs = 1500) {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
34
|
+
try {
|
|
35
|
+
const result = await fetchImpl(REGISTRY_LATEST_URL, {
|
|
36
|
+
signal: controller.signal,
|
|
37
|
+
headers: { accept: 'application/json' },
|
|
38
|
+
});
|
|
39
|
+
if (!result.ok)
|
|
40
|
+
return null;
|
|
41
|
+
const body = (await result.json());
|
|
42
|
+
return typeof body.version === 'string' && semver.valid(body.version)
|
|
43
|
+
? body.version
|
|
44
|
+
: null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function updateNotice(current, latest) {
|
|
54
|
+
if (current === UNKNOWN_VERSION ||
|
|
55
|
+
!semver.valid(current) ||
|
|
56
|
+
!semver.valid(latest) ||
|
|
57
|
+
!semver.gt(latest, current))
|
|
58
|
+
return null;
|
|
59
|
+
return `A new gaia version is available: ${current} → ${latest}\nRun \`gaia update\` to install it, then \`gaia upgrade\` to migrate your config.`;
|
|
60
|
+
}
|
|
61
|
+
function validRecord(value) {
|
|
62
|
+
if (!value || typeof value !== 'object')
|
|
63
|
+
return null;
|
|
64
|
+
const record = value;
|
|
65
|
+
if (typeof record.checkedAt !== 'string' ||
|
|
66
|
+
(record.latestVersion !== null && typeof record.latestVersion !== 'string'))
|
|
67
|
+
return null;
|
|
68
|
+
const time = Date.parse(record.checkedAt);
|
|
69
|
+
return Number.isFinite(time) ? record : null;
|
|
70
|
+
}
|
|
71
|
+
export async function fetchUpdateNotice(current, options = {}) {
|
|
72
|
+
const cachePath = options.cachePath ?? DEFAULT_UPDATE_CACHE_PATH;
|
|
73
|
+
const now = options.now?.() ?? new Date();
|
|
74
|
+
const ttl = options.ttlMs ?? UPDATE_CHECK_TTL_MS;
|
|
75
|
+
try {
|
|
76
|
+
const record = validRecord(JSON.parse(await readFile(cachePath, 'utf8')));
|
|
77
|
+
if (record) {
|
|
78
|
+
const age = now.getTime() - Date.parse(record.checkedAt);
|
|
79
|
+
if (age >= 0 && age < ttl)
|
|
80
|
+
return record.latestVersion
|
|
81
|
+
? updateNotice(current, record.latestVersion)
|
|
82
|
+
: null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch { }
|
|
86
|
+
const latestVersion = await fetchLatestVersion(options.fetchImpl, options.timeoutMs);
|
|
87
|
+
try {
|
|
88
|
+
await mkdir(dirname(cachePath), { recursive: true });
|
|
89
|
+
const temporary = `${cachePath}.${process.pid}.tmp`;
|
|
90
|
+
await writeFile(temporary, JSON.stringify({ checkedAt: now.toISOString(), latestVersion }), 'utf8');
|
|
91
|
+
await rename(temporary, cachePath);
|
|
92
|
+
}
|
|
93
|
+
catch { }
|
|
94
|
+
return latestVersion ? updateNotice(current, latestVersion) : null;
|
|
95
|
+
}
|
|
96
|
+
export function printVersionLine(out = console.log) {
|
|
97
|
+
const version = resolveCliVersion();
|
|
98
|
+
out(`gaia v${version}`);
|
|
99
|
+
return version;
|
|
100
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The derived liveness of a conductor.
|
|
3
|
+
*
|
|
4
|
+
* These three words are the server's, and they are deliberately the only ones:
|
|
5
|
+
* the dashboard, the ticket board, the CLI listing and the cockpit all describe
|
|
6
|
+
* the same conductor the same way. A `registry-only` fourth state existed while
|
|
7
|
+
* a local registry could name a conductor the control plane had never heard of;
|
|
8
|
+
* with the control plane as the sole list there is nothing left for it to
|
|
9
|
+
* denote.
|
|
10
|
+
*/
|
|
11
|
+
export type ConductorLiveness = 'running' | 'stale' | 'offline';
|
|
12
|
+
/** The raw liveness inputs of a `gaia_conductor` record. */
|
|
13
|
+
export interface ConductorLivenessInput {
|
|
14
|
+
status?: string | null;
|
|
15
|
+
/** Unix seconds of the last heartbeat. */
|
|
16
|
+
lastSeen?: number | null;
|
|
17
|
+
/** Unix seconds the lease runs out. */
|
|
18
|
+
leaseExpiresAt?: number | null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Derives whether a conductor is alive from its own record.
|
|
22
|
+
*
|
|
23
|
+
* `offline` is only ever self-declared: a conductor writes it as it exits, and
|
|
24
|
+
* a heartbeat sets `online` unconditionally, so the flag means "it shut down
|
|
25
|
+
* cleanly" rather than "it is not answering". Everything that is neither
|
|
26
|
+
* clearly shut down nor clearly fresh is `stale` — including a crash, which
|
|
27
|
+
* keeps `online` on the record until the lease lapses. Calling that state
|
|
28
|
+
* `running` would block a restart until the reaper caught up; calling it
|
|
29
|
+
* `offline` would claim a clean shutdown that did not happen.
|
|
30
|
+
*/
|
|
31
|
+
export declare function deriveConductorLiveness(input: ConductorLivenessInput, nowSeconds: number): ConductorLiveness;
|
|
32
|
+
/**
|
|
33
|
+
* A control-plane timestamp as unix seconds, or `null` when it says nothing.
|
|
34
|
+
*
|
|
35
|
+
* Drupal's JSON:API serialises a `timestamp` field as **ISO-8601**
|
|
36
|
+
* (`'2026-08-04T05:27:20+00:00'`), not as the unix integer the name suggests.
|
|
37
|
+
* Both readers of these fields got that wrong, in mirror-image ways: the
|
|
38
|
+
* cockpit's `typeof === 'number'` guard rejected the string and fell back to 0,
|
|
39
|
+
* showing a conductor that was demonstrably ticking as `stale` with an unknown
|
|
40
|
+
* age; the CLI declared the value `number` for the compiler alone, so every
|
|
41
|
+
* arithmetic comparison was NaN — and since NaN comparisons are false, `stale`
|
|
42
|
+
* was quietly unreachable there.
|
|
43
|
+
*
|
|
44
|
+
* `0` is treated as unknown rather than as 1970: no conductor heartbeat has
|
|
45
|
+
* ever legitimately been at the epoch, and reading it as a real timestamp would
|
|
46
|
+
* report a fresh conductor as decades stale.
|
|
47
|
+
*/
|
|
48
|
+
export declare function toUnixSeconds(value: unknown): number | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a conductor may go without a heartbeat before it is stale.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the server-side ConductorDeriver
|
|
5
|
+
* (web/modules/custom/gaia_core/modules/gaia_dashboard/src/Dashboard/ConductorDeriver.php).
|
|
6
|
+
*/
|
|
7
|
+
const STALE_WINDOW_SECONDS = 600;
|
|
8
|
+
/**
|
|
9
|
+
* Derives whether a conductor is alive from its own record.
|
|
10
|
+
*
|
|
11
|
+
* `offline` is only ever self-declared: a conductor writes it as it exits, and
|
|
12
|
+
* a heartbeat sets `online` unconditionally, so the flag means "it shut down
|
|
13
|
+
* cleanly" rather than "it is not answering". Everything that is neither
|
|
14
|
+
* clearly shut down nor clearly fresh is `stale` — including a crash, which
|
|
15
|
+
* keeps `online` on the record until the lease lapses. Calling that state
|
|
16
|
+
* `running` would block a restart until the reaper caught up; calling it
|
|
17
|
+
* `offline` would claim a clean shutdown that did not happen.
|
|
18
|
+
*/
|
|
19
|
+
export function deriveConductorLiveness(input, nowSeconds) {
|
|
20
|
+
if (input.status === 'offline')
|
|
21
|
+
return 'offline';
|
|
22
|
+
if (input.lastSeen == null || input.lastSeen <= 0)
|
|
23
|
+
return 'stale';
|
|
24
|
+
if (nowSeconds - input.lastSeen > STALE_WINDOW_SECONDS)
|
|
25
|
+
return 'stale';
|
|
26
|
+
if (input.leaseExpiresAt != null && input.leaseExpiresAt < nowSeconds) {
|
|
27
|
+
return 'stale';
|
|
28
|
+
}
|
|
29
|
+
return 'running';
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A control-plane timestamp as unix seconds, or `null` when it says nothing.
|
|
33
|
+
*
|
|
34
|
+
* Drupal's JSON:API serialises a `timestamp` field as **ISO-8601**
|
|
35
|
+
* (`'2026-08-04T05:27:20+00:00'`), not as the unix integer the name suggests.
|
|
36
|
+
* Both readers of these fields got that wrong, in mirror-image ways: the
|
|
37
|
+
* cockpit's `typeof === 'number'` guard rejected the string and fell back to 0,
|
|
38
|
+
* showing a conductor that was demonstrably ticking as `stale` with an unknown
|
|
39
|
+
* age; the CLI declared the value `number` for the compiler alone, so every
|
|
40
|
+
* arithmetic comparison was NaN — and since NaN comparisons are false, `stale`
|
|
41
|
+
* was quietly unreachable there.
|
|
42
|
+
*
|
|
43
|
+
* `0` is treated as unknown rather than as 1970: no conductor heartbeat has
|
|
44
|
+
* ever legitimately been at the epoch, and reading it as a real timestamp would
|
|
45
|
+
* report a fresh conductor as decades stale.
|
|
46
|
+
*/
|
|
47
|
+
export function toUnixSeconds(value) {
|
|
48
|
+
if (typeof value === 'number') {
|
|
49
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
50
|
+
}
|
|
51
|
+
if (typeof value === 'string' && value !== '') {
|
|
52
|
+
const parsed = Date.parse(value);
|
|
53
|
+
return Number.isNaN(parsed) ? null : Math.floor(parsed / 1000);
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
@@ -45,4 +45,6 @@ export declare function createLogger(opts: {
|
|
|
45
45
|
level?: string;
|
|
46
46
|
/** CLI override for the sink — beats env + default. */
|
|
47
47
|
sink?: string;
|
|
48
|
+
/** Pretty-output destination; test seam, defaults to stdout. */
|
|
49
|
+
prettyDestination?: NodeJS.WritableStream;
|
|
48
50
|
}): ConductorLogger;
|
package/dist/src/core/logger.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import pino from 'pino';
|
|
3
3
|
import pretty from 'pino-pretty';
|
|
4
|
+
import { resolveCliVersion } from '../cli/version-check.js';
|
|
4
5
|
/**
|
|
5
6
|
* Pick the log sink. Precedence: explicit CLI override (`sink`) > the
|
|
6
7
|
* GAIA_CONDUCTOR_LOG env var > default (`both` on a TTY, `file` otherwise).
|
|
@@ -37,8 +38,15 @@ export function createLogger(opts) {
|
|
|
37
38
|
const isTTY = opts.isTTY ?? Boolean(process.stdout.isTTY);
|
|
38
39
|
const level = resolveLevel(env, opts.level);
|
|
39
40
|
const sink = resolveSink(env, isTTY, opts.checkoutRoot, opts.sink);
|
|
41
|
+
const loggerOptions = {
|
|
42
|
+
level,
|
|
43
|
+
base: { gaia_version: resolveCliVersion() },
|
|
44
|
+
};
|
|
45
|
+
const prettyOptions = opts.prettyDestination
|
|
46
|
+
? { colorize: true, sync: true, destination: opts.prettyDestination }
|
|
47
|
+
: { colorize: true, sync: true };
|
|
40
48
|
if (sink.kind === 'stdout') {
|
|
41
|
-
return pino(
|
|
49
|
+
return pino(loggerOptions, pretty(prettyOptions));
|
|
42
50
|
}
|
|
43
51
|
const file = () => pino.destination({
|
|
44
52
|
dest: sink.path,
|
|
@@ -47,7 +55,7 @@ export function createLogger(opts) {
|
|
|
47
55
|
sync: true,
|
|
48
56
|
});
|
|
49
57
|
if (sink.kind === 'file') {
|
|
50
|
-
return pino(
|
|
58
|
+
return pino(loggerOptions, file());
|
|
51
59
|
}
|
|
52
60
|
// 'both' — tee via multistream so the pane stays readable AND the record is
|
|
53
61
|
// durable (GAIA-237). `level` is enforced by the logger, so each stream takes
|
|
@@ -56,8 +64,8 @@ export function createLogger(opts) {
|
|
|
56
64
|
// would silently drop `--log-level debug` from BOTH streams — so each entry
|
|
57
65
|
// carries the resolved level and the logger stays the single gate.
|
|
58
66
|
const at = level;
|
|
59
|
-
return pino(
|
|
60
|
-
{ level: at, stream: pretty(
|
|
67
|
+
return pino(loggerOptions, pino.multistream([
|
|
68
|
+
{ level: at, stream: pretty(prettyOptions) },
|
|
61
69
|
{ level: at, stream: file() },
|
|
62
70
|
]));
|
|
63
71
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform-facing half of pid verification, injected so tests never touch a
|
|
3
|
+
* real process.
|
|
4
|
+
*/
|
|
5
|
+
export interface ProcProbe {
|
|
6
|
+
/** Whether this platform can report a running process's cwd at all. */
|
|
7
|
+
readonly supported: boolean;
|
|
8
|
+
/** The cwd of a running process, or `undefined` when it cannot be read. */
|
|
9
|
+
cwdOf(pid: number): string | undefined;
|
|
10
|
+
/** Deliver a signal. Throws what the platform throws. */
|
|
11
|
+
send(pid: number, signal: NodeJS.Signals): void;
|
|
12
|
+
}
|
|
13
|
+
export type SignalFailureReason =
|
|
14
|
+
/** Nothing on record to signal — the conductor never reported a pid. */
|
|
15
|
+
'no-pid'
|
|
16
|
+
/** The pid is gone, or belongs to something that is not this conductor. */
|
|
17
|
+
| 'stale-pid'
|
|
18
|
+
/** This platform cannot prove what a pid is, so we refuse to guess. */
|
|
19
|
+
| 'unverifiable-platform'
|
|
20
|
+
/** Verified, but the OS refused the signal (permissions, race). */
|
|
21
|
+
| 'signal-failed';
|
|
22
|
+
export type SignalOutcome = {
|
|
23
|
+
ok: true;
|
|
24
|
+
pid: number;
|
|
25
|
+
signal: NodeJS.Signals;
|
|
26
|
+
} | {
|
|
27
|
+
ok: false;
|
|
28
|
+
reason: SignalFailureReason;
|
|
29
|
+
message: string;
|
|
30
|
+
};
|
|
31
|
+
export interface SignalVerifiedInput {
|
|
32
|
+
/** The last pid the conductor reported, if any. */
|
|
33
|
+
pid: number | undefined;
|
|
34
|
+
/** The directory that pid must be running in to count as this conductor. */
|
|
35
|
+
workspaceRoot: string;
|
|
36
|
+
signal: NodeJS.Signals;
|
|
37
|
+
probe?: ProcProbe;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Signals a process only after proving it is the conductor of `workspaceRoot`.
|
|
41
|
+
*
|
|
42
|
+
* The proof is the process's working directory. A conductor is started with its
|
|
43
|
+
* `.gaia` dir as cwd (herdr passes `--cwd <checkoutRoot>`, and that is what
|
|
44
|
+
* `workspace_root` stores), so a process running there is that conductor.
|
|
45
|
+
*
|
|
46
|
+
* The alternative — trusting `last_pid` — is unsafe in a way that only shows up
|
|
47
|
+
* rarely and badly: pids are recycled, so a stale one eventually names an
|
|
48
|
+
* unrelated program, and the "stop" would kill it. Refusing to signal an
|
|
49
|
+
* unverified pid costs a stopped conductor nothing (the operator sees why and
|
|
50
|
+
* the lease reaper marks it offline anyway) and costs a mis-signalled process
|
|
51
|
+
* everything.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately never throws: a cockpit calls this from a keypress handler and
|
|
54
|
+
* must render the reason on the row rather than unwind.
|
|
55
|
+
*/
|
|
56
|
+
export declare function signalVerifiedProcess(input: SignalVerifiedInput): SignalOutcome;
|
|
57
|
+
/**
|
|
58
|
+
* The real probe: `/proc/<pid>/cwd` on Linux.
|
|
59
|
+
*
|
|
60
|
+
* Only Linux is claimed as supported. macOS could shell out to `lsof`, but a
|
|
61
|
+
* wrong answer here signals the wrong process, so an unimplemented platform
|
|
62
|
+
* reports `supported: false` and stop degrades with a message rather than
|
|
63
|
+
* guessing. Start is unaffected everywhere.
|
|
64
|
+
*/
|
|
65
|
+
export declare function defaultProcProbe(): ProcProbe;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readlinkSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Signals a process only after proving it is the conductor of `workspaceRoot`.
|
|
5
|
+
*
|
|
6
|
+
* The proof is the process's working directory. A conductor is started with its
|
|
7
|
+
* `.gaia` dir as cwd (herdr passes `--cwd <checkoutRoot>`, and that is what
|
|
8
|
+
* `workspace_root` stores), so a process running there is that conductor.
|
|
9
|
+
*
|
|
10
|
+
* The alternative — trusting `last_pid` — is unsafe in a way that only shows up
|
|
11
|
+
* rarely and badly: pids are recycled, so a stale one eventually names an
|
|
12
|
+
* unrelated program, and the "stop" would kill it. Refusing to signal an
|
|
13
|
+
* unverified pid costs a stopped conductor nothing (the operator sees why and
|
|
14
|
+
* the lease reaper marks it offline anyway) and costs a mis-signalled process
|
|
15
|
+
* everything.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately never throws: a cockpit calls this from a keypress handler and
|
|
18
|
+
* must render the reason on the row rather than unwind.
|
|
19
|
+
*/
|
|
20
|
+
export function signalVerifiedProcess(input) {
|
|
21
|
+
const { pid, workspaceRoot, signal } = input;
|
|
22
|
+
const probe = input.probe ?? defaultProcProbe();
|
|
23
|
+
if (pid === undefined || !Number.isInteger(pid) || pid <= 0) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
reason: 'no-pid',
|
|
27
|
+
message: 'no pid on record for this conductor — it has not reported one since it last started',
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (!probe.supported) {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
reason: 'unverifiable-platform',
|
|
34
|
+
message: `cannot verify on this platform that pid ${pid} is the conductor of ${workspaceRoot}; refusing to signal an unverified pid`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const actual = probe.cwdOf(pid);
|
|
38
|
+
if (actual === undefined) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
reason: 'stale-pid',
|
|
42
|
+
message: `pid ${pid} is not running; it is the last known pid, not a live one (expected it in ${workspaceRoot})`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (resolve(actual) !== resolve(workspaceRoot)) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
reason: 'stale-pid',
|
|
49
|
+
message: `pid ${pid} runs in ${actual}, not in ${workspaceRoot} — the pid was recycled by another process; not signalling it`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
probe.send(pid, signal);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
reason: 'signal-failed',
|
|
59
|
+
message: `verified pid ${pid} but could not deliver ${signal}: ${String(err)}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, pid, signal };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The real probe: `/proc/<pid>/cwd` on Linux.
|
|
66
|
+
*
|
|
67
|
+
* Only Linux is claimed as supported. macOS could shell out to `lsof`, but a
|
|
68
|
+
* wrong answer here signals the wrong process, so an unimplemented platform
|
|
69
|
+
* reports `supported: false` and stop degrades with a message rather than
|
|
70
|
+
* guessing. Start is unaffected everywhere.
|
|
71
|
+
*/
|
|
72
|
+
export function defaultProcProbe() {
|
|
73
|
+
const supported = process.platform === 'linux';
|
|
74
|
+
return {
|
|
75
|
+
supported,
|
|
76
|
+
cwdOf(pid) {
|
|
77
|
+
if (!supported)
|
|
78
|
+
return undefined;
|
|
79
|
+
try {
|
|
80
|
+
return readlinkSync(`/proc/${pid}/cwd`);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// ESRCH (gone) and EACCES (another user's process) are both "cannot
|
|
84
|
+
// prove it is ours", which is the same answer for our purposes.
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
send(pid, signal) {
|
|
89
|
+
process.kill(pid, signal);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
export type { GaiaCommandHost, GaiaCommandPlugin } from './cli/commands.js';
|
|
2
2
|
export { declaresTopLevelKey, findDefaultExportObject, matchDelimiter, type PropEntry, readKey, scanTopLevelProperties, scanToTopLevelComma, skipBlockComment, skipLineComment, skipString, skipTrivia, } from './cli/config-source.js';
|
|
3
3
|
export type { GaiaConfigResolution, GaiaConfigSource, } from './cli/gaia-dir.js';
|
|
4
|
-
export { findGaiaConfig, findGaiaDir, findProjectGaiaDir, homeGaiaDir, resolveConfigPath, resolveGaiaConfigPath, } from './cli/gaia-dir.js';
|
|
4
|
+
export { findGaiaConfig, findGaiaDir, findProjectGaiaDir, homeGaiaDir, resolveConfigPath, resolveGaiaConfigPath, stemForConfigFile, } from './cli/gaia-dir.js';
|
|
5
5
|
export { type GaiaConnectionConfig, loadGaiaConfig, } from './cli/load-gaia-config.js';
|
|
6
6
|
export { legacyMachineContextPath, type MachineContext, machineContextPath, readMachineContext, resolveMachineContextPath, } from './cli/machine-context.js';
|
|
7
7
|
export { corePackageRoot, homeFallbackGaiaConfigPath, } from './cli/paths.js';
|
|
8
8
|
export { resolveModuleEslintStyle } from './cli/resolve-module.js';
|
|
9
|
-
export {
|
|
9
|
+
export { DEFAULT_UPDATE_CACHE_PATH, fetchLatestVersion, fetchUpdateNotice, GAIA_PACKAGE, printVersionLine, REGISTRY_LATEST_URL, resolveCliVersion, UNKNOWN_VERSION, UPDATE_CHECK_TTL_MS, type UpdateCheckOptions, updateNotice, } from './cli/version-check.js';
|
|
10
10
|
export { conductorId } from './core/conductor-id.js';
|
|
11
11
|
export { CommandRunner, ExecError, exec, setDefaultCommandRunner, } from './core/exec.js';
|
|
12
|
+
export { type ConductorLiveness, type ConductorLivenessInput, deriveConductorLiveness, toUnixSeconds, } from './core/liveness.js';
|
|
12
13
|
export { type ConductorLogger, createLogger } from './core/logger.js';
|
|
14
|
+
export { defaultProcProbe, type ProcProbe, type SignalFailureReason, type SignalOutcome, signalVerifiedProcess, } from './core/proc.js';
|
|
13
15
|
export { shellQuote } from './core/shell.js';
|
|
14
16
|
export { DEFAULT_SLUG_MAX_LENGTH, slugify } from './core/slug.js';
|
|
15
17
|
export { discoverAddons, type ResolvedConductorSlots, resolveConductorSlots, } from './plugins/discover-addons.js';
|
package/dist/src/index.js
CHANGED
|
@@ -3,15 +3,17 @@
|
|
|
3
3
|
// GAIA-218 strip pass share one answer to "which top-level properties does this
|
|
4
4
|
// config source declare?" — core is layer 0 and cannot import the conductor's copy.
|
|
5
5
|
export { declaresTopLevelKey, findDefaultExportObject, matchDelimiter, readKey, scanTopLevelProperties, scanToTopLevelComma, skipBlockComment, skipLineComment, skipString, skipTrivia, } from './cli/config-source.js';
|
|
6
|
-
export { findGaiaConfig, findGaiaDir, findProjectGaiaDir, homeGaiaDir, resolveConfigPath, resolveGaiaConfigPath, } from './cli/gaia-dir.js';
|
|
6
|
+
export { findGaiaConfig, findGaiaDir, findProjectGaiaDir, homeGaiaDir, resolveConfigPath, resolveGaiaConfigPath, stemForConfigFile, } from './cli/gaia-dir.js';
|
|
7
7
|
export { loadGaiaConfig, } from './cli/load-gaia-config.js';
|
|
8
8
|
export { legacyMachineContextPath, machineContextPath, readMachineContext, resolveMachineContextPath, } from './cli/machine-context.js';
|
|
9
9
|
export { corePackageRoot, homeFallbackGaiaConfigPath, } from './cli/paths.js';
|
|
10
10
|
export { resolveModuleEslintStyle } from './cli/resolve-module.js';
|
|
11
|
-
export {
|
|
11
|
+
export { DEFAULT_UPDATE_CACHE_PATH, fetchLatestVersion, fetchUpdateNotice, GAIA_PACKAGE, printVersionLine, REGISTRY_LATEST_URL, resolveCliVersion, UNKNOWN_VERSION, UPDATE_CHECK_TTL_MS, updateNotice, } from './cli/version-check.js';
|
|
12
12
|
export { conductorId } from './core/conductor-id.js';
|
|
13
13
|
export { CommandRunner, ExecError, exec, setDefaultCommandRunner, } from './core/exec.js';
|
|
14
|
+
export { deriveConductorLiveness, toUnixSeconds, } from './core/liveness.js';
|
|
14
15
|
export { createLogger } from './core/logger.js';
|
|
16
|
+
export { defaultProcProbe, signalVerifiedProcess, } from './core/proc.js';
|
|
15
17
|
export { shellQuote } from './core/shell.js';
|
|
16
18
|
export { DEFAULT_SLUG_MAX_LENGTH, slugify } from './core/slug.js';
|
|
17
19
|
// GAIA-215: the Storybook-style preset contract + the shared addon discovery.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaia-ai/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "GAIA surface-agnostic kernel: host contract, split-config helpers, addon preset/discovery machinery, shared primitives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"dropsh": "^0.5.8",
|
|
34
34
|
"pino": "^9.6.0",
|
|
35
35
|
"pino-pretty": "^13.0.0",
|
|
36
|
+
"semver": "^7.6.0",
|
|
36
37
|
"yaml": "^2.7.0"
|
|
37
38
|
}
|
|
38
39
|
}
|