@astrofoundry/pi-astro 0.34.0 → 0.35.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/README.md +1 -1
- package/agents/inference.md +4 -5
- package/extensions/astro-discord/tools-report.test.ts +77 -12
- package/extensions/astro-discord/tools-report.ts +108 -37
- package/extensions/specialist-gate/approvals.test.ts +1 -1
- package/extensions/specialist-gate/index.ts +1 -1
- package/package.json +1 -1
- package/skills/edge/SKILL.md +1 -1
- package/skills/inference/SKILL.md +3 -27
- package/skills/proxmox/SKILL.md +2 -2
- package/skills/security/SKILL.md +2 -2
- package/specialists/AGENTS.md +5 -5
- package/specialists/README.md +19 -13
- package/specialists/edge/run.ts +4 -4
- package/specialists/entry/remote/spc-arcane-install.sh +2 -2
- package/specialists/entry/remote/spc-arcane-op.py +3 -0
- package/specialists/entry/specialist-install.sh +266 -8
- package/specialists/entry/specialist-install.test.ts +50 -1
- package/specialists/inference/run.ts +12 -94
- package/specialists/lib/arcane-host.test.ts +24 -1
- package/specialists/lib/arcane-host.ts +6 -2
- package/specialists/lib/deployment.ts +1 -1
- package/specialists/provision/provision.sh +2 -2
- package/specialists/wrappers.test.ts +6 -16
- package/specialists/entry/remote/spc-inference-hermes-entry.sh +0 -27
- package/specialists/entry/remote/spc-inference-pulsar-entry.sh +0 -14
|
@@ -40,13 +40,33 @@ describe("specialist-install", () => {
|
|
|
40
40
|
[["rollback", "pi"]],
|
|
41
41
|
[["rollback", "node", "extra"]],
|
|
42
42
|
[["status", "now"]],
|
|
43
|
+
[["python", "3.14.8"]],
|
|
44
|
+
[["python", "3.14.8+2026101"]],
|
|
45
|
+
[["python", "3.14.8+20261013+1"]],
|
|
46
|
+
[["python", "3.14.8-20261013"]],
|
|
47
|
+
[["gcloud", "589.0"]],
|
|
48
|
+
[["bw", "cli-v2026.10.0"]],
|
|
49
|
+
[["jq", "jq-1.8.2"]],
|
|
50
|
+
[["rollback", "brew"]],
|
|
43
51
|
])("refuses %j with a usage error before anything else", (args) => {
|
|
44
52
|
const result = install(...args);
|
|
45
53
|
expect(result.status).toBe(2);
|
|
46
54
|
expect(result.stderr).toMatch(/^usage: specialist-install/);
|
|
47
55
|
});
|
|
48
56
|
|
|
49
|
-
it.skipIf(isRoot).each([
|
|
57
|
+
it.skipIf(isRoot).each([
|
|
58
|
+
[["node", "24.22.0"]],
|
|
59
|
+
[["wrappers", "0.31.0"]],
|
|
60
|
+
[["arcane-cli", "2.16.1"]],
|
|
61
|
+
[["trendarr", "0.6.0"]],
|
|
62
|
+
[["python", "3.14.8+20261013"]],
|
|
63
|
+
[["gcloud", "589.0.0"]],
|
|
64
|
+
[["bw", "2026.10.0"]],
|
|
65
|
+
[["jq", "1.8.2"]],
|
|
66
|
+
[["rollback", "trendarr"]],
|
|
67
|
+
[["rollback", "gcloud"]],
|
|
68
|
+
[["status"]],
|
|
69
|
+
])(
|
|
50
70
|
"accepts %j and then requires root",
|
|
51
71
|
(args) => {
|
|
52
72
|
const result = install(...args);
|
|
@@ -74,6 +94,35 @@ describe("specialist-install", () => {
|
|
|
74
94
|
expect(shellFunction("version_lt", `version_lt ${a} ${b}`).status).toBe(older ? 0 : 1);
|
|
75
95
|
});
|
|
76
96
|
|
|
97
|
+
it.each(["3.14.8+20261013", "3.10.0+20250101", "3.15.0+20270401"])("valid_python_version accepts %s", (v) => {
|
|
98
|
+
expect(shellFunction("valid_python_version", `valid_python_version '${v}'`).status).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it.each(["", "3.14.8", "3.14+20261013", "3.14.8+2026101", "3.14.8+20261013a", "+20261013", "3.14.8+ 20261013"])("valid_python_version rejects %j", (v) => {
|
|
102
|
+
expect(shellFunction("valid_python_version", `valid_python_version '${v}'`).status).toBe(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it.each([
|
|
106
|
+
["3.14.8+20261009", "3.14.8+20261013", true],
|
|
107
|
+
["3.14.8+20261013", "3.14.8+20261013", false],
|
|
108
|
+
["3.14.9+20261001", "3.14.8+20261013", false],
|
|
109
|
+
["3.14.7+20261101", "3.14.8+20261013", true],
|
|
110
|
+
])("python_lt %s < %s is %s", (a, b, older) => {
|
|
111
|
+
const script = readFileSync(SCRIPT, "utf-8");
|
|
112
|
+
const lt = /^version_lt\(\) \{\n[\s\S]*?^\}/m.exec(script)?.[0] ?? "";
|
|
113
|
+
expect(shellFunction("python_lt", `${lt}\npython_lt '${a}' '${b}'`).status).toBe(older ? 0 : 1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("installs gcloud from fixed components and checked downloads only", () => {
|
|
117
|
+
const text = readFileSync(SCRIPT, "utf-8");
|
|
118
|
+
expect(/^GCLOUD_COMPONENTS="([^"]+)"$/m.exec(text)?.[1].split(" ")).toEqual(["core", "core-nix", "gcloud-deps", "gcloud-deps-darwin-arm", "gcloud-crc32c-darwin-arm"]);
|
|
119
|
+
expect(text).toContain("channel=https://dl.google.com/dl/cloudsdk/channels/rapid\n");
|
|
120
|
+
expect(text).toContain('fetch "$channel/components-v$version.json"');
|
|
121
|
+
expect(text).toContain("https://github.com/astral-sh/python-build-standalone/releases/download/$tag");
|
|
122
|
+
expect(text).toContain("https://api.github.com/repos/bitwarden/clients/releases/tags/cli-v$version");
|
|
123
|
+
expect(text).toContain("https://github.com/jqlang/jq/releases/download/jq-$version");
|
|
124
|
+
});
|
|
125
|
+
|
|
77
126
|
it("installs the same services specialist-cli accepts", () => {
|
|
78
127
|
const services = /^SERVICES="([^"]+)"$/m.exec(readFileSync(SCRIPT, "utf-8"))?.[1].split(" ");
|
|
79
128
|
const allowed = /^ {2}([a-z|]+)\) ;;$/m.exec(readFileSync(ENTRY, "utf-8"))?.[1].split("|");
|
|
@@ -13,30 +13,16 @@ const SERVICE = "inference";
|
|
|
13
13
|
interface InferenceConfig extends Record<string, string> {
|
|
14
14
|
nexusHost: string;
|
|
15
15
|
nexusUser: string;
|
|
16
|
-
hermesHost: string;
|
|
17
|
-
hermesUser: string;
|
|
18
|
-
pulsarHost: string;
|
|
19
|
-
pulsarUser: string;
|
|
20
16
|
europaHealthUrl: string;
|
|
21
|
-
/** GitHub repository whose releases carry the Hermes upgrade notes, owner/name. */
|
|
22
|
-
hermesRepo: string;
|
|
23
17
|
}
|
|
24
18
|
|
|
25
19
|
const SHAPE = {
|
|
26
20
|
nexusHost: "string",
|
|
27
21
|
nexusUser: "string",
|
|
28
|
-
hermesHost: "string",
|
|
29
|
-
hermesUser: "string",
|
|
30
|
-
pulsarHost: "string",
|
|
31
|
-
pulsarUser: "string",
|
|
32
22
|
europaHealthUrl: "string",
|
|
33
|
-
hermesRepo: "string",
|
|
34
23
|
} as const;
|
|
35
24
|
|
|
36
|
-
type Target = "nexus" | "hermes" | "pulsar";
|
|
37
|
-
|
|
38
25
|
interface RemoteSpec {
|
|
39
|
-
target: Target;
|
|
40
26
|
args: number;
|
|
41
27
|
help: string;
|
|
42
28
|
timeoutMs: number;
|
|
@@ -45,40 +31,27 @@ interface RemoteSpec {
|
|
|
45
31
|
|
|
46
32
|
const MINUTE = 60_000;
|
|
47
33
|
|
|
48
|
-
/** Fixed remote commands; the forced
|
|
34
|
+
/** Fixed remote commands; the forced command on Nexus (user nexus) accepts exactly these names. */
|
|
49
35
|
export const INFERENCE_COMMANDS: Readonly<Record<string, RemoteSpec>> = {
|
|
50
|
-
"nexus-status": {
|
|
51
|
-
"nexus-restart": {
|
|
52
|
-
"nexus-start": {
|
|
53
|
-
"nexus-stop": {
|
|
54
|
-
"nexus-log": {
|
|
55
|
-
"nexus-disk": {
|
|
56
|
-
"hermes-status": { target: "hermes", args: 0, help: "both gateway user services", timeoutMs: MINUTE },
|
|
57
|
-
"hermes-journal": { target: "hermes", args: 1, help: "hermes-journal <since> user journal of both gateways", timeoutMs: MINUTE },
|
|
58
|
-
"hermes-errors": { target: "hermes", args: 0, help: "hermes logs errors", timeoutMs: MINUTE },
|
|
59
|
-
"hermes-restart": { target: "hermes", args: 1, help: "hermes-restart <gateway|api>", timeoutMs: 2 * MINUTE },
|
|
60
|
-
"hermes-api-health": { target: "hermes", args: 0, help: "/health and /v1/models codes on localhost:8650 (expect 200 and 401)", timeoutMs: MINUTE },
|
|
61
|
-
"hermes-version": { target: "hermes", args: 0, help: "installed Hermes version", timeoutMs: MINUTE },
|
|
62
|
-
"hermes-guest-status": { target: "pulsar", args: 0, help: "pct status 101", timeoutMs: MINUTE },
|
|
63
|
-
"hermes-snapshots": { target: "pulsar", args: 0, help: "pct listsnapshot 101", timeoutMs: MINUTE },
|
|
64
|
-
"hermes-upgrade": { target: "pulsar", args: 0, help: "hermes-upgrade --confirm snapshot LXC 101, hermes update, restart, health check", timeoutMs: 20 * MINUTE, confirm: true },
|
|
36
|
+
"nexus-status": { args: 0, help: "LaunchAgent states and /health codes of the embed and rerank servers", timeoutMs: MINUTE },
|
|
37
|
+
"nexus-restart": { args: 1, help: "nexus-restart <embed|rerank> launchctl kickstart -k", timeoutMs: 2 * MINUTE },
|
|
38
|
+
"nexus-start": { args: 1, help: "nexus-start <embed|rerank> launchctl bootstrap from the tracked plist", timeoutMs: 2 * MINUTE },
|
|
39
|
+
"nexus-stop": { args: 1, help: "nexus-stop <embed|rerank> --confirm launchctl bootout", timeoutMs: 2 * MINUTE, confirm: true },
|
|
40
|
+
"nexus-log": { args: 2, help: "nexus-log <embed|rerank> <lines>", timeoutMs: MINUTE },
|
|
41
|
+
"nexus-disk": { args: 0, help: "free space on the Nexus disk", timeoutMs: MINUTE },
|
|
65
42
|
};
|
|
66
43
|
|
|
67
44
|
const NEXUS_SERVICES = ["embed", "rerank"];
|
|
68
|
-
const HERMES_SERVICES = ["gateway", "api"];
|
|
69
|
-
const SINCE = /^[A-Za-z0-9 :\-+]{1,40}$/;
|
|
70
45
|
|
|
71
|
-
const HELP = `inference specialist (llama.cpp on Nexus,
|
|
46
|
+
const HELP = `inference specialist (llama.cpp on Nexus, Europa health)
|
|
72
47
|
|
|
73
48
|
<command> [args] fixed operations through forced-command SSH keys
|
|
74
49
|
${Object.entries(INFERENCE_COMMANDS)
|
|
75
50
|
.map(([name, c]) => ` ${name.padEnd(22)} ${c.help}`)
|
|
76
51
|
.join("\n")}
|
|
77
52
|
europa-health GET the Europa embedding /health over HTTP (Europa is usually off by design)
|
|
78
|
-
hermes-releases [n] latest n upstream Hermes releases, notes cut at 6000 chars (default 5)
|
|
79
|
-
hermes-release <tag> one release with its full notes; read every release between the installed version and the target before hermes-upgrade
|
|
80
53
|
|
|
81
|
-
Europa control
|
|
54
|
+
Europa control is an operator task.`;
|
|
82
55
|
|
|
83
56
|
export function buildInferenceRemote(args: string[]): { remote: string; spec: RemoteSpec } {
|
|
84
57
|
const [name, ...given] = args;
|
|
@@ -91,63 +64,14 @@ export function buildInferenceRemote(args: string[]): { remote: string; spec: Re
|
|
|
91
64
|
rest = rest.filter((_, i) => i !== index);
|
|
92
65
|
}
|
|
93
66
|
if (rest.length !== spec.args) throw new UsageError(`${name} takes ${spec.args} argument(s)`);
|
|
94
|
-
if (
|
|
67
|
+
if (spec.args >= 1 && !NEXUS_SERVICES.includes(rest[0])) throw new UsageError(`service must be one of ${NEXUS_SERVICES.join(", ")}`);
|
|
95
68
|
if (name === "nexus-log") {
|
|
96
69
|
const n = Number(rest[1]);
|
|
97
70
|
if (!Number.isInteger(n) || n < 1 || n > 2000) throw new UsageError("lines must be an integer from 1 to 2000");
|
|
98
71
|
}
|
|
99
|
-
if (name === "hermes-restart" && !HERMES_SERVICES.includes(rest[0])) throw new UsageError(`service must be one of ${HERMES_SERVICES.join(", ")}`);
|
|
100
|
-
if (name === "hermes-journal" && !SINCE.test(rest[0])) throw new UsageError("since: letters, digits, spaces, colon, plus, minus only");
|
|
101
72
|
return { remote: [name, ...rest].join(" "), spec };
|
|
102
73
|
}
|
|
103
74
|
|
|
104
|
-
interface GithubRelease {
|
|
105
|
-
tag_name?: string;
|
|
106
|
-
name?: string;
|
|
107
|
-
published_at?: string;
|
|
108
|
-
html_url?: string;
|
|
109
|
-
prerelease?: boolean;
|
|
110
|
-
body?: string;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Trims GitHub release objects to what an upgrade decision needs. */
|
|
114
|
-
export function compactReleases(releases: GithubRelease[]): { tag: string; name: string; published: string; prerelease: boolean; url: string; notes: string }[] {
|
|
115
|
-
return releases.map((r) => ({
|
|
116
|
-
tag: r.tag_name ?? "?",
|
|
117
|
-
name: r.name ?? "",
|
|
118
|
-
published: r.published_at ?? "",
|
|
119
|
-
prerelease: r.prerelease === true,
|
|
120
|
-
url: r.html_url ?? "",
|
|
121
|
-
notes: (r.body ?? "").slice(0, 6000),
|
|
122
|
-
}));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const RELEASE_TAG = /^[A-Za-z0-9._-]{1,60}$/;
|
|
126
|
-
|
|
127
|
-
async function github(config: InferenceConfig, path: string): Promise<unknown> {
|
|
128
|
-
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(config.hermesRepo)) throw new ServiceError("hermesRepo must be owner/name");
|
|
129
|
-
const response = await fetch(`https://api.github.com/repos/${config.hermesRepo}${path}`, {
|
|
130
|
-
headers: { Accept: "application/vnd.github+json", "User-Agent": "pi-astro-inference-specialist" },
|
|
131
|
-
signal: AbortSignal.timeout(30_000),
|
|
132
|
-
});
|
|
133
|
-
if (!response.ok) throw new ServiceError(`GitHub answered ${response.status} for ${config.hermesRepo}${path}`);
|
|
134
|
-
return response.json();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function hermesReleases(config: InferenceConfig, countArg: string | undefined): Promise<number> {
|
|
138
|
-
const n = countArg === undefined ? 5 : Number(countArg);
|
|
139
|
-
if (!Number.isInteger(n) || n < 1 || n > 30) throw new UsageError("hermes-releases [n]: n must be 1..30");
|
|
140
|
-
printJson({ repository: config.hermesRepo, releases: compactReleases((await github(config, `/releases?per_page=${n}`)) as GithubRelease[]) });
|
|
141
|
-
return 0;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async function hermesRelease(config: InferenceConfig, tag: string | undefined): Promise<number> {
|
|
145
|
-
if (tag === undefined || !RELEASE_TAG.test(tag)) throw new UsageError("hermes-release <tag>, e.g. v2026.9.14");
|
|
146
|
-
const release = (await github(config, `/releases/tags/${encodeURIComponent(tag)}`)) as GithubRelease;
|
|
147
|
-
printJson({ repository: config.hermesRepo, ...compactReleases([release])[0], notes: release.body ?? "" });
|
|
148
|
-
return 0;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
75
|
async function europaHealth(config: InferenceConfig): Promise<number> {
|
|
152
76
|
const result = await run("/usr/bin/curl", ["--silent", "--output", "/dev/null", "--max-time", "5", "--write-out", "%{http_code}", config.europaHealthUrl], { timeoutMs: 15_000 });
|
|
153
77
|
const httpCode = result.stdout.trim();
|
|
@@ -163,15 +87,9 @@ export async function command(args: string[]): Promise<number> {
|
|
|
163
87
|
}
|
|
164
88
|
const config = readConfig<InferenceConfig>(SERVICE, SHAPE);
|
|
165
89
|
if (args[0] === "europa-health") return europaHealth(config);
|
|
166
|
-
if (args[0] === "hermes-releases") return hermesReleases(config, args[1]);
|
|
167
|
-
if (args[0] === "hermes-release") return hermesRelease(config, args[1]);
|
|
168
90
|
const { remote, spec } = buildInferenceRemote(args);
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
hermes: { host: config.hermesHost, user: config.hermesUser, keyFile: secretPath(SERVICE, "SSH_KEY_HERMES") },
|
|
172
|
-
pulsar: { host: config.pulsarHost, user: config.pulsarUser, keyFile: secretPath(SERVICE, "SSH_KEY_PULSAR") },
|
|
173
|
-
};
|
|
174
|
-
const result = await sshFixed({ ...targets[spec.target], knownHostsFile: join(specialistsHome(), "config", "known_hosts") }, remote, spec.timeoutMs);
|
|
91
|
+
const nexus = { host: config.nexusHost, user: config.nexusUser, keyFile: secretPath(SERVICE, "SSH_KEY_NEXUS") };
|
|
92
|
+
const result = await sshFixed({ ...nexus, knownHostsFile: join(specialistsHome(), "config", "known_hosts") }, remote, spec.timeoutMs);
|
|
175
93
|
if (result.stdout.length > 0) printRaw(result.stdout);
|
|
176
94
|
if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `${args[0]} exited ${result.code}`);
|
|
177
95
|
return 0;
|
|
@@ -75,6 +75,29 @@ describe("Arcane host commands", () => {
|
|
|
75
75
|
["arcane-backup.timer", null],
|
|
76
76
|
]);
|
|
77
77
|
});
|
|
78
|
+
|
|
79
|
+
it("compares the probes in their subfolder and ignores names outside host/", () => {
|
|
80
|
+
dir = mkdtempSync(join(tmpdir(), "audit-"));
|
|
81
|
+
mkdirSync(join(dir, "host", "spc-arcane-probes"), { recursive: true });
|
|
82
|
+
writeFileSync(join(dir, "host", "spc-arcane-probes", "host_probe.py"), "print(1)\n");
|
|
83
|
+
writeFileSync(join(dir, "outside.txt"), "x\n");
|
|
84
|
+
const probe = createHash("sha256").update("print(1)\n").digest("hex");
|
|
85
|
+
const outside = createHash("sha256").update("x\n").digest("hex");
|
|
86
|
+
expect(
|
|
87
|
+
compareAudit(
|
|
88
|
+
[
|
|
89
|
+
{ name: "spc-arcane-probes/host_probe.py", state: "present", sha256: probe },
|
|
90
|
+
{ name: "../outside.txt", state: "present", sha256: outside },
|
|
91
|
+
{ name: "/etc/passwd", state: "present", sha256: outside },
|
|
92
|
+
],
|
|
93
|
+
dir,
|
|
94
|
+
).map((r) => [r.name, r.match]),
|
|
95
|
+
).toEqual([
|
|
96
|
+
["spc-arcane-probes/host_probe.py", true],
|
|
97
|
+
["../outside.txt", null],
|
|
98
|
+
["/etc/passwd", null],
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
78
101
|
});
|
|
79
102
|
});
|
|
80
103
|
|
|
@@ -115,7 +138,7 @@ describe("deployment checks", () => {
|
|
|
115
138
|
const expect_ = { project: "grimoire", commit: "abc1234", services: { "grimoire-api-1": D1 }, jobs: { "grimoire-migrate": D2 }, noHealth: [] };
|
|
116
139
|
const sync = syncFacts({ lastSyncCommit: "abc1234ffff", lastSyncStatus: "success", lastSyncAt: "t1" });
|
|
117
140
|
|
|
118
|
-
it("parses the
|
|
141
|
+
it("parses the host command arguments", () => {
|
|
119
142
|
expect(parseDeploymentArgs(["wait", "grimoire", "--commit", "abc1234", "--container", `grimoire-api-1=${D1}`, "--timeout", "120"])).toMatchObject({
|
|
120
143
|
wait: true,
|
|
121
144
|
timeoutS: 120,
|
|
@@ -134,15 +134,19 @@ export interface AuditFile {
|
|
|
134
134
|
gid?: number;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/** A tracked name under `host/`, such as `arcane-backup.sh` or `spc-arcane-probes/host_probe.py`; no `..`, no absolute path. */
|
|
138
|
+
const AUDIT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
|
|
139
|
+
|
|
137
140
|
/**
|
|
138
141
|
* Compares the host's file hashes with `host/<name>` in the specialist's
|
|
139
|
-
* checkout of the arcane repository; `match` is null when either side is missing
|
|
142
|
+
* checkout of the arcane repository; `match` is null when either side is missing
|
|
143
|
+
* or the host reports a name outside `host/`.
|
|
140
144
|
*/
|
|
141
145
|
export function compareAudit(files: readonly AuditFile[], checkout: string): { name: string; state: string; match: boolean | null; mode?: number; uid?: number; gid?: number }[] {
|
|
142
146
|
return files.map((f) => {
|
|
143
147
|
const source = join(checkout, "host", f.name);
|
|
144
148
|
let expected: string | null = null;
|
|
145
|
-
if (existsSync(source) && lstatSync(source).isFile()) expected = createHash("sha256").update(readFileSync(source)).digest("hex");
|
|
149
|
+
if (AUDIT_NAME.test(f.name) && existsSync(source) && lstatSync(source).isFile()) expected = createHash("sha256").update(readFileSync(source)).digest("hex");
|
|
146
150
|
return { name: f.name, state: f.state, match: f.state !== "present" || expected === null ? null : f.sha256 === expected, mode: f.mode, uid: f.uid, gid: f.gid };
|
|
147
151
|
});
|
|
148
152
|
}
|
|
@@ -119,7 +119,7 @@ export function syncFacts(row: Record<string, unknown>): SyncFacts {
|
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/** The checks
|
|
122
|
+
/** The deployment checks (sync commit and state, hook, each container's project, image, state, health) as one pure comparison. */
|
|
123
123
|
export function deploymentChecks(expect: DeploymentExpectation, sync: SyncFacts, containers: readonly (ContainerFacts | null)[], syncAfter: SyncFacts): { status: "ok" | "failed" | "unknown"; checks: DeploymentCheck[] } {
|
|
124
124
|
const checks: DeploymentCheck[] = [];
|
|
125
125
|
const commitOk = sync.commit !== null && (sync.commit.startsWith(expect.commit) || expect.commit.startsWith(sync.commit));
|
|
@@ -12,8 +12,8 @@ client_file="$config_dir/client.env"
|
|
|
12
12
|
password_file="$config_dir/master-password"
|
|
13
13
|
root="$home/.specialists"
|
|
14
14
|
collection_name="${SPECIALISTS_COLLECTION:-specialists}"
|
|
15
|
-
BW=/
|
|
16
|
-
JQ=/
|
|
15
|
+
BW=/usr/local/libexec/specialist-bw/current/bw
|
|
16
|
+
JQ=/usr/local/libexec/specialist-jq/current/jq
|
|
17
17
|
|
|
18
18
|
for f in "$client_file" "$password_file"; do
|
|
19
19
|
[ -f "$f" ] || { echo "missing $f" >&2; exit 2; }
|
|
@@ -7,7 +7,7 @@ import { buildBackupRemote, command as backupCommand } from "./backup/run.ts";
|
|
|
7
7
|
import { parseParams, parseRecordFilters, technitiumRequest, command as dnsCommand } from "./dns/run.ts";
|
|
8
8
|
import { buildVpsRemote, command as edgeCommand, isTransientSshFailure, PUBLIC_ROUTES, routeProbe } from "./edge/run.ts";
|
|
9
9
|
import { API_PREFIXES, buildDmzRemote, validateApiPath, command as identityCommand } from "./identity/run.ts";
|
|
10
|
-
import { buildInferenceRemote,
|
|
10
|
+
import { buildInferenceRemote, command as inferenceCommand } from "./inference/run.ts";
|
|
11
11
|
import { MAX_ADD_IDS, buildTrendarrCall, interpretRun, parseImdbIds, trendarrConfig, trendarrError, trendarrWarnings, command as mediaCommand } from "./media/run.ts";
|
|
12
12
|
import { ServiceError, UsageError } from "./lib/errors.ts";
|
|
13
13
|
import { safeRepoPath, safeWritePath } from "./lib/repo.ts";
|
|
@@ -189,7 +189,7 @@ describe("edge wrapper", () => {
|
|
|
189
189
|
const curl = async (args: string[]) => {
|
|
190
190
|
seen.push(args);
|
|
191
191
|
const url = args.at(-1) ?? "";
|
|
192
|
-
return { stdout: url.includes("/auth/v1/settings") ? "401 0.12" : url.includes("studio") ? "302 0.10" : "200 0.05", code: 0 };
|
|
192
|
+
return { stdout: url.includes("/auth/v1/settings") ? "401 0.12" : url.includes("oauth-authorization-server") ? "404 0.08" : url.includes("studio") || url.includes("mealie") ? "302 0.10" : "200 0.05", code: 0 };
|
|
193
193
|
};
|
|
194
194
|
const all = await routeProbe([], curl);
|
|
195
195
|
expect(all).toHaveLength(Object.keys(PUBLIC_ROUTES).length);
|
|
@@ -429,31 +429,21 @@ describe("proxmox wrapper", () => {
|
|
|
429
429
|
});
|
|
430
430
|
|
|
431
431
|
describe("inference wrapper", () => {
|
|
432
|
-
it("maps commands to
|
|
433
|
-
expect(buildInferenceRemote(["nexus-status"])).
|
|
432
|
+
it("maps commands to fixed remote strings and enforces confirmation", () => {
|
|
433
|
+
expect(buildInferenceRemote(["nexus-status"]).remote).toBe("nexus-status");
|
|
434
434
|
expect(buildInferenceRemote(["nexus-log", "embed", "100"]).remote).toBe("nexus-log embed 100");
|
|
435
435
|
expect(buildInferenceRemote(["nexus-stop", "rerank", "--confirm"]).remote).toBe("nexus-stop rerank");
|
|
436
|
-
expect(buildInferenceRemote(["hermes-upgrade", "--confirm"])).toMatchObject({ remote: "hermes-upgrade", spec: { target: "pulsar" } });
|
|
437
|
-
expect(buildInferenceRemote(["hermes-journal", "1 hour ago"])).toMatchObject({ spec: { target: "hermes" } });
|
|
438
436
|
expect(() => buildInferenceRemote(["nexus-stop", "rerank"])).toThrow(/--confirm/);
|
|
439
|
-
expect(() => buildInferenceRemote(["hermes-upgrade"])).toThrow(/--confirm/);
|
|
440
437
|
expect(() => buildInferenceRemote(["nexus-restart", "server"])).toThrow(/service/);
|
|
441
438
|
expect(() => buildInferenceRemote(["nexus-log", "embed", "5000"])).toThrow(/lines/);
|
|
442
|
-
expect(() => buildInferenceRemote(["hermes-restart", "discord"])).toThrow(/service/);
|
|
443
439
|
expect(() => buildInferenceRemote(["europa-start"])).toThrow(/unknown command/);
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
it("compacts github releases", () => {
|
|
447
|
-
const releases = compactReleases([{ tag_name: "v2026.9.1", name: "Sept", published_at: "2026-09-10T00:00:00Z", html_url: "https://x/y", prerelease: false, body: "x".repeat(7000) }, {}]);
|
|
448
|
-
expect(releases[0]).toMatchObject({ tag: "v2026.9.1", name: "Sept", prerelease: false, url: "https://x/y" });
|
|
449
|
-
expect(releases[0].notes.length).toBe(6000);
|
|
450
|
-
expect(releases[1]).toEqual({ tag: "?", name: "", published: "", prerelease: false, url: "", notes: "" });
|
|
440
|
+
expect(() => buildInferenceRemote(["hermes-status"])).toThrow(/unknown command/);
|
|
451
441
|
});
|
|
452
442
|
|
|
453
443
|
it("prints help without touching config", async () => {
|
|
454
444
|
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
455
445
|
expect(await inferenceCommand(["help"])).toBe(0);
|
|
456
|
-
expect(String(write.mock.calls[0][0])).toContain("
|
|
446
|
+
expect(String(write.mock.calls[0][0])).toContain("europa-health");
|
|
457
447
|
write.mockRestore();
|
|
458
448
|
});
|
|
459
449
|
});
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
# Forced command for the spc-inference key on LXC 101 (user hermes, no sudo).
|
|
3
|
-
# authorized_keys line in /home/hermes/.ssh/authorized_keys:
|
|
4
|
-
# command="/usr/local/sbin/spc-inference-hermes-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-inference-hermes@cortex
|
|
5
|
-
set -eu
|
|
6
|
-
export PATH="$HOME/.local/bin:$HOME/.hermes/hermes-agent/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
|
7
|
-
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
|
|
8
|
-
cmd="${SSH_ORIGINAL_COMMAND:-}"
|
|
9
|
-
name="${cmd%% *}"
|
|
10
|
-
rest=""
|
|
11
|
-
[ "$cmd" != "$name" ] && rest="${cmd#* }"
|
|
12
|
-
since_ok() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9 :+-]{1,40}$'; }
|
|
13
|
-
units="hermes-gateway.service hermes-gateway-api.service"
|
|
14
|
-
|
|
15
|
-
case "$name" in
|
|
16
|
-
hermes-status) systemctl --user status $units --no-pager --lines=5 || true ;;
|
|
17
|
-
hermes-journal) since_ok "$rest" || { echo "bad since" >&2; exit 2; }; journalctl --user -u hermes-gateway.service -u hermes-gateway-api.service --since "$rest" --no-pager -n 200 ;;
|
|
18
|
-
hermes-errors) hermes logs errors ;;
|
|
19
|
-
hermes-restart)
|
|
20
|
-
case "$rest" in gateway) u=hermes-gateway.service ;; api) u=hermes-gateway-api.service ;; *) echo "bad service" >&2; exit 2 ;; esac
|
|
21
|
-
systemctl --user restart "$u"; sleep 3; systemctl --user status "$u" --no-pager --lines=5 || true ;;
|
|
22
|
-
hermes-api-health)
|
|
23
|
-
printf 'health=%s\n' "$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://localhost:8650/health || echo 000)"
|
|
24
|
-
printf 'models=%s (401 expected)\n' "$(curl -s -o /dev/null -w '%{http_code}' -m 5 http://localhost:8650/v1/models || echo 000)" ;;
|
|
25
|
-
hermes-version) hermes --version | head -1 ;;
|
|
26
|
-
*) echo "refused: $cmd" >&2; exit 2 ;;
|
|
27
|
-
esac
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
# Forced command for the spc-inference key on Pulsar (root): Hermes guest operations.
|
|
3
|
-
# authorized_keys line:
|
|
4
|
-
# command="/usr/local/sbin/spc-inference-pulsar-entry",no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... spc-inference-pulsar@cortex
|
|
5
|
-
set -eu
|
|
6
|
-
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
7
|
-
cmd="${SSH_ORIGINAL_COMMAND:-}"
|
|
8
|
-
|
|
9
|
-
case "$cmd" in
|
|
10
|
-
hermes-guest-status) pct status 101 ;;
|
|
11
|
-
hermes-snapshots) pct listsnapshot 101 ;;
|
|
12
|
-
hermes-upgrade) /usr/local/bin/hermes-upgrade ;;
|
|
13
|
-
*) echo "refused: $cmd" >&2; exit 2 ;;
|
|
14
|
-
esac
|