@openpalm/lib 0.11.0-beta.8 → 0.11.0-rc.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.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-sources.test.ts +206 -0
  4. package/src/control-plane/akm-sources.ts +234 -0
  5. package/src/control-plane/akm-user-env.test.ts +142 -0
  6. package/src/control-plane/akm-user-env.ts +167 -0
  7. package/src/control-plane/backup.ts +14 -5
  8. package/src/control-plane/channels.ts +48 -29
  9. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  10. package/src/control-plane/compose-args.test.ts +67 -30
  11. package/src/control-plane/compose-args.ts +63 -8
  12. package/src/control-plane/config-persistence.ts +95 -136
  13. package/src/control-plane/core-assets.ts +21 -44
  14. package/src/control-plane/docker.ts +15 -14
  15. package/src/control-plane/env.test.ts +10 -10
  16. package/src/control-plane/env.ts +1 -1
  17. package/src/control-plane/extends-support.test.ts +8 -8
  18. package/src/control-plane/fs-atomic.ts +15 -0
  19. package/src/control-plane/home.ts +34 -46
  20. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  21. package/src/control-plane/host-akm-sharing.ts +129 -0
  22. package/src/control-plane/host-opencode.test.ts +82 -10
  23. package/src/control-plane/host-opencode.ts +42 -13
  24. package/src/control-plane/install-edge-cases.test.ts +98 -105
  25. package/src/control-plane/install-lock.ts +7 -7
  26. package/src/control-plane/lifecycle.ts +37 -36
  27. package/src/control-plane/markdown-task.ts +30 -50
  28. package/src/control-plane/opencode-client.ts +1 -1
  29. package/src/control-plane/paths.ts +61 -46
  30. package/src/control-plane/profile-ids.ts +21 -0
  31. package/src/control-plane/provider-models.ts +3 -3
  32. package/src/control-plane/registry.test.ts +107 -90
  33. package/src/control-plane/registry.ts +288 -109
  34. package/src/control-plane/rollback.ts +8 -38
  35. package/src/control-plane/scheduler.ts +10 -7
  36. package/src/control-plane/secret-audit.test.ts +159 -0
  37. package/src/control-plane/secret-audit.ts +255 -0
  38. package/src/control-plane/secret-mappings.ts +2 -2
  39. package/src/control-plane/secrets-files.test.ts +99 -0
  40. package/src/control-plane/secrets-files.ts +113 -0
  41. package/src/control-plane/secrets.ts +113 -86
  42. package/src/control-plane/setup-config.schema.json +1 -1
  43. package/src/control-plane/setup-status.ts +6 -11
  44. package/src/control-plane/setup.test.ts +140 -44
  45. package/src/control-plane/setup.ts +85 -62
  46. package/src/control-plane/skeleton-guardrail.test.ts +64 -55
  47. package/src/control-plane/spec-to-env.test.ts +63 -26
  48. package/src/control-plane/spec-to-env.ts +49 -12
  49. package/src/control-plane/stack-spec.test.ts +15 -11
  50. package/src/control-plane/stack-spec.ts +31 -10
  51. package/src/control-plane/task-files.test.ts +45 -0
  52. package/src/control-plane/task-files.ts +51 -0
  53. package/src/control-plane/types.ts +2 -4
  54. package/src/control-plane/ui-assets.test.ts +130 -0
  55. package/src/control-plane/ui-assets.ts +132 -57
  56. package/src/control-plane/validate.ts +13 -15
  57. package/src/index.ts +86 -16
  58. package/src/control-plane/akm-vault.test.ts +0 -105
  59. package/src/control-plane/akm-vault.ts +0 -311
  60. package/src/control-plane/core-assets.test.ts +0 -104
  61. package/src/control-plane/migrate-0110.test.ts +0 -177
  62. package/src/control-plane/migrate-0110.ts +0 -99
  63. package/src/control-plane/registry-components.test.ts +0 -391
@@ -8,25 +8,23 @@ import {
8
8
  resolveConfigDir,
9
9
  resolveStashDir,
10
10
  resolveWorkspaceDir,
11
- resolveCacheDir,
12
- resolveStateDir,
11
+ resolveDataDir,
13
12
  resolveStackDir,
14
13
  } from "./home.js";
15
- import { ensureSecrets } from "./secrets.js";
14
+ import { ensureSecrets, readStackSecretEnv } from "./secrets.js";
16
15
  import {
17
16
  resolveRuntimeFiles,
18
17
  writeRuntimeFiles,
19
- buildEnvFiles,
20
18
  discoverStackOverlays,
21
19
  ensureComposeVolumeTargets,
22
20
  } from "./config-persistence.js";
23
- import { readStackSpec } from "./stack-spec.js";
24
21
  import { refreshCoreAssets } from "./core-assets.js";
25
22
  import { isSetupComplete } from "./setup-status.js";
26
23
  import { snapshotCurrentState } from "./rollback.js";
27
24
  import { checkDocker, composePreflight, composePull, composeUp, composeConfigServices, resolveComposeProjectName } from "./docker.js";
25
+ import { buildComposeOptions } from "./compose-args.js";
28
26
  import { acquireInstallLock, releaseInstallLock } from "./install-lock.js";
29
- import { listEnabledAddonIds } from "./registry.js";
27
+ import { getAddonServiceNames, listEnabledAddonIds } from "./registry.js";
30
28
 
31
29
  const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
32
30
  const SEMVER_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
@@ -37,8 +35,7 @@ export function createState(): ControlPlaneState {
37
35
  const configDir = resolveConfigDir();
38
36
  const stashDir = resolveStashDir();
39
37
  const workspaceDir = resolveWorkspaceDir();
40
- const cacheDir = resolveCacheDir();
41
- const stateDir = resolveStateDir();
38
+ const dataDir = resolveDataDir();
42
39
  const stackDir = resolveStackDir();
43
40
 
44
41
  const services: Record<string, "running" | "stopped"> = {};
@@ -51,8 +48,7 @@ export function createState(): ControlPlaneState {
51
48
  configDir,
52
49
  stashDir,
53
50
  workspaceDir,
54
- cacheDir,
55
- stateDir,
51
+ dataDir,
56
52
  stackDir,
57
53
  services,
58
54
  artifacts: { compose: "" },
@@ -60,6 +56,7 @@ export function createState(): ControlPlaneState {
60
56
  };
61
57
 
62
58
  ensureSecrets(bootstrapState);
59
+ Object.assign(process.env, readStackSecretEnv(stackDir));
63
60
 
64
61
  return bootstrapState;
65
62
  }
@@ -74,7 +71,7 @@ async function reconcileCore(
74
71
  }
75
72
 
76
73
  for (const addonName of listEnabledAddonIds(state.homeDir)) {
77
- mkdirSync(`${state.stateDir}/${addonName}`, { recursive: true });
74
+ mkdirSync(`${state.dataDir}/${addonName}`, { recursive: true });
78
75
  }
79
76
 
80
77
  const active: string[] = [];
@@ -89,8 +86,7 @@ async function reconcileCore(
89
86
  // Preflight: validate compose merge before mutation.
90
87
  // Mandatory when compose files exist and OP_SKIP_COMPOSE_PREFLIGHT is not set.
91
88
  // Fails if Docker is unavailable (Docker is required for any compose operation).
92
- const files = buildComposeFileList(state);
93
- const envFiles = buildEnvFiles(state);
89
+ const { files, envFiles, profiles } = buildComposeOptions(state);
94
90
  if (files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
95
91
  const dockerCheck = await checkDocker();
96
92
  if (!dockerCheck.ok) {
@@ -99,12 +95,13 @@ async function reconcileCore(
99
95
  "Docker must be running before install/update/apply operations."
100
96
  );
101
97
  }
102
- const preflight = await composePreflight({ files, envFiles });
98
+ const preflight = await composePreflight({ files, envFiles, profiles });
103
99
  if (!preflight.ok) {
104
- const projectName = resolveComposeProjectName();
100
+ const projectName = resolveComposeProjectName(Object.assign({}, ...envFiles.map((f) => parseEnvFile(f))));
105
101
  const fileArgs = files.flatMap((f) => ["-f", f]).join(" ");
106
102
  const envArgs = envFiles.filter(existsSync).flatMap((f) => ["--env-file", f]).join(" ");
107
- const resolvedCmd = `docker compose ${fileArgs} --project-name ${projectName} ${envArgs} config --quiet`;
103
+ const profileArgs = profiles.flatMap((p) => ["--profile", p]).join(" ");
104
+ const resolvedCmd = `docker compose ${fileArgs} --project-name ${projectName} ${envArgs} ${profileArgs} config --quiet`;
108
105
  throw new Error(
109
106
  `Compose preflight failed: ${preflight.stderr}\n` +
110
107
  `Resolved command: ${resolvedCmd}\n` +
@@ -125,7 +122,7 @@ async function reconcileCore(
125
122
  }
126
123
 
127
124
  export async function applyInstall(state: ControlPlaneState): Promise<void> {
128
- const lock = acquireInstallLock(state.stateDir);
125
+ const lock = acquireInstallLock(state.dataDir);
129
126
  if (!lock) throw new Error("Another install is already in progress");
130
127
  try {
131
128
  await reconcileCore(state, { activateServices: true });
@@ -139,7 +136,7 @@ export async function applyInstall(state: ControlPlaneState): Promise<void> {
139
136
  }
140
137
 
141
138
  export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted: string[] }> {
142
- const lock = acquireInstallLock(state.stateDir);
139
+ const lock = acquireInstallLock(state.dataDir);
143
140
  if (!lock) throw new Error("Another install is already in progress");
144
141
  try {
145
142
  return { restarted: await reconcileCore(state, {}) };
@@ -149,7 +146,7 @@ export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted
149
146
  }
150
147
 
151
148
  export async function applyUninstall(state: ControlPlaneState): Promise<{ stopped: string[] }> {
152
- const lock = acquireInstallLock(state.stateDir);
149
+ const lock = acquireInstallLock(state.dataDir);
153
150
  if (!lock) throw new Error("Another install is already in progress");
154
151
  try {
155
152
  return { stopped: await reconcileCore(state, { deactivateServices: true }) };
@@ -179,7 +176,7 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
179
176
  namespace: string;
180
177
  tag: string;
181
178
  }> {
182
- const systemEnvPath = `${state.stackDir}/stack.env`;
179
+ const systemEnvPath = `${state.stashDir}/env/stack.env`;
183
180
  const parsed = parseEnvFile(systemEnvPath);
184
181
  const namespace = (parsed.OP_IMAGE_NAMESPACE ?? process.env.OP_IMAGE_NAMESPACE ?? "openpalm").trim().toLowerCase();
185
182
 
@@ -187,10 +184,14 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
187
184
  throw new Error(`Invalid image namespace in system.env: ${namespace}`);
188
185
  }
189
186
 
187
+ // `assistant` is the version-of-record image: all platform images
188
+ // (assistant, guardian, channel, voice) are published in lockstep under the
189
+ // same OP_IMAGE_TAG, so its newest tag is the canonical platform version.
190
+
190
191
  let response: Response;
191
192
  try {
192
193
  response = await fetch(
193
- `https://registry.hub.docker.com/v2/repositories/${namespace}/admin/tags?page_size=25&ordering=last_updated`,
194
+ `https://registry.hub.docker.com/v2/repositories/${namespace}/assistant/tags?page_size=25&ordering=last_updated`,
194
195
  { headers: { Accept: "application/json" } }
195
196
  );
196
197
  } catch (e) {
@@ -221,7 +222,7 @@ export async function applyUpgrade(
221
222
  updated: string[];
222
223
  restarted: string[];
223
224
  }> {
224
- const lock = acquireInstallLock(state.stateDir);
225
+ const lock = acquireInstallLock(state.dataDir);
225
226
  if (!lock) throw new Error("Another install is already in progress");
226
227
  try {
227
228
  const { backupDir, updated } = await refreshCoreAssets();
@@ -247,15 +248,14 @@ export type UpgradeResult = {
247
248
  * Callers handle their own audit logging and admin self-recreation.
248
249
  */
249
250
  export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeResult> {
250
- const files = buildComposeFileList(state);
251
- const envFiles = buildEnvFiles(state);
251
+ const composeOpts = buildComposeOptions(state);
252
252
 
253
253
  // Compose preflight runs inside `applyUpgrade` -> `reconcileCore`, so we
254
254
  // skip the redundant top-level call. Any merge failure aborts before
255
255
  // mutation just the same.
256
256
 
257
257
  // 1. Snapshot stack.env for rollback on failure
258
- const stackEnvPath = `${state.stackDir}/stack.env`;
258
+ const stackEnvPath = `${state.stashDir}/env/stack.env`;
259
259
  let originalStackEnv: string | null = null;
260
260
  try {
261
261
  originalStackEnv = readFileSync(stackEnvPath, "utf-8");
@@ -278,15 +278,15 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
278
278
  throw e;
279
279
  }
280
280
 
281
- // 3. Pull images
282
- const pullResult = await composePull({ files, envFiles });
281
+ // 3. Pull all images (core + addons, including profile-gated voice)
282
+ const pullResult = await composePull(composeOpts);
283
283
  if (!pullResult.ok) {
284
284
  throw new Error(`Failed to pull images: ${pullResult.stderr}`);
285
285
  }
286
286
 
287
- // 4. Recreate containers
287
+ // 4. Recreate containers (includes profiles for voice addon)
288
288
  const services = await buildManagedServices(state);
289
- const upResult = await composeUp({ files, envFiles, services, removeOrphans: true });
289
+ const upResult = await composeUp({ ...composeOpts, services, removeOrphans: true });
290
290
  if (!upResult.ok) {
291
291
  throw new Error(`Images pulled but failed to recreate containers: ${upResult.stderr}`);
292
292
  }
@@ -305,7 +305,7 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
305
305
  * Used by the admin "set version" action — skips the auto-detect step in performUpgrade.
306
306
  */
307
307
  export async function applyTagChange(state: ControlPlaneState, tag: string): Promise<UpgradeResult> {
308
- const stackEnvPath = `${state.stackDir}/stack.env`;
308
+ const stackEnvPath = `${state.stashDir}/env/stack.env`;
309
309
  const currentContent = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
310
310
  writeFileSync(stackEnvPath, mergeEnvContent(currentContent, { OP_IMAGE_TAG: tag }, { uncomment: true }));
311
311
  const upgradeResult = await applyUpgrade(state);
@@ -319,16 +319,15 @@ export async function applyTagChange(state: ControlPlaneState, tag: string): Pro
319
319
  }
320
320
 
321
321
  export function buildComposeFileList(state: ControlPlaneState): string[] {
322
- return discoverStackOverlays(state.stackDir);
322
+ return discoverStackOverlays(state.stackDir, state.homeDir);
323
323
  }
324
324
 
325
325
  export async function buildManagedServices(state: ControlPlaneState): Promise<string[]> {
326
- const files = buildComposeFileList(state);
327
- const envFiles = buildEnvFiles(state);
326
+ const composeOpts = buildComposeOptions(state);
328
327
 
329
328
  // Prefer compose-derived service list when Docker is available
330
- if (files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
331
- const result = await composeConfigServices({ files, envFiles });
329
+ if (composeOpts.files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
330
+ const result = await composeConfigServices(composeOpts);
332
331
  if (result.ok && result.services.length > 0) {
333
332
  return result.services;
334
333
  }
@@ -336,7 +335,9 @@ export async function buildManagedServices(state: ControlPlaneState): Promise<st
336
335
 
337
336
  // Fallback: static inference from CORE_SERVICES + active addon overlays
338
337
  const services: string[] = [...CORE_SERVICES];
339
- services.push(...listEnabledAddonIds(state.homeDir));
338
+ for (const addon of listEnabledAddonIds(state.homeDir)) {
339
+ services.push(...getAddonServiceNames(state.homeDir, addon));
340
+ }
340
341
  return services;
341
342
  }
342
343
 
@@ -1,21 +1,18 @@
1
1
  /**
2
- * AKM markdown task parser.
2
+ * AKM task parser.
3
3
  *
4
- * Task files are markdown with YAML frontmatter. The frontmatter defines the
5
- * schedule and target; for inline-prompt tasks the markdown body is the prompt.
6
- *
7
- * Supported target types:
8
- * command — `command: [...]` YAML array (argv), run via Bun.spawn / akm tasks run
9
- * prompt — `prompt: inline` + markdown body as the prompt text
4
+ * Task files are YAML documents in knowledge/tasks/. Supported target types:
5
+ * command — `command: [...]` YAML array (argv)
6
+ * prompt — `prompt: <text>` inline prompt text
10
7
  * workflow — `workflow: workflow:<ref>` + optional `params` map
11
8
  */
12
9
  import { parse as parseYaml } from "yaml";
13
10
  import { existsSync, readdirSync, readFileSync } from "node:fs";
14
- import { join } from "node:path";
11
+ import { basename, join } from "node:path";
15
12
  import type { AutomationConfig } from "./scheduler.js";
16
13
  import { createLogger } from "../logger.js";
17
14
 
18
- const logger = createLogger("markdown-task");
15
+ const logger = createLogger("task-file");
19
16
 
20
17
  // ── Types ─────────────────────────────────────────────────────────────────
21
18
 
@@ -35,29 +32,11 @@ export type MarkdownTaskTarget =
35
32
  | { kind: "prompt"; profile?: string; body: string }
36
33
  | { kind: "workflow"; ref: string; params: Record<string, unknown> };
37
34
 
38
- // ── Frontmatter splitter ──────────────────────────────────────────────────
39
-
40
- interface ParsedFile {
41
- frontmatter: string;
42
- body: string;
43
- }
44
-
45
- function splitFrontmatter(content: string): ParsedFile | null {
46
- // Must start with ---
47
- if (!content.startsWith("---")) return null;
48
- const after = content.slice(3);
49
- const end = after.indexOf("\n---");
50
- if (end === -1) return null;
51
- return {
52
- frontmatter: after.slice(0, end).trim(),
53
- body: after.slice(end + 4).trim(),
54
- };
55
- }
56
-
57
35
  // ── Parser ────────────────────────────────────────────────────────────────
58
36
 
59
37
  export function parseMarkdownTask(filePath: string): MarkdownTask | null {
60
- const id = filePath.replace(/.*[\\/]/, "").replace(/\.md$/, "");
38
+ const fileName = basename(filePath);
39
+ const id = fileName.replace(/\.(?:ya?ml|md)$/, "");
61
40
  let raw: string;
62
41
  try {
63
42
  raw = readFileSync(filePath, "utf-8");
@@ -66,22 +45,17 @@ export function parseMarkdownTask(filePath: string): MarkdownTask | null {
66
45
  return null;
67
46
  }
68
47
 
69
- const parts = splitFrontmatter(raw);
70
- if (!parts) {
71
- logger.warn("task file missing frontmatter delimiters", { filePath });
72
- return null;
73
- }
74
-
48
+ const { frontmatter, body } = splitTaskSource(raw);
75
49
  let fm: Record<string, unknown>;
76
50
  try {
77
- fm = parseYaml(parts.frontmatter) as Record<string, unknown>;
51
+ const parsed = parseYaml(frontmatter);
52
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
53
+ logger.warn("task YAML is not an object", { filePath });
54
+ return null;
55
+ }
56
+ fm = parsed as Record<string, unknown>;
78
57
  } catch (err) {
79
- logger.warn("failed to parse task frontmatter", { filePath, error: String(err) });
80
- return null;
81
- }
82
-
83
- if (!fm || typeof fm !== "object") {
84
- logger.warn("task frontmatter is not an object", { filePath });
58
+ logger.warn("failed to parse task YAML", { filePath, error: String(err) });
85
59
  return null;
86
60
  }
87
61
 
@@ -106,19 +80,19 @@ export function parseMarkdownTask(filePath: string): MarkdownTask | null {
106
80
  }
107
81
  target = { kind: "command", cmd };
108
82
  } else if (fm.prompt !== undefined) {
109
- if (fm.prompt !== "inline") {
110
- // Future: handle asset-ref and file-path prompt sources
111
- logger.warn("task 'prompt' supports only 'inline' currently", { filePath });
83
+ if (typeof fm.prompt !== "string" || !fm.prompt.trim()) {
84
+ logger.warn("task 'prompt' must be a non-empty string", { filePath });
112
85
  return null;
113
86
  }
114
- if (!parts.body) {
115
- logger.warn("prompt:inline task has no markdown body", { filePath });
87
+ const promptBody = fm.prompt.trim() === "inline" ? body.trim() : fm.prompt.trim();
88
+ if (!promptBody) {
89
+ logger.warn("task prompt body is empty", { filePath });
116
90
  return null;
117
91
  }
118
92
  target = {
119
93
  kind: "prompt",
120
94
  profile: typeof fm.profile === "string" ? fm.profile : undefined,
121
- body: parts.body,
95
+ body: promptBody,
122
96
  };
123
97
  } else if (fm.workflow !== undefined) {
124
98
  if (typeof fm.workflow !== "string") {
@@ -149,13 +123,19 @@ export function parseMarkdownTask(filePath: string): MarkdownTask | null {
149
123
  };
150
124
  }
151
125
 
126
+ function splitTaskSource(raw: string): { frontmatter: string; body: string } {
127
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
128
+ if (!match) return { frontmatter: raw, body: "" };
129
+ return { frontmatter: match[1] ?? "", body: match[2] ?? "" };
130
+ }
131
+
152
132
  export function loadMarkdownTasks(stashDir: string): MarkdownTask[] {
153
133
  const dir = join(stashDir, "tasks");
154
134
  if (!existsSync(dir)) return [];
155
135
 
156
136
  const tasks: MarkdownTask[] = [];
157
137
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
158
- if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
138
+ if (!entry.isFile() || (!entry.name.endsWith(".md") && !entry.name.endsWith(".yml") && !entry.name.endsWith(".yaml"))) continue;
159
139
  const task = parseMarkdownTask(join(dir, entry.name));
160
140
  if (task) tasks.push(task);
161
141
  }
@@ -195,6 +175,6 @@ export function taskToAutomationConfig(task: MarkdownTask): AutomationConfig {
195
175
  agent,
196
176
  },
197
177
  on_failure: "log",
198
- fileName: `${task.id}.md`,
178
+ fileName: basename(task.source.path),
199
179
  };
200
180
  }
@@ -2,7 +2,7 @@
2
2
  * Shared OpenCode REST API client.
3
3
  *
4
4
  * Factory function that returns typed accessors for an OpenCode server
5
- * at a configurable base URL. Used by both the admin (container) and
5
+ * at a configurable base URL. Used by both the admin UI (host process) and
6
6
  * CLI (host subprocess) to talk to OpenCode.
7
7
  */
8
8
 
@@ -5,78 +5,93 @@
5
5
  * When the directory layout changes, update this file only.
6
6
  *
7
7
  * Layout:
8
- * config/ — user-editable config + system config files (auth.json, akm/)
9
- * config/stack/ — compose runtime + stack config (stack.env, guardian.env, stack.yml, addons/)
10
- * cache/ regenerable/semi-persistent data (akm cache, guardian cache, rollback)
11
- * state/ persistent service data (assistant, admin, guardian, logs, backups, registry)
12
- * stash/ — akm knowledge (skills, vaults, agents)
8
+ * config/ — user-editable config + system config files (akm/)
9
+ * config/stack/ — compose runtime + stack config (stack.env, stack.yml, auth.json, fixed compose files)
10
+ * data/ — persistent service data, logs, backups, rollback
11
+ * knowledge/ akm knowledge (skills, env, secrets, agents)
13
12
  * workspace/ — shared work area
14
13
  */
14
+ import { dirname, basename } from "node:path";
15
15
  import type { ControlPlaneState } from "./types.js";
16
16
 
17
17
  // ── Config directory — user + system config ─────────────────────────────────
18
18
 
19
- /** OpenCode auth token store */
20
- export const authJsonPath = (s: ControlPlaneState): string => `${s.configDir}/auth.json`;
21
- /** akm setup config directory (AKM_CONFIG_DIR) */
19
+ /**
20
+ * OpenCode auth token store. Provider credentials are sensitive, so they live
21
+ * under knowledge/secrets/ (out of config/stack/) and are bind-mounted into
22
+ * every OpenCode-based container (assistant + guardian).
23
+ */
24
+ export const authJsonPath = (s: ControlPlaneState): string => `${s.stashDir}/secrets/auth.json`;
25
+ /** akm config directory mounted at /etc/akm */
22
26
  export const akmConfigDir = (s: ControlPlaneState): string => `${s.configDir}/akm`;
23
- /** akm setup config file (written by admin on capability save) */
27
+ /** akm setup config file (written by the admin UI AKM action and CLI install) */
24
28
  export const akmConfigPath = (s: ControlPlaneState): string => `${s.configDir}/akm/config.json`;
25
29
  export const tasksDir = (s: ControlPlaneState): string => `${s.stashDir}/tasks`;
26
30
  export const assistantConfigDir = (s: ControlPlaneState): string => `${s.configDir}/assistant`;
31
+ /** Guardian OpenCode global config dir — bind-mounted at /etc/opencode */
32
+ export const guardianConfigDir = (s: ControlPlaneState): string => `${s.configDir}/guardian`;
27
33
 
28
34
  // ── Config/stack directory — compose runtime + stack config ─────────────────
29
35
 
30
- /** System env: capabilities, secrets, tokens */
31
- export const stackEnvPath = (s: ControlPlaneState): string => `${s.stackDir}/stack.env`;
32
- /** Guardian HMAC channel secrets */
33
- export const guardianEnvPath = (s: ControlPlaneState): string => `${s.stackDir}/guardian.env`;
34
- /** Stack spec: capability assignments */
35
- export const stackSpecFilePath = (s: ControlPlaneState): string => `${s.stackDir}/stack.yml`;
36
-
37
- // ── Cache directory regenerable/semi-persistent ───────────────────────────
36
+ /**
37
+ * System env: non-secret runtime configuration (the Compose `--env-file`).
38
+ * Lives under knowledge/env/ alongside the user env file (akm `env:stack`).
39
+ */
40
+ export const stackEnvPath = (s: ControlPlaneState): string => `${s.stashDir}/env/stack.env`;
41
+ /**
42
+ * Resolve the OP_HOME root from a stackDir. Normally `<home>/config/stack`;
43
+ * falls back to the stackDir itself for callers/tests that pass a home-shaped
44
+ * dir. Mirrors `resolveHomeDirFromStackDir` in secrets-files.ts so the env and
45
+ * secret dirs resolve consistently from the same input.
46
+ */
47
+ const homeFromStackDir = (stackDir: string): string =>
48
+ basename(stackDir) === "stack" && basename(dirname(stackDir)) === "config"
49
+ ? dirname(dirname(stackDir))
50
+ : stackDir;
38
51
 
39
- export const akmCacheDir = (s: ControlPlaneState): string => `${s.cacheDir}/akm`;
40
- export const guardianCacheDir = (s: ControlPlaneState): string => `${s.cacheDir}/guardian`;
41
- export const rollbackDir = (s: ControlPlaneState): string => `${s.cacheDir}/rollback`;
52
+ /**
53
+ * Same as `stackEnvPath` but resolved from a `stackDir` for the few callers
54
+ * that only have the stack dir, not full state.
55
+ */
56
+ export const stackEnvPathFromStackDir = (stackDir: string): string => `${homeFromStackDir(stackDir)}/knowledge/env/stack.env`;
42
57
 
43
- // ── State directory persistent service data ───────────────────────────────
58
+ // ── Operational state directories ───────────────────────────────────────────
44
59
 
45
- export const assistantServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/assistant`;
46
- export const adminServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/admin`;
47
- export const guardianServiceDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian`;
48
- export const guardianStashDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian/stash`;
49
- export const guardianAkmDir = (s: ControlPlaneState): string => `${s.stateDir}/guardian/akm`;
50
- /** Shared akm operational data (data/, state/ — NOT config, which lives in config/akm/) */
51
- export const akmStateDir = (s: ControlPlaneState): string => `${s.stateDir}/akm`;
52
- export const taskLogDir = (s: ControlPlaneState, id: string): string => `${s.cacheDir}/akm/tasks/logs/${id}`;
53
- export const taskLogsRootDir = (s: ControlPlaneState): string => `${s.cacheDir}/akm/tasks/logs`;
54
- export const logsDir = (s: ControlPlaneState): string => `${s.stateDir}/logs`;
60
+ export const akmCacheDir = (s: ControlPlaneState): string => `${s.dataDir}/akm/cache`;
61
+ export const rollbackDir = (s: ControlPlaneState): string => `${s.dataDir}/rollback`;
62
+ export const logsDir = (s: ControlPlaneState): string => `${s.dataDir}/logs`;
55
63
  /**
56
64
  * Guardian's own audit log of channel ingress (HMAC verify, replay, rate
57
65
  * limit). Phase 6 of the auth/proxy refactor removed the OpenPalm-side
58
66
  * `admin-audit.jsonl` — OpenCode session logs are the audit trail for
59
67
  * chat + tool activity.
60
68
  */
61
- export const guardianAuditPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/guardian-audit.log`;
62
- /** One-shot 0.11.0 migration log (OP_UI_TOKEN OPENCODE_SERVER_PASSWORD, endpoints.json move) */
63
- export const migration0110LogPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/migration-0.11.0.log`;
64
- export const backupsDir = (s: ControlPlaneState): string => `${s.stateDir}/backups`;
65
- export const registryDir = (s: ControlPlaneState): string => `${s.stateDir}/registry`;
66
- export const registryAddonsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/addons`;
67
- export const registryAutomationsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/automations`;
68
- export const secretsDir = (s: ControlPlaneState): string => `${s.stateDir}/secrets`;
69
- export const secretProviderPath = (s: ControlPlaneState): string => `${s.stateDir}/secrets/provider.json`;
70
- export const secretsIndexPath = (s: ControlPlaneState): string => `${s.stateDir}/secrets/plaintext-index.json`;
71
- export const passStoreDir = (s: ControlPlaneState): string => `${s.stateDir}/secrets/pass-store`;
69
+ export const guardianAuditPath = (s: ControlPlaneState): string => `${s.dataDir}/logs/guardian-audit.log`;
70
+ export const backupsDir = (s: ControlPlaneState): string => `${s.dataDir}/backups`;
71
+
72
+ // ── State directory persistent service data ───────────────────────────────
72
73
 
73
- // ── Stash directory ─────────────────────────────────────────────────────────
74
+ export const assistantServiceDir = (s: ControlPlaneState): string => `${s.dataDir}/assistant`;
75
+ export const guardianServiceDir = (s: ControlPlaneState): string => `${s.dataDir}/guardian`;
76
+ export const guardianAkmDir = (s: ControlPlaneState): string => `${s.dataDir}/guardian/akm`;
77
+ /** akm durable data — NOT config, which lives in config/akm/ */
78
+ export const akmDataDir = (s: ControlPlaneState): string => `${s.dataDir}/akm/data`;
79
+ export const taskLogDir = (s: ControlPlaneState, id: string): string => `${s.dataDir}/akm/cache/tasks/logs/${id}`;
80
+ export const taskLogsRootDir = (s: ControlPlaneState): string => `${s.dataDir}/akm/cache/tasks/logs`;
81
+ export const secretsDir = (s: ControlPlaneState): string => `${s.dataDir}/secrets`;
82
+ export const secretProviderPath = (s: ControlPlaneState): string => `${s.dataDir}/secrets/provider.json`;
83
+ export const secretsIndexPath = (s: ControlPlaneState): string => `${s.dataDir}/secrets/plaintext-index.json`;
84
+ export const passStoreDir = (s: ControlPlaneState): string => `${s.dataDir}/secrets/pass-store`;
74
85
 
75
- /** akm vault:user file — lives in the stash */
76
- export const akmUserVaultPath = (s: ControlPlaneState): string => `${s.stashDir}/vaults/user.env`;
86
+ // ── Knowledge directory ─────────────────────────────────────────────────────
87
+ // The akm env:user file path (`knowledge/env/user.env`) is owned by
88
+ // `akm-user-env.ts` (`userEnvPathSync`), which also handles its read/write and
89
+ // legacy migration — kept there rather than duplicated as a bare path here.
77
90
 
78
91
  // ── Stack directory ─────────────────────────────────────────────────────────
79
92
 
80
93
  export const coreComposePath = (s: ControlPlaneState): string => `${s.stackDir}/core.compose.yml`;
81
- export const addonsStackDir = (s: ControlPlaneState): string => `${s.stackDir}/addons`;
94
+ export const servicesComposePath = (s: ControlPlaneState): string => `${s.stackDir}/services.compose.yml`;
95
+ export const channelsComposePath = (s: ControlPlaneState): string => `${s.stackDir}/channels.compose.yml`;
96
+ export const customComposePath = (s: ControlPlaneState): string => `${s.stackDir}/custom.compose.yml`;
82
97
  export const addonComposePath = (s: ControlPlaneState, name: string): string => `${s.stackDir}/addons/${name}/compose.yml`;
@@ -0,0 +1,21 @@
1
+ export type HardwareProfileVariant = 'cpu' | 'cuda' | 'rocm';
2
+
3
+ const PROFILE_ID_RE = /^addon\.([a-z0-9-]+)(?:\.(cpu|cuda|rocm))?$/;
4
+
5
+ export function addonProfileId(addon: string, variant: HardwareProfileVariant): string {
6
+ return `addon.${addon}.${variant}`;
7
+ }
8
+
9
+ export function resolveHardwareProfileVariant(profileId: string): HardwareProfileVariant | null {
10
+ return (profileId.match(PROFILE_ID_RE)?.[2] as HardwareProfileVariant | undefined) ?? null;
11
+ }
12
+
13
+ export function canonicalAddonProfileSelection(addon: string, profile: string): string {
14
+ const trimmed = profile.trim();
15
+ if (!trimmed) return '';
16
+
17
+ const match = trimmed.match(PROFILE_ID_RE);
18
+ if (!match || match[1] !== addon) return '';
19
+
20
+ return trimmed;
21
+ }
@@ -4,7 +4,7 @@
4
4
  * Used by the admin capabilities test endpoint and the CLI setup wizard
5
5
  * to enumerate the models a configured provider exposes.
6
6
  */
7
- import { readStackEnv } from "./secrets.js";
7
+ import { readStackRuntimeEnv } from "./secrets.js";
8
8
  import { PROVIDER_DEFAULT_URLS } from "../provider-constants.js";
9
9
 
10
10
  /** Static model list for Anthropic (no listing API available). */
@@ -24,7 +24,7 @@ const ANTHROPIC_MODELS = [
24
24
  *
25
25
  * - Empty input → empty string.
26
26
  * - `env:NAME` form → looks up `NAME` in `process.env` first, then falls back
27
- * to `config/stack/stack.env` resolved against `stackDir`.
27
+ * to `knowledge/secrets/<NAME>` resolved against `stackDir`.
28
28
  * - Anything else → returned verbatim (treated as a literal key value).
29
29
  */
30
30
  function resolveApiKey(apiKeyRef: string, stackDir: string): string {
@@ -34,7 +34,7 @@ function resolveApiKey(apiKeyRef: string, stackDir: string): string {
34
34
  const varName = apiKeyRef.slice(4);
35
35
  if (process.env[varName]) return process.env[varName]!;
36
36
 
37
- const secrets = readStackEnv(stackDir);
37
+ const secrets = readStackRuntimeEnv(stackDir);
38
38
  return secrets[varName] ?? "";
39
39
  }
40
40