@openpalm/lib 0.9.8 → 0.10.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 (56) hide show
  1. package/README.md +31 -71
  2. package/package.json +1 -1
  3. package/src/control-plane/audit.ts +4 -4
  4. package/src/control-plane/backup.ts +31 -0
  5. package/src/control-plane/channels.ts +88 -156
  6. package/src/control-plane/cleanup-guardrails.test.ts +289 -0
  7. package/src/control-plane/compose-args.test.ts +170 -0
  8. package/src/control-plane/compose-args.ts +57 -0
  9. package/src/control-plane/config-persistence.ts +270 -0
  10. package/src/control-plane/core-assets.ts +58 -234
  11. package/src/control-plane/crypto.ts +14 -0
  12. package/src/control-plane/docker.ts +94 -204
  13. package/src/control-plane/env-schema-validation.test.ts +118 -0
  14. package/src/control-plane/extends-support.test.ts +105 -0
  15. package/src/control-plane/home.ts +133 -0
  16. package/src/control-plane/install-edge-cases.test.ts +314 -717
  17. package/src/control-plane/lifecycle.ts +215 -233
  18. package/src/control-plane/lock.test.ts +194 -0
  19. package/src/control-plane/lock.ts +176 -0
  20. package/src/control-plane/memory-config.ts +34 -160
  21. package/src/control-plane/opencode-client.test.ts +154 -0
  22. package/src/control-plane/opencode-client.ts +113 -0
  23. package/src/control-plane/provider-config.ts +34 -0
  24. package/src/control-plane/redact-schema.ts +50 -0
  25. package/src/control-plane/registry-components.test.ts +313 -0
  26. package/src/control-plane/registry.test.ts +414 -0
  27. package/src/control-plane/registry.ts +418 -0
  28. package/src/control-plane/rollback.ts +128 -0
  29. package/src/control-plane/scheduler.ts +18 -190
  30. package/src/control-plane/secret-backend.test.ts +359 -0
  31. package/src/control-plane/secret-backend.ts +322 -0
  32. package/src/control-plane/secret-mappings.ts +185 -0
  33. package/src/control-plane/secrets.ts +186 -112
  34. package/src/control-plane/setup-config.schema.json +306 -0
  35. package/src/control-plane/setup-status.ts +15 -8
  36. package/src/control-plane/setup-validation.ts +90 -0
  37. package/src/control-plane/setup.test.ts +336 -929
  38. package/src/control-plane/setup.ts +159 -849
  39. package/src/control-plane/spec-to-env.test.ts +100 -0
  40. package/src/control-plane/spec-to-env.ts +195 -0
  41. package/src/control-plane/spec-validator.ts +159 -0
  42. package/src/control-plane/stack-spec.test.ts +150 -0
  43. package/src/control-plane/stack-spec.ts +101 -22
  44. package/src/control-plane/types.ts +6 -99
  45. package/src/control-plane/validate.ts +107 -0
  46. package/src/index.ts +101 -159
  47. package/src/provider-constants.ts +2 -31
  48. package/src/control-plane/connection-mapping.ts +0 -191
  49. package/src/control-plane/connection-migration-flags.ts +0 -40
  50. package/src/control-plane/connection-profiles.ts +0 -317
  51. package/src/control-plane/core-asset-provider.ts +0 -21
  52. package/src/control-plane/fs-asset-provider.ts +0 -65
  53. package/src/control-plane/fs-registry-provider.ts +0 -46
  54. package/src/control-plane/paths.ts +0 -77
  55. package/src/control-plane/registry-provider.ts +0 -19
  56. package/src/control-plane/staging.ts +0 -399
@@ -1,16 +1,4 @@
1
- /**
2
- * In-process automation scheduler — replaces system cron.
3
- *
4
- * Uses Croner for cron job scheduling within the Node.js process.
5
- * Automations are .yml files in STATE_HOME/automations/ with four
6
- * action types: api (admin API call), http (any URL), shell (execFile),
7
- * assistant (OpenCode session message).
8
- *
9
- * Security: shell actions use execFile with argument arrays — no shell
10
- * interpolation. API actions auto-inject the admin token. Assistant
11
- * actions validate the session ID before URL interpolation.
12
- */
13
- import { Cron } from "croner";
1
+ /** Automation scheduler — types, parsing, and action execution. */
14
2
  import { parse as parseYaml } from "yaml";
15
3
  import { execFile } from "node:child_process";
16
4
  import { existsSync, readdirSync, readFileSync } from "node:fs";
@@ -19,7 +7,6 @@ import { createLogger } from "../logger.js";
19
7
 
20
8
  const logger = createLogger("scheduler");
21
9
 
22
- // ── Types ─────────────────────────────────────────────────────────────
23
10
 
24
11
  export type ActionType = "api" | "http" | "shell" | "assistant";
25
12
 
@@ -32,11 +19,7 @@ export type AutomationAction = {
32
19
  headers?: Record<string, string>;
33
20
  command?: string[];
34
21
  timeout?: number;
35
- /** The prompt text to send to the assistant (assistant action only). */
36
22
  content?: string;
37
- /** OpenCode agent label for the session (assistant action only, optional).
38
- * Currently used in the session title for identification/audit purposes.
39
- * Will be forwarded as an API parameter when OpenCode adds agent selection support. */
40
23
  agent?: string;
41
24
  };
42
25
 
@@ -51,13 +34,6 @@ export type AutomationConfig = {
51
34
  fileName: string;
52
35
  };
53
36
 
54
- type ActiveJob = {
55
- cron: Cron;
56
- config: AutomationConfig;
57
- };
58
-
59
- // ── Execution Log ─────────────────────────────────────────────────────
60
-
61
37
  export type ExecutionLogEntry = {
62
38
  at: string;
63
39
  ok: boolean;
@@ -65,36 +41,6 @@ export type ExecutionLogEntry = {
65
41
  error?: string;
66
42
  };
67
43
 
68
- const MAX_LOG_ENTRIES = 50;
69
- const executionLogs = new Map<string, ExecutionLogEntry[]>();
70
-
71
- function recordExecution(fileName: string, entry: ExecutionLogEntry): void {
72
- let entries = executionLogs.get(fileName);
73
- if (!entries) {
74
- entries = [];
75
- executionLogs.set(fileName, entries);
76
- }
77
- entries.push(entry);
78
- if (entries.length > MAX_LOG_ENTRIES) {
79
- executionLogs.set(fileName, entries.slice(-MAX_LOG_ENTRIES));
80
- }
81
- }
82
-
83
- /** Return recent execution log entries for an automation (newest first). */
84
- export function getExecutionLog(fileName: string): ExecutionLogEntry[] {
85
- return [...(executionLogs.get(fileName) ?? [])].reverse();
86
- }
87
-
88
- /** Return all execution logs keyed by fileName. */
89
- export function getAllExecutionLogs(): Record<string, ExecutionLogEntry[]> {
90
- const result: Record<string, ExecutionLogEntry[]> = {};
91
- for (const [fileName, entries] of executionLogs) {
92
- result[fileName] = [...entries].reverse();
93
- }
94
- return result;
95
- }
96
-
97
- // ── Schedule Presets ──────────────────────────────────────────────────
98
44
 
99
45
  export const SCHEDULE_PRESETS: Record<string, string> = {
100
46
  "every-minute": "* * * * *",
@@ -108,20 +54,11 @@ export const SCHEDULE_PRESETS: Record<string, string> = {
108
54
  "weekly-sunday-4am": "0 4 * * 0"
109
55
  };
110
56
 
111
- /**
112
- * Resolve a schedule string: if it matches a preset name, return the
113
- * cron expression; otherwise pass through as-is (assumed cron syntax).
114
- */
57
+ /** Resolve a preset name to cron expression, or pass through raw cron. */
115
58
  export function resolveSchedule(schedule: string): string {
116
59
  return SCHEDULE_PRESETS[schedule] ?? schedule;
117
60
  }
118
61
 
119
- // ── YAML Parsing ──────────────────────────────────────────────────────
120
-
121
- /**
122
- * Parse and validate a YAML automation file.
123
- * Returns null if the content is invalid (with a warning logged).
124
- */
125
62
  export function parseAutomationYaml(
126
63
  content: string,
127
64
  fileName: string
@@ -139,14 +76,12 @@ export function parseAutomationYaml(
139
76
  return null;
140
77
  }
141
78
 
142
- // schedule is required
143
79
  const rawSchedule = doc.schedule;
144
80
  if (typeof rawSchedule !== "string" || !rawSchedule.trim()) {
145
81
  logger.warn("automation missing or empty 'schedule'", { fileName });
146
82
  return null;
147
83
  }
148
84
 
149
- // action is required and must be an object with a valid type
150
85
  const action = doc.action;
151
86
  if (!action || typeof action !== "object") {
152
87
  logger.warn("automation missing or invalid 'action'", { fileName });
@@ -163,7 +98,6 @@ export function parseAutomationYaml(
163
98
  return null;
164
99
  }
165
100
 
166
- // Validate action-specific required fields
167
101
  if (actionType === "api" && typeof actionObj.path !== "string") {
168
102
  logger.warn("api action missing 'path'", { fileName });
169
103
  return null;
@@ -199,7 +133,9 @@ export function parseAutomationYaml(
199
133
  path: typeof actionObj.path === "string" ? actionObj.path : undefined,
200
134
  url: typeof actionObj.url === "string" ? actionObj.url : undefined,
201
135
  body: actionObj.body,
202
- headers: isStringRecord(actionObj.headers) ? actionObj.headers : undefined,
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,
203
139
  command: Array.isArray(actionObj.command)
204
140
  ? actionObj.command.map(String)
205
141
  : undefined,
@@ -216,20 +152,8 @@ export function parseAutomationYaml(
216
152
  };
217
153
  }
218
154
 
219
- function isStringRecord(v: unknown): v is Record<string, string> {
220
- if (!v || typeof v !== "object") return false;
221
- return Object.values(v as Record<string, unknown>).every(
222
- (val) => typeof val === "string"
223
- );
224
- }
225
-
226
- // ── Load Automations ──────────────────────────────────────────────────
227
-
228
- /**
229
- * Read and parse all .yml automation files from STATE_HOME/automations/.
230
- */
231
- export function loadAutomations(stateDir: string): AutomationConfig[] {
232
- const dir = join(stateDir, "automations");
155
+ export function loadAutomations(configDir: string): AutomationConfig[] {
156
+ const dir = join(configDir, "automations");
233
157
  if (!existsSync(dir)) return [];
234
158
 
235
159
  const files = readdirSync(dir, { withFileTypes: true });
@@ -253,12 +177,10 @@ export function loadAutomations(stateDir: string): AutomationConfig[] {
253
177
  return configs;
254
178
  }
255
179
 
256
- // ── Action Execution ──────────────────────────────────────────────────
257
180
 
258
181
  export const SAFE_PATH_RE = /^\/admin\/[a-zA-Z0-9/._-]+$/;
259
182
 
260
- /** Execute an API action — auto-injects admin token and base URL. */
261
- async function executeApiAction(
183
+ export async function executeApiAction(
262
184
  action: AutomationAction,
263
185
  adminToken: string
264
186
  ): Promise<void> {
@@ -266,7 +188,7 @@ async function executeApiAction(
266
188
  logger.warn(`Scheduler: rejecting unsafe action path: ${action.path}`);
267
189
  return;
268
190
  }
269
- const adminUrl = process.env.OPENPALM_ADMIN_API_URL || "http://admin:8100";
191
+ const adminUrl = process.env.OP_ADMIN_API_URL || "http://admin:8100";
270
192
  const url = `${adminUrl}${action.path}`;
271
193
  const { "x-admin-token": _dropped, "authorization": _dropped2, ...safeHeaders } = action.headers ?? {};
272
194
  const headers: Record<string, string> = {
@@ -294,8 +216,8 @@ async function executeApiAction(
294
216
  }
295
217
  }
296
218
 
297
- /** Execute an HTTP action no auto-auth. */
298
- async function executeHttpAction(action: AutomationAction): Promise<void> {
219
+ export async function executeHttpAction(action: AutomationAction): Promise<void> {
220
+ if (!action.url) throw new Error("http action requires a url");
299
221
  const headers: Record<string, string> = { ...action.headers };
300
222
  if (action.body) {
301
223
  headers["content-type"] = headers["content-type"] ?? "application/json";
@@ -303,7 +225,7 @@ async function executeHttpAction(action: AutomationAction): Promise<void> {
303
225
  const controller = new AbortController();
304
226
  const timer = setTimeout(() => controller.abort(), action.timeout ?? 30_000);
305
227
  try {
306
- const resp = await fetch(action.url!, {
228
+ const resp = await fetch(action.url, {
307
229
  method: action.method ?? "GET",
308
230
  headers,
309
231
  body: action.body ? JSON.stringify(action.body) : undefined,
@@ -317,17 +239,15 @@ async function executeHttpAction(action: AutomationAction): Promise<void> {
317
239
  }
318
240
  }
319
241
 
320
- /** Safe env vars allowlisted for shell automation actions. */
321
242
  const SHELL_SAFE_ENV_KEYS = [
322
243
  "PATH", "HOME", "LANG", "LC_ALL", "TZ", "NODE_ENV",
323
- "OPENPALM_CONFIG_HOME", "OPENPALM_STATE_HOME", "OPENPALM_DATA_HOME",
244
+ "OP_HOME",
324
245
  ];
325
246
 
326
- /** Execute a shell action uses execFile with argument array (no shell interpolation). */
327
- function executeShellAction(action: AutomationAction): Promise<void> {
328
- const cmd = action.command!;
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;
329
250
 
330
- // Build a minimal env from the allowlist — never leak secrets to shell commands
331
251
  const safeEnv: Record<string, string> = {};
332
252
  for (const key of SHELL_SAFE_ENV_KEYS) {
333
253
  if (process.env[key]) safeEnv[key] = process.env[key]!;
@@ -349,23 +269,18 @@ function executeShellAction(action: AutomationAction): Promise<void> {
349
269
  });
350
270
  }
351
271
 
352
- /**
353
- * Execute an assistant action — creates an OpenCode session and sends the
354
- * prompt directly via the OpenCode REST API (no guardian needed).
355
- */
356
- async function executeAssistantAction(action: AutomationAction): Promise<void> {
272
+ export async function executeAssistantAction(action: AutomationAction): Promise<void> {
357
273
  if (!action.content) {
358
274
  throw new Error("assistant action requires a non-empty 'content' field");
359
275
  }
360
276
 
361
- const baseUrl = process.env.OPENCODE_API_URL ?? "http://localhost:4096";
277
+ const baseUrl = process.env.OPENCODE_API_URL ?? "http://assistant:4096";
362
278
  const password = process.env.OPENCODE_SERVER_PASSWORD;
363
279
  const headers: Record<string, string> = { "content-type": "application/json" };
364
280
  if (password) {
365
281
  headers["authorization"] = `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`;
366
282
  }
367
283
 
368
- // Create session
369
284
  const sessionRes = await fetch(`${baseUrl}/session`, {
370
285
  method: "POST",
371
286
  headers,
@@ -381,7 +296,6 @@ async function executeAssistantAction(action: AutomationAction): Promise<void> {
381
296
  throw new Error("Invalid session ID from assistant");
382
297
  }
383
298
 
384
- // Send message
385
299
  const msgRes = await fetch(`${baseUrl}/session/${sessionId}/message`, {
386
300
  method: "POST",
387
301
  headers,
@@ -395,7 +309,6 @@ async function executeAssistantAction(action: AutomationAction): Promise<void> {
395
309
  logger.info("assistant action completed");
396
310
  }
397
311
 
398
- /** Dispatch to the correct action executor. */
399
312
  export async function executeAction(
400
313
  action: AutomationAction,
401
314
  adminToken: string
@@ -411,88 +324,3 @@ export async function executeAction(
411
324
  return executeAssistantAction(action);
412
325
  }
413
326
  }
414
-
415
- // ── Scheduler Lifecycle ───────────────────────────────────────────────
416
-
417
- let activeJobs: ActiveJob[] = [];
418
-
419
- /**
420
- * Start the in-process scheduler. Reads automations from STATE_HOME,
421
- * creates Croner jobs for each enabled one.
422
- */
423
- export function startScheduler(stateDir: string, adminToken: string): void {
424
- const configs = loadAutomations(stateDir);
425
- const enabled = configs.filter((c) => c.enabled);
426
-
427
- for (const config of enabled) {
428
- try {
429
- const cron = new Cron(config.schedule, {
430
- timezone: config.timezone,
431
- protect: true // over-run protection
432
- }, async () => {
433
- const start = Date.now();
434
- try {
435
- await executeAction(config.action, adminToken);
436
- const durationMs = Date.now() - start;
437
- recordExecution(config.fileName, { at: new Date().toISOString(), ok: true, durationMs });
438
- logger.info("automation executed", { name: config.name, fileName: config.fileName, durationMs });
439
- } catch (err) {
440
- const durationMs = Date.now() - start;
441
- const errorMsg = String(err);
442
- recordExecution(config.fileName, { at: new Date().toISOString(), ok: false, durationMs, error: errorMsg });
443
- logger.error("automation failed", {
444
- name: config.name,
445
- fileName: config.fileName,
446
- error: errorMsg
447
- });
448
- }
449
- });
450
-
451
- activeJobs.push({ cron, config });
452
- } catch (err) {
453
- logger.error("failed to schedule automation", {
454
- name: config.name,
455
- fileName: config.fileName,
456
- schedule: config.schedule,
457
- error: String(err)
458
- });
459
- }
460
- }
461
-
462
- logger.info(`scheduler started with ${activeJobs.length} automation(s)`);
463
- }
464
-
465
- /** Stop all active Croner jobs. */
466
- export function stopScheduler(): void {
467
- for (const job of activeJobs) {
468
- job.cron.stop();
469
- }
470
- const count = activeJobs.length;
471
- activeJobs = [];
472
- executionLogs.clear();
473
- if (count > 0) {
474
- logger.info(`scheduler stopped (${count} job(s) cleared)`);
475
- }
476
- }
477
-
478
- /** Reload: stop all jobs, then start fresh. */
479
- export function reloadScheduler(stateDir: string, adminToken: string): void {
480
- stopScheduler();
481
- startScheduler(stateDir, adminToken);
482
- }
483
-
484
- /** Return current scheduler status for debugging. */
485
- export function getSchedulerStatus(): {
486
- jobCount: number;
487
- jobs: { name: string; fileName: string; schedule: string; running: boolean }[];
488
- } {
489
- return {
490
- jobCount: activeJobs.length,
491
- jobs: activeJobs.map((j) => ({
492
- name: j.config.name,
493
- fileName: j.config.fileName,
494
- schedule: j.config.schedule,
495
- running: j.cron.isRunning()
496
- }))
497
- };
498
- }