@openpalm/lib 0.10.1 → 0.11.0-beta.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 (55) hide show
  1. package/README.md +2 -2
  2. package/package.json +7 -3
  3. package/src/control-plane/admin-token.ts +73 -0
  4. package/src/control-plane/akm-vault.test.ts +108 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/audit.ts +3 -2
  7. package/src/control-plane/channels.ts +3 -3
  8. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  9. package/src/control-plane/compose-args.test.ts +25 -21
  10. package/src/control-plane/config-persistence.ts +103 -64
  11. package/src/control-plane/core-assets.test.ts +104 -0
  12. package/src/control-plane/core-assets.ts +54 -57
  13. package/src/control-plane/docker.ts +55 -21
  14. package/src/control-plane/env.test.ts +25 -1
  15. package/src/control-plane/env.ts +80 -0
  16. package/src/control-plane/home.ts +66 -69
  17. package/src/control-plane/host-opencode.test.ts +263 -0
  18. package/src/control-plane/host-opencode.ts +229 -0
  19. package/src/control-plane/install-edge-cases.test.ts +182 -244
  20. package/src/control-plane/install-lock.ts +157 -0
  21. package/src/control-plane/lifecycle.ts +57 -56
  22. package/src/control-plane/markdown-task.ts +200 -0
  23. package/src/control-plane/paths.ts +75 -0
  24. package/src/control-plane/provider-config.ts +2 -2
  25. package/src/control-plane/provider-models.ts +154 -0
  26. package/src/control-plane/registry-components.test.ts +102 -25
  27. package/src/control-plane/registry.test.ts +49 -47
  28. package/src/control-plane/registry.ts +71 -50
  29. package/src/control-plane/rollback.ts +17 -16
  30. package/src/control-plane/scheduler.ts +75 -262
  31. package/src/control-plane/secret-backend.test.ts +98 -108
  32. package/src/control-plane/secret-backend.ts +221 -181
  33. package/src/control-plane/secret-mappings.ts +3 -6
  34. package/src/control-plane/secrets.ts +83 -47
  35. package/src/control-plane/setup-config.schema.json +2 -14
  36. package/src/control-plane/setup-status.ts +4 -29
  37. package/src/control-plane/setup-validation.ts +21 -21
  38. package/src/control-plane/setup.test.ts +122 -227
  39. package/src/control-plane/setup.ts +224 -125
  40. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  41. package/src/control-plane/spec-to-env.test.ts +59 -58
  42. package/src/control-plane/spec-to-env.ts +39 -140
  43. package/src/control-plane/spec-validator.ts +2 -99
  44. package/src/control-plane/stack-spec.test.ts +21 -77
  45. package/src/control-plane/stack-spec.ts +7 -83
  46. package/src/control-plane/types.ts +17 -15
  47. package/src/control-plane/ui-assets.ts +349 -0
  48. package/src/control-plane/validate.ts +44 -79
  49. package/src/index.ts +77 -44
  50. package/src/logger.test.ts +228 -0
  51. package/src/logger.ts +71 -1
  52. package/src/provider-constants.ts +22 -1
  53. package/src/control-plane/env-schema-validation.test.ts +0 -118
  54. package/src/control-plane/memory-config.ts +0 -298
  55. package/src/control-plane/redact-schema.ts +0 -50
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Self-healing install lock for the setup wizard phase.
3
+ *
4
+ * Both `performSetup` (config writes) and `startDeploy` (Docker work) need an
5
+ * exclusive lock against concurrent installs. The lock file lives at
6
+ * `<stateDir>/.install.lock` and contains `<pid>\n<timestamp>\n`.
7
+ *
8
+ * Self-healing rules:
9
+ * - On EEXIST, parse the holder PID. If the process is gone (`process.kill(pid, 0)`
10
+ * throws ESRCH) the lock is stale and we remove + retry once.
11
+ * - If the timestamp is older than STALE_AFTER_MS the lock is stale and we
12
+ * remove + retry once.
13
+ * - If the file is unparseable (e.g. written by an older version) fall back to
14
+ * mtime > STALE_AFTER_MS.
15
+ *
16
+ * On any unexpected error (permissions, ENOSPC, etc.) we return null so the
17
+ * caller surfaces "install_in_progress" rather than silently fake-acquiring.
18
+ */
19
+ import { openSync, writeSync, closeSync, readFileSync, statSync, rmSync, mkdirSync, constants } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { createLogger } from "../logger.js";
22
+
23
+ const logger = createLogger("install-lock");
24
+
25
+ const STALE_AFTER_MS = 30 * 60 * 1000; // 30 minutes
26
+
27
+ export type InstallLockHandle = {
28
+ path: string;
29
+ };
30
+
31
+ function isProcessAlive(pid: number): boolean {
32
+ try {
33
+ process.kill(pid, 0);
34
+ return true;
35
+ } catch (err) {
36
+ // ESRCH = no such process. EPERM = process exists but we don't own it.
37
+ return (err as NodeJS.ErrnoException).code === "EPERM";
38
+ }
39
+ }
40
+
41
+ function parseLockContent(content: string): { pid: number | null; timestamp: number | null } {
42
+ const lines = content.split("\n");
43
+ const pid = Number.parseInt(lines[0] ?? "", 10);
44
+ const timestamp = Number.parseInt(lines[1] ?? "", 10);
45
+ return {
46
+ pid: Number.isFinite(pid) && pid > 0 ? pid : null,
47
+ timestamp: Number.isFinite(timestamp) && timestamp > 0 ? timestamp : null,
48
+ };
49
+ }
50
+
51
+ function isStale(path: string): boolean {
52
+ let content = "";
53
+ try {
54
+ content = readFileSync(path, "utf-8");
55
+ } catch {
56
+ // Can't read — assume held; caller will surface error.
57
+ return false;
58
+ }
59
+ const { pid, timestamp } = parseLockContent(content);
60
+ if (pid !== null) {
61
+ if (!isProcessAlive(pid)) return true;
62
+ if (timestamp !== null && Date.now() - timestamp > STALE_AFTER_MS) return true;
63
+ return false;
64
+ }
65
+ // Unparseable — fall back to mtime.
66
+ try {
67
+ const stat = statSync(path);
68
+ return Date.now() - stat.mtimeMs > STALE_AFTER_MS;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ function tryCreate(path: string): boolean {
75
+ const content = `${process.pid}\n${Date.now()}\n`;
76
+ try {
77
+ const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o644);
78
+ try {
79
+ writeSync(fd, content);
80
+ } finally {
81
+ try { closeSync(fd); } catch { /* best-effort */ }
82
+ }
83
+ return true;
84
+ } catch (err: unknown) {
85
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") return false;
86
+ // Unexpected error — propagate so caller returns null.
87
+ throw err;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Try to acquire the install lock under `stateDir`. Returns a handle on
93
+ * success or null if the lock is held by a live, recent install (or on any
94
+ * unexpected filesystem error — caller should surface "install_in_progress").
95
+ *
96
+ * Callers MUST call `releaseInstallLock()` in a finally block when done.
97
+ */
98
+ export function acquireInstallLock(stateDir: string): InstallLockHandle | null {
99
+ try {
100
+ mkdirSync(stateDir, { recursive: true });
101
+ } catch (err) {
102
+ logger.warn("failed to ensure state dir for install lock", {
103
+ stateDir,
104
+ error: err instanceof Error ? err.message : String(err),
105
+ });
106
+ return null;
107
+ }
108
+ const path = join(stateDir, ".install.lock");
109
+
110
+ try {
111
+ if (tryCreate(path)) return { path };
112
+ } catch (err) {
113
+ logger.warn("unexpected error acquiring install lock", {
114
+ path,
115
+ error: err instanceof Error ? err.message : String(err),
116
+ });
117
+ return null;
118
+ }
119
+
120
+ // EEXIST — check whether the existing lock is stale.
121
+ if (!isStale(path)) return null;
122
+
123
+ logger.info("removing stale install lock and retrying acquire", { path });
124
+ try {
125
+ rmSync(path, { force: true });
126
+ } catch (err) {
127
+ logger.warn("failed to remove stale install lock", {
128
+ path,
129
+ error: err instanceof Error ? err.message : String(err),
130
+ });
131
+ return null;
132
+ }
133
+
134
+ try {
135
+ if (tryCreate(path)) return { path };
136
+ } catch (err) {
137
+ logger.warn("unexpected error re-acquiring install lock", {
138
+ path,
139
+ error: err instanceof Error ? err.message : String(err),
140
+ });
141
+ return null;
142
+ }
143
+ // Lost the race with another acquirer.
144
+ return null;
145
+ }
146
+
147
+ export function releaseInstallLock(handle: InstallLockHandle | null): void {
148
+ if (!handle) return;
149
+ try {
150
+ rmSync(handle.path, { force: true });
151
+ } catch (err) {
152
+ logger.warn("failed to release install lock", {
153
+ path: handle.path,
154
+ error: err instanceof Error ? err.message : String(err),
155
+ });
156
+ }
157
+ }
@@ -1,30 +1,32 @@
1
1
  /** Lifecycle helpers — state factory, apply transitions, compose file list. */
2
- import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from "node:fs";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
3
3
  import { parseEnvFile, mergeEnvContent } from "./env.js";
4
- import type { ControlPlaneState, CallerType } from "./types.js";
4
+ import type { ControlPlaneState, CallerType, AuditContext } from "./types.js";
5
5
  import { CORE_SERVICES } from "./types.js";
6
6
  import {
7
7
  resolveOpenPalmHome,
8
8
  resolveConfigDir,
9
- resolveVaultDir,
10
- resolveDataDir,
11
- resolveLogsDir,
12
- resolveCacheHome,
9
+ resolveStashDir,
10
+ resolveWorkspaceDir,
11
+ resolveCacheDir,
12
+ resolveStateDir,
13
+ resolveStackDir,
13
14
  } from "./home.js";
14
15
  import { ensureSecrets, readStackEnv, updateSystemSecretsEnv } from "./secrets.js";
15
16
  import {
16
17
  resolveRuntimeFiles,
17
18
  writeRuntimeFiles,
18
- randomHex,
19
19
  buildEnvFiles,
20
20
  discoverStackOverlays,
21
+ ensureComposeVolumeTargets,
21
22
  } from "./config-persistence.js";
22
23
  import { readStackSpec } from "./stack-spec.js";
23
- import { refreshCoreAssets, ensureMemoryDir } from "./core-assets.js";
24
+ import { refreshCoreAssets } from "./core-assets.js";
24
25
  import { isSetupComplete } from "./setup-status.js";
25
26
  import { snapshotCurrentState } from "./rollback.js";
26
27
  import { checkDocker, composePreflight, composePull, composeUp, composeConfigServices, resolveComposeProjectName } from "./docker.js";
27
28
  import { acquireLock, releaseLock } from "./lock.js";
29
+ import { appendAudit } from "./audit.js";
28
30
  import { listEnabledAddonIds } from "./registry.js";
29
31
 
30
32
  const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
@@ -36,27 +38,27 @@ export function createState(
36
38
  ): ControlPlaneState {
37
39
  const homeDir = resolveOpenPalmHome();
38
40
  const configDir = resolveConfigDir();
39
- const vaultDir = resolveVaultDir();
40
- const dataDir = resolveDataDir();
41
- const logsDir = resolveLogsDir();
42
- const cacheDir = resolveCacheHome();
41
+ const stashDir = resolveStashDir();
42
+ const workspaceDir = resolveWorkspaceDir();
43
+ const cacheDir = resolveCacheDir();
44
+ const stateDir = resolveStateDir();
45
+ const stackDir = resolveStackDir();
43
46
 
44
47
  const services: Record<string, "running" | "stopped"> = {};
45
48
  for (const name of CORE_SERVICES) {
46
49
  services[name] = "stopped";
47
50
  }
48
51
 
49
- const setupToken = randomHex(16);
50
52
  const bootstrapState: ControlPlaneState = {
51
- adminToken: adminToken ?? process.env.OP_ADMIN_TOKEN ?? "",
53
+ adminToken: adminToken ?? process.env.OP_UI_TOKEN ?? "",
52
54
  assistantToken: "",
53
- setupToken,
54
55
  homeDir,
55
56
  configDir,
56
- vaultDir,
57
- dataDir,
58
- logsDir,
57
+ stashDir,
58
+ workspaceDir,
59
59
  cacheDir,
60
+ stateDir,
61
+ stackDir,
60
62
  services,
61
63
  artifacts: { compose: "" },
62
64
  artifactMeta: [],
@@ -65,35 +67,21 @@ export function createState(
65
67
 
66
68
  ensureSecrets(bootstrapState);
67
69
 
68
- const stackEnv = readStackEnv(vaultDir);
70
+ const stackEnv = readStackEnv(stackDir);
69
71
  // Precedence: explicit parameter > stack.env > process.env.
70
72
  bootstrapState.adminToken =
71
73
  adminToken
72
- ?? stackEnv.OP_ADMIN_TOKEN
73
- ?? process.env.OP_ADMIN_TOKEN
74
+ ?? stackEnv.OP_UI_TOKEN
75
+ ?? process.env.OP_UI_TOKEN
74
76
  ?? "";
75
77
  bootstrapState.assistantToken =
76
78
  stackEnv.OP_ASSISTANT_TOKEN
77
79
  ?? process.env.OP_ASSISTANT_TOKEN
78
80
  ?? "";
79
81
 
80
- writeSetupTokenFile(bootstrapState);
81
-
82
82
  return bootstrapState;
83
83
  }
84
84
 
85
- export function writeSetupTokenFile(state: ControlPlaneState): void {
86
- const tokenPath = `${state.dataDir}/setup-token.txt`;
87
- const setupComplete = isSetupComplete(state.vaultDir);
88
-
89
- if (setupComplete) {
90
- try { unlinkSync(tokenPath); } catch { /* already gone */ }
91
- } else {
92
- mkdirSync(state.dataDir, { recursive: true });
93
- writeFileSync(tokenPath, state.setupToken + "\n", { mode: 0o600 });
94
- }
95
- }
96
-
97
85
 
98
86
  async function reconcileCore(
99
87
  state: ControlPlaneState,
@@ -102,10 +90,9 @@ async function reconcileCore(
102
90
  if (opts.activateServices) {
103
91
  for (const s of CORE_SERVICES) state.services[s] = "running";
104
92
  }
105
- ensureMemoryDir(state.dataDir);
106
93
 
107
94
  for (const addonName of listEnabledAddonIds(state.homeDir)) {
108
- mkdirSync(`${state.dataDir}/${addonName}`, { recursive: true });
95
+ mkdirSync(`${state.stateDir}/${addonName}`, { recursive: true });
109
96
  }
110
97
 
111
98
  const active: string[] = [];
@@ -155,28 +142,46 @@ async function reconcileCore(
155
142
  return active;
156
143
  }
157
144
 
158
- export async function applyInstall(state: ControlPlaneState): Promise<void> {
145
+ export async function applyInstall(state: ControlPlaneState, ctx?: AuditContext): Promise<void> {
159
146
  const lock = acquireLock(state.homeDir, "install");
160
147
  try {
161
148
  await reconcileCore(state, { activateServices: true });
149
+ // Pre-create host-side volume mount targets as the current user so
150
+ // Docker doesn't create them root-owned (which causes EACCES inside
151
+ // non-root containers).
152
+ ensureComposeVolumeTargets(state);
153
+ if (ctx) appendAudit(state, ctx.actor, "install", {}, true, ctx.requestId ?? "", ctx.callerType ?? "unknown");
154
+ } catch (err) {
155
+ if (ctx) appendAudit(state, ctx.actor, "install", { error: String(err) }, false, ctx.requestId ?? "", ctx.callerType ?? "unknown");
156
+ throw err;
162
157
  } finally {
163
158
  releaseLock(lock);
164
159
  }
165
160
  }
166
161
 
167
- export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted: string[] }> {
162
+ export async function applyUpdate(state: ControlPlaneState, ctx?: AuditContext): Promise<{ restarted: string[] }> {
168
163
  const lock = acquireLock(state.homeDir, "update");
169
164
  try {
170
- return { restarted: await reconcileCore(state, {}) };
165
+ const result = { restarted: await reconcileCore(state, {}) };
166
+ if (ctx) appendAudit(state, ctx.actor, "update", { restarted: result.restarted }, true, ctx.requestId ?? "", ctx.callerType ?? "unknown");
167
+ return result;
168
+ } catch (err) {
169
+ if (ctx) appendAudit(state, ctx.actor, "update", { error: String(err) }, false, ctx.requestId ?? "", ctx.callerType ?? "unknown");
170
+ throw err;
171
171
  } finally {
172
172
  releaseLock(lock);
173
173
  }
174
174
  }
175
175
 
176
- export async function applyUninstall(state: ControlPlaneState): Promise<{ stopped: string[] }> {
176
+ export async function applyUninstall(state: ControlPlaneState, ctx?: AuditContext): Promise<{ stopped: string[] }> {
177
177
  const lock = acquireLock(state.homeDir, "uninstall");
178
178
  try {
179
- return { stopped: await reconcileCore(state, { deactivateServices: true }) };
179
+ const result = { stopped: await reconcileCore(state, { deactivateServices: true }) };
180
+ if (ctx) appendAudit(state, ctx.actor, "uninstall", { stopped: result.stopped }, true, ctx.requestId ?? "", ctx.callerType ?? "unknown");
181
+ return result;
182
+ } catch (err) {
183
+ if (ctx) appendAudit(state, ctx.actor, "uninstall", { error: String(err) }, false, ctx.requestId ?? "", ctx.callerType ?? "unknown");
184
+ throw err;
180
185
  } finally {
181
186
  releaseLock(lock);
182
187
  }
@@ -203,7 +208,7 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
203
208
  namespace: string;
204
209
  tag: string;
205
210
  }> {
206
- const systemEnvPath = `${state.vaultDir}/stack/stack.env`;
211
+ const systemEnvPath = `${state.stackDir}/stack.env`;
207
212
  const parsed = parseEnvFile(systemEnvPath);
208
213
  const namespace = (parsed.OP_IMAGE_NAMESPACE ?? process.env.OP_IMAGE_NAMESPACE ?? "openpalm").trim().toLowerCase();
209
214
 
@@ -273,22 +278,18 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
273
278
  const files = buildComposeFileList(state);
274
279
  const envFiles = buildEnvFiles(state);
275
280
 
276
- // 1. Preflight: validate compose merge before any mutation
277
- if (files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
278
- const preflight = await composePreflight({ files, envFiles });
279
- if (!preflight.ok) {
280
- throw new Error(`Compose preflight failed: ${preflight.stderr}`);
281
- }
282
- }
281
+ // Compose preflight runs inside `applyUpgrade` -> `reconcileCore`, so we
282
+ // skip the redundant top-level call. Any merge failure aborts before
283
+ // mutation just the same.
283
284
 
284
- // 2. Snapshot stack.env for rollback on failure
285
- const stackEnvPath = `${state.vaultDir}/stack/stack.env`;
285
+ // 1. Snapshot stack.env for rollback on failure
286
+ const stackEnvPath = `${state.stackDir}/stack.env`;
286
287
  let originalStackEnv: string | null = null;
287
288
  try {
288
289
  originalStackEnv = readFileSync(stackEnvPath, "utf-8");
289
290
  } catch { /* stack.env may not exist yet */ }
290
291
 
291
- // 3. Update image tag + refresh core assets
292
+ // 2. Update image tag + refresh core assets
292
293
  let imageTag: string;
293
294
  let namespace: string;
294
295
  let upgradeResult: { backupDir: string | null; updated: string[]; restarted: string[] };
@@ -305,13 +306,13 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
305
306
  throw e;
306
307
  }
307
308
 
308
- // 4. Pull images
309
+ // 3. Pull images
309
310
  const pullResult = await composePull({ files, envFiles });
310
311
  if (!pullResult.ok) {
311
312
  throw new Error(`Failed to pull images: ${pullResult.stderr}`);
312
313
  }
313
314
 
314
- // 5. Recreate containers
315
+ // 4. Recreate containers
315
316
  const services = await buildManagedServices(state);
316
317
  const upResult = await composeUp({ files, envFiles, services, removeOrphans: true });
317
318
  if (!upResult.ok) {
@@ -328,7 +329,7 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
328
329
  }
329
330
 
330
331
  export function buildComposeFileList(state: ControlPlaneState): string[] {
331
- return discoverStackOverlays(`${state.homeDir}/stack`);
332
+ return discoverStackOverlays(state.stackDir);
332
333
  }
333
334
 
334
335
  export async function buildManagedServices(state: ControlPlaneState): Promise<string[]> {
@@ -0,0 +1,200 @@
1
+ /**
2
+ * AKM markdown task parser.
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
10
+ * workflow — `workflow: workflow:<ref>` + optional `params` map
11
+ */
12
+ import { parse as parseYaml } from "yaml";
13
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import type { AutomationConfig } from "./scheduler.js";
16
+ import { createLogger } from "../logger.js";
17
+
18
+ const logger = createLogger("markdown-task");
19
+
20
+ // ── Types ─────────────────────────────────────────────────────────────────
21
+
22
+ export interface MarkdownTask {
23
+ id: string;
24
+ schedule: string;
25
+ enabled: boolean;
26
+ description?: string;
27
+ tags?: string[];
28
+ timeoutMs?: number;
29
+ target: MarkdownTaskTarget;
30
+ source: { path: string };
31
+ }
32
+
33
+ export type MarkdownTaskTarget =
34
+ | { kind: "command"; cmd: string[] }
35
+ | { kind: "prompt"; profile?: string; body: string }
36
+ | { kind: "workflow"; ref: string; params: Record<string, unknown> };
37
+
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
+ // ── Parser ────────────────────────────────────────────────────────────────
58
+
59
+ export function parseMarkdownTask(filePath: string): MarkdownTask | null {
60
+ const id = filePath.replace(/.*[\\/]/, "").replace(/\.md$/, "");
61
+ let raw: string;
62
+ try {
63
+ raw = readFileSync(filePath, "utf-8");
64
+ } catch (err) {
65
+ logger.warn("failed to read task file", { filePath, error: String(err) });
66
+ return null;
67
+ }
68
+
69
+ const parts = splitFrontmatter(raw);
70
+ if (!parts) {
71
+ logger.warn("task file missing frontmatter delimiters", { filePath });
72
+ return null;
73
+ }
74
+
75
+ let fm: Record<string, unknown>;
76
+ try {
77
+ fm = parseYaml(parts.frontmatter) as Record<string, unknown>;
78
+ } 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 });
85
+ return null;
86
+ }
87
+
88
+ const schedule = fm.schedule;
89
+ if (typeof schedule !== "string" || !schedule.trim()) {
90
+ logger.warn("task missing or empty 'schedule'", { filePath });
91
+ return null;
92
+ }
93
+
94
+ // Resolve target type from frontmatter
95
+ let target: MarkdownTaskTarget;
96
+
97
+ if (fm.command !== undefined) {
98
+ const cmd = Array.isArray(fm.command)
99
+ ? fm.command.map(String)
100
+ : typeof fm.command === "string"
101
+ ? [fm.command]
102
+ : null;
103
+ if (!cmd || cmd.length === 0) {
104
+ logger.warn("task 'command' must be a non-empty array", { filePath });
105
+ return null;
106
+ }
107
+ target = { kind: "command", cmd };
108
+ } 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 });
112
+ return null;
113
+ }
114
+ if (!parts.body) {
115
+ logger.warn("prompt:inline task has no markdown body", { filePath });
116
+ return null;
117
+ }
118
+ target = {
119
+ kind: "prompt",
120
+ profile: typeof fm.profile === "string" ? fm.profile : undefined,
121
+ body: parts.body,
122
+ };
123
+ } else if (fm.workflow !== undefined) {
124
+ if (typeof fm.workflow !== "string") {
125
+ logger.warn("task 'workflow' must be a string ref", { filePath });
126
+ return null;
127
+ }
128
+ target = {
129
+ kind: "workflow",
130
+ ref: fm.workflow,
131
+ params: (fm.params && typeof fm.params === "object" && !Array.isArray(fm.params))
132
+ ? fm.params as Record<string, unknown>
133
+ : {},
134
+ };
135
+ } else {
136
+ logger.warn("task must have one of: command, prompt, workflow", { filePath });
137
+ return null;
138
+ }
139
+
140
+ return {
141
+ id,
142
+ schedule: schedule.trim(),
143
+ enabled: fm.enabled !== false,
144
+ description: typeof fm.description === "string" ? fm.description : undefined,
145
+ tags: Array.isArray(fm.tags) ? fm.tags.map(String) : undefined,
146
+ timeoutMs: typeof fm.timeoutMs === "number" ? fm.timeoutMs : undefined,
147
+ target,
148
+ source: { path: filePath },
149
+ };
150
+ }
151
+
152
+ export function loadMarkdownTasks(stashDir: string): MarkdownTask[] {
153
+ const dir = join(stashDir, "tasks");
154
+ if (!existsSync(dir)) return [];
155
+
156
+ const tasks: MarkdownTask[] = [];
157
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
158
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
159
+ const task = parseMarkdownTask(join(dir, entry.name));
160
+ if (task) tasks.push(task);
161
+ }
162
+ return tasks;
163
+ }
164
+
165
+ // ── AutomationConfig adapter ──────────────────────────────────────────────
166
+ // Keeps the GET /admin/automations response shape compatible so the existing
167
+ // UI does not require changes.
168
+
169
+ export function taskToAutomationConfig(task: MarkdownTask): AutomationConfig {
170
+ const { target } = task;
171
+
172
+ let actionType: "shell" | "assistant" | "workflow" | "api" | "http";
173
+ let content: string | undefined;
174
+ let agent: string | undefined;
175
+
176
+ if (target.kind === "command") {
177
+ actionType = "shell";
178
+ } else if (target.kind === "prompt") {
179
+ actionType = "assistant";
180
+ content = target.body;
181
+ agent = target.profile;
182
+ } else {
183
+ actionType = "workflow";
184
+ }
185
+
186
+ return {
187
+ name: task.id,
188
+ description: task.description ?? "",
189
+ schedule: task.schedule,
190
+ timezone: "",
191
+ enabled: task.enabled,
192
+ action: {
193
+ type: actionType,
194
+ content,
195
+ agent,
196
+ },
197
+ on_failure: "log",
198
+ fileName: `${task.id}.md`,
199
+ };
200
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Authoritative path resolution for the OpenPalm control plane.
3
+ *
4
+ * Every consumer imports from here instead of concatenating paths inline.
5
+ * When the directory layout changes, update this file only.
6
+ *
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)
13
+ * workspace/ — shared work area
14
+ */
15
+ import type { ControlPlaneState } from "./types.js";
16
+
17
+ // ── Config directory — user + system config ─────────────────────────────────
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) */
22
+ export const akmConfigDir = (s: ControlPlaneState): string => `${s.configDir}/akm`;
23
+ /** akm setup config file (written by admin on capability save) */
24
+ export const akmConfigPath = (s: ControlPlaneState): string => `${s.configDir}/akm/config.json`;
25
+ export const tasksDir = (s: ControlPlaneState): string => `${s.stashDir}/tasks`;
26
+ export const assistantConfigDir = (s: ControlPlaneState): string => `${s.configDir}/assistant`;
27
+
28
+ // ── Config/stack directory — compose runtime + stack config ─────────────────
29
+
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 ───────────────────────────
38
+
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`;
42
+
43
+ // ── State directory — persistent service data ───────────────────────────────
44
+
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`;
55
+ export const adminAuditPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/admin-audit.jsonl`;
56
+ export const guardianAuditPath = (s: ControlPlaneState): string => `${s.stateDir}/logs/guardian-audit.log`;
57
+ export const backupsDir = (s: ControlPlaneState): string => `${s.stateDir}/backups`;
58
+ export const registryDir = (s: ControlPlaneState): string => `${s.stateDir}/registry`;
59
+ export const registryAddonsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/addons`;
60
+ export const registryAutomationsDir = (s: ControlPlaneState): string => `${s.stateDir}/registry/automations`;
61
+ export const secretsDir = (s: ControlPlaneState): string => `${s.stateDir}/secrets`;
62
+ export const secretProviderPath = (s: ControlPlaneState): string => `${s.stateDir}/secrets/provider.json`;
63
+ export const secretsIndexPath = (s: ControlPlaneState): string => `${s.stateDir}/secrets/plaintext-index.json`;
64
+ export const passStoreDir = (s: ControlPlaneState): string => `${s.stateDir}/secrets/pass-store`;
65
+
66
+ // ── Stash directory ─────────────────────────────────────────────────────────
67
+
68
+ /** akm vault:user file — lives in the stash */
69
+ export const akmUserVaultPath = (s: ControlPlaneState): string => `${s.stashDir}/vaults/user.env`;
70
+
71
+ // ── Stack directory ─────────────────────────────────────────────────────────
72
+
73
+ export const coreComposePath = (s: ControlPlaneState): string => `${s.stackDir}/core.compose.yml`;
74
+ export const addonsStackDir = (s: ControlPlaneState): string => `${s.stackDir}/addons`;
75
+ export const addonComposePath = (s: ControlPlaneState, name: string): string => `${s.stackDir}/addons/${name}/compose.yml`;