@reserendipity/dsh-superagent 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/NOTICE +50 -0
- package/README.md +206 -2
- package/cordis.patch.yml +214 -0
- package/examples/profile.cordis.patch.yml +31 -0
- package/lib/client.js +7695 -0
- package/lib/feishu-Dvj-1eJf.js +1325 -0
- package/lib/feishu-watch.d.ts +125 -0
- package/lib/feishu-watch.js +2 -0
- package/lib/github-pull-DRiuuSs8.js +597 -0
- package/lib/github-watch.d.ts +2 -0
- package/lib/github-watch.js +836 -0
- package/lib/host-BIWKSB5j.js +124 -0
- package/lib/inbox-watch.d.ts +43 -0
- package/lib/inbox-watch.js +343 -0
- package/lib/index.d.ts +369 -0
- package/lib/index.js +13654 -0
- package/lib/instance-status-Cqaju-dw.js +87 -0
- package/lib/instances-view.d.ts +164 -0
- package/lib/instances-view.js +258 -0
- package/lib/intake-TNIkqilh.d.ts +735 -0
- package/lib/kind--8woUEex.d.ts +1019 -0
- package/lib/poll-Bm5EYA2c.js +819 -0
- package/lib/schemas-MhNERx8d.js +4484 -0
- package/lib/schemas-lx-F2nnt.d.ts +2134 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +159 -4
- package/prompts/admission.md +68 -0
- package/prompts/cli.md +17 -0
- package/prompts/dispatch.md +59 -0
- package/prompts/handoff-orchestrator.md +4 -0
- package/prompts/handoff-worker.md +7 -0
- package/prompts/handoff.md +6 -0
- package/prompts/orchestrator.md +16 -0
- package/prompts/presets/brief/communication.md +4 -0
- package/prompts/presets/brief/delivery.md +3 -0
- package/prompts/presets/brief/scope.md +4 -0
- package/prompts/presets/evidence/communication.md +6 -0
- package/prompts/presets/evidence/delivery.md +4 -0
- package/prompts/presets/evidence/scope.md +5 -0
- package/prompts/presets/result/communication.md +4 -0
- package/prompts/presets/result/delivery.md +3 -0
- package/prompts/presets/result/scope.md +5 -0
- package/prompts/replies.md +35 -0
- package/prompts/shared.md +9 -0
- package/prompts/subscriptions.md +13 -0
- package/prompts/team/communication.md +101 -0
- package/prompts/team/delivery.md +35 -0
- package/prompts/team/scope.md +26 -0
- package/prompts/work.md +49 -0
- package/prompts/worker.md +10 -0
- package/schema/declarative-settings.example.json +26 -0
- package/schema/declarative-settings.schema.json +348 -0
- package/skills/README.md +14 -0
- package/skills/dsh-superagent-feishu/SKILL.md +12 -0
- package/skills/dsh-superagent-github/SKILL.md +14 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { a as literal, c as object, i as boolean, l as record, n as _enum, s as number, u as string } from "./schemas-MhNERx8d.js";
|
|
2
|
+
import { chmodSync, renameSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const INSTANCE_STATUS_FILE = "instance-status.json";
|
|
5
|
+
const INSTANCE_STATUS_INTERVAL_MS = 1e4;
|
|
6
|
+
/** Older than this mtime means the instance stopped publishing, whatever its pid claims. */
|
|
7
|
+
const INSTANCE_STATUS_STALE_MS = 3e4;
|
|
8
|
+
const HealthEntrySchema = object({
|
|
9
|
+
state: _enum([
|
|
10
|
+
"starting",
|
|
11
|
+
"ready",
|
|
12
|
+
"paused",
|
|
13
|
+
"error"
|
|
14
|
+
]),
|
|
15
|
+
detail: string(),
|
|
16
|
+
at: string().optional(),
|
|
17
|
+
code: string().optional(),
|
|
18
|
+
values: record(string(), string()).optional()
|
|
19
|
+
});
|
|
20
|
+
const SetupProblemSchema = object({
|
|
21
|
+
place: string(),
|
|
22
|
+
code: string(),
|
|
23
|
+
detail: string()
|
|
24
|
+
});
|
|
25
|
+
const InstanceStatusSchema = object({
|
|
26
|
+
schema: literal(1),
|
|
27
|
+
/** The instance record's name when the shell exports SUPERAGENT_INSTANCE; the viewer falls back to the record name. */
|
|
28
|
+
name: string().nullable(),
|
|
29
|
+
pid: number().int().positive(),
|
|
30
|
+
startedAt: string(),
|
|
31
|
+
/** When the content last changed; the mtime is the heartbeat, not this field. */
|
|
32
|
+
writtenAt: string(),
|
|
33
|
+
state: _enum([
|
|
34
|
+
"starting",
|
|
35
|
+
"running",
|
|
36
|
+
"stopping"
|
|
37
|
+
]),
|
|
38
|
+
health: record(string(), HealthEntrySchema),
|
|
39
|
+
load: object({
|
|
40
|
+
running: number().int().nonnegative(),
|
|
41
|
+
queued: number().int().nonnegative(),
|
|
42
|
+
maxWorkers: number().int().positive()
|
|
43
|
+
}),
|
|
44
|
+
paused: boolean(),
|
|
45
|
+
setup: object({
|
|
46
|
+
configured: boolean(),
|
|
47
|
+
problem: SetupProblemSchema.nullable()
|
|
48
|
+
}),
|
|
49
|
+
watchdog: object({
|
|
50
|
+
everyMinutes: number(),
|
|
51
|
+
stallMinutes: number(),
|
|
52
|
+
lastTickAt: string().nullable()
|
|
53
|
+
}),
|
|
54
|
+
tickets: object({
|
|
55
|
+
open: number().int().nonnegative(),
|
|
56
|
+
stalled: number().int().nonnegative()
|
|
57
|
+
}),
|
|
58
|
+
configPath: string().nullable()
|
|
59
|
+
});
|
|
60
|
+
const instanceStatusPath = (stateDir) => join(stateDir, INSTANCE_STATUS_FILE);
|
|
61
|
+
/**
|
|
62
|
+
* Validates, then replaces the status file atomically: a temporary file in the same directory is renamed over the
|
|
63
|
+
* target, so a reader never parses a half-written document. Mode 0600 is requested on the write (Windows keeps its own
|
|
64
|
+
* ACLs; the call is there for the Unix builds of the plugin).
|
|
65
|
+
*/
|
|
66
|
+
function writeInstanceStatus(stateDir, status) {
|
|
67
|
+
const parsed = InstanceStatusSchema.parse(status);
|
|
68
|
+
const target = instanceStatusPath(stateDir);
|
|
69
|
+
const temporary = target + ".tmp-" + process.pid;
|
|
70
|
+
writeFileSync(temporary, JSON.stringify(parsed, null, 2) + "\n", {
|
|
71
|
+
encoding: "utf8",
|
|
72
|
+
mode: 384
|
|
73
|
+
});
|
|
74
|
+
renameSync(temporary, target);
|
|
75
|
+
try {
|
|
76
|
+
chmodSync(target, 384);
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
/** Heartbeat without a rewrite: refresh the mtime of an already published file. */
|
|
80
|
+
function touchInstanceStatus(stateDir) {
|
|
81
|
+
const now = /* @__PURE__ */ new Date();
|
|
82
|
+
try {
|
|
83
|
+
utimesSync(instanceStatusPath(stateDir), now, now);
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
//#endregion
|
|
87
|
+
export { touchInstanceStatus as a, InstanceStatusSchema as i, INSTANCE_STATUS_INTERVAL_MS as n, writeInstanceStatus as o, INSTANCE_STATUS_STALE_MS as r, INSTANCE_STATUS_FILE as t };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { S as $strip, _ as output, a as ZodLiteral, c as ZodObject, d as ZodRecord, f as ZodString, i as ZodEnum, l as ZodOptional, n as ZodBoolean, o as ZodNullable, s as ZodNumber } from "./schemas-lx-F2nnt.js";
|
|
2
|
+
//#region src/app/instance-status.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The per-instance status file: the only write side of the cross-instance view.
|
|
5
|
+
*
|
|
6
|
+
* A separate process cannot read another instance's health from the database without opening it, and opening it takes
|
|
7
|
+
* the very lock the instance holds. So each instance publishes a small, versioned snapshot of what it already knows
|
|
8
|
+
* (health, load, pause, Watchdog schedule, ticket counts), and the viewer only reads it. `schema` is the contract: a
|
|
9
|
+
* reader that does not know a newer number reports that instance as incompatible instead of guessing.
|
|
10
|
+
*
|
|
11
|
+
* The file is rewritten only when its content changes; between rewrites the instance touches its mtime, which is what
|
|
12
|
+
* the viewer uses as the heartbeat. Rewriting every interval would churn the disk for no new information.
|
|
13
|
+
*/
|
|
14
|
+
declare const INSTANCE_STATUS_SCHEMA = 1;
|
|
15
|
+
declare const InstanceStatusSchema: ZodObject<{
|
|
16
|
+
schema: ZodLiteral<1>;
|
|
17
|
+
name: ZodNullable<ZodString>;
|
|
18
|
+
pid: ZodNumber;
|
|
19
|
+
startedAt: ZodString;
|
|
20
|
+
writtenAt: ZodString;
|
|
21
|
+
state: ZodEnum<{
|
|
22
|
+
starting: "starting";
|
|
23
|
+
running: "running";
|
|
24
|
+
stopping: "stopping";
|
|
25
|
+
}>;
|
|
26
|
+
health: ZodRecord<ZodString, ZodObject<{
|
|
27
|
+
state: ZodEnum<{
|
|
28
|
+
error: "error";
|
|
29
|
+
starting: "starting";
|
|
30
|
+
ready: "ready";
|
|
31
|
+
paused: "paused";
|
|
32
|
+
}>;
|
|
33
|
+
detail: ZodString;
|
|
34
|
+
at: ZodOptional<ZodString>;
|
|
35
|
+
code: ZodOptional<ZodString>;
|
|
36
|
+
values: ZodOptional<ZodRecord<ZodString, ZodString>>;
|
|
37
|
+
}, $strip>>;
|
|
38
|
+
load: ZodObject<{
|
|
39
|
+
running: ZodNumber;
|
|
40
|
+
queued: ZodNumber;
|
|
41
|
+
maxWorkers: ZodNumber;
|
|
42
|
+
}, $strip>;
|
|
43
|
+
paused: ZodBoolean;
|
|
44
|
+
setup: ZodObject<{
|
|
45
|
+
configured: ZodBoolean;
|
|
46
|
+
problem: ZodNullable<ZodObject<{
|
|
47
|
+
place: ZodString;
|
|
48
|
+
code: ZodString;
|
|
49
|
+
detail: ZodString;
|
|
50
|
+
}, $strip>>;
|
|
51
|
+
}, $strip>;
|
|
52
|
+
watchdog: ZodObject<{
|
|
53
|
+
everyMinutes: ZodNumber;
|
|
54
|
+
stallMinutes: ZodNumber;
|
|
55
|
+
lastTickAt: ZodNullable<ZodString>;
|
|
56
|
+
}, $strip>;
|
|
57
|
+
tickets: ZodObject<{
|
|
58
|
+
open: ZodNumber;
|
|
59
|
+
stalled: ZodNumber;
|
|
60
|
+
}, $strip>;
|
|
61
|
+
configPath: ZodNullable<ZodString>;
|
|
62
|
+
}, $strip>;
|
|
63
|
+
type InstanceStatus = output<typeof InstanceStatusSchema>;
|
|
64
|
+
//#endregion
|
|
65
|
+
//#region src/app/instances-view.d.ts
|
|
66
|
+
/**
|
|
67
|
+
* The read side of the cross-instance view: one report per record in the instance registry `config/instances.json`, built only
|
|
68
|
+
* from files another process already wrote. This module never writes, never opens the instance database, never takes
|
|
69
|
+
* the state lock and never starts or stops anything — the `ViewIo` seam exists so a test can prove that.
|
|
70
|
+
*
|
|
71
|
+
* What each report is derived from:
|
|
72
|
+
* - `<stateDir>/instance-status.json` (written by the instance itself, versioned by `schema`);
|
|
73
|
+
* - `<stateDir>.lock` (proper-lockfile's directory: presence and mtime, never acquired);
|
|
74
|
+
* - the instance's own pid, checked with signal 0;
|
|
75
|
+
* - as a fallback for instances older than the status file, a read-only `GET state` on the record's port.
|
|
76
|
+
*
|
|
77
|
+
* Liveness is the status file's mtime, not `writtenAt`: the instance touches the mtime every interval and rewrites the
|
|
78
|
+
* content only when it changed.
|
|
79
|
+
*/
|
|
80
|
+
type InstanceState = 'running' | 'unresponsive' | 'dead' | 'stale-lock' | 'incompatible' | 'unknown';
|
|
81
|
+
interface InstanceRecord {
|
|
82
|
+
name: string;
|
|
83
|
+
label?: string;
|
|
84
|
+
profile?: string;
|
|
85
|
+
stateDir?: string;
|
|
86
|
+
port?: number;
|
|
87
|
+
}
|
|
88
|
+
interface InstanceReport {
|
|
89
|
+
name: string;
|
|
90
|
+
label: string | null;
|
|
91
|
+
stateDir: string | null;
|
|
92
|
+
state: InstanceState;
|
|
93
|
+
reason: string | null;
|
|
94
|
+
source: 'status' | 'http' | 'none';
|
|
95
|
+
pid: number | null;
|
|
96
|
+
pidAlive: boolean | null;
|
|
97
|
+
heartbeatAgeSeconds: number | null;
|
|
98
|
+
lockPresent: boolean;
|
|
99
|
+
lockAgeSeconds: number | null;
|
|
100
|
+
statusSchema: number | null;
|
|
101
|
+
writtenAt: string | null;
|
|
102
|
+
startedAt: string | null;
|
|
103
|
+
health: InstanceStatus['health'] | null;
|
|
104
|
+
load: InstanceStatus['load'] | null;
|
|
105
|
+
paused: boolean | null;
|
|
106
|
+
setup: InstanceStatus['setup'] | null;
|
|
107
|
+
watchdog: InstanceStatus['watchdog'] | null;
|
|
108
|
+
tickets: InstanceStatus['tickets'] | null;
|
|
109
|
+
configPath: string | null;
|
|
110
|
+
}
|
|
111
|
+
interface InstancesView {
|
|
112
|
+
schema: typeof INSTANCE_STATUS_SCHEMA;
|
|
113
|
+
collectedAt: string;
|
|
114
|
+
instances: InstanceReport[];
|
|
115
|
+
/** Why the record list itself could not be read; the instance reports are then empty. */
|
|
116
|
+
problem: string | null;
|
|
117
|
+
}
|
|
118
|
+
/** The filesystem seam: only reads. */
|
|
119
|
+
interface ViewIo {
|
|
120
|
+
existsSync: (path: string) => boolean;
|
|
121
|
+
readFileSync: (path: string, encoding: 'utf8') => string;
|
|
122
|
+
statSync: (path: string) => {
|
|
123
|
+
mtimeMs: number;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** An `GET state` answer, mapped into the same report fields. */
|
|
127
|
+
interface ProbeReply {
|
|
128
|
+
health?: Record<string, {
|
|
129
|
+
state: string;
|
|
130
|
+
detail: string;
|
|
131
|
+
}>;
|
|
132
|
+
load?: {
|
|
133
|
+
running: number;
|
|
134
|
+
queued: number;
|
|
135
|
+
maxWorkers: number;
|
|
136
|
+
};
|
|
137
|
+
paused?: boolean;
|
|
138
|
+
setup?: {
|
|
139
|
+
configured: boolean;
|
|
140
|
+
problem: {
|
|
141
|
+
place: string;
|
|
142
|
+
code: string;
|
|
143
|
+
detail: string;
|
|
144
|
+
} | null;
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
interface CollectOptions {
|
|
148
|
+
/** Directory that holds `config/instances.json` (the install root the caller names). */
|
|
149
|
+
installRoot?: string;
|
|
150
|
+
/** Tests inject records directly; production reads them from `installRoot`. */
|
|
151
|
+
records?: readonly InstanceRecord[];
|
|
152
|
+
now?: number;
|
|
153
|
+
io?: ViewIo;
|
|
154
|
+
/** Tests inject liveness; production asks the OS. */
|
|
155
|
+
pidAlive?: (pid: number) => boolean;
|
|
156
|
+
/** Tests inject a stub; production probes over HTTP only when no status file exists. */
|
|
157
|
+
probe?: (port: number) => Promise<ProbeReply | null>;
|
|
158
|
+
}
|
|
159
|
+
/** Builds the report list; the only exported behaviour, used by the CLI and by tests. */
|
|
160
|
+
declare function collectInstances(options?: CollectOptions): Promise<InstancesView>;
|
|
161
|
+
/** The human table the `--instances-status` flag prints; the JSON form is the data layer items 8 and 12 consume. */
|
|
162
|
+
declare function formatInstances(view: InstancesView): string;
|
|
163
|
+
//#endregion
|
|
164
|
+
export { CollectOptions, InstanceRecord, InstanceReport, InstanceState, InstancesView, ProbeReply, ViewIo, collectInstances, formatInstances };
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { i as InstanceStatusSchema, r as INSTANCE_STATUS_STALE_MS, t as INSTANCE_STATUS_FILE } from "./instance-status-Cqaju-dw.js";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
//#region src/app/instances-view.ts
|
|
6
|
+
const nodeIo = {
|
|
7
|
+
existsSync: (path) => existsSync(path),
|
|
8
|
+
readFileSync: (path, encoding) => readFileSync(path, encoding),
|
|
9
|
+
statSync: (path) => statSync(path)
|
|
10
|
+
};
|
|
11
|
+
const defaultPidAlive = (pid) => {
|
|
12
|
+
try {
|
|
13
|
+
process.kill(pid, 0);
|
|
14
|
+
return true;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
return error.code === "EPERM";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const readOnlyGet = async (port) => {
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(`http://127.0.0.1:${port}/orchestrator/api/state`, {
|
|
22
|
+
headers: { "x-orchestrator-request": "1" },
|
|
23
|
+
signal: AbortSignal.timeout(1500)
|
|
24
|
+
});
|
|
25
|
+
return response.ok ? await response.json() : null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const seconds = (milliseconds) => Math.max(0, Math.round(milliseconds / 1e3));
|
|
31
|
+
/** Reads `config/instances.json` in the shape the desktop shell wrote (the shell was removed on 2026-11-07; the file is still accepted). */
|
|
32
|
+
function readRecords(installRoot, io) {
|
|
33
|
+
const file = join(installRoot, "config", "instances.json");
|
|
34
|
+
if (!io.existsSync(file)) return {
|
|
35
|
+
records: [],
|
|
36
|
+
problem: `no instance records at ${file}`
|
|
37
|
+
};
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(io.readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
|
40
|
+
const list = Array.isArray(parsed) ? parsed : parsed?.instances;
|
|
41
|
+
if (!Array.isArray(list)) return {
|
|
42
|
+
records: [],
|
|
43
|
+
problem: `${file} holds no instance list`
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
records: list.filter((entry) => typeof entry === "object" && entry !== null && typeof entry.name === "string"),
|
|
47
|
+
problem: null
|
|
48
|
+
};
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return {
|
|
51
|
+
records: [],
|
|
52
|
+
problem: `${file} could not be read: ${error instanceof Error ? error.message : String(error)}`
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function lockOf(stateDir, io, now) {
|
|
57
|
+
const path = stateDir + ".lock";
|
|
58
|
+
if (!io.existsSync(path)) return {
|
|
59
|
+
present: false,
|
|
60
|
+
ageSeconds: null
|
|
61
|
+
};
|
|
62
|
+
try {
|
|
63
|
+
return {
|
|
64
|
+
present: true,
|
|
65
|
+
ageSeconds: seconds(now - io.statSync(path).mtimeMs)
|
|
66
|
+
};
|
|
67
|
+
} catch {
|
|
68
|
+
return {
|
|
69
|
+
present: true,
|
|
70
|
+
ageSeconds: null
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function heartbeatOf(stateDir, io, now) {
|
|
75
|
+
try {
|
|
76
|
+
return seconds(now - io.statSync(join(stateDir, INSTANCE_STATUS_FILE)).mtimeMs);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Parses one instance's status file, naming the exact way it failed instead of collapsing to "unknown". */
|
|
82
|
+
function statusOf(file, io) {
|
|
83
|
+
if (!io.existsSync(file)) return { missing: true };
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(io.readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return {
|
|
89
|
+
failure: "unknown",
|
|
90
|
+
reason: `${INSTANCE_STATUS_FILE} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
91
|
+
schema: null
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const version = typeof parsed === "object" && parsed !== null && typeof parsed.schema === "number" ? parsed.schema : null;
|
|
95
|
+
const checked = InstanceStatusSchema.safeParse(parsed);
|
|
96
|
+
if (!checked.success) return version !== null && version !== 1 ? {
|
|
97
|
+
failure: "incompatible",
|
|
98
|
+
reason: `${INSTANCE_STATUS_FILE} declares schema ${version}; this viewer reads 1`,
|
|
99
|
+
schema: version
|
|
100
|
+
} : {
|
|
101
|
+
failure: "unknown",
|
|
102
|
+
reason: `${INSTANCE_STATUS_FILE} does not match schema 1: ${checked.error.issues[0]?.message ?? "invalid"}`,
|
|
103
|
+
schema: version
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
status: checked.data,
|
|
107
|
+
schema: checked.data.schema
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function base(record, stateDir) {
|
|
111
|
+
return {
|
|
112
|
+
name: record.name,
|
|
113
|
+
label: record.label ?? null,
|
|
114
|
+
stateDir,
|
|
115
|
+
state: "unknown",
|
|
116
|
+
reason: null,
|
|
117
|
+
source: "none",
|
|
118
|
+
pid: null,
|
|
119
|
+
pidAlive: null,
|
|
120
|
+
heartbeatAgeSeconds: null,
|
|
121
|
+
lockPresent: false,
|
|
122
|
+
lockAgeSeconds: null,
|
|
123
|
+
statusSchema: null,
|
|
124
|
+
writtenAt: null,
|
|
125
|
+
startedAt: null,
|
|
126
|
+
health: null,
|
|
127
|
+
load: null,
|
|
128
|
+
paused: null,
|
|
129
|
+
setup: null,
|
|
130
|
+
watchdog: null,
|
|
131
|
+
tickets: null,
|
|
132
|
+
configPath: null
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** One record's report. Every failure is named on that record only; the other instances are unaffected. */
|
|
136
|
+
async function reportOf(record, options) {
|
|
137
|
+
const { now, io, pidAlive, probe } = options;
|
|
138
|
+
if (!record.stateDir) return {
|
|
139
|
+
...base(record, null),
|
|
140
|
+
reason: "the record names no stateDir, so there is nothing to observe"
|
|
141
|
+
};
|
|
142
|
+
const stateDir = record.stateDir;
|
|
143
|
+
const report = base(record, stateDir);
|
|
144
|
+
const lock = lockOf(stateDir, io, now);
|
|
145
|
+
report.lockPresent = lock.present;
|
|
146
|
+
report.lockAgeSeconds = lock.ageSeconds;
|
|
147
|
+
const read = statusOf(join(stateDir, INSTANCE_STATUS_FILE), io);
|
|
148
|
+
if ("failure" in read) return {
|
|
149
|
+
...report,
|
|
150
|
+
state: read.failure,
|
|
151
|
+
source: "status",
|
|
152
|
+
statusSchema: read.schema,
|
|
153
|
+
reason: read.reason
|
|
154
|
+
};
|
|
155
|
+
if ("missing" in read) {
|
|
156
|
+
if ((record.port ?? 0) > 0) {
|
|
157
|
+
const reply = await probe(record.port);
|
|
158
|
+
if (reply) return {
|
|
159
|
+
...report,
|
|
160
|
+
state: "running",
|
|
161
|
+
source: "http",
|
|
162
|
+
health: reply.health,
|
|
163
|
+
load: reply.load ?? null,
|
|
164
|
+
paused: reply.paused ?? null,
|
|
165
|
+
setup: reply.setup ?? null,
|
|
166
|
+
reason: "no status file; probed the read-only state route"
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (!lock.present) return {
|
|
170
|
+
...report,
|
|
171
|
+
state: "dead",
|
|
172
|
+
reason: "no status file and no state lock (the instance is stopped)"
|
|
173
|
+
};
|
|
174
|
+
const old = (lock.ageSeconds ?? 0) > INSTANCE_STATUS_STALE_MS / 1e3;
|
|
175
|
+
return {
|
|
176
|
+
...report,
|
|
177
|
+
state: old ? "stale-lock" : "unknown",
|
|
178
|
+
source: "none",
|
|
179
|
+
reason: old ? `no status file; the state lock has not moved for ${lock.ageSeconds ?? "?"}s (a crash left it behind)` : "no status file; the state lock is held and fresh (the instance predates the status file or is still starting)"
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const status = read.status;
|
|
183
|
+
const heartbeat = heartbeatOf(stateDir, io, now);
|
|
184
|
+
const alive = pidAlive(status.pid);
|
|
185
|
+
const state = !alive ? "dead" : heartbeat !== null && heartbeat > 30 ? "unresponsive" : "running";
|
|
186
|
+
return {
|
|
187
|
+
...report,
|
|
188
|
+
state,
|
|
189
|
+
source: "status",
|
|
190
|
+
pid: status.pid,
|
|
191
|
+
pidAlive: alive,
|
|
192
|
+
heartbeatAgeSeconds: heartbeat,
|
|
193
|
+
statusSchema: read.schema,
|
|
194
|
+
writtenAt: status.writtenAt,
|
|
195
|
+
startedAt: status.startedAt,
|
|
196
|
+
health: status.health,
|
|
197
|
+
load: status.load,
|
|
198
|
+
paused: status.paused,
|
|
199
|
+
setup: status.setup,
|
|
200
|
+
watchdog: status.watchdog,
|
|
201
|
+
tickets: status.tickets,
|
|
202
|
+
configPath: status.configPath,
|
|
203
|
+
reason: !alive ? `pid ${status.pid} is not running${lock.present ? `; the state lock is still present (${lock.ageSeconds ?? "?"}s old)` : ""}; last change ${status.writtenAt}` : state === "unresponsive" ? `the heartbeat has not moved for ${heartbeat ?? "?"}s` : null
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Builds the report list; the only exported behaviour, used by the CLI and by tests. */
|
|
207
|
+
async function collectInstances(options = {}) {
|
|
208
|
+
const now = options.now ?? Date.now();
|
|
209
|
+
const io = options.io ?? nodeIo;
|
|
210
|
+
const pidAlive = options.pidAlive ?? defaultPidAlive;
|
|
211
|
+
const probe = options.probe ?? readOnlyGet;
|
|
212
|
+
const collectedAt = new Date(now).toISOString();
|
|
213
|
+
let records = options.records ? [...options.records] : [];
|
|
214
|
+
let problem = null;
|
|
215
|
+
if (!options.records) {
|
|
216
|
+
if (!options.installRoot) problem = "no install root was given and no records were injected";
|
|
217
|
+
else ({records, problem} = readRecords(options.installRoot, io));
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
schema: 1,
|
|
221
|
+
collectedAt,
|
|
222
|
+
instances: await Promise.all(records.map((record) => reportOf(record, {
|
|
223
|
+
now,
|
|
224
|
+
io,
|
|
225
|
+
pidAlive,
|
|
226
|
+
probe
|
|
227
|
+
}))),
|
|
228
|
+
problem
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const column = (text, width) => text.length >= width ? text : text + " ".repeat(width - text.length);
|
|
232
|
+
/** The human table the `--instances-status` flag prints; the JSON form is the data layer items 8 and 12 consume. */
|
|
233
|
+
function formatInstances(view) {
|
|
234
|
+
if (view.problem) return `problem: ${view.problem}`;
|
|
235
|
+
return [`${column("NAME", 14)}${column("STATE", 13)}${column("HEARTBEAT", 11)}${column("PID", 8)}${column("LOAD", 10)}${column("HEALTH", 28)} REASON`, ...view.instances.map((instance) => {
|
|
236
|
+
const health = instance.health ? Object.values(instance.health).every((entry) => entry.state === "ready") ? "all ready" : Object.entries(instance.health).filter(([, entry]) => entry.state !== "ready").map(([part, entry]) => `${part}:${entry.state}`).join(" ") : "—";
|
|
237
|
+
const load = instance.load ? `${instance.load.running}/${instance.load.maxWorkers} +${instance.load.queued}q` : "—";
|
|
238
|
+
const heartbeat = instance.heartbeatAgeSeconds === null ? "—" : `${instance.heartbeatAgeSeconds}s`;
|
|
239
|
+
return `${column(instance.name, 14)}${column(instance.state, 13)}${column(heartbeat, 11)}${column(instance.pid === null ? "—" : String(instance.pid), 8)}${column(load, 10)}${column(health, 28)} ${instance.reason ?? "—"}`;
|
|
240
|
+
})].join("\n");
|
|
241
|
+
}
|
|
242
|
+
async function main() {
|
|
243
|
+
const argv = process.argv.slice(2);
|
|
244
|
+
const asJson = argv.includes("--json");
|
|
245
|
+
const rootIndex = argv.indexOf("--install-root");
|
|
246
|
+
const installRoot = rootIndex >= 0 ? argv[rootIndex + 1] : process.env.SUPERAGENT_INSTALL_ROOT;
|
|
247
|
+
if (!installRoot) {
|
|
248
|
+
process.stderr.write("usage: instances-view.js --instances-status [--json] --install-root <dir>\n");
|
|
249
|
+
process.exitCode = 2;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const view = await collectInstances({ installRoot });
|
|
253
|
+
process.stdout.write((asJson ? JSON.stringify(view, null, 2) : formatInstances(view)) + "\n");
|
|
254
|
+
}
|
|
255
|
+
/** Runs only when node executes this built file, never when a test imports the module. */
|
|
256
|
+
if (process.argv[1] && basename(process.argv[1]) === "instances-view.js" && fileURLToPath(import.meta.url) === process.argv[1]) main();
|
|
257
|
+
//#endregion
|
|
258
|
+
export { collectInstances, formatInstances };
|