@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
|
@@ -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,
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
resolveStashDir,
|
|
10
|
+
resolveWorkspaceDir,
|
|
11
|
+
resolveCacheDir,
|
|
12
|
+
resolveStateDir,
|
|
13
|
+
resolveStackDir,
|
|
13
14
|
} from "./home.js";
|
|
14
|
-
import { ensureSecrets
|
|
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
|
|
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
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const
|
|
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
|
-
|
|
57
|
-
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
//
|
|
285
|
-
const stackEnvPath = `${state.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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(
|
|
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
|
+
}
|