spell-runtime 1.0.0 → 1.2.0
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 +109 -7
- package/README.txt +90 -7
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +70 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/server.d.ts +19 -0
- package/dist/api/server.js +868 -0
- package/dist/api/server.js.map +1 -0
- package/dist/api/ui.d.ts +2 -0
- package/dist/api/ui.js +474 -0
- package/dist/api/ui.js.map +1 -0
- package/dist/bundle/install.js +9 -0
- package/dist/bundle/install.js.map +1 -1
- package/dist/cli/index.js +93 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/contract/buttonRegistry.d.ts +21 -0
- package/dist/contract/buttonRegistry.js +103 -0
- package/dist/contract/buttonRegistry.js.map +1 -0
- package/dist/logging/executionLog.js +3 -1
- package/dist/logging/executionLog.js.map +1 -1
- package/dist/runner/cast.js +43 -3
- package/dist/runner/cast.js.map +1 -1
- package/dist/runner/dockerRunner.d.ts +9 -0
- package/dist/runner/dockerRunner.js +121 -0
- package/dist/runner/dockerRunner.js.map +1 -0
- package/dist/runner/spell-runner.d.ts +11 -0
- package/dist/runner/spell-runner.js +144 -0
- package/dist/runner/spell-runner.js.map +1 -0
- package/dist/signature/bundleDigest.d.ts +6 -0
- package/dist/signature/bundleDigest.js +89 -0
- package/dist/signature/bundleDigest.js.map +1 -0
- package/dist/signature/signatureFile.d.ts +12 -0
- package/dist/signature/signatureFile.js +79 -0
- package/dist/signature/signatureFile.js.map +1 -0
- package/dist/signature/signing.d.ts +27 -0
- package/dist/signature/signing.js +87 -0
- package/dist/signature/signing.js.map +1 -0
- package/dist/signature/trustStore.d.ts +20 -0
- package/dist/signature/trustStore.js +173 -0
- package/dist/signature/trustStore.js.map +1 -0
- package/dist/signature/verify.d.ts +12 -0
- package/dist/signature/verify.js +119 -0
- package/dist/signature/verify.js.map +1 -0
- package/dist/types.d.ts +8 -0
- package/dist/util/paths.d.ts +2 -0
- package/dist/util/paths.js +9 -0
- package/dist/util/paths.js.map +1 -1
- package/dist/util/platform.d.ts +2 -0
- package/dist/util/platform.js +38 -0
- package/dist/util/platform.js.map +1 -1
- package/dist/util/redact.d.ts +2 -0
- package/dist/util/redact.js +66 -0
- package/dist/util/redact.js.map +1 -0
- package/package.json +5 -2
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.startExecutionApiServer = startExecutionApiServer;
|
|
7
|
+
const node_http_1 = require("node:http");
|
|
8
|
+
const promises_1 = require("node:fs/promises");
|
|
9
|
+
const node_os_1 = require("node:os");
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const node_crypto_1 = require("node:crypto");
|
|
12
|
+
const node_child_process_1 = require("node:child_process");
|
|
13
|
+
const buttonRegistry_1 = require("../contract/buttonRegistry");
|
|
14
|
+
const paths_1 = require("../util/paths");
|
|
15
|
+
const ui_1 = require("./ui");
|
|
16
|
+
const DEFAULT_BODY_LIMIT = 64 * 1024;
|
|
17
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
18
|
+
const DEFAULT_RATE_WINDOW_MS = 60_000;
|
|
19
|
+
const DEFAULT_RATE_MAX = 20;
|
|
20
|
+
const DEFAULT_MAX_CONCURRENT_EXECUTIONS = 4;
|
|
21
|
+
const DEFAULT_LIST_LIMIT = 100;
|
|
22
|
+
const MAX_LIST_LIMIT = 500;
|
|
23
|
+
const DEFAULT_LOG_RETENTION_DAYS = 14;
|
|
24
|
+
const DEFAULT_LOG_MAX_FILES = 500;
|
|
25
|
+
async function startExecutionApiServer(options = {}) {
|
|
26
|
+
const registryPath = options.registryPath ?? node_path_1.default.join(process.cwd(), "examples", "button-registry.v1.json");
|
|
27
|
+
const registry = await (0, buttonRegistry_1.loadButtonRegistryFromFile)(registryPath);
|
|
28
|
+
await (0, paths_1.ensureSpellDirs)();
|
|
29
|
+
const executionIndexPath = node_path_1.default.join((0, paths_1.logsRoot)(), "index.json");
|
|
30
|
+
const jobs = await loadExecutionJobsIndex(executionIndexPath);
|
|
31
|
+
const recovered = recoverInterruptedJobs(jobs);
|
|
32
|
+
const postHistoryByIp = new Map();
|
|
33
|
+
const runningJobPromises = new Set();
|
|
34
|
+
let persistQueue = Promise.resolve();
|
|
35
|
+
const persistJobs = async () => {
|
|
36
|
+
persistQueue = persistQueue
|
|
37
|
+
.catch(() => undefined)
|
|
38
|
+
.then(async () => {
|
|
39
|
+
await writeExecutionJobsIndex(executionIndexPath, jobs);
|
|
40
|
+
});
|
|
41
|
+
await persistQueue;
|
|
42
|
+
};
|
|
43
|
+
const bodyLimit = options.requestBodyLimitBytes ?? DEFAULT_BODY_LIMIT;
|
|
44
|
+
const executionTimeoutMs = options.executionTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
45
|
+
const rateWindowMs = options.rateLimitWindowMs ?? DEFAULT_RATE_WINDOW_MS;
|
|
46
|
+
const rateMaxRequests = options.rateLimitMaxRequests ?? DEFAULT_RATE_MAX;
|
|
47
|
+
const maxConcurrentExecutions = options.maxConcurrentExecutions ?? DEFAULT_MAX_CONCURRENT_EXECUTIONS;
|
|
48
|
+
const authTokens = new Set((options.authTokens ?? []).map((token) => token.trim()).filter((token) => token.length > 0));
|
|
49
|
+
const authKeys = parseAuthKeys(options.authKeys ?? []);
|
|
50
|
+
if (authTokens.size > 0 && authKeys.length > 0) {
|
|
51
|
+
throw new Error("API auth config error: use either authTokens or authKeys (role-based), not both");
|
|
52
|
+
}
|
|
53
|
+
const logRetentionDays = options.logRetentionDays ?? DEFAULT_LOG_RETENTION_DAYS;
|
|
54
|
+
const logMaxFiles = options.logMaxFiles ?? DEFAULT_LOG_MAX_FILES;
|
|
55
|
+
const logsDirectory = (0, paths_1.logsRoot)();
|
|
56
|
+
const prunedOnBoot = await applyLogRetentionPolicy(logsDirectory, jobs, logRetentionDays, logMaxFiles);
|
|
57
|
+
if (recovered > 0 || prunedOnBoot) {
|
|
58
|
+
await persistJobs();
|
|
59
|
+
}
|
|
60
|
+
const server = (0, node_http_1.createServer)(async (req, res) => {
|
|
61
|
+
try {
|
|
62
|
+
const method = req.method ?? "GET";
|
|
63
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
64
|
+
const pathname = url.pathname;
|
|
65
|
+
if (method === "GET" && (pathname === "/" || pathname === "/ui")) {
|
|
66
|
+
return sendText(res, 200, (0, ui_1.renderReceiptsHtml)(), "text/html; charset=utf-8");
|
|
67
|
+
}
|
|
68
|
+
if (method === "GET" && pathname === "/ui/app.js") {
|
|
69
|
+
return sendText(res, 200, (0, ui_1.renderReceiptsClientJs)(), "text/javascript; charset=utf-8");
|
|
70
|
+
}
|
|
71
|
+
const route = normalizeRoute(url.pathname);
|
|
72
|
+
if (method === "GET" && route === "/health") {
|
|
73
|
+
return sendJson(res, 200, { ok: true });
|
|
74
|
+
}
|
|
75
|
+
const authContext = requiresApiAuth(route) ? authorizeRequest(req, authTokens, authKeys) : { ok: true };
|
|
76
|
+
if (requiresApiAuth(route)) {
|
|
77
|
+
if (!authContext.ok) {
|
|
78
|
+
return sendJson(res, 401, {
|
|
79
|
+
ok: false,
|
|
80
|
+
error_code: authContext.errorCode,
|
|
81
|
+
message: authContext.message
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (method === "POST" && route === "/spell-executions") {
|
|
86
|
+
if (countInFlightJobs(jobs) >= maxConcurrentExecutions) {
|
|
87
|
+
return sendJson(res, 429, {
|
|
88
|
+
ok: false,
|
|
89
|
+
error_code: "CONCURRENCY_LIMITED",
|
|
90
|
+
message: `too many in-flight executions: max ${maxConcurrentExecutions}`
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const ip = req.socket.remoteAddress ?? "unknown";
|
|
94
|
+
if (!allowRate(ip, postHistoryByIp, rateWindowMs, rateMaxRequests)) {
|
|
95
|
+
return sendJson(res, 429, {
|
|
96
|
+
ok: false,
|
|
97
|
+
error_code: "RATE_LIMITED",
|
|
98
|
+
message: "too many requests"
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
let parsed;
|
|
102
|
+
let entry;
|
|
103
|
+
try {
|
|
104
|
+
const payload = (await readJsonBody(req, bodyLimit));
|
|
105
|
+
parsed = parseCreateExecutionRequest(payload);
|
|
106
|
+
entry = (0, buttonRegistry_1.resolveButtonEntry)(registry, parsed.button_id);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
const message = error.message;
|
|
110
|
+
if (message.includes("request body too large")) {
|
|
111
|
+
return sendJson(res, 413, {
|
|
112
|
+
ok: false,
|
|
113
|
+
error_code: "INPUT_TOO_LARGE",
|
|
114
|
+
message: `input payload too large: max ${bodyLimit} bytes`
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (message.startsWith("unknown button_id:")) {
|
|
118
|
+
return sendJson(res, 404, {
|
|
119
|
+
ok: false,
|
|
120
|
+
error_code: "BUTTON_NOT_FOUND",
|
|
121
|
+
message
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return sendJson(res, 400, {
|
|
125
|
+
ok: false,
|
|
126
|
+
error_code: "BAD_REQUEST",
|
|
127
|
+
message
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const actorRole = ("role" in authContext ? authContext.role : undefined) ??
|
|
131
|
+
parsed.actor_role ??
|
|
132
|
+
req.headers["x-role"]?.toString() ??
|
|
133
|
+
"anonymous";
|
|
134
|
+
if (!entry.allowed_roles.includes(actorRole)) {
|
|
135
|
+
return sendJson(res, 403, {
|
|
136
|
+
ok: false,
|
|
137
|
+
error_code: "ROLE_NOT_ALLOWED",
|
|
138
|
+
message: `actor role not allowed: ${actorRole}`
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (entry.required_confirmations.risk && !parsed.confirmation?.risk_acknowledged) {
|
|
142
|
+
return sendJson(res, 400, {
|
|
143
|
+
ok: false,
|
|
144
|
+
error_code: "RISK_CONFIRMATION_REQUIRED",
|
|
145
|
+
message: "risk confirmation is required"
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (entry.required_confirmations.billing && !parsed.confirmation?.billing_acknowledged) {
|
|
149
|
+
return sendJson(res, 400, {
|
|
150
|
+
ok: false,
|
|
151
|
+
error_code: "BILLING_CONFIRMATION_REQUIRED",
|
|
152
|
+
message: "billing confirmation is required"
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const input = deepMerge(entry.defaults, parsed.input ?? {});
|
|
156
|
+
const inputSizeBytes = Buffer.byteLength(JSON.stringify(input), "utf8");
|
|
157
|
+
if (inputSizeBytes > bodyLimit) {
|
|
158
|
+
return sendJson(res, 413, {
|
|
159
|
+
ok: false,
|
|
160
|
+
error_code: "INPUT_TOO_LARGE",
|
|
161
|
+
message: `input payload too large: max ${bodyLimit} bytes`
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const executionId = `exec_${Date.now()}_${(0, node_crypto_1.randomUUID)().slice(0, 8)}`;
|
|
165
|
+
const now = new Date().toISOString();
|
|
166
|
+
const job = {
|
|
167
|
+
execution_id: executionId,
|
|
168
|
+
button_id: entry.button_id,
|
|
169
|
+
spell_id: entry.spell_id,
|
|
170
|
+
version: entry.version,
|
|
171
|
+
require_signature: entry.require_signature ?? false,
|
|
172
|
+
status: "queued",
|
|
173
|
+
actor_role: actorRole,
|
|
174
|
+
created_at: now
|
|
175
|
+
};
|
|
176
|
+
jobs.set(executionId, job);
|
|
177
|
+
await persistJobs();
|
|
178
|
+
let runningJob;
|
|
179
|
+
runningJob = runJob(job, input, parsed.dry_run ?? false, entry.required_confirmations, entry.require_signature ?? false, executionTimeoutMs, jobs, persistJobs, {
|
|
180
|
+
logsDirectory,
|
|
181
|
+
logRetentionDays,
|
|
182
|
+
logMaxFiles
|
|
183
|
+
})
|
|
184
|
+
.catch(() => undefined)
|
|
185
|
+
.finally(() => {
|
|
186
|
+
runningJobPromises.delete(runningJob);
|
|
187
|
+
});
|
|
188
|
+
runningJobPromises.add(runningJob);
|
|
189
|
+
return sendJson(res, 202, {
|
|
190
|
+
ok: true,
|
|
191
|
+
execution_id: executionId,
|
|
192
|
+
status: job.status
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (method === "GET" && route === "/buttons") {
|
|
196
|
+
return sendJson(res, 200, {
|
|
197
|
+
ok: true,
|
|
198
|
+
version: registry.version,
|
|
199
|
+
buttons: registry.buttons.map((button) => ({
|
|
200
|
+
button_id: button.button_id,
|
|
201
|
+
label: button.label ?? button.button_id,
|
|
202
|
+
description: button.description ?? "",
|
|
203
|
+
spell_id: button.spell_id,
|
|
204
|
+
version: button.version,
|
|
205
|
+
defaults: button.defaults,
|
|
206
|
+
required_confirmations: button.required_confirmations,
|
|
207
|
+
require_signature: button.require_signature ?? false,
|
|
208
|
+
allowed_roles: button.allowed_roles
|
|
209
|
+
}))
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (method === "GET" && route === "/spell-executions") {
|
|
213
|
+
let query;
|
|
214
|
+
try {
|
|
215
|
+
query = parseListExecutionsQuery(url.searchParams);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
return sendJson(res, 400, {
|
|
219
|
+
ok: false,
|
|
220
|
+
error_code: "INVALID_QUERY",
|
|
221
|
+
message: error.message
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
const executions = Array.from(jobs.values())
|
|
225
|
+
.filter((job) => matchJobByQuery(job, query))
|
|
226
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at))
|
|
227
|
+
.slice(0, query.limit)
|
|
228
|
+
.map((job) => summarizeJob(job));
|
|
229
|
+
return sendJson(res, 200, {
|
|
230
|
+
ok: true,
|
|
231
|
+
filters: {
|
|
232
|
+
status: query.statuses ? Array.from(query.statuses) : [],
|
|
233
|
+
button_id: query.buttonId ?? null,
|
|
234
|
+
limit: query.limit
|
|
235
|
+
},
|
|
236
|
+
executions
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (method === "GET" && route.startsWith("/spell-executions/")) {
|
|
240
|
+
const executionId = route.slice("/spell-executions/".length);
|
|
241
|
+
if (!executionId || !/^[a-zA-Z0-9_.-]+$/.test(executionId)) {
|
|
242
|
+
return sendJson(res, 400, { ok: false, error_code: "INVALID_EXECUTION_ID", message: "invalid execution id" });
|
|
243
|
+
}
|
|
244
|
+
const job = jobs.get(executionId);
|
|
245
|
+
if (!job) {
|
|
246
|
+
return sendJson(res, 404, { ok: false, error_code: "EXECUTION_NOT_FOUND", message: "execution not found" });
|
|
247
|
+
}
|
|
248
|
+
return sendJson(res, 200, {
|
|
249
|
+
ok: true,
|
|
250
|
+
execution: summarizeJob(job),
|
|
251
|
+
receipt: job.receipt ?? null
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return sendJson(res, 404, { ok: false, error_code: "NOT_FOUND", message: "route not found" });
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
return sendJson(res, 500, {
|
|
258
|
+
ok: false,
|
|
259
|
+
error_code: "INTERNAL_ERROR",
|
|
260
|
+
message: error.message
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
await new Promise((resolve, reject) => {
|
|
265
|
+
server.once("error", reject);
|
|
266
|
+
server.listen(options.port ?? 0, () => resolve());
|
|
267
|
+
});
|
|
268
|
+
const address = server.address();
|
|
269
|
+
if (!address || typeof address === "string") {
|
|
270
|
+
throw new Error("failed to acquire server port");
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
port: address.port,
|
|
274
|
+
close: async () => {
|
|
275
|
+
await new Promise((resolve, reject) => {
|
|
276
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
277
|
+
});
|
|
278
|
+
await Promise.allSettled(Array.from(runningJobPromises));
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
async function runJob(job, input, dryRun, confirmations, requireSignature, executionTimeoutMs, jobs, persistJobs, retention) {
|
|
283
|
+
const cliPath = node_path_1.default.resolve(process.cwd(), "dist", "cli", "index.js");
|
|
284
|
+
const tempDir = await (0, promises_1.mkdtemp)(node_path_1.default.join((0, node_os_1.tmpdir)(), "spell-exec-api-"));
|
|
285
|
+
const inputPath = node_path_1.default.join(tempDir, "input.json");
|
|
286
|
+
await (0, promises_1.writeFile)(inputPath, JSON.stringify(input), "utf8");
|
|
287
|
+
const args = [cliPath, "cast", job.spell_id, "--version", job.version, "--input", inputPath];
|
|
288
|
+
if (dryRun)
|
|
289
|
+
args.push("--dry-run");
|
|
290
|
+
if (confirmations.risk)
|
|
291
|
+
args.push("--yes");
|
|
292
|
+
if (confirmations.billing)
|
|
293
|
+
args.push("--allow-billing");
|
|
294
|
+
if (requireSignature)
|
|
295
|
+
args.push("--require-signature");
|
|
296
|
+
const running = {
|
|
297
|
+
...job,
|
|
298
|
+
status: "running",
|
|
299
|
+
started_at: new Date().toISOString()
|
|
300
|
+
};
|
|
301
|
+
jobs.set(job.execution_id, running);
|
|
302
|
+
await persistJobs();
|
|
303
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, args, {
|
|
304
|
+
shell: false,
|
|
305
|
+
cwd: process.cwd(),
|
|
306
|
+
env: process.env
|
|
307
|
+
});
|
|
308
|
+
let stdout = "";
|
|
309
|
+
let stderr = "";
|
|
310
|
+
let timeoutHit = false;
|
|
311
|
+
child.stdout.on("data", (chunk) => {
|
|
312
|
+
stdout += chunk.toString();
|
|
313
|
+
});
|
|
314
|
+
child.stderr.on("data", (chunk) => {
|
|
315
|
+
stderr += chunk.toString();
|
|
316
|
+
});
|
|
317
|
+
const timer = setTimeout(() => {
|
|
318
|
+
timeoutHit = true;
|
|
319
|
+
child.kill("SIGTERM");
|
|
320
|
+
}, executionTimeoutMs);
|
|
321
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
322
|
+
child.once("error", reject);
|
|
323
|
+
child.once("close", resolve);
|
|
324
|
+
}).catch(() => -1);
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
const runtimeExecutionId = findLineValue(stdout, "execution_id:");
|
|
327
|
+
const runtimeLogPath = findLineValue(stdout, "log:");
|
|
328
|
+
let receipt;
|
|
329
|
+
if (runtimeLogPath) {
|
|
330
|
+
receipt = await loadSanitizedReceipt(runtimeLogPath).catch(() => undefined);
|
|
331
|
+
}
|
|
332
|
+
const finishedBase = {
|
|
333
|
+
...running,
|
|
334
|
+
finished_at: new Date().toISOString(),
|
|
335
|
+
runtime_execution_id: runtimeExecutionId,
|
|
336
|
+
runtime_log_path: runtimeLogPath,
|
|
337
|
+
receipt
|
|
338
|
+
};
|
|
339
|
+
if (timeoutHit) {
|
|
340
|
+
jobs.set(job.execution_id, {
|
|
341
|
+
...finishedBase,
|
|
342
|
+
status: "timeout",
|
|
343
|
+
error_code: "EXECUTION_TIMEOUT",
|
|
344
|
+
message: `execution exceeded timeout ${executionTimeoutMs}ms`
|
|
345
|
+
});
|
|
346
|
+
await persistJobs();
|
|
347
|
+
await applyLogRetentionAndPersist(retention, jobs, persistJobs);
|
|
348
|
+
await (0, promises_1.rm)(tempDir, { recursive: true, force: true });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (exitCode === 0) {
|
|
352
|
+
jobs.set(job.execution_id, {
|
|
353
|
+
...finishedBase,
|
|
354
|
+
status: "succeeded",
|
|
355
|
+
message: "completed"
|
|
356
|
+
});
|
|
357
|
+
await persistJobs();
|
|
358
|
+
await applyLogRetentionAndPersist(retention, jobs, persistJobs);
|
|
359
|
+
await (0, promises_1.rm)(tempDir, { recursive: true, force: true });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const mapped = mapRuntimeError(stderr || stdout);
|
|
363
|
+
jobs.set(job.execution_id, {
|
|
364
|
+
...finishedBase,
|
|
365
|
+
status: "failed",
|
|
366
|
+
error_code: mapped.code,
|
|
367
|
+
message: mapped.message
|
|
368
|
+
});
|
|
369
|
+
await persistJobs();
|
|
370
|
+
await applyLogRetentionAndPersist(retention, jobs, persistJobs);
|
|
371
|
+
await (0, promises_1.rm)(tempDir, { recursive: true, force: true });
|
|
372
|
+
}
|
|
373
|
+
function summarizeJob(job) {
|
|
374
|
+
return {
|
|
375
|
+
execution_id: job.execution_id,
|
|
376
|
+
button_id: job.button_id,
|
|
377
|
+
spell_id: job.spell_id,
|
|
378
|
+
version: job.version,
|
|
379
|
+
require_signature: job.require_signature,
|
|
380
|
+
status: job.status,
|
|
381
|
+
actor_role: job.actor_role,
|
|
382
|
+
created_at: job.created_at,
|
|
383
|
+
started_at: job.started_at,
|
|
384
|
+
finished_at: job.finished_at,
|
|
385
|
+
error_code: job.error_code,
|
|
386
|
+
message: job.message,
|
|
387
|
+
runtime_execution_id: job.runtime_execution_id
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
async function loadSanitizedReceipt(runtimeLogPath) {
|
|
391
|
+
const raw = await (0, promises_1.readFile)(runtimeLogPath, "utf8");
|
|
392
|
+
const parsed = JSON.parse(raw);
|
|
393
|
+
const steps = Array.isArray(parsed.steps)
|
|
394
|
+
? parsed.steps.map((step) => {
|
|
395
|
+
const s = step;
|
|
396
|
+
return {
|
|
397
|
+
stepName: s.stepName,
|
|
398
|
+
uses: s.uses,
|
|
399
|
+
started_at: s.started_at,
|
|
400
|
+
finished_at: s.finished_at,
|
|
401
|
+
success: s.success,
|
|
402
|
+
exitCode: s.exitCode,
|
|
403
|
+
message: s.message
|
|
404
|
+
};
|
|
405
|
+
})
|
|
406
|
+
: [];
|
|
407
|
+
return {
|
|
408
|
+
execution_id: parsed.execution_id,
|
|
409
|
+
id: parsed.id,
|
|
410
|
+
version: parsed.version,
|
|
411
|
+
started_at: parsed.started_at,
|
|
412
|
+
finished_at: parsed.finished_at,
|
|
413
|
+
summary: parsed.summary,
|
|
414
|
+
checks: parsed.checks,
|
|
415
|
+
steps,
|
|
416
|
+
success: parsed.success,
|
|
417
|
+
error: parsed.error
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function mapRuntimeError(raw) {
|
|
421
|
+
if (/signature required:/.test(raw)) {
|
|
422
|
+
return { code: "SIGNATURE_REQUIRED", message: "signature required" };
|
|
423
|
+
}
|
|
424
|
+
if (/risk .* requires --yes/.test(raw)) {
|
|
425
|
+
return { code: "RISK_CONFIRMATION_REQUIRED", message: "risk confirmation required" };
|
|
426
|
+
}
|
|
427
|
+
if (/billing enabled requires --allow-billing/.test(raw)) {
|
|
428
|
+
return { code: "BILLING_CONFIRMATION_REQUIRED", message: "billing confirmation required" };
|
|
429
|
+
}
|
|
430
|
+
if (/missing connector token/.test(raw)) {
|
|
431
|
+
return { code: "CONNECTOR_TOKEN_MISSING", message: "connector token missing" };
|
|
432
|
+
}
|
|
433
|
+
if (/platform mismatch:/.test(raw)) {
|
|
434
|
+
return { code: "PLATFORM_UNSUPPORTED", message: "platform unsupported" };
|
|
435
|
+
}
|
|
436
|
+
if (/input does not match schema/.test(raw)) {
|
|
437
|
+
return { code: "INPUT_SCHEMA_INVALID", message: "input schema invalid" };
|
|
438
|
+
}
|
|
439
|
+
return { code: "EXECUTION_FAILED", message: "execution failed" };
|
|
440
|
+
}
|
|
441
|
+
function findLineValue(stdout, prefix) {
|
|
442
|
+
const lines = stdout.split(/\r?\n/);
|
|
443
|
+
for (const line of lines) {
|
|
444
|
+
if (line.startsWith(prefix)) {
|
|
445
|
+
return line.slice(prefix.length).trim();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
function parseCreateExecutionRequest(payload) {
|
|
451
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
452
|
+
throw new Error("request body must be an object");
|
|
453
|
+
}
|
|
454
|
+
const obj = payload;
|
|
455
|
+
const allowedKeys = new Set(["button_id", "dry_run", "input", "confirmation", "actor_role"]);
|
|
456
|
+
for (const key of Object.keys(obj)) {
|
|
457
|
+
if (!allowedKeys.has(key)) {
|
|
458
|
+
throw new Error(`unsupported field in request body: ${key}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const buttonId = obj.button_id;
|
|
462
|
+
if (typeof buttonId !== "string" || !buttonId.trim()) {
|
|
463
|
+
throw new Error("button_id is required");
|
|
464
|
+
}
|
|
465
|
+
const dryRun = obj.dry_run;
|
|
466
|
+
if (dryRun !== undefined && typeof dryRun !== "boolean") {
|
|
467
|
+
throw new Error("dry_run must be boolean");
|
|
468
|
+
}
|
|
469
|
+
const input = obj.input;
|
|
470
|
+
if (input !== undefined && (!input || typeof input !== "object" || Array.isArray(input))) {
|
|
471
|
+
throw new Error("input must be an object");
|
|
472
|
+
}
|
|
473
|
+
const confirmation = obj.confirmation;
|
|
474
|
+
if (confirmation !== undefined && (!confirmation || typeof confirmation !== "object" || Array.isArray(confirmation))) {
|
|
475
|
+
throw new Error("confirmation must be an object");
|
|
476
|
+
}
|
|
477
|
+
const actorRole = obj.actor_role;
|
|
478
|
+
if (actorRole !== undefined && (typeof actorRole !== "string" || !actorRole.trim())) {
|
|
479
|
+
throw new Error("actor_role must be a non-empty string");
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
button_id: buttonId,
|
|
483
|
+
dry_run: dryRun,
|
|
484
|
+
input: input ?? {},
|
|
485
|
+
confirmation: confirmation,
|
|
486
|
+
actor_role: actorRole
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function normalizeRoute(pathname) {
|
|
490
|
+
if (pathname.startsWith("/api/")) {
|
|
491
|
+
return pathname.slice(4) || "/";
|
|
492
|
+
}
|
|
493
|
+
return pathname;
|
|
494
|
+
}
|
|
495
|
+
async function readJsonBody(req, maxBytes) {
|
|
496
|
+
const chunks = [];
|
|
497
|
+
let total = 0;
|
|
498
|
+
for await (const chunk of req) {
|
|
499
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
500
|
+
total += buf.byteLength;
|
|
501
|
+
if (total > maxBytes) {
|
|
502
|
+
throw new Error(`request body too large: max ${maxBytes} bytes`);
|
|
503
|
+
}
|
|
504
|
+
chunks.push(buf);
|
|
505
|
+
}
|
|
506
|
+
if (chunks.length === 0) {
|
|
507
|
+
return {};
|
|
508
|
+
}
|
|
509
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
510
|
+
return JSON.parse(text);
|
|
511
|
+
}
|
|
512
|
+
function sendJson(res, statusCode, payload) {
|
|
513
|
+
const body = JSON.stringify(payload);
|
|
514
|
+
res.statusCode = statusCode;
|
|
515
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
516
|
+
res.setHeader("content-length", Buffer.byteLength(body));
|
|
517
|
+
res.end(body);
|
|
518
|
+
}
|
|
519
|
+
function sendText(res, statusCode, body, contentType) {
|
|
520
|
+
res.statusCode = statusCode;
|
|
521
|
+
res.setHeader("content-type", contentType);
|
|
522
|
+
res.setHeader("content-length", Buffer.byteLength(body));
|
|
523
|
+
res.end(body);
|
|
524
|
+
}
|
|
525
|
+
function allowRate(ip, history, windowMs, maxRequests) {
|
|
526
|
+
const now = Date.now();
|
|
527
|
+
const list = history.get(ip) ?? [];
|
|
528
|
+
const filtered = list.filter((time) => now - time <= windowMs);
|
|
529
|
+
if (filtered.length >= maxRequests) {
|
|
530
|
+
history.set(ip, filtered);
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
filtered.push(now);
|
|
534
|
+
history.set(ip, filtered);
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
function countInFlightJobs(jobs) {
|
|
538
|
+
let total = 0;
|
|
539
|
+
for (const job of jobs.values()) {
|
|
540
|
+
if (job.status === "queued" || job.status === "running") {
|
|
541
|
+
total += 1;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return total;
|
|
545
|
+
}
|
|
546
|
+
function parseListExecutionsQuery(params) {
|
|
547
|
+
const statusParam = params.get("status");
|
|
548
|
+
const buttonIdParam = params.get("button_id");
|
|
549
|
+
const limitParam = params.get("limit");
|
|
550
|
+
let statuses = null;
|
|
551
|
+
if (statusParam && statusParam.trim() !== "") {
|
|
552
|
+
statuses = new Set();
|
|
553
|
+
for (const raw of statusParam.split(",")) {
|
|
554
|
+
const value = raw.trim();
|
|
555
|
+
if (!value)
|
|
556
|
+
continue;
|
|
557
|
+
if (!isJobStatus(value)) {
|
|
558
|
+
throw new Error(`invalid status filter: ${value}`);
|
|
559
|
+
}
|
|
560
|
+
statuses.add(value);
|
|
561
|
+
}
|
|
562
|
+
if (statuses.size === 0) {
|
|
563
|
+
statuses = null;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
let limit = DEFAULT_LIST_LIMIT;
|
|
567
|
+
if (limitParam && limitParam.trim() !== "") {
|
|
568
|
+
const parsed = Number(limitParam);
|
|
569
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > MAX_LIST_LIMIT) {
|
|
570
|
+
throw new Error(`invalid limit: must be integer in range 1-${MAX_LIST_LIMIT}`);
|
|
571
|
+
}
|
|
572
|
+
limit = parsed;
|
|
573
|
+
}
|
|
574
|
+
const buttonId = buttonIdParam && buttonIdParam.trim() !== "" ? buttonIdParam.trim() : null;
|
|
575
|
+
return {
|
|
576
|
+
statuses,
|
|
577
|
+
buttonId,
|
|
578
|
+
limit
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
function matchJobByQuery(job, query) {
|
|
582
|
+
if (query.statuses && !query.statuses.has(job.status)) {
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
if (query.buttonId && job.button_id !== query.buttonId) {
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
function isJobStatus(value) {
|
|
591
|
+
return value === "queued" || value === "running" || value === "succeeded" || value === "failed" || value === "timeout";
|
|
592
|
+
}
|
|
593
|
+
function requiresApiAuth(route) {
|
|
594
|
+
return route === "/buttons" || route === "/spell-executions" || route.startsWith("/spell-executions/");
|
|
595
|
+
}
|
|
596
|
+
function authorizeRequest(req, authTokens, authKeys) {
|
|
597
|
+
if (authTokens.size === 0 && authKeys.length === 0) {
|
|
598
|
+
return { ok: true };
|
|
599
|
+
}
|
|
600
|
+
const token = readAuthToken(req);
|
|
601
|
+
if (!token) {
|
|
602
|
+
return { ok: false, errorCode: "AUTH_REQUIRED", message: "authorization token is required" };
|
|
603
|
+
}
|
|
604
|
+
if (authKeys.length > 0) {
|
|
605
|
+
for (const key of authKeys) {
|
|
606
|
+
if (secureTokenEquals(key.token, token)) {
|
|
607
|
+
return { ok: true, role: key.role };
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return { ok: false, errorCode: "AUTH_INVALID", message: "invalid authorization token" };
|
|
611
|
+
}
|
|
612
|
+
for (const expectedToken of authTokens) {
|
|
613
|
+
if (secureTokenEquals(expectedToken, token)) {
|
|
614
|
+
return { ok: true };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return { ok: false, errorCode: "AUTH_INVALID", message: "invalid authorization token" };
|
|
618
|
+
}
|
|
619
|
+
function parseAuthKeys(entries) {
|
|
620
|
+
const out = [];
|
|
621
|
+
const seenTokens = new Set();
|
|
622
|
+
for (const raw of entries) {
|
|
623
|
+
const trimmed = raw.trim();
|
|
624
|
+
if (!trimmed) {
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const delimiter = trimmed.includes("=") ? "=" : ":";
|
|
628
|
+
const idx = trimmed.indexOf(delimiter);
|
|
629
|
+
if (idx <= 0 || idx >= trimmed.length - 1) {
|
|
630
|
+
throw new Error(`invalid auth key entry: ${trimmed}`);
|
|
631
|
+
}
|
|
632
|
+
const role = trimmed.slice(0, idx).trim();
|
|
633
|
+
const token = trimmed.slice(idx + 1).trim();
|
|
634
|
+
if (!role || !/^[a-zA-Z0-9_-]{1,64}$/.test(role)) {
|
|
635
|
+
throw new Error(`invalid auth key role: ${role || "(empty)"}`);
|
|
636
|
+
}
|
|
637
|
+
if (!token) {
|
|
638
|
+
throw new Error(`invalid auth key token: ${role} has empty token`);
|
|
639
|
+
}
|
|
640
|
+
if (seenTokens.has(token)) {
|
|
641
|
+
throw new Error(`duplicate auth key token configured for role: ${role}`);
|
|
642
|
+
}
|
|
643
|
+
seenTokens.add(token);
|
|
644
|
+
out.push({ role, token });
|
|
645
|
+
}
|
|
646
|
+
return out;
|
|
647
|
+
}
|
|
648
|
+
function readAuthToken(req) {
|
|
649
|
+
const authorization = req.headers.authorization;
|
|
650
|
+
if (typeof authorization === "string") {
|
|
651
|
+
const matched = /^Bearer\s+(.+)$/.exec(authorization.trim());
|
|
652
|
+
if (matched && matched[1]) {
|
|
653
|
+
return matched[1].trim();
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const apiKey = req.headers["x-api-key"];
|
|
657
|
+
if (typeof apiKey === "string" && apiKey.trim() !== "") {
|
|
658
|
+
return apiKey.trim();
|
|
659
|
+
}
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
function secureTokenEquals(expected, actual) {
|
|
663
|
+
const expectedBuf = Buffer.from(expected);
|
|
664
|
+
const actualBuf = Buffer.from(actual);
|
|
665
|
+
if (expectedBuf.length !== actualBuf.length) {
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
return (0, node_crypto_1.timingSafeEqual)(expectedBuf, actualBuf);
|
|
669
|
+
}
|
|
670
|
+
function recoverInterruptedJobs(jobs) {
|
|
671
|
+
let recovered = 0;
|
|
672
|
+
const now = new Date().toISOString();
|
|
673
|
+
for (const [executionId, job] of jobs.entries()) {
|
|
674
|
+
if (job.status !== "queued" && job.status !== "running") {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
jobs.set(executionId, {
|
|
678
|
+
...job,
|
|
679
|
+
status: "failed",
|
|
680
|
+
finished_at: now,
|
|
681
|
+
error_code: "SERVER_RESTARTED",
|
|
682
|
+
message: "execution interrupted by server restart"
|
|
683
|
+
});
|
|
684
|
+
recovered += 1;
|
|
685
|
+
}
|
|
686
|
+
return recovered;
|
|
687
|
+
}
|
|
688
|
+
async function loadExecutionJobsIndex(filePath) {
|
|
689
|
+
let raw;
|
|
690
|
+
try {
|
|
691
|
+
raw = await (0, promises_1.readFile)(filePath, "utf8");
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
return new Map();
|
|
695
|
+
}
|
|
696
|
+
let parsed;
|
|
697
|
+
try {
|
|
698
|
+
parsed = JSON.parse(raw);
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
return new Map();
|
|
702
|
+
}
|
|
703
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
704
|
+
return new Map();
|
|
705
|
+
}
|
|
706
|
+
const index = parsed;
|
|
707
|
+
if (index.version !== "v1" || !Array.isArray(index.executions)) {
|
|
708
|
+
return new Map();
|
|
709
|
+
}
|
|
710
|
+
const jobs = new Map();
|
|
711
|
+
for (const item of index.executions) {
|
|
712
|
+
if (!isExecutionJob(item)) {
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
jobs.set(item.execution_id, {
|
|
716
|
+
...item,
|
|
717
|
+
require_signature: Boolean(item.require_signature)
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
return jobs;
|
|
721
|
+
}
|
|
722
|
+
async function writeExecutionJobsIndex(filePath, jobs) {
|
|
723
|
+
const payload = {
|
|
724
|
+
version: "v1",
|
|
725
|
+
updated_at: new Date().toISOString(),
|
|
726
|
+
executions: Array.from(jobs.values())
|
|
727
|
+
};
|
|
728
|
+
await (0, promises_1.writeFile)(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
729
|
+
}
|
|
730
|
+
async function applyLogRetentionAndPersist(retention, jobs, persistJobs) {
|
|
731
|
+
const pruned = await applyLogRetentionPolicy(retention.logsDirectory, jobs, retention.logRetentionDays, retention.logMaxFiles);
|
|
732
|
+
if (pruned) {
|
|
733
|
+
await persistJobs();
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
async function applyLogRetentionPolicy(logsDirectory, jobs, logRetentionDays, logMaxFiles) {
|
|
737
|
+
const entries = await (0, promises_1.readdir)(logsDirectory).catch(() => []);
|
|
738
|
+
const candidates = [];
|
|
739
|
+
for (const entry of entries) {
|
|
740
|
+
if (!entry.endsWith(".json") || entry === "index.json") {
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
const filePath = node_path_1.default.join(logsDirectory, entry);
|
|
744
|
+
const info = await (0, promises_1.stat)(filePath).catch(() => null);
|
|
745
|
+
if (!info || !info.isFile()) {
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
candidates.push({
|
|
749
|
+
fileName: entry,
|
|
750
|
+
mtimeMs: info.mtimeMs
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
const toDelete = new Set();
|
|
754
|
+
if (logRetentionDays > 0) {
|
|
755
|
+
const cutoff = Date.now() - logRetentionDays * 24 * 60 * 60 * 1000;
|
|
756
|
+
for (const candidate of candidates) {
|
|
757
|
+
if (candidate.mtimeMs < cutoff) {
|
|
758
|
+
toDelete.add(candidate.fileName);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const remaining = candidates
|
|
763
|
+
.filter((candidate) => !toDelete.has(candidate.fileName))
|
|
764
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs || b.fileName.localeCompare(a.fileName));
|
|
765
|
+
if (logMaxFiles > 0 && remaining.length > logMaxFiles) {
|
|
766
|
+
for (let i = logMaxFiles; i < remaining.length; i += 1) {
|
|
767
|
+
toDelete.add(remaining[i].fileName);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
let changed = false;
|
|
771
|
+
if (toDelete.size > 0) {
|
|
772
|
+
for (const fileName of toDelete) {
|
|
773
|
+
const removed = await (0, promises_1.rm)(node_path_1.default.join(logsDirectory, fileName), { force: true })
|
|
774
|
+
.then(() => true)
|
|
775
|
+
.catch(() => false);
|
|
776
|
+
if (removed) {
|
|
777
|
+
changed = true;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const jobIdsToDelete = new Set();
|
|
782
|
+
const jobsList = Array.from(jobs.values());
|
|
783
|
+
if (logRetentionDays > 0) {
|
|
784
|
+
const cutoff = Date.now() - logRetentionDays * 24 * 60 * 60 * 1000;
|
|
785
|
+
for (const job of jobsList) {
|
|
786
|
+
const time = readJobTimestamp(job);
|
|
787
|
+
if (time !== null && time < cutoff) {
|
|
788
|
+
jobIdsToDelete.add(job.execution_id);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
const remainingJobs = jobsList
|
|
793
|
+
.filter((job) => !jobIdsToDelete.has(job.execution_id))
|
|
794
|
+
.sort((a, b) => {
|
|
795
|
+
const t1 = readJobTimestamp(a) ?? 0;
|
|
796
|
+
const t2 = readJobTimestamp(b) ?? 0;
|
|
797
|
+
return t2 - t1;
|
|
798
|
+
});
|
|
799
|
+
if (logMaxFiles > 0 && remainingJobs.length > logMaxFiles) {
|
|
800
|
+
for (let i = logMaxFiles; i < remainingJobs.length; i += 1) {
|
|
801
|
+
jobIdsToDelete.add(remainingJobs[i].execution_id);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
const retainedLogPaths = new Set();
|
|
805
|
+
for (const job of jobsList) {
|
|
806
|
+
if (jobIdsToDelete.has(job.execution_id)) {
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
if (job.runtime_log_path) {
|
|
810
|
+
retainedLogPaths.add(job.runtime_log_path);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
for (const executionId of jobIdsToDelete) {
|
|
814
|
+
const job = jobs.get(executionId);
|
|
815
|
+
if (!job) {
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (job.runtime_log_path) {
|
|
819
|
+
// A runtime log path can (rarely) be shared if the runtime execution id collides.
|
|
820
|
+
// Do not delete a log file that is still referenced by a retained job.
|
|
821
|
+
if (!retainedLogPaths.has(job.runtime_log_path)) {
|
|
822
|
+
await (0, promises_1.rm)(job.runtime_log_path, { force: true }).catch(() => undefined);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
jobs.delete(executionId);
|
|
826
|
+
changed = true;
|
|
827
|
+
}
|
|
828
|
+
return changed;
|
|
829
|
+
}
|
|
830
|
+
function readJobTimestamp(job) {
|
|
831
|
+
const source = job.finished_at ?? job.created_at;
|
|
832
|
+
const value = Date.parse(source);
|
|
833
|
+
if (!Number.isFinite(value)) {
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
return value;
|
|
837
|
+
}
|
|
838
|
+
function isExecutionJob(value) {
|
|
839
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
const obj = value;
|
|
843
|
+
return (typeof obj.execution_id === "string" &&
|
|
844
|
+
typeof obj.button_id === "string" &&
|
|
845
|
+
typeof obj.spell_id === "string" &&
|
|
846
|
+
typeof obj.version === "string" &&
|
|
847
|
+
(obj.require_signature === undefined || typeof obj.require_signature === "boolean") &&
|
|
848
|
+
typeof obj.actor_role === "string" &&
|
|
849
|
+
typeof obj.created_at === "string" &&
|
|
850
|
+
typeof obj.status === "string" &&
|
|
851
|
+
isJobStatus(obj.status));
|
|
852
|
+
}
|
|
853
|
+
function deepMerge(base, patch) {
|
|
854
|
+
const out = { ...base };
|
|
855
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
856
|
+
const baseValue = out[key];
|
|
857
|
+
if (isPlainObject(baseValue) && isPlainObject(value)) {
|
|
858
|
+
out[key] = deepMerge(baseValue, value);
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
out[key] = value;
|
|
862
|
+
}
|
|
863
|
+
return out;
|
|
864
|
+
}
|
|
865
|
+
function isPlainObject(value) {
|
|
866
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
867
|
+
}
|
|
868
|
+
//# sourceMappingURL=server.js.map
|