@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.
- package/README.md +31 -71
- package/package.json +1 -1
- package/src/control-plane/audit.ts +4 -4
- package/src/control-plane/backup.ts +31 -0
- package/src/control-plane/channels.ts +88 -156
- package/src/control-plane/cleanup-guardrails.test.ts +289 -0
- package/src/control-plane/compose-args.test.ts +170 -0
- package/src/control-plane/compose-args.ts +57 -0
- package/src/control-plane/config-persistence.ts +270 -0
- package/src/control-plane/core-assets.ts +58 -234
- package/src/control-plane/crypto.ts +14 -0
- package/src/control-plane/docker.ts +94 -204
- package/src/control-plane/env-schema-validation.test.ts +118 -0
- package/src/control-plane/extends-support.test.ts +105 -0
- package/src/control-plane/home.ts +133 -0
- package/src/control-plane/install-edge-cases.test.ts +314 -717
- package/src/control-plane/lifecycle.ts +215 -233
- package/src/control-plane/lock.test.ts +194 -0
- package/src/control-plane/lock.ts +176 -0
- package/src/control-plane/memory-config.ts +34 -160
- package/src/control-plane/opencode-client.test.ts +154 -0
- package/src/control-plane/opencode-client.ts +113 -0
- package/src/control-plane/provider-config.ts +34 -0
- package/src/control-plane/redact-schema.ts +50 -0
- package/src/control-plane/registry-components.test.ts +313 -0
- package/src/control-plane/registry.test.ts +414 -0
- package/src/control-plane/registry.ts +418 -0
- package/src/control-plane/rollback.ts +128 -0
- package/src/control-plane/scheduler.ts +18 -190
- package/src/control-plane/secret-backend.test.ts +359 -0
- package/src/control-plane/secret-backend.ts +322 -0
- package/src/control-plane/secret-mappings.ts +185 -0
- package/src/control-plane/secrets.ts +186 -112
- package/src/control-plane/setup-config.schema.json +306 -0
- package/src/control-plane/setup-status.ts +15 -8
- package/src/control-plane/setup-validation.ts +90 -0
- package/src/control-plane/setup.test.ts +336 -929
- package/src/control-plane/setup.ts +159 -849
- package/src/control-plane/spec-to-env.test.ts +100 -0
- package/src/control-plane/spec-to-env.ts +195 -0
- package/src/control-plane/spec-validator.ts +159 -0
- package/src/control-plane/stack-spec.test.ts +150 -0
- package/src/control-plane/stack-spec.ts +101 -22
- package/src/control-plane/types.ts +6 -99
- package/src/control-plane/validate.ts +107 -0
- package/src/index.ts +101 -159
- package/src/provider-constants.ts +2 -31
- package/src/control-plane/connection-mapping.ts +0 -191
- package/src/control-plane/connection-migration-flags.ts +0 -40
- package/src/control-plane/connection-profiles.ts +0 -317
- package/src/control-plane/core-asset-provider.ts +0 -21
- package/src/control-plane/fs-asset-provider.ts +0 -65
- package/src/control-plane/fs-registry-provider.ts +0 -46
- package/src/control-plane/paths.ts +0 -77
- package/src/control-plane/registry-provider.ts +0 -19
- 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:
|
|
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
|
|
220
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
"
|
|
244
|
+
"OP_HOME",
|
|
324
245
|
];
|
|
325
246
|
|
|
326
|
-
|
|
327
|
-
|
|
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://
|
|
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
|
-
}
|