@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
@@ -1,24 +1,27 @@
1
- /** Automation scheduler — types, parsing, and action execution. */
2
- import { parse as parseYaml } from "yaml";
1
+ /**
2
+ * Automation scheduler types and akm CLI integration.
3
+ *
4
+ * Automations are AKM markdown task files at ${stashDir}/tasks/*.md.
5
+ * Scheduling is handled by the OS cron daemon (via `akm tasks sync`).
6
+ * Execution is handled by `akm tasks run <id>`.
7
+ */
3
8
  import { execFile } from "node:child_process";
4
- import { existsSync, readdirSync, readFileSync } from "node:fs";
9
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
5
10
  import { join } from "node:path";
6
11
  import { createLogger } from "../logger.js";
12
+ import { loadMarkdownTasks, taskToAutomationConfig } from "./markdown-task.js";
7
13
 
8
14
  const logger = createLogger("scheduler");
9
15
 
16
+ // ── Types ─────────────────────────────────────────────────────────────────
10
17
 
11
- export type ActionType = "api" | "http" | "shell" | "assistant";
18
+ export type ActionType = "api" | "http" | "shell" | "assistant" | "workflow";
12
19
 
13
20
  export type AutomationAction = {
14
21
  type: ActionType;
15
22
  method?: string;
16
23
  path?: string;
17
24
  url?: string;
18
- body?: unknown;
19
- headers?: Record<string, string>;
20
- command?: string[];
21
- timeout?: number;
22
25
  content?: string;
23
26
  agent?: string;
24
27
  };
@@ -34,13 +37,7 @@ export type AutomationConfig = {
34
37
  fileName: string;
35
38
  };
36
39
 
37
- export type ExecutionLogEntry = {
38
- at: string;
39
- ok: boolean;
40
- durationMs: number;
41
- error?: string;
42
- };
43
-
40
+ // ── Schedule presets (UI display labels only) ─────────────────────────────
44
41
 
45
42
  export const SCHEDULE_PRESETS: Record<string, string> = {
46
43
  "every-minute": "* * * * *",
@@ -54,213 +51,55 @@ export const SCHEDULE_PRESETS: Record<string, string> = {
54
51
  "weekly-sunday-4am": "0 4 * * 0"
55
52
  };
56
53
 
57
- /** Resolve a preset name to cron expression, or pass through raw cron. */
58
- export function resolveSchedule(schedule: string): string {
59
- return SCHEDULE_PRESETS[schedule] ?? schedule;
60
- }
61
-
62
- export function parseAutomationYaml(
63
- content: string,
64
- fileName: string
65
- ): AutomationConfig | null {
66
- let doc: Record<string, unknown>;
67
- try {
68
- doc = parseYaml(content) as Record<string, unknown>;
69
- } catch (err) {
70
- logger.warn("failed to parse automation YAML", { fileName, error: String(err) });
71
- return null;
72
- }
73
-
74
- if (!doc || typeof doc !== "object") {
75
- logger.warn("automation YAML is not an object", { fileName });
76
- return null;
77
- }
54
+ // ── Load automations from AKM task files ──────────────────────────────────
78
55
 
79
- const rawSchedule = doc.schedule;
80
- if (typeof rawSchedule !== "string" || !rawSchedule.trim()) {
81
- logger.warn("automation missing or empty 'schedule'", { fileName });
82
- return null;
83
- }
84
-
85
- const action = doc.action;
86
- if (!action || typeof action !== "object") {
87
- logger.warn("automation missing or invalid 'action'", { fileName });
88
- return null;
89
- }
90
-
91
- const actionObj = action as Record<string, unknown>;
92
- const actionType = actionObj.type as string | undefined;
93
- if (!actionType || !["api", "http", "shell", "assistant"].includes(actionType)) {
94
- logger.warn("automation action has invalid 'type'", {
95
- fileName,
96
- type: String(actionType)
97
- });
98
- return null;
99
- }
100
-
101
- if (actionType === "api" && typeof actionObj.path !== "string") {
102
- logger.warn("api action missing 'path'", { fileName });
103
- return null;
104
- }
105
- if (actionType === "http" && typeof actionObj.url !== "string") {
106
- logger.warn("http action missing 'url'", { fileName });
107
- return null;
108
- }
109
- if (actionType === "shell") {
110
- if (!Array.isArray(actionObj.command) || actionObj.command.length === 0) {
111
- logger.warn("shell action missing or empty 'command' array", { fileName });
112
- return null;
113
- }
114
- }
115
- if (actionType === "assistant") {
116
- if (typeof actionObj.content !== "string" || !actionObj.content.trim()) {
117
- logger.warn("assistant action missing or empty 'content'", { fileName });
118
- return null;
119
- }
120
- }
121
-
122
- const schedule = resolveSchedule(rawSchedule.trim());
123
-
124
- return {
125
- name: typeof doc.name === "string" ? doc.name : fileName.replace(/\.yml$/, ""),
126
- description: typeof doc.description === "string" ? doc.description : "",
127
- schedule,
128
- timezone: typeof doc.timezone === "string" ? doc.timezone : "UTC",
129
- enabled: doc.enabled !== false,
130
- action: {
131
- type: actionType as ActionType,
132
- method: typeof actionObj.method === "string" ? actionObj.method : "GET",
133
- path: typeof actionObj.path === "string" ? actionObj.path : undefined,
134
- url: typeof actionObj.url === "string" ? actionObj.url : undefined,
135
- body: actionObj.body,
136
- headers: (actionObj.headers && typeof actionObj.headers === "object" && !Array.isArray(actionObj.headers) &&
137
- Object.values(actionObj.headers as Record<string, unknown>).every((v) => typeof v === "string"))
138
- ? (actionObj.headers as Record<string, string>) : undefined,
139
- command: Array.isArray(actionObj.command)
140
- ? actionObj.command.map(String)
141
- : undefined,
142
- content: typeof actionObj.content === "string" ? actionObj.content : undefined,
143
- agent: typeof actionObj.agent === "string" ? actionObj.agent : undefined,
144
- timeout:
145
- typeof actionObj.timeout === "number"
146
- ? actionObj.timeout
147
- : actionType === "assistant" ? 120_000 : 30_000
148
- },
149
- on_failure:
150
- doc.on_failure === "audit" ? "audit" : "log",
151
- fileName
152
- };
153
- }
154
-
155
- export function loadAutomations(configDir: string): AutomationConfig[] {
156
- const dir = join(configDir, "automations");
157
- if (!existsSync(dir)) return [];
158
-
159
- const files = readdirSync(dir, { withFileTypes: true });
160
- const configs: AutomationConfig[] = [];
161
-
162
- for (const entry of files) {
163
- if (!entry.isFile()) continue;
164
- if (!entry.name.endsWith(".yml")) {
165
- logger.warn("non-.yml file in automations dir (ignored)", {
166
- file: entry.name,
167
- hint: "automation files must use .yml extension"
168
- });
169
- continue;
170
- }
171
-
172
- const content = readFileSync(join(dir, entry.name), "utf-8");
173
- const config = parseAutomationYaml(content, entry.name);
174
- if (config) configs.push(config);
175
- }
176
-
177
- return configs;
56
+ export function loadAutomations(stashDir: string): AutomationConfig[] {
57
+ return loadMarkdownTasks(stashDir).map(taskToAutomationConfig);
178
58
  }
179
59
 
60
+ // ── Execute an automation via akm tasks run ───────────────────────────────
180
61
 
181
- export const SAFE_PATH_RE = /^\/admin\/[a-zA-Z0-9/._-]+$/;
182
-
183
- export async function executeApiAction(
184
- action: AutomationAction,
185
- adminToken: string
186
- ): Promise<void> {
187
- if (!action.path || !SAFE_PATH_RE.test(action.path) || action.path.includes('..')) {
188
- logger.warn(`Scheduler: rejecting unsafe action path: ${action.path}`);
189
- return;
190
- }
191
- const adminUrl = process.env.OP_ADMIN_API_URL || "http://admin:8100";
192
- const url = `${adminUrl}${action.path}`;
193
- const { "x-admin-token": _dropped, "authorization": _dropped2, ...safeHeaders } = action.headers ?? {};
194
- const headers: Record<string, string> = {
195
- ...safeHeaders,
196
- "x-admin-token": adminToken,
197
- "x-requested-by": "automation",
198
- };
199
- if (action.body) {
200
- headers["content-type"] = "application/json";
201
- }
202
- const controller = new AbortController();
203
- const timer = setTimeout(() => controller.abort(), action.timeout ?? 30_000);
204
- try {
205
- const resp = await fetch(url, {
206
- method: action.method ?? "GET",
207
- headers,
208
- body: action.body ? JSON.stringify(action.body) : undefined,
209
- signal: controller.signal
210
- });
211
- if (!resp.ok) {
212
- throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
213
- }
214
- } finally {
215
- clearTimeout(timer);
216
- }
62
+ export interface AutomationRunResult {
63
+ ok: boolean;
64
+ status: string;
65
+ error?: string;
217
66
  }
218
67
 
219
- export async function executeHttpAction(action: AutomationAction): Promise<void> {
220
- if (!action.url) throw new Error("http action requires a url");
221
- const headers: Record<string, string> = { ...action.headers };
222
- if (action.body) {
223
- headers["content-type"] = headers["content-type"] ?? "application/json";
224
- }
225
- const controller = new AbortController();
226
- const timer = setTimeout(() => controller.abort(), action.timeout ?? 30_000);
227
- try {
228
- const resp = await fetch(action.url, {
229
- method: action.method ?? "GET",
230
- headers,
231
- body: action.body ? JSON.stringify(action.body) : undefined,
232
- signal: controller.signal
233
- });
234
- if (!resp.ok) {
235
- throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
236
- }
237
- } finally {
238
- clearTimeout(timer);
239
- }
68
+ export async function executeAutomation(
69
+ id: string,
70
+ akmEnv: NodeJS.ProcessEnv,
71
+ ): Promise<AutomationRunResult> {
72
+ // Strip .md suffix if caller passes the full filename
73
+ const taskId = id.replace(/\.md$/, "");
74
+ return new Promise((resolve) => {
75
+ execFile(
76
+ "akm",
77
+ ["tasks", "run", taskId],
78
+ { env: { ...process.env, ...akmEnv } },
79
+ (error, _stdout, stderr) => {
80
+ if (error) {
81
+ const msg = stderr?.trim() || error.message;
82
+ logger.warn("akm tasks run failed", { id: taskId, error: msg });
83
+ resolve({ ok: false, status: "failed", error: msg });
84
+ } else {
85
+ resolve({ ok: true, status: "completed" });
86
+ }
87
+ }
88
+ );
89
+ });
240
90
  }
241
91
 
242
- const SHELL_SAFE_ENV_KEYS = [
243
- "PATH", "HOME", "LANG", "LC_ALL", "TZ", "NODE_ENV",
244
- "OP_HOME",
245
- ];
246
-
247
- export function executeShellAction(action: AutomationAction): Promise<void> {
248
- if (!action.command?.length) throw new Error("shell action requires a non-empty command array");
249
- const cmd = action.command;
250
-
251
- const safeEnv: Record<string, string> = {};
252
- for (const key of SHELL_SAFE_ENV_KEYS) {
253
- if (process.env[key]) safeEnv[key] = process.env[key]!;
254
- }
92
+ // ── Sync crontab with stash/tasks/*.md ───────────────────────────────────
255
93
 
94
+ export async function syncAutomations(akmEnv: NodeJS.ProcessEnv): Promise<void> {
256
95
  return new Promise((resolve, reject) => {
257
96
  execFile(
258
- cmd[0],
259
- cmd.slice(1),
260
- { env: safeEnv, timeout: action.timeout ?? 30_000 },
97
+ "akm",
98
+ ["tasks", "sync"],
99
+ { env: { ...process.env, ...akmEnv } },
261
100
  (error, _stdout, stderr) => {
262
101
  if (error) {
263
- reject(new Error(`shell command failed: ${stderr || error.message}`));
102
+ reject(new Error(stderr?.trim() || error.message));
264
103
  } else {
265
104
  resolve();
266
105
  }
@@ -269,58 +108,32 @@ export function executeShellAction(action: AutomationAction): Promise<void> {
269
108
  });
270
109
  }
271
110
 
272
- export async function executeAssistantAction(action: AutomationAction): Promise<void> {
273
- if (!action.content) {
274
- throw new Error("assistant action requires a non-empty 'content' field");
275
- }
276
-
277
- const baseUrl = process.env.OPENCODE_API_URL ?? "http://assistant:4096";
278
- const password = process.env.OPENCODE_SERVER_PASSWORD;
279
- const headers: Record<string, string> = { "content-type": "application/json" };
280
- if (password) {
281
- headers["authorization"] = `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`;
282
- }
283
-
284
- const sessionRes = await fetch(`${baseUrl}/session`, {
285
- method: "POST",
286
- headers,
287
- signal: AbortSignal.timeout(10_000),
288
- body: JSON.stringify({ title: `automation/${action.agent ?? "default"}` }),
289
- });
290
- if (!sessionRes.ok) {
291
- const body = await sessionRes.text().catch(() => "");
292
- throw new Error(`OpenCode POST /session ${sessionRes.status}: ${body}`);
293
- }
294
- const { id: sessionId } = (await sessionRes.json()) as { id: string };
295
- if (typeof sessionId !== "string" || !/^[a-zA-Z0-9_-]+$/.test(sessionId)) {
296
- throw new Error("Invalid session ID from assistant");
297
- }
298
-
299
- const msgRes = await fetch(`${baseUrl}/session/${sessionId}/message`, {
300
- method: "POST",
301
- headers,
302
- signal: AbortSignal.timeout(action.timeout ?? 120_000),
303
- body: JSON.stringify({ parts: [{ type: "text", text: action.content }] }),
304
- });
305
- if (!msgRes.ok) {
306
- const body = await msgRes.text().catch(() => "");
307
- throw new Error(`OpenCode POST /session/${sessionId}/message ${msgRes.status}: ${body}`);
308
- }
309
- logger.info("assistant action completed");
310
- }
311
-
312
- export async function executeAction(
313
- action: AutomationAction,
314
- adminToken: string
315
- ): Promise<void> {
316
- switch (action.type) {
317
- case "api":
318
- return executeApiAction(action, adminToken);
319
- case "http":
320
- return executeHttpAction(action);
321
- case "shell":
322
- return executeShellAction(action);
323
- case "assistant":
324
- return executeAssistantAction(action);
111
+ // ── Read akm task execution logs ──────────────────────────────────────────
112
+
113
+ export function readAutomationLogs(
114
+ id: string,
115
+ cacheDir: string,
116
+ limit: number = 50,
117
+ ): string[] {
118
+ const taskId = id.replace(/\.md$/, "");
119
+ const logDir = join(cacheDir, "akm", "tasks", "logs", taskId);
120
+ if (!existsSync(logDir)) return [];
121
+
122
+ const logFiles = readdirSync(logDir, { withFileTypes: true })
123
+ .filter((e) => e.isFile() && e.name.endsWith(".log"))
124
+ .map((e) => ({ name: e.name, path: join(logDir, e.name) }))
125
+ .sort((a, b) => b.name.localeCompare(a.name)); // newest first (ISO timestamp names)
126
+
127
+ const lines: string[] = [];
128
+ for (const { path } of logFiles) {
129
+ if (lines.length >= limit) break;
130
+ try {
131
+ const content = readFileSync(path, "utf-8");
132
+ const fileLines = content.split("\n").filter(Boolean).reverse(); // newest within file last
133
+ lines.push(...fileLines.slice(0, limit - lines.length));
134
+ } catch {
135
+ // skip unreadable log files
136
+ }
325
137
  }
138
+ return lines.slice(0, limit);
326
139
  }