@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.
- package/README.md +2 -2
- package/package.json +7 -3
- package/src/control-plane/admin-token.ts +73 -0
- package/src/control-plane/akm-vault.test.ts +105 -0
- package/src/control-plane/akm-vault.ts +307 -0
- package/src/control-plane/channels.ts +3 -3
- package/src/control-plane/cleanup-guardrails.test.ts +8 -9
- package/src/control-plane/compose-args.test.ts +25 -24
- package/src/control-plane/compose-errors.test.ts +106 -0
- package/src/control-plane/compose-errors.ts +117 -0
- package/src/control-plane/config-persistence.ts +103 -65
- package/src/control-plane/core-assets.test.ts +104 -0
- package/src/control-plane/core-assets.ts +54 -57
- package/src/control-plane/docker.ts +55 -21
- package/src/control-plane/env.test.ts +25 -1
- package/src/control-plane/env.ts +80 -0
- package/src/control-plane/home.ts +66 -69
- package/src/control-plane/host-opencode.test.ts +260 -0
- package/src/control-plane/host-opencode.ts +229 -0
- package/src/control-plane/install-edge-cases.test.ts +187 -289
- package/src/control-plane/install-lock.ts +157 -0
- package/src/control-plane/lifecycle.ts +34 -65
- package/src/control-plane/markdown-task.ts +200 -0
- package/src/control-plane/migrate-0110.test.ts +177 -0
- package/src/control-plane/migrate-0110.ts +99 -0
- package/src/control-plane/paths.ts +82 -0
- package/src/control-plane/provider-config.ts +2 -2
- package/src/control-plane/provider-models.ts +154 -0
- package/src/control-plane/registry-components.test.ts +105 -27
- package/src/control-plane/registry.test.ts +49 -47
- package/src/control-plane/registry.ts +71 -50
- package/src/control-plane/rollback.ts +17 -16
- package/src/control-plane/scheduler.ts +75 -262
- package/src/control-plane/secret-backend.test.ts +98 -111
- package/src/control-plane/secret-backend.ts +221 -181
- package/src/control-plane/secret-mappings.ts +4 -8
- package/src/control-plane/secrets.ts +93 -51
- package/src/control-plane/setup-config.schema.json +5 -17
- package/src/control-plane/setup-status.ts +9 -29
- package/src/control-plane/setup-validation.ts +23 -23
- package/src/control-plane/setup.test.ts +138 -239
- package/src/control-plane/setup.ts +215 -130
- package/src/control-plane/skeleton-guardrail.test.ts +151 -0
- package/src/control-plane/spec-to-env.test.ts +59 -58
- package/src/control-plane/spec-to-env.ts +52 -142
- package/src/control-plane/spec-validator.ts +2 -99
- package/src/control-plane/stack-spec.test.ts +21 -77
- package/src/control-plane/stack-spec.ts +7 -83
- package/src/control-plane/types.ts +12 -28
- package/src/control-plane/ui-assets.ts +349 -0
- package/src/control-plane/validate.ts +44 -79
- package/src/index.ts +86 -48
- package/src/logger.test.ts +228 -0
- package/src/logger.ts +71 -1
- package/src/provider-constants.ts +22 -1
- package/src/control-plane/audit.ts +0 -40
- package/src/control-plane/env-schema-validation.test.ts +0 -118
- package/src/control-plane/memory-config.ts +0 -298
- package/src/control-plane/redact-schema.ts +0 -50
|
@@ -1,24 +1,27 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
{ env:
|
|
97
|
+
"akm",
|
|
98
|
+
["tasks", "sync"],
|
|
99
|
+
{ env: { ...process.env, ...akmEnv } },
|
|
261
100
|
(error, _stdout, stderr) => {
|
|
262
101
|
if (error) {
|
|
263
|
-
reject(new Error(
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
}
|