@openpalm/lib 0.10.2 → 0.11.0-beta.2

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 (59) 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 +105 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/channels.ts +3 -3
  7. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  8. package/src/control-plane/compose-args.test.ts +25 -24
  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 +103 -65
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +54 -57
  14. package/src/control-plane/docker.ts +55 -21
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +80 -0
  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 +187 -289
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +34 -65
  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/paths.ts +82 -0
  27. package/src/control-plane/provider-config.ts +2 -2
  28. package/src/control-plane/provider-models.ts +154 -0
  29. package/src/control-plane/registry-components.test.ts +105 -27
  30. package/src/control-plane/registry.test.ts +49 -47
  31. package/src/control-plane/registry.ts +71 -50
  32. package/src/control-plane/rollback.ts +17 -16
  33. package/src/control-plane/scheduler.ts +75 -262
  34. package/src/control-plane/secret-backend.test.ts +98 -111
  35. package/src/control-plane/secret-backend.ts +221 -181
  36. package/src/control-plane/secret-mappings.ts +4 -8
  37. package/src/control-plane/secrets.ts +93 -51
  38. package/src/control-plane/setup-config.schema.json +5 -17
  39. package/src/control-plane/setup-status.ts +9 -29
  40. package/src/control-plane/setup-validation.ts +23 -23
  41. package/src/control-plane/setup.test.ts +138 -239
  42. package/src/control-plane/setup.ts +215 -130
  43. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  44. package/src/control-plane/spec-to-env.test.ts +59 -58
  45. package/src/control-plane/spec-to-env.ts +52 -142
  46. package/src/control-plane/spec-validator.ts +2 -99
  47. package/src/control-plane/stack-spec.test.ts +21 -77
  48. package/src/control-plane/stack-spec.ts +7 -83
  49. package/src/control-plane/types.ts +12 -28
  50. package/src/control-plane/ui-assets.ts +349 -0
  51. package/src/control-plane/validate.ts +44 -79
  52. package/src/index.ts +86 -48
  53. package/src/logger.test.ts +228 -0
  54. package/src/logger.ts +71 -1
  55. package/src/provider-constants.ts +22 -1
  56. package/src/control-plane/audit.ts +0 -40
  57. package/src/control-plane/env-schema-validation.test.ts +0 -118
  58. package/src/control-plane/memory-config.ts +0 -298
  59. 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,26 +1,27 @@
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
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";
@@ -31,69 +32,38 @@ const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
31
32
  const SEMVER_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
32
33
 
33
34
 
34
- export function createState(
35
- adminToken?: string
36
- ): ControlPlaneState {
35
+ export function createState(): ControlPlaneState {
37
36
  const homeDir = resolveOpenPalmHome();
38
37
  const configDir = resolveConfigDir();
39
- const vaultDir = resolveVaultDir();
40
- const dataDir = resolveDataDir();
41
- const logsDir = resolveLogsDir();
42
- const cacheDir = resolveCacheHome();
38
+ const stashDir = resolveStashDir();
39
+ const workspaceDir = resolveWorkspaceDir();
40
+ const cacheDir = resolveCacheDir();
41
+ const stateDir = resolveStateDir();
42
+ const stackDir = resolveStackDir();
43
43
 
44
44
  const services: Record<string, "running" | "stopped"> = {};
45
45
  for (const name of CORE_SERVICES) {
46
46
  services[name] = "stopped";
47
47
  }
48
48
 
49
- const setupToken = randomHex(16);
50
49
  const bootstrapState: ControlPlaneState = {
51
- adminToken: adminToken ?? process.env.OP_ADMIN_TOKEN ?? "",
52
- assistantToken: "",
53
- setupToken,
54
50
  homeDir,
55
51
  configDir,
56
- vaultDir,
57
- dataDir,
58
- logsDir,
52
+ stashDir,
53
+ workspaceDir,
59
54
  cacheDir,
55
+ stateDir,
56
+ stackDir,
60
57
  services,
61
58
  artifacts: { compose: "" },
62
59
  artifactMeta: [],
63
- audit: [],
64
60
  };
65
61
 
66
62
  ensureSecrets(bootstrapState);
67
63
 
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
64
  return bootstrapState;
83
65
  }
84
66
 
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
67
 
98
68
  async function reconcileCore(
99
69
  state: ControlPlaneState,
@@ -102,10 +72,9 @@ async function reconcileCore(
102
72
  if (opts.activateServices) {
103
73
  for (const s of CORE_SERVICES) state.services[s] = "running";
104
74
  }
105
- ensureMemoryDir(state.dataDir);
106
75
 
107
76
  for (const addonName of listEnabledAddonIds(state.homeDir)) {
108
- mkdirSync(`${state.dataDir}/${addonName}`, { recursive: true });
77
+ mkdirSync(`${state.stateDir}/${addonName}`, { recursive: true });
109
78
  }
110
79
 
111
80
  const active: string[] = [];
@@ -159,6 +128,10 @@ export async function applyInstall(state: ControlPlaneState): Promise<void> {
159
128
  const lock = acquireLock(state.homeDir, "install");
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
136
  releaseLock(lock);
164
137
  }
@@ -203,7 +176,7 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
203
176
  namespace: string;
204
177
  tag: string;
205
178
  }> {
206
- const systemEnvPath = `${state.vaultDir}/stack/stack.env`;
179
+ const systemEnvPath = `${state.stackDir}/stack.env`;
207
180
  const parsed = parseEnvFile(systemEnvPath);
208
181
  const namespace = (parsed.OP_IMAGE_NAMESPACE ?? process.env.OP_IMAGE_NAMESPACE ?? "openpalm").trim().toLowerCase();
209
182
 
@@ -273,22 +246,18 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
273
246
  const files = buildComposeFileList(state);
274
247
  const envFiles = buildEnvFiles(state);
275
248
 
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
- }
249
+ // Compose preflight runs inside `applyUpgrade` -> `reconcileCore`, so we
250
+ // skip the redundant top-level call. Any merge failure aborts before
251
+ // mutation just the same.
283
252
 
284
- // 2. Snapshot stack.env for rollback on failure
285
- const stackEnvPath = `${state.vaultDir}/stack/stack.env`;
253
+ // 1. Snapshot stack.env for rollback on failure
254
+ const stackEnvPath = `${state.stackDir}/stack.env`;
286
255
  let originalStackEnv: string | null = null;
287
256
  try {
288
257
  originalStackEnv = readFileSync(stackEnvPath, "utf-8");
289
258
  } catch { /* stack.env may not exist yet */ }
290
259
 
291
- // 3. Update image tag + refresh core assets
260
+ // 2. Update image tag + refresh core assets
292
261
  let imageTag: string;
293
262
  let namespace: string;
294
263
  let upgradeResult: { backupDir: string | null; updated: string[]; restarted: string[] };
@@ -305,13 +274,13 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
305
274
  throw e;
306
275
  }
307
276
 
308
- // 4. Pull images
277
+ // 3. Pull images
309
278
  const pullResult = await composePull({ files, envFiles });
310
279
  if (!pullResult.ok) {
311
280
  throw new Error(`Failed to pull images: ${pullResult.stderr}`);
312
281
  }
313
282
 
314
- // 5. Recreate containers
283
+ // 4. Recreate containers
315
284
  const services = await buildManagedServices(state);
316
285
  const upResult = await composeUp({ files, envFiles, services, removeOrphans: true });
317
286
  if (!upResult.ok) {
@@ -328,7 +297,7 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
328
297
  }
329
298
 
330
299
  export function buildComposeFileList(state: ControlPlaneState): string[] {
331
- return discoverStackOverlays(`${state.homeDir}/stack`);
300
+ return discoverStackOverlays(state.stackDir);
332
301
  }
333
302
 
334
303
  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
+ }