@openpalm/lib 0.10.2 → 0.11.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +4 -2
  2. package/package.json +11 -3
  3. package/src/control-plane/akm-vault.test.ts +105 -0
  4. package/src/control-plane/akm-vault.ts +311 -0
  5. package/src/control-plane/channels.ts +11 -9
  6. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  7. package/src/control-plane/compose-args.test.ts +25 -33
  8. package/src/control-plane/compose-args.ts +0 -4
  9. package/src/control-plane/compose-errors.test.ts +106 -0
  10. package/src/control-plane/compose-errors.ts +117 -0
  11. package/src/control-plane/config-persistence.ts +148 -73
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +111 -58
  14. package/src/control-plane/docker.ts +70 -25
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +84 -1
  17. package/src/control-plane/home.ts +66 -69
  18. package/src/control-plane/host-opencode.test.ts +260 -0
  19. package/src/control-plane/host-opencode.ts +229 -0
  20. package/src/control-plane/install-edge-cases.test.ts +190 -292
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +65 -75
  23. package/src/control-plane/markdown-task.ts +200 -0
  24. package/src/control-plane/migrate-0110.test.ts +177 -0
  25. package/src/control-plane/migrate-0110.ts +99 -0
  26. package/src/control-plane/operator-ids.test.ts +130 -0
  27. package/src/control-plane/operator-ids.ts +89 -0
  28. package/src/control-plane/paths.ts +80 -0
  29. package/src/control-plane/provider-models.ts +154 -0
  30. package/src/control-plane/registry-components.test.ts +105 -27
  31. package/src/control-plane/registry.test.ts +247 -51
  32. package/src/control-plane/registry.ts +404 -54
  33. package/src/control-plane/rollback.ts +17 -16
  34. package/src/control-plane/scheduler.ts +75 -262
  35. package/src/control-plane/secret-mappings.ts +4 -8
  36. package/src/control-plane/secrets.ts +97 -55
  37. package/src/control-plane/setup-config.schema.json +5 -17
  38. package/src/control-plane/setup-status.ts +9 -29
  39. package/src/control-plane/setup-validation.ts +23 -23
  40. package/src/control-plane/setup.test.ts +143 -244
  41. package/src/control-plane/setup.ts +216 -133
  42. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  43. package/src/control-plane/spec-to-env.test.ts +75 -60
  44. package/src/control-plane/spec-to-env.ts +68 -153
  45. package/src/control-plane/stack-spec.test.ts +22 -84
  46. package/src/control-plane/stack-spec.ts +9 -89
  47. package/src/control-plane/types.ts +9 -29
  48. package/src/control-plane/ui-assets.ts +385 -0
  49. package/src/control-plane/validate.ts +44 -79
  50. package/src/index.ts +102 -56
  51. package/src/logger.test.ts +228 -0
  52. package/src/logger.ts +71 -1
  53. package/src/provider-constants.ts +22 -1
  54. package/src/control-plane/audit.ts +0 -40
  55. package/src/control-plane/env-schema-validation.test.ts +0 -118
  56. package/src/control-plane/lock.test.ts +0 -194
  57. package/src/control-plane/lock.ts +0 -176
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/provider-config.ts +0 -34
  60. package/src/control-plane/redact-schema.ts +0 -50
  61. package/src/control-plane/secret-backend.test.ts +0 -359
  62. package/src/control-plane/secret-backend.ts +0 -322
  63. package/src/control-plane/spec-validator.ts +0 -159
@@ -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,99 +1,68 @@
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
4
  import type { ControlPlaneState, CallerType } 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
- import { ensureSecrets, readStackEnv, updateSystemSecretsEnv } from "./secrets.js";
15
+ import { ensureSecrets } 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
- import { readStackSpec } from "./stack-spec.js";
23
- import { refreshCoreAssets, ensureMemoryDir } from "./core-assets.js";
23
+ import { refreshCoreAssets } from "./core-assets.js";
24
24
  import { isSetupComplete } from "./setup-status.js";
25
25
  import { snapshotCurrentState } from "./rollback.js";
26
26
  import { checkDocker, composePreflight, composePull, composeUp, composeConfigServices, resolveComposeProjectName } from "./docker.js";
27
- import { acquireLock, releaseLock } from "./lock.js";
27
+ import { acquireInstallLock, releaseInstallLock } from "./install-lock.js";
28
28
  import { listEnabledAddonIds } from "./registry.js";
29
29
 
30
30
  const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
31
31
  const SEMVER_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
32
32
 
33
33
 
34
- export function createState(
35
- adminToken?: string
36
- ): ControlPlaneState {
34
+ export function createState(): ControlPlaneState {
37
35
  const homeDir = resolveOpenPalmHome();
38
36
  const configDir = resolveConfigDir();
39
- const vaultDir = resolveVaultDir();
40
- const dataDir = resolveDataDir();
41
- const logsDir = resolveLogsDir();
42
- const cacheDir = resolveCacheHome();
37
+ const stashDir = resolveStashDir();
38
+ const workspaceDir = resolveWorkspaceDir();
39
+ const cacheDir = resolveCacheDir();
40
+ const stateDir = resolveStateDir();
41
+ const stackDir = resolveStackDir();
43
42
 
44
43
  const services: Record<string, "running" | "stopped"> = {};
45
44
  for (const name of CORE_SERVICES) {
46
45
  services[name] = "stopped";
47
46
  }
48
47
 
49
- const setupToken = randomHex(16);
50
48
  const bootstrapState: ControlPlaneState = {
51
- adminToken: adminToken ?? process.env.OP_ADMIN_TOKEN ?? "",
52
- assistantToken: "",
53
- setupToken,
54
49
  homeDir,
55
50
  configDir,
56
- vaultDir,
57
- dataDir,
58
- logsDir,
51
+ stashDir,
52
+ workspaceDir,
59
53
  cacheDir,
54
+ stateDir,
55
+ stackDir,
60
56
  services,
61
57
  artifacts: { compose: "" },
62
58
  artifactMeta: [],
63
- audit: [],
64
59
  };
65
60
 
66
61
  ensureSecrets(bootstrapState);
67
62
 
68
- const stackEnv = readStackEnv(vaultDir);
69
- // Precedence: explicit parameter > stack.env > process.env.
70
- bootstrapState.adminToken =
71
- adminToken
72
- ?? stackEnv.OP_ADMIN_TOKEN
73
- ?? process.env.OP_ADMIN_TOKEN
74
- ?? "";
75
- bootstrapState.assistantToken =
76
- stackEnv.OP_ASSISTANT_TOKEN
77
- ?? process.env.OP_ASSISTANT_TOKEN
78
- ?? "";
79
-
80
- writeSetupTokenFile(bootstrapState);
81
-
82
63
  return bootstrapState;
83
64
  }
84
65
 
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
66
 
98
67
  async function reconcileCore(
99
68
  state: ControlPlaneState,
@@ -102,10 +71,9 @@ async function reconcileCore(
102
71
  if (opts.activateServices) {
103
72
  for (const s of CORE_SERVICES) state.services[s] = "running";
104
73
  }
105
- ensureMemoryDir(state.dataDir);
106
74
 
107
75
  for (const addonName of listEnabledAddonIds(state.homeDir)) {
108
- mkdirSync(`${state.dataDir}/${addonName}`, { recursive: true });
76
+ mkdirSync(`${state.stateDir}/${addonName}`, { recursive: true });
109
77
  }
110
78
 
111
79
  const active: string[] = [];
@@ -156,29 +124,36 @@ async function reconcileCore(
156
124
  }
157
125
 
158
126
  export async function applyInstall(state: ControlPlaneState): Promise<void> {
159
- const lock = acquireLock(state.homeDir, "install");
127
+ const lock = acquireInstallLock(state.stateDir);
128
+ if (!lock) throw new Error("Another install is already in progress");
160
129
  try {
161
130
  await reconcileCore(state, { activateServices: true });
131
+ // Pre-create host-side volume mount targets as the current user so
132
+ // Docker doesn't create them root-owned (which causes EACCES inside
133
+ // non-root containers).
134
+ ensureComposeVolumeTargets(state);
162
135
  } finally {
163
- releaseLock(lock);
136
+ releaseInstallLock(lock);
164
137
  }
165
138
  }
166
139
 
167
140
  export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted: string[] }> {
168
- const lock = acquireLock(state.homeDir, "update");
141
+ const lock = acquireInstallLock(state.stateDir);
142
+ if (!lock) throw new Error("Another install is already in progress");
169
143
  try {
170
144
  return { restarted: await reconcileCore(state, {}) };
171
145
  } finally {
172
- releaseLock(lock);
146
+ releaseInstallLock(lock);
173
147
  }
174
148
  }
175
149
 
176
150
  export async function applyUninstall(state: ControlPlaneState): Promise<{ stopped: string[] }> {
177
- const lock = acquireLock(state.homeDir, "uninstall");
151
+ const lock = acquireInstallLock(state.stateDir);
152
+ if (!lock) throw new Error("Another install is already in progress");
178
153
  try {
179
154
  return { stopped: await reconcileCore(state, { deactivateServices: true }) };
180
155
  } finally {
181
- releaseLock(lock);
156
+ releaseInstallLock(lock);
182
157
  }
183
158
  }
184
159
 
@@ -203,7 +178,7 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
203
178
  namespace: string;
204
179
  tag: string;
205
180
  }> {
206
- const systemEnvPath = `${state.vaultDir}/stack/stack.env`;
181
+ const systemEnvPath = `${state.stackDir}/stack.env`;
207
182
  const parsed = parseEnvFile(systemEnvPath);
208
183
  const namespace = (parsed.OP_IMAGE_NAMESPACE ?? process.env.OP_IMAGE_NAMESPACE ?? "openpalm").trim().toLowerCase();
209
184
 
@@ -245,13 +220,14 @@ export async function applyUpgrade(
245
220
  updated: string[];
246
221
  restarted: string[];
247
222
  }> {
248
- const lock = acquireLock(state.homeDir, "upgrade");
223
+ const lock = acquireInstallLock(state.stateDir);
224
+ if (!lock) throw new Error("Another install is already in progress");
249
225
  try {
250
226
  const { backupDir, updated } = await refreshCoreAssets();
251
227
  const restarted = await reconcileCore(state, {});
252
228
  return { backupDir, updated, restarted };
253
229
  } finally {
254
- releaseLock(lock);
230
+ releaseInstallLock(lock);
255
231
  }
256
232
  }
257
233
 
@@ -273,22 +249,18 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
273
249
  const files = buildComposeFileList(state);
274
250
  const envFiles = buildEnvFiles(state);
275
251
 
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
- }
252
+ // Compose preflight runs inside `applyUpgrade` -> `reconcileCore`, so we
253
+ // skip the redundant top-level call. Any merge failure aborts before
254
+ // mutation just the same.
283
255
 
284
- // 2. Snapshot stack.env for rollback on failure
285
- const stackEnvPath = `${state.vaultDir}/stack/stack.env`;
256
+ // 1. Snapshot stack.env for rollback on failure
257
+ const stackEnvPath = `${state.stackDir}/stack.env`;
286
258
  let originalStackEnv: string | null = null;
287
259
  try {
288
260
  originalStackEnv = readFileSync(stackEnvPath, "utf-8");
289
261
  } catch { /* stack.env may not exist yet */ }
290
262
 
291
- // 3. Update image tag + refresh core assets
263
+ // 2. Update image tag + refresh core assets
292
264
  let imageTag: string;
293
265
  let namespace: string;
294
266
  let upgradeResult: { backupDir: string | null; updated: string[]; restarted: string[] };
@@ -305,13 +277,13 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
305
277
  throw e;
306
278
  }
307
279
 
308
- // 4. Pull images
280
+ // 3. Pull images
309
281
  const pullResult = await composePull({ files, envFiles });
310
282
  if (!pullResult.ok) {
311
283
  throw new Error(`Failed to pull images: ${pullResult.stderr}`);
312
284
  }
313
285
 
314
- // 5. Recreate containers
286
+ // 4. Recreate containers
315
287
  const services = await buildManagedServices(state);
316
288
  const upResult = await composeUp({ files, envFiles, services, removeOrphans: true });
317
289
  if (!upResult.ok) {
@@ -327,8 +299,26 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
327
299
  };
328
300
  }
329
301
 
302
+ /**
303
+ * Set a specific image tag in stack.env then pull images and restart containers.
304
+ * Used by the admin "set version" action — skips the auto-detect step in performUpgrade.
305
+ */
306
+ export async function applyTagChange(state: ControlPlaneState, tag: string): Promise<UpgradeResult> {
307
+ const stackEnvPath = `${state.stackDir}/stack.env`;
308
+ const currentContent = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
309
+ writeFileSync(stackEnvPath, mergeEnvContent(currentContent, { OP_IMAGE_TAG: tag }, { uncomment: true }));
310
+ const upgradeResult = await applyUpgrade(state);
311
+ return {
312
+ imageTag: tag,
313
+ namespace: "openpalm",
314
+ backupDir: upgradeResult.backupDir,
315
+ assetsUpdated: upgradeResult.updated,
316
+ restarted: upgradeResult.restarted,
317
+ };
318
+ }
319
+
330
320
  export function buildComposeFileList(state: ControlPlaneState): string[] {
331
- return discoverStackOverlays(`${state.homeDir}/stack`);
321
+ return discoverStackOverlays(state.stackDir);
332
322
  }
333
323
 
334
324
  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
+ }