@openpalm/lib 0.11.0-beta.11 → 0.11.0-beta.13

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