@openpalm/lib 0.11.0-rc.6 → 0.11.1
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/package.json +1 -1
- package/src/control-plane/compose-args.test.ts +4 -2
- package/src/control-plane/compose-args.ts +2 -3
- package/src/control-plane/config-persistence.ts +45 -4
- package/src/control-plane/core-assets.ts +24 -16
- package/src/control-plane/defaults.ts +16 -0
- package/src/control-plane/env.ts +15 -0
- package/src/control-plane/hardware-detect.ts +114 -0
- package/src/control-plane/home.ts +3 -3
- package/src/control-plane/install-edge-cases.test.ts +2 -31
- package/src/control-plane/lifecycle.ts +75 -21
- package/src/control-plane/migrations.test.ts +272 -0
- package/src/control-plane/migrations.ts +423 -0
- package/src/control-plane/paths.ts +1 -1
- package/src/control-plane/registry.ts +25 -8
- package/src/control-plane/setup-recommendation.test.ts +94 -0
- package/src/control-plane/setup-recommendation.ts +98 -0
- package/src/control-plane/setup.test.ts +2 -22
- package/src/control-plane/setup.ts +0 -4
- package/src/control-plane/skeleton-guardrail.test.ts +3 -2
- package/src/control-plane/spec-to-env.ts +2 -2
- package/src/control-plane/upgrade-path.test.ts +113 -0
- package/src/index.ts +24 -10
- package/src/control-plane/stack-spec.test.ts +0 -98
- package/src/control-plane/stack-spec.ts +0 -88
package/package.json
CHANGED
|
@@ -47,7 +47,9 @@ function seedAddon(name: string): void {
|
|
|
47
47
|
const stackDir = join(tempDir, "config", "stack");
|
|
48
48
|
mkdirSync(stackDir, { recursive: true });
|
|
49
49
|
writeFileSync(join(stackDir, "channels.compose.yml"), `services:\n ${name}:\n profiles: [\"addon.${name}\"]\n image: test\n`);
|
|
50
|
-
|
|
50
|
+
const envDir = join(tempDir, "knowledge", "env");
|
|
51
|
+
mkdirSync(envDir, { recursive: true });
|
|
52
|
+
writeFileSync(join(envDir, "stack.env"), `OP_ENABLED_ADDONS=${name}\n`);
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
beforeEach(() => {
|
|
@@ -70,7 +72,7 @@ describe("buildComposeOptions", () => {
|
|
|
70
72
|
expect(opts.files[0]).toContain("core.compose.yml");
|
|
71
73
|
});
|
|
72
74
|
|
|
73
|
-
it("includes fixed channel compose and profile from
|
|
75
|
+
it("includes fixed channel compose and profile from OP_ENABLED_ADDONS", () => {
|
|
74
76
|
seedCoreCompose();
|
|
75
77
|
seedAddon("chat");
|
|
76
78
|
|
|
@@ -10,9 +10,8 @@ import type { ControlPlaneState } from "./types.js";
|
|
|
10
10
|
import { buildComposeFileList } from "./lifecycle.js";
|
|
11
11
|
import { buildEnvFiles } from "./config-persistence.js";
|
|
12
12
|
import { resolveComposeProjectName } from "./docker.js";
|
|
13
|
-
import { parseEnvFile } from "./env.js";
|
|
13
|
+
import { parseEnvFile, parseEnabledAddons } from "./env.js";
|
|
14
14
|
import { canonicalAddonProfileSelection } from "./profile-ids.js";
|
|
15
|
-
import { listStackSpecAddons } from "./stack-spec.js";
|
|
16
15
|
|
|
17
16
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
18
17
|
|
|
@@ -45,7 +44,7 @@ export function resolveActiveProfiles(state: ControlPlaneState): string[] {
|
|
|
45
44
|
if (ollamaProfile) profiles.push(ollamaProfile);
|
|
46
45
|
}
|
|
47
46
|
|
|
48
|
-
for (const addon of
|
|
47
|
+
for (const addon of parseEnabledAddons(env.OP_ENABLED_ADDONS)) {
|
|
49
48
|
if (addon === 'voice') {
|
|
50
49
|
profiles.push(canonicalAddonProfileSelection('voice', env.OP_VOICE_PROFILE ?? '') || 'addon.voice.cpu');
|
|
51
50
|
} else if (addon === 'ollama') {
|
|
@@ -5,16 +5,18 @@
|
|
|
5
5
|
* Files are validated in-place before writing; rollback is handled by
|
|
6
6
|
* the rollback module (snapshot to OP_HOME/data/rollback/).
|
|
7
7
|
*/
|
|
8
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
|
|
8
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, chownSync } from "node:fs";
|
|
9
9
|
import { dirname, resolve as resolvePath } from "node:path";
|
|
10
10
|
import { parse as yamlParse } from "yaml";
|
|
11
|
+
import { createLogger } from "../logger.js";
|
|
11
12
|
import { parseEnvContent, parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
|
|
12
13
|
import { assertNoSecretLikeStackEnvKeys, isSecretLikeStackEnvKey } from './secrets.js';
|
|
13
14
|
import { ensureSecret } from './secrets-files.js';
|
|
14
15
|
import type { ControlPlaneState, ArtifactMeta } from "./types.js";
|
|
15
16
|
import { listEnabledAddonIds } from "./registry.js";
|
|
16
|
-
import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
|
|
17
|
-
import { SPEC_DEFAULTS } from "./
|
|
17
|
+
import { resolveOperatorIds, hasUsableOperatorId, type OperatorIds } from "./operator-ids.js";
|
|
18
|
+
import { SPEC_DEFAULTS } from "./defaults.js";
|
|
19
|
+
import { CURRENT_LAYOUT_VERSION } from "./migrations.js";
|
|
18
20
|
|
|
19
21
|
import {
|
|
20
22
|
readCoreCompose,
|
|
@@ -25,6 +27,8 @@ import { sha256, randomHex } from "./crypto.js";
|
|
|
25
27
|
|
|
26
28
|
const DEFAULT_IMAGE_TAG = "latest";
|
|
27
29
|
|
|
30
|
+
const logger = createLogger("config-persistence");
|
|
31
|
+
|
|
28
32
|
// ── Env File Management ──────────────────────────────────────────────
|
|
29
33
|
|
|
30
34
|
/**
|
|
@@ -128,6 +132,12 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
128
132
|
`OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
|
|
129
133
|
`OP_IMAGE_TAG=${DEFAULT_IMAGE_TAG}`,
|
|
130
134
|
"",
|
|
135
|
+
"# ── Layout (on-disk schema version; managed by the migration harness) ──",
|
|
136
|
+
`OP_LAYOUT_VERSION=${CURRENT_LAYOUT_VERSION}`,
|
|
137
|
+
"",
|
|
138
|
+
"# ── Enabled addons (comma-separated; managed via the Add-ons UI / CLI) ──",
|
|
139
|
+
"OP_ENABLED_ADDONS=",
|
|
140
|
+
"",
|
|
131
141
|
"# ── Ports (38XX range) ──────────────────────────────────────────────",
|
|
132
142
|
"# Guardian is network-only (no host port) — channels reach it via",
|
|
133
143
|
"# http://guardian:8080 over the channel_lan Docker network.",
|
|
@@ -216,6 +226,13 @@ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
|
|
|
216
226
|
const composeFiles = discoverStackOverlays(state.stackDir, state.homeDir);
|
|
217
227
|
if (composeFiles.length === 0) return;
|
|
218
228
|
|
|
229
|
+
// Resolve the operator UID/GID compose runs containers as (`user:`), so we
|
|
230
|
+
// can chown the dirs we pre-create to match. Without this, dirs created by
|
|
231
|
+
// a root-running install (or a host UID that differs from the forced
|
|
232
|
+
// container UID) are unwritable inside the non-root container — on OrbStack
|
|
233
|
+
// real UIDs are preserved, so e.g. ollama's mkdir is denied (issue #452).
|
|
234
|
+
const operatorIds = resolveOperatorIds(state.homeDir);
|
|
235
|
+
|
|
219
236
|
const envVars: Record<string, string> = {
|
|
220
237
|
...(process.env as Record<string, string>),
|
|
221
238
|
...parseEnvFile(`${state.stashDir}/env/stack.env`),
|
|
@@ -257,16 +274,40 @@ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
|
|
|
257
274
|
const isFile = basename.includes('.');
|
|
258
275
|
|
|
259
276
|
if (isFile) {
|
|
260
|
-
|
|
277
|
+
const parent = dirname(resolvedHostPath);
|
|
278
|
+
mkdirSync(parent, { recursive: true });
|
|
261
279
|
writeFileSync(resolvedHostPath, '');
|
|
280
|
+
chownVolumeTarget(parent, operatorIds);
|
|
281
|
+
chownVolumeTarget(resolvedHostPath, operatorIds);
|
|
262
282
|
} else {
|
|
263
283
|
mkdirSync(resolvedHostPath, { recursive: true });
|
|
284
|
+
chownVolumeTarget(resolvedHostPath, operatorIds);
|
|
264
285
|
}
|
|
265
286
|
}
|
|
266
287
|
}
|
|
267
288
|
}
|
|
268
289
|
}
|
|
269
290
|
|
|
291
|
+
/**
|
|
292
|
+
* chown a just-created bind-mount target to the operator UID/GID so the
|
|
293
|
+
* non-root container (`user: ${OP_UID}:${OP_GID}`) can write to it.
|
|
294
|
+
*
|
|
295
|
+
* No-op on Windows (chown is meaningless there) or when no operator can be
|
|
296
|
+
* resolved. A failure (e.g. not the owner) is logged and swallowed — the
|
|
297
|
+
* mkdir already succeeded and Docker Desktop's gRPC-FUSE masks ownership
|
|
298
|
+
* anyway, so a chown failure must not abort the install.
|
|
299
|
+
*/
|
|
300
|
+
function chownVolumeTarget(path: string, operatorIds: OperatorIds | null): void {
|
|
301
|
+
if (process.platform === "win32" || !operatorIds) return;
|
|
302
|
+
try {
|
|
303
|
+
chownSync(path, operatorIds.uid, operatorIds.gid);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
logger.warn(
|
|
306
|
+
`Could not chown volume target ${path} to ${operatorIds.uid}:${operatorIds.gid}: ${error instanceof Error ? error.message : String(error)}`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
270
311
|
// ── Persistence (direct-write to live paths) ────────────────────────
|
|
271
312
|
|
|
272
313
|
export function writeRuntimeFiles(
|
|
@@ -56,18 +56,25 @@ export function ensureOpenCodeSystemConfig(): void {
|
|
|
56
56
|
|
|
57
57
|
const REPO = "itlackey/openpalm";
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
// The version to download assets for is ALWAYS passed in by the caller (the
|
|
60
|
+
// upgrade flow resolves the canonical platform tag — the newest published
|
|
61
|
+
// `openpalm/assistant` Docker tag, e.g. "v0.11.0-rc.6" — and threads it here).
|
|
62
|
+
// This module intentionally does NOT resolve the version itself: no env var, no
|
|
63
|
+
// `import.meta.url` package.json read (which breaks when the lib is bundled into
|
|
64
|
+
// the UI/electron), and never a silent "main" fallback (main's asset layout can
|
|
65
|
+
// differ from a released install). Bundler-agnostic by construction.
|
|
66
|
+
|
|
67
|
+
function normalizeAssetRef(version: string): string {
|
|
68
|
+
const v = version.trim();
|
|
69
|
+
if (!v) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"Cannot download OpenPalm stack assets: no version provided. " +
|
|
72
|
+
"The caller must pass the target release tag (e.g. \"v0.11.0-rc.6\")."
|
|
64
73
|
);
|
|
65
|
-
return `v${pkgJson.version}`;
|
|
66
|
-
} catch {
|
|
67
|
-
return "main";
|
|
68
74
|
}
|
|
75
|
+
// GitHub release/raw refs are `vX.Y.Z`; accept a bare semver and add the `v`.
|
|
76
|
+
return /^\d/.test(v) ? `v${v}` : v;
|
|
69
77
|
}
|
|
70
|
-
const VERSION = resolveAssetVersion();
|
|
71
78
|
|
|
72
79
|
// Persona files (openpalm.md, system.md), stash seeds, and user-editable config
|
|
73
80
|
// files are intentionally NOT in this list. They are seeded once (never
|
|
@@ -85,9 +92,9 @@ const SEEDED_ASSETS: { relPath: string; githubFilename: string }[] = [
|
|
|
85
92
|
{ relPath: "config/stack/custom.compose.yml", githubFilename: ".openpalm/config/stack/custom.compose.yml" },
|
|
86
93
|
];
|
|
87
94
|
|
|
88
|
-
async function downloadAsset(filename: string): Promise<string> {
|
|
89
|
-
const releaseUrl = `https://github.com/${REPO}/releases/download/${
|
|
90
|
-
const rawUrl = `https://raw.githubusercontent.com/${REPO}/${
|
|
95
|
+
async function downloadAsset(filename: string, version: string): Promise<string> {
|
|
96
|
+
const releaseUrl = `https://github.com/${REPO}/releases/download/${version}/${filename}`;
|
|
97
|
+
const rawUrl = `https://raw.githubusercontent.com/${REPO}/${version}/${filename}`;
|
|
91
98
|
|
|
92
99
|
for (const url of [releaseUrl, rawUrl]) {
|
|
93
100
|
try {
|
|
@@ -97,19 +104,20 @@ async function downloadAsset(filename: string): Promise<string> {
|
|
|
97
104
|
// try next URL
|
|
98
105
|
}
|
|
99
106
|
}
|
|
100
|
-
throw new Error(`Failed to download ${filename} from GitHub (tried release and raw URLs for version "${
|
|
107
|
+
throw new Error(`Failed to download ${filename} from GitHub (tried release and raw URLs for version "${version}")`);
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
export async function refreshCoreAssets(): Promise<{
|
|
110
|
+
export async function refreshCoreAssets(version: string): Promise<{
|
|
104
111
|
backupDir: string | null;
|
|
105
112
|
updated: string[];
|
|
106
113
|
}> {
|
|
114
|
+
const ref = normalizeAssetRef(version);
|
|
107
115
|
const homeDir = resolveOpenPalmHome();
|
|
108
116
|
const updated: string[] = [];
|
|
109
117
|
let backupDir: string | null = null;
|
|
110
118
|
|
|
111
119
|
for (const asset of MANAGED_ASSETS) {
|
|
112
|
-
const freshContent = await downloadAsset(asset.githubFilename);
|
|
120
|
+
const freshContent = await downloadAsset(asset.githubFilename, ref);
|
|
113
121
|
const targetPath = join(homeDir, asset.relPath);
|
|
114
122
|
|
|
115
123
|
if (existsSync(targetPath)) {
|
|
@@ -135,7 +143,7 @@ export async function refreshCoreAssets(): Promise<{
|
|
|
135
143
|
for (const asset of SEEDED_ASSETS) {
|
|
136
144
|
const targetPath = join(homeDir, asset.relPath);
|
|
137
145
|
if (existsSync(targetPath)) continue;
|
|
138
|
-
const freshContent = await downloadAsset(asset.githubFilename);
|
|
146
|
+
const freshContent = await downloadAsset(asset.githubFilename, ref);
|
|
139
147
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
140
148
|
writeFileSync(targetPath, freshContent);
|
|
141
149
|
updated.push(asset.relPath);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stack defaults (ports + image). Formerly in stack-spec.ts; kept after the
|
|
3
|
+
* stack.yml removal because these are the canonical fallback values used when a
|
|
4
|
+
* key is absent from stack.env.
|
|
5
|
+
*/
|
|
6
|
+
export const SPEC_DEFAULTS = {
|
|
7
|
+
ports: {
|
|
8
|
+
assistant: 3800,
|
|
9
|
+
hostUi: 3880,
|
|
10
|
+
assistantSsh: 2222,
|
|
11
|
+
},
|
|
12
|
+
image: {
|
|
13
|
+
namespace: "openpalm",
|
|
14
|
+
tag: "latest",
|
|
15
|
+
},
|
|
16
|
+
} as const;
|
package/src/control-plane/env.ts
CHANGED
|
@@ -82,6 +82,21 @@ export function upsertEnvValue(content: string, key: string, value: string): str
|
|
|
82
82
|
return `${content}${suffix}${line}\n`;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** Addon name shape (matches the former stack.yml validation). */
|
|
86
|
+
export const ADDON_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse the `OP_ENABLED_ADDONS` stack.env value (comma-separated) into a
|
|
90
|
+
* validated, de-duplicated, sorted list of addon ids. Replaces the former
|
|
91
|
+
* stack.yml `addons[]` array as the authoritative enabled-addon record.
|
|
92
|
+
*/
|
|
93
|
+
export function parseEnabledAddons(value: string | undefined): string[] {
|
|
94
|
+
if (!value) return [];
|
|
95
|
+
return [...new Set(
|
|
96
|
+
value.split(',').map((v) => v.trim()).filter((v) => ADDON_NAME_RE.test(v)),
|
|
97
|
+
)].sort();
|
|
98
|
+
}
|
|
99
|
+
|
|
85
100
|
export const RELEASE_TAG_REGEX = /^v?\d+\.\d+\.\d+(?:[-+](?:[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?$/;
|
|
86
101
|
|
|
87
102
|
/**
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Host GPU / VRAM detection for setup recommendations.
|
|
2
|
+
//
|
|
3
|
+
// Data-driven on purpose: each entry in GPU_PROBES is a vendor + a command to
|
|
4
|
+
// run + a pure parser. Adding a new accelerator (Intel Arc, Apple Metal, a new
|
|
5
|
+
// rocm/CUDA query, etc.) is a one-entry change here — nothing downstream needs to
|
|
6
|
+
// know. detectGpu() runs every probe, ignores the ones whose tool is absent, and
|
|
7
|
+
// returns the single best (highest-VRAM) result, or null when no GPU is found.
|
|
8
|
+
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { createLogger } from "../logger.js";
|
|
11
|
+
|
|
12
|
+
const logger = createLogger("hardware-detect");
|
|
13
|
+
|
|
14
|
+
export type GpuVendor = "nvidia" | "amd" | "unknown";
|
|
15
|
+
|
|
16
|
+
export type GpuInfo = {
|
|
17
|
+
vendor: GpuVendor;
|
|
18
|
+
/** Human-readable adapter name, e.g. "NVIDIA GeForce RTX 4090". */
|
|
19
|
+
name: string;
|
|
20
|
+
/** Total VRAM in MiB. 0 when the tool reported the GPU but not its memory. */
|
|
21
|
+
vramMb: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type GpuProbe = {
|
|
25
|
+
vendor: GpuVendor;
|
|
26
|
+
command: string;
|
|
27
|
+
args: string[];
|
|
28
|
+
/** Pure parser: tool stdout -> detected GPUs. Must not throw. */
|
|
29
|
+
parse: (stdout: string) => GpuInfo[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Parse `nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits`. */
|
|
33
|
+
export function parseNvidiaSmi(stdout: string): GpuInfo[] {
|
|
34
|
+
return stdout
|
|
35
|
+
.split("\n")
|
|
36
|
+
.map((line) => line.trim())
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.map((line): GpuInfo | null => {
|
|
39
|
+
// "NVIDIA GeForce RTX 4090, 24564"
|
|
40
|
+
const idx = line.lastIndexOf(",");
|
|
41
|
+
if (idx === -1) return null;
|
|
42
|
+
const name = line.slice(0, idx).trim();
|
|
43
|
+
const vramMb = Number.parseInt(line.slice(idx + 1).trim(), 10);
|
|
44
|
+
if (!name || !Number.isFinite(vramMb)) return null;
|
|
45
|
+
return { vendor: "nvidia", name, vramMb };
|
|
46
|
+
})
|
|
47
|
+
.filter((g): g is GpuInfo => g !== null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Parse `rocm-smi --showmeminfo vram --showproductname --json`. */
|
|
51
|
+
export function parseRocmSmi(stdout: string): GpuInfo[] {
|
|
52
|
+
let doc: Record<string, Record<string, string>>;
|
|
53
|
+
try {
|
|
54
|
+
doc = JSON.parse(stdout);
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
const out: GpuInfo[] = [];
|
|
59
|
+
for (const card of Object.values(doc)) {
|
|
60
|
+
if (!card || typeof card !== "object") continue;
|
|
61
|
+
// rocm-smi key names drift across versions — match loosely.
|
|
62
|
+
const vramKey = Object.keys(card).find((k) => /vram total memory/i.test(k));
|
|
63
|
+
const nameKey = Object.keys(card).find((k) => /product name|card series|gfx/i.test(k));
|
|
64
|
+
const bytes = vramKey ? Number.parseInt(String(card[vramKey]).trim(), 10) : NaN;
|
|
65
|
+
const vramMb = Number.isFinite(bytes) ? Math.round(bytes / (1024 * 1024)) : 0;
|
|
66
|
+
out.push({ vendor: "amd", name: nameKey ? String(card[nameKey]).trim() : "AMD GPU", vramMb });
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const GPU_PROBES: GpuProbe[] = [
|
|
72
|
+
{
|
|
73
|
+
vendor: "nvidia",
|
|
74
|
+
command: "nvidia-smi",
|
|
75
|
+
args: ["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"],
|
|
76
|
+
parse: parseNvidiaSmi,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
vendor: "amd",
|
|
80
|
+
command: "rocm-smi",
|
|
81
|
+
args: ["--showmeminfo", "vram", "--showproductname", "--json"],
|
|
82
|
+
parse: parseRocmSmi,
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
function run(command: string, args: string[], timeoutMs = 3_000): Promise<string | null> {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
execFile(command, args, { timeout: timeoutMs }, (err, stdout) => {
|
|
89
|
+
// ENOENT (tool not installed) and any non-zero exit -> not available.
|
|
90
|
+
resolve(err ? null : stdout?.toString() ?? "");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Detect the host's best GPU. Returns the highest-VRAM adapter across all probes,
|
|
97
|
+
* or null when none is found. Never throws.
|
|
98
|
+
*/
|
|
99
|
+
export async function detectGpu(): Promise<GpuInfo | null> {
|
|
100
|
+
const found: GpuInfo[] = [];
|
|
101
|
+
await Promise.all(
|
|
102
|
+
GPU_PROBES.map(async (probe) => {
|
|
103
|
+
const stdout = await run(probe.command, probe.args);
|
|
104
|
+
if (stdout === null) return;
|
|
105
|
+
try {
|
|
106
|
+
found.push(...probe.parse(stdout));
|
|
107
|
+
} catch (error) {
|
|
108
|
+
logger.debug("gpu probe parse failed", { vendor: probe.vendor, error: String(error) });
|
|
109
|
+
}
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
if (found.length === 0) return null;
|
|
113
|
+
return found.reduce((best, g) => (g.vramMb > best.vramMb ? g : best));
|
|
114
|
+
}
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Single ~/.openpalm/ root:
|
|
5
5
|
* config/ — user-editable config + system config files (akm/)
|
|
6
|
-
* config/stack/ — compose
|
|
6
|
+
* config/stack/ — fixed compose files (no stack.env/secrets/stack.yml)
|
|
7
7
|
* data/ — persistent service data, logs, backups, rollback
|
|
8
|
-
* knowledge/ — akm knowledge (env, secrets, tasks)
|
|
8
|
+
* knowledge/ — akm knowledge (env, secrets, tasks); env/stack.env is the
|
|
9
|
+
* authoritative stack composition + versions record
|
|
9
10
|
* workspace/ — shared assistant work area
|
|
10
|
-
* config/stack/ — compose runtime assets + stack config (stack.env, stack.yml)
|
|
11
11
|
*/
|
|
12
12
|
import { mkdirSync } from "node:fs";
|
|
13
13
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
} from "./setup.js";
|
|
27
27
|
import type { SetupSpec, SetupConnection } from "./setup.js";
|
|
28
28
|
import type { ControlPlaneState } from "./types.js";
|
|
29
|
-
import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
|
|
30
29
|
import { readSecret } from './secrets-files.js';
|
|
31
30
|
|
|
32
31
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
@@ -313,11 +312,6 @@ describe("Existing Install", () => {
|
|
|
313
312
|
})
|
|
314
313
|
);
|
|
315
314
|
|
|
316
|
-
// stack.yml is just a version marker now
|
|
317
|
-
const specAfterSecond = readStackSpec(stackDir);
|
|
318
|
-
expect(specAfterSecond).not.toBeNull();
|
|
319
|
-
expect(specAfterSecond!.version).toBe(2);
|
|
320
|
-
|
|
321
315
|
const auth = JSON.parse(readFileSync(join(homeDir, "knowledge", "secrets", "auth.json"), "utf-8"));
|
|
322
316
|
expect(auth.groq.key).toBe("gsk-test-key-456");
|
|
323
317
|
});
|
|
@@ -426,12 +420,6 @@ describe("Broken/Corrupt State", () => {
|
|
|
426
420
|
}
|
|
427
421
|
});
|
|
428
422
|
|
|
429
|
-
// Scenario 13: Missing stack.yml returns null
|
|
430
|
-
it("readStackSpec returns null when stack.yml missing", () => {
|
|
431
|
-
const spec = readStackSpec(stackDir);
|
|
432
|
-
expect(spec).toBeNull();
|
|
433
|
-
});
|
|
434
|
-
|
|
435
423
|
// Scenario 14: knowledge/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
|
|
436
424
|
it("performSetup creates missing subdirectories", async () => {
|
|
437
425
|
// Seed the minimal env files first
|
|
@@ -453,16 +441,6 @@ describe("Broken/Corrupt State", () => {
|
|
|
453
441
|
expect(existsSync(join(homeDir, "knowledge", "tasks"))).toBe(true);
|
|
454
442
|
});
|
|
455
443
|
|
|
456
|
-
// Scenario 15: openpalm.yaml with old version
|
|
457
|
-
it("readStackSpec returns null for version 1 spec", () => {
|
|
458
|
-
writeFileSync(
|
|
459
|
-
join(stackDir, STACK_SPEC_FILENAME),
|
|
460
|
-
"version: 1\nconnections: []\n"
|
|
461
|
-
);
|
|
462
|
-
|
|
463
|
-
const spec = readStackSpec(stackDir);
|
|
464
|
-
expect(spec).toBeNull();
|
|
465
|
-
});
|
|
466
444
|
});
|
|
467
445
|
|
|
468
446
|
// =====================================================================
|
|
@@ -562,10 +540,6 @@ describe("Setup Input Variations", () => {
|
|
|
562
540
|
|
|
563
541
|
const result = await performSetup(input);
|
|
564
542
|
expect(result.ok).toBe(true);
|
|
565
|
-
|
|
566
|
-
const spec = readStackSpec(stackDir);
|
|
567
|
-
expect(spec).not.toBeNull();
|
|
568
|
-
expect(spec!.version).toBe(2);
|
|
569
543
|
});
|
|
570
544
|
|
|
571
545
|
// Scenario 21: Multiple providers map to correct env vars
|
|
@@ -626,12 +600,9 @@ describe("performSetup end-to-end artifacts", () => {
|
|
|
626
600
|
rmSync(homeDir, { recursive: true, force: true });
|
|
627
601
|
});
|
|
628
602
|
|
|
629
|
-
it("
|
|
603
|
+
it("does not create a stack.yml (addon state lives in stack.env)", async () => {
|
|
630
604
|
await performSetup(makeValidSpec());
|
|
631
|
-
|
|
632
|
-
const spec = readStackSpec(stackDir);
|
|
633
|
-
expect(spec).not.toBeNull();
|
|
634
|
-
expect(spec!.version).toBe(2);
|
|
605
|
+
expect(existsSync(join(stackDir, "stack.yml"))).toBe(false);
|
|
635
606
|
});
|
|
636
607
|
|
|
637
608
|
it("writes akm config with embedding dims from setup spec", async () => {
|
|
@@ -172,10 +172,7 @@ function resolveNewestDockerTag(payload: unknown): string | null {
|
|
|
172
172
|
return fallback;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
|
|
176
|
-
namespace: string;
|
|
177
|
-
tag: string;
|
|
178
|
-
}> {
|
|
175
|
+
function resolveImageNamespace(state: ControlPlaneState): string {
|
|
179
176
|
const systemEnvPath = `${state.stashDir}/env/stack.env`;
|
|
180
177
|
const parsed = parseEnvFile(systemEnvPath);
|
|
181
178
|
const namespace = (parsed.OP_IMAGE_NAMESPACE ?? process.env.OP_IMAGE_NAMESPACE ?? "openpalm").trim().toLowerCase();
|
|
@@ -183,11 +180,21 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
|
|
|
183
180
|
if (!IMAGE_NAMESPACE_RE.test(namespace)) {
|
|
184
181
|
throw new Error(`Invalid image namespace in system.env: ${namespace}`);
|
|
185
182
|
}
|
|
183
|
+
return namespace;
|
|
184
|
+
}
|
|
186
185
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Resolve the newest published platform tag from the Docker registry.
|
|
188
|
+
*
|
|
189
|
+
* `assistant` is the version-of-record image: all platform images
|
|
190
|
+
* (assistant, guardian, channel, voice) are published in lockstep under the
|
|
191
|
+
* same OP_IMAGE_TAG, so its newest tag is the canonical platform version.
|
|
192
|
+
*
|
|
193
|
+
* Used both to auto-detect during "Update now" and to resolve a requested
|
|
194
|
+
* `latest` selection into a concrete release tag before fetching stack assets
|
|
195
|
+
* (GitHub has no asset tree at a `latest` ref).
|
|
196
|
+
*/
|
|
197
|
+
export async function resolveLatestPlatformTag(namespace: string): Promise<string> {
|
|
191
198
|
let response: Response;
|
|
192
199
|
try {
|
|
193
200
|
response = await fetch(
|
|
@@ -207,6 +214,16 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
|
|
|
207
214
|
if (!latestTag) {
|
|
208
215
|
throw new Error("No usable Docker image tag found");
|
|
209
216
|
}
|
|
217
|
+
return latestTag;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function updateStackEnvToLatestImageTag(state: ControlPlaneState): Promise<{
|
|
221
|
+
namespace: string;
|
|
222
|
+
tag: string;
|
|
223
|
+
}> {
|
|
224
|
+
const systemEnvPath = `${state.stashDir}/env/stack.env`;
|
|
225
|
+
const namespace = resolveImageNamespace(state);
|
|
226
|
+
const latestTag = await resolveLatestPlatformTag(namespace);
|
|
210
227
|
|
|
211
228
|
const currentContent = existsSync(systemEnvPath) ? readFileSync(systemEnvPath, "utf-8") : "";
|
|
212
229
|
const updatedContent = mergeEnvContent(currentContent, { OP_IMAGE_TAG: latestTag }, { uncomment: true });
|
|
@@ -216,7 +233,9 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
|
|
|
216
233
|
}
|
|
217
234
|
|
|
218
235
|
export async function applyUpgrade(
|
|
219
|
-
state: ControlPlaneState
|
|
236
|
+
state: ControlPlaneState,
|
|
237
|
+
/** Release tag whose stack assets to fetch (e.g. "v0.11.0-rc.6"). Caller-supplied. */
|
|
238
|
+
version: string
|
|
220
239
|
): Promise<{
|
|
221
240
|
backupDir: string | null;
|
|
222
241
|
updated: string[];
|
|
@@ -225,7 +244,7 @@ export async function applyUpgrade(
|
|
|
225
244
|
const lock = acquireInstallLock(state.dataDir);
|
|
226
245
|
if (!lock) throw new Error("Another install is already in progress");
|
|
227
246
|
try {
|
|
228
|
-
const { backupDir, updated } = await refreshCoreAssets();
|
|
247
|
+
const { backupDir, updated } = await refreshCoreAssets(version);
|
|
229
248
|
const restarted = await reconcileCore(state, {});
|
|
230
249
|
return { backupDir, updated, restarted };
|
|
231
250
|
} finally {
|
|
@@ -269,7 +288,9 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
|
|
|
269
288
|
const tagResult = await updateStackEnvToLatestImageTag(state);
|
|
270
289
|
imageTag = tagResult.tag;
|
|
271
290
|
namespace = tagResult.namespace;
|
|
272
|
-
|
|
291
|
+
// The resolved platform tag IS the version whose stack assets we fetch —
|
|
292
|
+
// keeps compose files and images in lockstep.
|
|
293
|
+
upgradeResult = await applyUpgrade(state, imageTag);
|
|
273
294
|
} catch (e) {
|
|
274
295
|
// Restore stack.env on failure
|
|
275
296
|
if (originalStackEnv !== null) {
|
|
@@ -284,9 +305,14 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
|
|
|
284
305
|
throw new Error(`Failed to pull images: ${pullResult.stderr}`);
|
|
285
306
|
}
|
|
286
307
|
|
|
287
|
-
// 4. Recreate containers (includes profiles for voice addon)
|
|
308
|
+
// 4. Recreate containers (includes profiles for voice addon).
|
|
309
|
+
// forceRecreate is REQUIRED: channel adapters are installed at container
|
|
310
|
+
// startup from npm dist-tags (CHANNEL_PACKAGE, e.g. @openpalm/channel-discord@latest),
|
|
311
|
+
// so an unchanged compose config would leave those containers running on the
|
|
312
|
+
// old adapter. --force-recreate guarantees guardian + channel containers
|
|
313
|
+
// restart and re-resolve their dist-tag adapters (issue #450).
|
|
288
314
|
const services = await buildManagedServices(state);
|
|
289
|
-
const upResult = await composeUp({ ...composeOpts, services, removeOrphans: true });
|
|
315
|
+
const upResult = await composeUp({ ...composeOpts, services, forceRecreate: true, removeOrphans: true });
|
|
290
316
|
if (!upResult.ok) {
|
|
291
317
|
throw new Error(`Images pulled but failed to recreate containers: ${upResult.stderr}`);
|
|
292
318
|
}
|
|
@@ -305,13 +331,34 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
|
|
|
305
331
|
* Used by the admin "set version" action — skips the auto-detect step in performUpgrade.
|
|
306
332
|
*/
|
|
307
333
|
export async function applyTagChange(state: ControlPlaneState, tag: string): Promise<UpgradeResult> {
|
|
334
|
+
const namespace = resolveImageNamespace(state);
|
|
335
|
+
|
|
336
|
+
// "latest" (or an empty selection) is not a real GitHub ref — there are no
|
|
337
|
+
// `.openpalm/...` stack assets at a `latest` tag, so refreshCoreAssets would
|
|
338
|
+
// fail with a raw download error. Resolve it to the concrete newest published
|
|
339
|
+
// platform tag BEFORE writing the env or fetching assets, so images and
|
|
340
|
+
// stack assets stay in lockstep on a real release tag.
|
|
341
|
+
const requested = tag.trim();
|
|
342
|
+
let resolvedTag = requested;
|
|
343
|
+
if (requested === "" || requested.toLowerCase() === "latest") {
|
|
344
|
+
try {
|
|
345
|
+
resolvedTag = await resolveLatestPlatformTag(namespace);
|
|
346
|
+
} catch (e) {
|
|
347
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
348
|
+
throw new Error(
|
|
349
|
+
`Cannot resolve "latest" to a concrete release: ${msg}. ` +
|
|
350
|
+
"Check your network connection or select a specific version."
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
308
355
|
const stackEnvPath = `${state.stashDir}/env/stack.env`;
|
|
309
356
|
const currentContent = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
|
|
310
|
-
writeFileSync(stackEnvPath, mergeEnvContent(currentContent, { OP_IMAGE_TAG:
|
|
311
|
-
const upgradeResult = await applyUpgrade(state);
|
|
357
|
+
writeFileSync(stackEnvPath, mergeEnvContent(currentContent, { OP_IMAGE_TAG: resolvedTag }, { uncomment: true }));
|
|
358
|
+
const upgradeResult = await applyUpgrade(state, resolvedTag);
|
|
312
359
|
return {
|
|
313
|
-
imageTag:
|
|
314
|
-
namespace
|
|
360
|
+
imageTag: resolvedTag,
|
|
361
|
+
namespace,
|
|
315
362
|
backupDir: upgradeResult.backupDir,
|
|
316
363
|
assetsUpdated: upgradeResult.updated,
|
|
317
364
|
restarted: upgradeResult.restarted,
|
|
@@ -325,20 +372,27 @@ export function buildComposeFileList(state: ControlPlaneState): string[] {
|
|
|
325
372
|
export async function buildManagedServices(state: ControlPlaneState): Promise<string[]> {
|
|
326
373
|
const composeOpts = buildComposeOptions(state);
|
|
327
374
|
|
|
375
|
+
// Always force-recreate the core services (assistant + guardian) on upgrade,
|
|
376
|
+
// regardless of how the service set is discovered. getAddonServiceNames
|
|
377
|
+
// deliberately EXCLUDES guardian, so a fallback that relied on it alone would
|
|
378
|
+
// drop guardian from the recreated set when channel profiles are active —
|
|
379
|
+
// leaving guardian on stale state (issue #450).
|
|
380
|
+
const services = new Set<string>(CORE_SERVICES);
|
|
381
|
+
|
|
328
382
|
// Prefer compose-derived service list when Docker is available
|
|
329
383
|
if (composeOpts.files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
|
|
330
384
|
const result = await composeConfigServices(composeOpts);
|
|
331
385
|
if (result.ok && result.services.length > 0) {
|
|
332
|
-
|
|
386
|
+
for (const s of result.services) services.add(s);
|
|
387
|
+
return [...services];
|
|
333
388
|
}
|
|
334
389
|
}
|
|
335
390
|
|
|
336
391
|
// Fallback: static inference from CORE_SERVICES + active addon overlays
|
|
337
|
-
const services: string[] = [...CORE_SERVICES];
|
|
338
392
|
for (const addon of listEnabledAddonIds(state.homeDir)) {
|
|
339
|
-
|
|
393
|
+
for (const s of getAddonServiceNames(state.homeDir, addon)) services.add(s);
|
|
340
394
|
}
|
|
341
|
-
return services;
|
|
395
|
+
return [...services];
|
|
342
396
|
}
|
|
343
397
|
|
|
344
398
|
|