@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.18

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 (66) 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 +69 -30
  11. package/src/control-plane/compose-args.ts +62 -8
  12. package/src/control-plane/config-persistence.ts +102 -136
  13. package/src/control-plane/core-assets.ts +45 -60
  14. package/src/control-plane/defaults.ts +16 -0
  15. package/src/control-plane/docker.ts +15 -14
  16. package/src/control-plane/env.test.ts +10 -10
  17. package/src/control-plane/env.ts +16 -1
  18. package/src/control-plane/extends-support.test.ts +8 -8
  19. package/src/control-plane/fs-atomic.ts +15 -0
  20. package/src/control-plane/home.ts +34 -46
  21. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  22. package/src/control-plane/host-akm-sharing.ts +129 -0
  23. package/src/control-plane/host-opencode.test.ts +82 -10
  24. package/src/control-plane/host-opencode.ts +42 -13
  25. package/src/control-plane/install-edge-cases.test.ts +100 -136
  26. package/src/control-plane/install-lock.ts +7 -7
  27. package/src/control-plane/lifecycle.ts +45 -40
  28. package/src/control-plane/markdown-task.ts +30 -50
  29. package/src/control-plane/migrations.test.ts +272 -0
  30. package/src/control-plane/migrations.ts +423 -0
  31. package/src/control-plane/opencode-client.ts +1 -1
  32. package/src/control-plane/paths.ts +61 -46
  33. package/src/control-plane/profile-ids.ts +21 -0
  34. package/src/control-plane/provider-models.ts +3 -3
  35. package/src/control-plane/registry.test.ts +107 -90
  36. package/src/control-plane/registry.ts +301 -110
  37. package/src/control-plane/rollback.ts +8 -38
  38. package/src/control-plane/scheduler.ts +10 -7
  39. package/src/control-plane/secret-audit.test.ts +159 -0
  40. package/src/control-plane/secret-audit.ts +255 -0
  41. package/src/control-plane/secret-mappings.ts +2 -2
  42. package/src/control-plane/secrets-files.test.ts +99 -0
  43. package/src/control-plane/secrets-files.ts +113 -0
  44. package/src/control-plane/secrets.ts +113 -86
  45. package/src/control-plane/setup-config.schema.json +1 -1
  46. package/src/control-plane/setup-status.ts +6 -11
  47. package/src/control-plane/setup.test.ts +137 -61
  48. package/src/control-plane/setup.ts +82 -63
  49. package/src/control-plane/skeleton-guardrail.test.ts +66 -56
  50. package/src/control-plane/spec-to-env.test.ts +63 -26
  51. package/src/control-plane/spec-to-env.ts +51 -14
  52. package/src/control-plane/task-files.test.ts +45 -0
  53. package/src/control-plane/task-files.ts +51 -0
  54. package/src/control-plane/types.ts +2 -4
  55. package/src/control-plane/ui-assets.test.ts +333 -0
  56. package/src/control-plane/ui-assets.ts +290 -142
  57. package/src/control-plane/validate.ts +13 -15
  58. package/src/index.ts +96 -26
  59. package/src/control-plane/akm-vault.test.ts +0 -105
  60. package/src/control-plane/akm-vault.ts +0 -311
  61. package/src/control-plane/core-assets.test.ts +0 -104
  62. package/src/control-plane/migrate-0110.test.ts +0 -177
  63. package/src/control-plane/migrate-0110.ts +0 -99
  64. package/src/control-plane/registry-components.test.ts +0 -391
  65. package/src/control-plane/stack-spec.test.ts +0 -94
  66. package/src/control-plane/stack-spec.ts +0 -67
@@ -20,6 +20,7 @@ import {
20
20
  getRegistryAutomation,
21
21
  getRegistryAddonConfig,
22
22
  listAvailableAddonIds,
23
+ listEnabledAddonIds,
23
24
  getAddonServiceNames,
24
25
  getAddonProfiles,
25
26
  getAddonProfileSelection,
@@ -31,6 +32,7 @@ import {
31
32
  annotateAddonProfileAvailability,
32
33
  __addonAvailabilityTestHooks,
33
34
  } from "./registry.js";
35
+ import { readSecret } from './secrets-files.js';
34
36
 
35
37
  // ── Validation Tests ─────────────────────────────────────────────────
36
38
 
@@ -205,76 +207,84 @@ describe("materialized registry catalog", () => {
205
207
 
206
208
  it("materializes addons and automations into OP_HOME/registry", () => {
207
209
  const sourceRoot = join(tmpDir, 'repo');
208
- const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
209
- const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
210
+ const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
211
+ const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
210
212
 
211
213
  mkdirSync(addonDir, { recursive: true });
212
214
  mkdirSync(automationsDir, { recursive: true });
213
215
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
214
216
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
215
- writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
217
+ writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
216
218
 
217
219
  const root = materializeRegistryCatalog(sourceRoot);
218
220
 
219
- expect(root).toBe(join(process.env.OP_HOME!, 'state', 'registry'));
221
+ expect(root).toBe(join(process.env.OP_HOME!, 'data', 'registry'));
220
222
  expect(existsSync(join(root, 'addons', 'chat', 'compose.yml'))).toBe(true);
221
223
  expect(existsSync(join(root, 'addons', 'chat', '.env.schema'))).toBe(true);
222
- expect(readFileSync(join(root, 'automations', 'cleanup.md'), 'utf-8')).toContain('Cleanup');
224
+ expect(readFileSync(join(root, 'automations', 'cleanup.yml'), 'utf-8')).toContain('Cleanup');
223
225
  });
224
226
 
225
227
  it("discovers materialized registry entries", () => {
226
228
  const sourceRoot = join(tmpDir, 'repo');
227
- const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
228
- const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
229
+ const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
230
+ const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
229
231
 
230
232
  mkdirSync(addonDir, { recursive: true });
231
233
  mkdirSync(automationsDir, { recursive: true });
232
234
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
233
235
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
234
- writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
236
+ writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
235
237
 
236
238
  materializeRegistryCatalog(sourceRoot);
237
239
 
238
240
  const components = discoverRegistryComponents();
239
- const stashDir = join(process.env.OP_HOME!, 'stash');
241
+ const stashDir = join(process.env.OP_HOME!, 'knowledge');
240
242
  const automations = discoverRegistryAutomations(stashDir);
241
243
 
242
244
  expect(Object.keys(components)).toEqual(['chat']);
243
245
  expect(components.chat?.schema).toContain('CHANNEL_CHAT_SECRET');
244
- expect(automations.map((entry) => entry.name)).toEqual(['cleanup']);
245
- expect(getRegistryAutomation('cleanup')).toContain('schedule: "0 3 * * *"');
246
+ expect(automations.map((entry) => entry.name)).toContain('akm-improve');
247
+ expect(getRegistryAutomation('akm-improve')).toContain('akm improve');
246
248
  });
247
249
 
248
250
  it("returns addon config metadata from the materialized registry", () => {
249
251
  const sourceRoot = join(tmpDir, 'repo');
250
- const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
251
- const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
252
+ const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
253
+ const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
252
254
 
253
255
  mkdirSync(addonDir, { recursive: true });
254
256
  mkdirSync(automationsDir, { recursive: true });
255
257
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
256
258
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
257
- writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
259
+ writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
258
260
 
259
261
  materializeRegistryCatalog(sourceRoot);
260
262
 
261
- expect(getRegistryAddonConfig(process.env.OP_HOME!, 'chat')).toEqual({
262
- schemaPath: 'state/registry/addons/chat/.env.schema',
263
- userEnvPath: 'config/stack/stack.env',
264
- envSchema: 'CHANNEL_CHAT_SECRET=\n',
265
- });
263
+ const cfg = getRegistryAddonConfig(process.env.OP_HOME!, 'chat');
264
+ expect(cfg.userEnvPath).toBe('knowledge/env/stack.env');
265
+ expect(cfg.envSchema).toBe('CHANNEL_CHAT_SECRET=\n');
266
+ expect(cfg.schemaPath.endsWith('/data/registry/addons/chat/.env.schema')).toBe(true);
267
+ });
268
+
269
+ it("falls back to the built-in addon schema when no registry is materialized", () => {
270
+ // No materialized registry → built-in schema for first-party addons.
271
+ const discord = getRegistryAddonConfig(process.env.OP_HOME!, 'discord');
272
+ expect(discord.envSchema).toContain('DISCORD_BOT_TOKEN');
273
+ expect(discord.schemaPath).toBe('');
274
+ // ssh is compose/profile-only — no configurable env.
275
+ expect(getRegistryAddonConfig(process.env.OP_HOME!, 'ssh').envSchema).toBe('');
266
276
  });
267
277
 
268
278
  it("verifies the materialized registry and returns counts", () => {
269
279
  const sourceRoot = join(tmpDir, 'repo');
270
- const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
271
- const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
280
+ const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
281
+ const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
272
282
 
273
283
  mkdirSync(addonDir, { recursive: true });
274
284
  mkdirSync(automationsDir, { recursive: true });
275
285
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
276
286
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
277
- writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
287
+ writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
278
288
 
279
289
  const root = materializeRegistryCatalog(sourceRoot);
280
290
 
@@ -285,65 +295,58 @@ describe("materialized registry catalog", () => {
285
295
  });
286
296
  });
287
297
 
288
- it("returns no available addons when the registry addons directory is missing", () => {
289
- expect(listAvailableAddonIds()).toEqual([]);
298
+ it("returns static built-in addons without requiring a registry directory", () => {
299
+ expect(listAvailableAddonIds()).toEqual(['api', 'chat', 'discord', 'ollama', 'slack', 'ssh', 'voice']);
290
300
  });
291
301
 
292
302
  it("fails when source catalog is incomplete", () => {
293
303
  const sourceRoot = join(tmpDir, 'repo');
294
- mkdirSync(join(sourceRoot, '.openpalm', 'state', 'registry', 'addons'), { recursive: true });
295
- mkdirSync(join(sourceRoot, '.openpalm', 'state', 'registry', 'automations'), { recursive: true });
304
+ mkdirSync(join(sourceRoot, '.openpalm', 'data', 'registry', 'addons'), { recursive: true });
305
+ mkdirSync(join(sourceRoot, '.openpalm', 'data', 'registry', 'automations'), { recursive: true });
296
306
 
297
307
  expect(() => materializeRegistryCatalog(sourceRoot)).toThrow('Registry catalog is incomplete');
298
308
  });
299
309
 
300
- it("enables and disables addons through the runtime stack directory", () => {
310
+ it("enables and disables addons through explicit runtime state", () => {
301
311
  const sourceRoot = join(tmpDir, 'repo');
302
- const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
303
- const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
312
+ const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
313
+ const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
304
314
 
305
315
  mkdirSync(addonDir, { recursive: true });
306
316
  mkdirSync(automationsDir, { recursive: true });
307
317
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
308
318
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
309
- writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
319
+ writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
310
320
 
311
321
  materializeRegistryCatalog(sourceRoot);
312
322
 
313
323
  const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
314
324
  expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', true)).toMatchObject({ ok: true });
315
- expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
325
+ expect(listEnabledAddonIds(process.env.OP_HOME!)).toEqual(['chat']);
326
+ expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(false);
316
327
 
317
328
  expect(setAddonEnabled(process.env.OP_HOME!, stackDir, 'chat', false)).toMatchObject({ ok: true });
318
- expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat'))).toBe(false);
329
+ expect(listEnabledAddonIds(process.env.OP_HOME!)).toEqual([]);
319
330
  });
320
331
 
321
- it("returns addon service names from stack or registry compose files", () => {
322
- const sourceRoot = join(tmpDir, 'repo');
323
- const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'proxy-test');
324
- const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
325
-
326
- mkdirSync(addonDir, { recursive: true });
327
- mkdirSync(automationsDir, { recursive: true });
328
- writeFileSync(join(addonDir, 'compose.yml'), 'services:\n svc-a:\n image: image-a\n svc-b:\n image: image-b\n');
329
- writeFileSync(join(addonDir, '.env.schema'), 'PROXY_TOKEN=\n');
330
- writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
331
-
332
- materializeRegistryCatalog(sourceRoot);
332
+ it("returns addon service names from fixed compose files", () => {
333
+ const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
334
+ mkdirSync(stackDir, { recursive: true });
335
+ writeFileSync(join(stackDir, 'custom.compose.yml'), 'services:\n proxy-test:\n profiles: ["addon.proxy-test"]\n image: image-a\n proxy-test-worker:\n profiles: ["addon.proxy-test"]\n image: image-b\n');
333
336
 
334
- expect(getAddonServiceNames(process.env.OP_HOME!, 'proxy-test')).toEqual(['svc-a', 'svc-b']);
337
+ expect(getAddonServiceNames(process.env.OP_HOME!, 'proxy-test')).toEqual(['proxy-test', 'proxy-test-worker']);
335
338
  });
336
339
 
337
340
  it("toggles addons and generates channel secrets when enabling channel addons", () => {
338
341
  const sourceRoot = join(tmpDir, 'repo');
339
- const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
340
- const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
342
+ const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
343
+ const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
341
344
 
342
345
  mkdirSync(addonDir, { recursive: true });
343
346
  mkdirSync(automationsDir, { recursive: true });
344
347
  writeFileSync(join(addonDir, 'compose.yml'), 'services:\n chat:\n image: test\n environment:\n CHANNEL_NAME: "Chat"\n CHANNEL_ID: "chat"\n');
345
348
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
346
- writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
349
+ writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
347
350
 
348
351
  materializeRegistryCatalog(sourceRoot);
349
352
 
@@ -353,8 +356,8 @@ describe("materialized registry catalog", () => {
353
356
  changed: true,
354
357
  services: ['chat'],
355
358
  });
356
- expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat', 'compose.yml'))).toBe(true);
357
- expect(readFileSync(join(process.env.OP_HOME!, 'config', 'stack', 'guardian.env'), 'utf-8')).toMatch(/CHANNEL_CHAT_SECRET=/);
359
+ expect(listEnabledAddonIds(process.env.OP_HOME!)).toEqual(['chat']);
360
+ expect(readSecret(join(process.env.OP_HOME!, 'config', 'stack'), 'channel_chat_secret')).toBeTruthy();
358
361
 
359
362
  expect(setAddonEnabled(process.env.OP_HOME!, join(process.env.OP_HOME!, 'config', 'stack'), 'chat', false)).toEqual({
360
363
  ok: true,
@@ -362,20 +365,21 @@ describe("materialized registry catalog", () => {
362
365
  changed: true,
363
366
  services: ['chat'],
364
367
  });
365
- expect(existsSync(join(process.env.OP_HOME!, 'config', 'stack', 'addons', 'chat'))).toBe(false);
368
+ expect(listEnabledAddonIds(process.env.OP_HOME!)).toEqual([]);
366
369
  });
367
370
 
368
371
  it("backs up OP_HOME without recursively copying backups", () => {
369
372
  mkdirSync(join(process.env.OP_HOME!, 'config'), { recursive: true });
370
- mkdirSync(join(process.env.OP_HOME!, 'backups', 'old-backup'), { recursive: true });
373
+ mkdirSync(join(process.env.OP_HOME!, 'data', 'backups', 'old-backup'), { recursive: true });
371
374
  writeFileSync(join(process.env.OP_HOME!, 'config', 'stack.yml'), 'llm: test\n');
372
- writeFileSync(join(process.env.OP_HOME!, 'backups', 'old-backup', 'marker.txt'), 'old\n');
375
+ writeFileSync(join(process.env.OP_HOME!, 'data', 'backups', 'old-backup', 'marker.txt'), 'old\n');
373
376
 
374
377
  const backupDir = backupOpenPalmHome(process.env.OP_HOME!);
375
378
 
376
379
  expect(backupDir).not.toBeNull();
377
380
  expect(existsSync(join(backupDir!, 'config', 'stack.yml'))).toBe(true);
378
- expect(existsSync(join(backupDir!, 'backups'))).toBe(false);
381
+ expect(existsSync(join(backupDir!, 'cache'))).toBe(false);
382
+ expect(existsSync(join(backupDir!, 'data', 'backups'))).toBe(false);
379
383
  });
380
384
 
381
385
  it("writes backups under the provided homeDir even when OP_HOME points elsewhere", () => {
@@ -383,7 +387,7 @@ describe("materialized registry catalog", () => {
383
387
  const otherHome = join(tmpDir, 'other-home');
384
388
 
385
389
  mkdirSync(join(actualHome, 'config'), { recursive: true });
386
- mkdirSync(join(otherHome, 'backups'), { recursive: true });
390
+ mkdirSync(join(otherHome, 'data', 'backups'), { recursive: true });
387
391
  writeFileSync(join(actualHome, 'config', 'stack.yml'), 'llm: local\n');
388
392
 
389
393
  process.env.OP_HOME = otherHome;
@@ -391,15 +395,15 @@ describe("materialized registry catalog", () => {
391
395
  const backupDir = backupOpenPalmHome(actualHome);
392
396
 
393
397
  expect(backupDir).not.toBeNull();
394
- expect(backupDir!.startsWith(join(actualHome, 'backups'))).toBe(true);
398
+ expect(backupDir!.startsWith(join(actualHome, 'data', 'backups'))).toBe(true);
395
399
  expect(existsSync(join(backupDir!, 'config', 'stack.yml'))).toBe(true);
396
- expect(existsSync(join(otherHome, 'backups', 'config', 'stack.yml'))).toBe(false);
400
+ expect(existsSync(join(otherHome, 'data', 'backups', 'config', 'stack.yml'))).toBe(false);
397
401
  });
398
402
 
399
403
  it("parses compose profiles + openpalm.profile.* labels per addon", () => {
400
404
  const sourceRoot = join(tmpDir, 'repo');
401
- const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'voice');
402
- const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
405
+ const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'voice');
406
+ const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
403
407
 
404
408
  mkdirSync(addonDir, { recursive: true });
405
409
  mkdirSync(automationsDir, { recursive: true });
@@ -408,13 +412,13 @@ describe("materialized registry catalog", () => {
408
412
  [
409
413
  'services:',
410
414
  ' voice:',
411
- ' profiles: [cpu]',
415
+ ' profiles: ["addon.voice.cpu"]',
412
416
  ' image: openpalm/voice:cpu',
413
417
  ' labels:',
414
418
  ' openpalm.profile.label: CPU',
415
419
  ' openpalm.profile.default: "true"',
416
420
  ' voice-cuda:',
417
- ' profiles: [cuda]',
421
+ ' profiles: ["addon.voice.cuda"]',
418
422
  ' image: openpalm/voice:cuda',
419
423
  ' labels:',
420
424
  ' openpalm.profile.label: NVIDIA',
@@ -423,44 +427,57 @@ describe("materialized registry catalog", () => {
423
427
  ].join('\n'),
424
428
  );
425
429
  writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
426
- writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
430
+ writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
427
431
 
428
432
  materializeRegistryCatalog(sourceRoot);
429
433
 
430
434
  const profiles = getAddonProfiles(process.env.OP_HOME!, 'voice');
431
435
  expect(profiles).toEqual([
432
- { id: 'cpu', services: ['voice'], label: 'CPU', default: true },
433
- { id: 'cuda', services: ['voice-cuda'], label: 'NVIDIA', requires: 'nvidia-container-toolkit' },
436
+ { id: 'addon.voice.cpu', services: ['voice'], label: 'CPU', default: true },
437
+ { id: 'addon.voice.cuda', services: ['voice-cuda'], label: 'NVIDIA (CUDA 12.1)', requires: 'nvidia-container-toolkit' },
438
+ { id: 'addon.voice.rocm', services: ['voice-rocm'], label: 'AMD (ROCm 6.x)', requires: 'amdgpu kernel module' },
434
439
  ]);
435
440
  });
436
441
 
437
442
  it("round-trips addon profile selection through stack.env", () => {
438
443
  const sourceRoot = join(tmpDir, 'repo');
439
- const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'voice');
440
- const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
444
+ const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'voice');
445
+ const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
441
446
 
442
447
  mkdirSync(addonDir, { recursive: true });
443
448
  mkdirSync(automationsDir, { recursive: true });
444
- writeFileSync(join(addonDir, 'compose.yml'), 'services:\n voice:\n profiles: [cpu]\n image: x\n');
449
+ writeFileSync(join(addonDir, 'compose.yml'), 'services:\n voice:\n profiles: ["addon.voice.cpu"]\n image: x\n');
445
450
  writeFileSync(join(addonDir, '.env.schema'), 'VOICE=\n');
446
- writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
451
+ writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
447
452
 
448
453
  materializeRegistryCatalog(sourceRoot);
449
454
 
450
455
  const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
456
+ const stackEnv = join(process.env.OP_HOME!, 'knowledge', 'env', 'stack.env');
457
+ mkdirSync(stackDir, { recursive: true });
458
+ mkdirSync(join(process.env.OP_HOME!, 'knowledge', 'env'), { recursive: true });
459
+ writeFileSync(stackEnv, '');
460
+
461
+ expect(getAddonProfileSelection(stackDir, 'voice')).toBeNull();
462
+ setAddonProfileSelection(stackDir, 'voice', 'addon.voice.cuda');
463
+ expect(getAddonProfileSelection(stackDir, 'voice')).toBe('addon.voice.cuda');
464
+ expect(readFileSync(stackEnv, 'utf-8')).toContain('OP_VOICE_PROFILE=addon.voice.cuda');
465
+ });
466
+
467
+ it("ignores non-canonical addon profile values when reading stack.env", () => {
468
+ const stackDir = join(process.env.OP_HOME!, 'config', 'stack');
469
+ const stackEnv = join(process.env.OP_HOME!, 'knowledge', 'env', 'stack.env');
451
470
  mkdirSync(stackDir, { recursive: true });
452
- writeFileSync(join(stackDir, 'stack.env'), '');
471
+ mkdirSync(join(process.env.OP_HOME!, 'knowledge', 'env'), { recursive: true });
472
+ writeFileSync(stackEnv, 'OP_VOICE_PROFILE=not-canonical\n');
453
473
 
454
474
  expect(getAddonProfileSelection(stackDir, 'voice')).toBeNull();
455
- setAddonProfileSelection(stackDir, 'voice', 'cuda');
456
- expect(getAddonProfileSelection(stackDir, 'voice')).toBe('cuda');
457
- expect(readFileSync(join(stackDir, 'stack.env'), 'utf-8')).toContain('OP_VOICE_PROFILE=cuda');
458
475
  });
459
476
 
460
- it("installs and uninstalls automations through stash/tasks", () => {
477
+ it("installs and uninstalls automations through knowledge/tasks", () => {
461
478
  const sourceRoot = join(tmpDir, 'repo');
462
- const addonDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons', 'chat');
463
- const automationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
479
+ const addonDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'addons', 'chat');
480
+ const automationsDir = join(sourceRoot, '.openpalm', 'data', 'registry', 'automations');
464
481
  const configDir = join(process.env.OP_HOME!, 'config');
465
482
 
466
483
  mkdirSync(addonDir, { recursive: true });
@@ -468,16 +485,16 @@ describe("materialized registry catalog", () => {
468
485
  mkdirSync(configDir, { recursive: true });
469
486
  writeFileSync(join(addonDir, 'compose.yml'), 'services: {}\n');
470
487
  writeFileSync(join(addonDir, '.env.schema'), 'CHANNEL_CHAT_SECRET=\n');
471
- writeFileSync(join(automationsDir, 'cleanup.md'), '---\ndescription: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n---\n');
488
+ writeFileSync(join(automationsDir, 'cleanup.yml'), 'description: Cleanup\nschedule: "0 3 * * *"\ncommand: ["echo","clean"]\n');
472
489
 
473
490
  materializeRegistryCatalog(sourceRoot);
474
491
 
475
- const stashDir = join(process.env.OP_HOME!, 'stash');
492
+ const stashDir = join(process.env.OP_HOME!, 'knowledge');
476
493
  expect(installAutomationFromRegistry('cleanup', stashDir)).toEqual({ ok: true });
477
- expect(readFileSync(join(stashDir, 'tasks', 'cleanup.md'), 'utf-8')).toContain('Cleanup');
494
+ expect(readFileSync(join(stashDir, 'tasks', 'cleanup.yml'), 'utf-8')).toContain('Cleanup');
478
495
 
479
496
  expect(uninstallAutomation('cleanup', stashDir)).toEqual({ ok: true });
480
- expect(existsSync(join(stashDir, 'tasks', 'cleanup.md'))).toBe(false);
497
+ expect(existsSync(join(stashDir, 'tasks', 'cleanup.yml'))).toBe(false);
481
498
  });
482
499
  });
483
500
 
@@ -493,7 +510,7 @@ describe("getAddonProfileAvailability", () => {
493
510
  });
494
511
 
495
512
  it("returns available:true for the cpu profile (no host requirements)", async () => {
496
- const result = await getAddonProfileAvailability({ id: 'cpu' });
513
+ const result = await getAddonProfileAvailability({ id: 'addon.voice.cpu' });
497
514
  expect(result.available).toBe(true);
498
515
  expect(result.reason).toBeUndefined();
499
516
  });
@@ -504,8 +521,8 @@ describe("getAddonProfileAvailability", () => {
504
521
  });
505
522
 
506
523
  it("caches the result across calls (probe runs only once)", async () => {
507
- const a = await getAddonProfileAvailability({ id: 'cpu' });
508
- const b = await getAddonProfileAvailability({ id: 'cpu' });
524
+ const a = await getAddonProfileAvailability({ id: 'addon.voice.cpu' });
525
+ const b = await getAddonProfileAvailability({ id: 'addon.voice.cpu' });
509
526
  expect(a).toBe(b); // same reference — cached
510
527
  });
511
528
 
@@ -514,7 +531,7 @@ describe("getAddonProfileAvailability", () => {
514
531
  // we just assert the contract: when neither signal is present, the
515
532
  // reason mentions nvidia-container-toolkit. If a future GPU host runs
516
533
  // this test, the assertion still tolerates the success case.
517
- const result = await getAddonProfileAvailability({ id: 'cuda' });
534
+ const result = await getAddonProfileAvailability({ id: 'addon.voice.cuda' });
518
535
  if (!result.available) {
519
536
  expect(result.reason).toContain('NVIDIA');
520
537
  } else {
@@ -524,7 +541,7 @@ describe("getAddonProfileAvailability", () => {
524
541
  });
525
542
 
526
543
  it("probes rocm: returns available:false when /dev/kfd is missing", async () => {
527
- const result = await getAddonProfileAvailability({ id: 'rocm' });
544
+ const result = await getAddonProfileAvailability({ id: 'addon.voice.rocm' });
528
545
  if (!result.available) {
529
546
  expect(result.reason).toContain('ROCm');
530
547
  } else {
@@ -538,7 +555,7 @@ describe("getAddonProfileAvailability", () => {
538
555
  // through to the manifest-inspect probe and (until 0.11.0-rocm6
539
556
  // ships) get the "image not published yet" copy. Both must mention
540
557
  // ROCm so operator-facing copy stays consistent.
541
- const result = await getAddonProfileAvailability({ id: 'rocm' });
558
+ const result = await getAddonProfileAvailability({ id: 'addon.voice.rocm' });
542
559
  if (!result.available && existsSync('/dev/kfd') && existsSync('/dev/dri')) {
543
560
  expect(result.reason).toMatch(/image not published|CPU profile/i);
544
561
  }
@@ -588,21 +605,21 @@ describe("annotateAddonProfileAvailability", () => {
588
605
 
589
606
  it("decorates each profile with available + optional reason", async () => {
590
607
  const out = await annotateAddonProfileAvailability([
591
- { id: 'cpu', services: ['voice'], label: 'CPU', default: true },
592
- { id: 'rocm', services: ['voice-rocm'], label: 'AMD' },
608
+ { id: 'addon.voice.cpu', services: ['voice'], label: 'CPU', default: true },
609
+ { id: 'addon.voice.rocm', services: ['voice-rocm'], label: 'AMD' },
593
610
  ]);
594
611
  expect(out).toHaveLength(2);
595
- expect(out[0]?.id).toBe('cpu');
612
+ expect(out[0]?.id).toBe('addon.voice.cpu');
596
613
  expect(out[0]?.available).toBe(true);
597
614
  // Preserves original fields.
598
615
  expect(out[0]?.label).toBe('CPU');
599
616
  expect(out[0]?.default).toBe(true);
600
- expect(out[1]?.id).toBe('rocm');
617
+ expect(out[1]?.id).toBe('addon.voice.rocm');
601
618
  expect(typeof out[1]?.available).toBe('boolean');
602
619
  });
603
620
 
604
621
  it("does not mutate the input array", async () => {
605
- const input = [{ id: 'cpu', services: ['voice'] }];
622
+ const input = [{ id: 'addon.voice.cpu', services: ['voice'] }];
606
623
  const before = JSON.parse(JSON.stringify(input));
607
624
  await annotateAddonProfileAvailability(input);
608
625
  expect(input).toEqual(before);