@hasna/loops 0.4.1 → 0.4.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 +1 -1
- package/dist/api/index.d.ts +8 -2
- package/dist/api/index.js +961 -4
- package/dist/cli/index.js +458 -96
- package/dist/daemon/index.js +48 -14
- package/dist/index.d.ts +2 -0
- package/dist/index.js +740 -52
- package/dist/lib/format.d.ts +3 -1
- package/dist/lib/mode.js +18 -2
- package/dist/lib/route/todos-cli.d.ts +1 -0
- package/dist/lib/route/types.d.ts +10 -0
- package/dist/lib/storage/contract.d.ts +133 -0
- package/dist/lib/storage/contract.js +1 -0
- package/dist/lib/storage/index.d.ts +6 -0
- package/dist/lib/storage/index.js +4612 -0
- package/dist/lib/storage/postgres-schema.d.ts +4 -0
- package/dist/lib/storage/postgres-schema.js +328 -0
- package/dist/lib/storage/postgres.d.ts +22 -0
- package/dist/lib/storage/postgres.js +423 -0
- package/dist/lib/storage/sqlite.d.ts +84 -0
- package/dist/lib/storage/sqlite.js +4187 -0
- package/dist/lib/store.d.ts +3 -0
- package/dist/lib/store.js +27 -10
- package/dist/lib/template-kit.d.ts +10 -8
- package/dist/lib/templates.d.ts +1 -0
- package/dist/mcp/index.js +48 -14
- package/dist/runner/index.d.ts +23 -0
- package/dist/runner/index.js +1746 -4
- package/dist/sdk/index.js +30 -12
- package/docs/DEPLOYMENT_MODES.md +15 -7
- package/package.json +18 -2
package/dist/api/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// package.json
|
|
4
4
|
var package_default = {
|
|
5
5
|
name: "@hasna/loops",
|
|
6
|
-
version: "0.4.
|
|
6
|
+
version: "0.4.2",
|
|
7
7
|
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
8
8
|
type: "module",
|
|
9
9
|
main: "dist/index.js",
|
|
@@ -43,6 +43,22 @@ var package_default = {
|
|
|
43
43
|
"./storage": {
|
|
44
44
|
types: "./dist/lib/store.d.ts",
|
|
45
45
|
import: "./dist/lib/store.js"
|
|
46
|
+
},
|
|
47
|
+
"./storage/contract": {
|
|
48
|
+
types: "./dist/lib/storage/contract.d.ts",
|
|
49
|
+
import: "./dist/lib/storage/contract.js"
|
|
50
|
+
},
|
|
51
|
+
"./storage/sqlite": {
|
|
52
|
+
types: "./dist/lib/storage/sqlite.d.ts",
|
|
53
|
+
import: "./dist/lib/storage/sqlite.js"
|
|
54
|
+
},
|
|
55
|
+
"./storage/postgres": {
|
|
56
|
+
types: "./dist/lib/storage/postgres.d.ts",
|
|
57
|
+
import: "./dist/lib/storage/postgres.js"
|
|
58
|
+
},
|
|
59
|
+
"./storage/postgres-schema": {
|
|
60
|
+
types: "./dist/lib/storage/postgres-schema.d.ts",
|
|
61
|
+
import: "./dist/lib/storage/postgres-schema.js"
|
|
46
62
|
}
|
|
47
63
|
},
|
|
48
64
|
files: [
|
|
@@ -53,7 +69,7 @@ var package_default = {
|
|
|
53
69
|
"LICENSE"
|
|
54
70
|
],
|
|
55
71
|
scripts: {
|
|
56
|
-
build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts src/lib/mode.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
|
|
72
|
+
build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts src/lib/mode.ts src/lib/storage/index.ts src/lib/storage/contract.ts src/lib/storage/sqlite.ts src/lib/storage/postgres.ts src/lib/storage/postgres-schema.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
|
|
57
73
|
"build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
|
|
58
74
|
typecheck: "tsc --noEmit",
|
|
59
75
|
test: "bun test",
|
|
@@ -253,8 +269,503 @@ function deploymentStatusLine(status) {
|
|
|
253
269
|
}
|
|
254
270
|
|
|
255
271
|
// src/api/index.ts
|
|
272
|
+
import { timingSafeEqual } from "crypto";
|
|
256
273
|
import { Command } from "commander";
|
|
274
|
+
|
|
275
|
+
// src/lib/errors.ts
|
|
276
|
+
class CodedError extends Error {
|
|
277
|
+
code;
|
|
278
|
+
constructor(code, message) {
|
|
279
|
+
super(message);
|
|
280
|
+
this.name = new.target.name;
|
|
281
|
+
this.code = code;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
class LoopNotFoundError extends CodedError {
|
|
286
|
+
constructor(idOrName) {
|
|
287
|
+
super("LOOP_NOT_FOUND", `loop not found: ${idOrName}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
class LoopArchivedError extends CodedError {
|
|
292
|
+
constructor(idOrName) {
|
|
293
|
+
super("LOOP_ARCHIVED", `loop is archived: ${idOrName}; unarchive it before modifying`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
class AmbiguousNameError extends CodedError {
|
|
298
|
+
constructor(name) {
|
|
299
|
+
super("AMBIGUOUS_NAME", `ambiguous loop name: ${name}; use a loop id`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
class ValidationError extends CodedError {
|
|
304
|
+
constructor(message) {
|
|
305
|
+
super("VALIDATION_ERROR", message);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// src/lib/format.ts
|
|
310
|
+
var TEXT_OUTPUT_LIMIT = 32 * 1024;
|
|
311
|
+
var SENSITIVE_PAYLOAD_KEYS = new Set(["env", "error", "prompt", "reason", "stderr", "stdout"]);
|
|
312
|
+
function redact(value, visible = 0) {
|
|
313
|
+
if (!value)
|
|
314
|
+
return value;
|
|
315
|
+
if (value.length <= visible)
|
|
316
|
+
return value;
|
|
317
|
+
if (visible <= 0)
|
|
318
|
+
return `[redacted ${value.length} chars]`;
|
|
319
|
+
return `${value.slice(0, visible)}... [redacted ${value.length - visible} chars]`;
|
|
320
|
+
}
|
|
321
|
+
function truncateTextOutput(value) {
|
|
322
|
+
if (value.length <= TEXT_OUTPUT_LIMIT)
|
|
323
|
+
return value;
|
|
324
|
+
return `${value.slice(0, TEXT_OUTPUT_LIMIT)}
|
|
325
|
+
[truncated ${value.length - TEXT_OUTPUT_LIMIT} chars]`;
|
|
326
|
+
}
|
|
327
|
+
function redactSensitivePayload(value, key) {
|
|
328
|
+
if (key && SENSITIVE_PAYLOAD_KEYS.has(key)) {
|
|
329
|
+
if (typeof value === "string")
|
|
330
|
+
return redact(value);
|
|
331
|
+
if (value === undefined || value === null)
|
|
332
|
+
return value;
|
|
333
|
+
return "[redacted]";
|
|
334
|
+
}
|
|
335
|
+
if (Array.isArray(value))
|
|
336
|
+
return value.map((item) => redactSensitivePayload(item));
|
|
337
|
+
if (value && typeof value === "object") {
|
|
338
|
+
return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [entryKey, redactSensitivePayload(entryValue, entryKey)]));
|
|
339
|
+
}
|
|
340
|
+
return value;
|
|
341
|
+
}
|
|
342
|
+
function textOutputBlocks(value, opts = {}) {
|
|
343
|
+
const indent = opts.indent ?? "";
|
|
344
|
+
const nested = `${indent} `;
|
|
345
|
+
const blocks = [];
|
|
346
|
+
for (const [label, output] of [
|
|
347
|
+
["stdout", value.stdout],
|
|
348
|
+
["stderr", value.stderr]
|
|
349
|
+
]) {
|
|
350
|
+
if (!output)
|
|
351
|
+
continue;
|
|
352
|
+
blocks.push(`${indent}${label}:`);
|
|
353
|
+
for (const line of truncateTextOutput(output).replace(/\s+$/, "").split(/\r?\n/)) {
|
|
354
|
+
blocks.push(`${nested}${line}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return blocks;
|
|
358
|
+
}
|
|
359
|
+
function publicLoop(loop) {
|
|
360
|
+
const target = loop.target.type === "command" ? { ...loop.target, env: loop.target.env ? "[redacted]" : undefined } : loop.target.type === "agent" ? { ...loop.target, prompt: redact(loop.target.prompt) } : loop.target;
|
|
361
|
+
return {
|
|
362
|
+
...loop,
|
|
363
|
+
target
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
function publicRun(run, showOutput = false, opts = {}) {
|
|
367
|
+
return {
|
|
368
|
+
...run,
|
|
369
|
+
stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
|
|
370
|
+
stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined,
|
|
371
|
+
error: opts.redactError ? redact(run.error) : run.error
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function publicExecutorResult(result, showOutput = false) {
|
|
375
|
+
return {
|
|
376
|
+
...result,
|
|
377
|
+
stdout: showOutput ? result.stdout : result.stdout ? `[redacted ${result.stdout.length} chars]` : undefined,
|
|
378
|
+
stderr: showOutput ? result.stderr : result.stderr ? `[redacted ${result.stderr.length} chars]` : undefined,
|
|
379
|
+
error: redact(result.error)
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function publicWorkflow(workflow) {
|
|
383
|
+
return {
|
|
384
|
+
...workflow,
|
|
385
|
+
steps: workflow.steps.map((step) => ({
|
|
386
|
+
...step,
|
|
387
|
+
target: step.target.type === "agent" ? { ...step.target, prompt: redact(step.target.prompt) } : step.target.type === "command" && step.target.env ? { ...step.target, env: "[redacted]" } : step.target
|
|
388
|
+
}))
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
function publicWorkflowRun(run) {
|
|
392
|
+
return { ...run, error: redact(run.error) };
|
|
393
|
+
}
|
|
394
|
+
function publicWorkflowInvocation(invocation) {
|
|
395
|
+
return redactSensitivePayload(invocation);
|
|
396
|
+
}
|
|
397
|
+
function publicWorkflowWorkItem(item) {
|
|
398
|
+
return { ...item, lastReason: redact(item.lastReason, 240) };
|
|
399
|
+
}
|
|
400
|
+
function publicWorkflowStepRun(run, showOutput = false) {
|
|
401
|
+
return {
|
|
402
|
+
...run,
|
|
403
|
+
stdout: showOutput ? run.stdout : run.stdout ? `[redacted ${run.stdout.length} chars]` : undefined,
|
|
404
|
+
stderr: showOutput ? run.stderr : run.stderr ? `[redacted ${run.stderr.length} chars]` : undefined,
|
|
405
|
+
error: redact(run.error)
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function publicWorkflowEvent(event) {
|
|
409
|
+
return { ...event, payload: redactSensitivePayload(event.payload) };
|
|
410
|
+
}
|
|
411
|
+
function publicGoal(goal) {
|
|
412
|
+
return {
|
|
413
|
+
...goal,
|
|
414
|
+
objective: redact(goal.objective, 120)
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function publicGoalRun(run) {
|
|
418
|
+
return {
|
|
419
|
+
...run,
|
|
420
|
+
evidence: redactSensitivePayload(run.evidence),
|
|
421
|
+
rawResponse: redactSensitivePayload(run.rawResponse)
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/lib/recurrence.ts
|
|
426
|
+
function assertDate(value, label) {
|
|
427
|
+
const date = new Date(value);
|
|
428
|
+
if (Number.isNaN(date.getTime()))
|
|
429
|
+
throw new Error(`invalid ${label}: ${value}`);
|
|
430
|
+
return date;
|
|
431
|
+
}
|
|
432
|
+
function parseField(expr, min, max) {
|
|
433
|
+
const out = new Set;
|
|
434
|
+
for (const part of expr.split(",")) {
|
|
435
|
+
const [rangePart, stepPart] = part.split("/");
|
|
436
|
+
const step = stepPart ? Number(stepPart) : 1;
|
|
437
|
+
if (!Number.isInteger(step) || step <= 0)
|
|
438
|
+
throw new Error(`invalid cron step: ${part}`);
|
|
439
|
+
let lo = min;
|
|
440
|
+
let hi = max;
|
|
441
|
+
if (rangePart && rangePart !== "*") {
|
|
442
|
+
const bounds = rangePart.split("-");
|
|
443
|
+
if (bounds.length === 1) {
|
|
444
|
+
lo = hi = Number(bounds[0]);
|
|
445
|
+
} else if (bounds.length === 2) {
|
|
446
|
+
lo = Number(bounds[0]);
|
|
447
|
+
hi = Number(bounds[1]);
|
|
448
|
+
} else {
|
|
449
|
+
throw new Error(`invalid cron range: ${part}`);
|
|
450
|
+
}
|
|
451
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi))
|
|
452
|
+
throw new Error(`invalid cron field: ${part}`);
|
|
453
|
+
}
|
|
454
|
+
for (let v = lo;v <= hi; v += step) {
|
|
455
|
+
if (v < min || v > max)
|
|
456
|
+
throw new Error(`cron value out of range: ${v} in ${expr}`);
|
|
457
|
+
out.add(v);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return out;
|
|
461
|
+
}
|
|
462
|
+
var MINUTE_MS = 60000;
|
|
463
|
+
var PARSED_CRON_CACHE_LIMIT = 256;
|
|
464
|
+
var parsedCronCache = new Map;
|
|
465
|
+
var emittedScheduleWarnings = new Set;
|
|
466
|
+
function warnOnce(key, message) {
|
|
467
|
+
if (emittedScheduleWarnings.has(key))
|
|
468
|
+
return;
|
|
469
|
+
emittedScheduleWarnings.add(key);
|
|
470
|
+
console.warn(`[open-loops] WARN ${message}`);
|
|
471
|
+
}
|
|
472
|
+
function parseCron(expr) {
|
|
473
|
+
const cached = parsedCronCache.get(expr);
|
|
474
|
+
if (cached)
|
|
475
|
+
return cached;
|
|
476
|
+
const fields = expr.trim().split(/\s+/);
|
|
477
|
+
if (fields.length !== 5)
|
|
478
|
+
throw new Error(`cron must have 5 fields, got ${fields.length}: "${expr}"`);
|
|
479
|
+
const [minute, hour, dom, month, dow] = fields;
|
|
480
|
+
const dowSet = parseField(dow, 0, 7);
|
|
481
|
+
if (dowSet.has(7)) {
|
|
482
|
+
dowSet.delete(7);
|
|
483
|
+
dowSet.add(0);
|
|
484
|
+
}
|
|
485
|
+
const parsed = {
|
|
486
|
+
minute: parseField(minute, 0, 59),
|
|
487
|
+
hour: parseField(hour, 0, 23),
|
|
488
|
+
dom: parseField(dom, 1, 31),
|
|
489
|
+
month: parseField(month, 1, 12),
|
|
490
|
+
dow: dowSet,
|
|
491
|
+
domRestricted: dom !== "*",
|
|
492
|
+
dowRestricted: dow !== "*"
|
|
493
|
+
};
|
|
494
|
+
if (parsedCronCache.size >= PARSED_CRON_CACHE_LIMIT) {
|
|
495
|
+
const oldest = parsedCronCache.keys().next().value;
|
|
496
|
+
if (oldest !== undefined)
|
|
497
|
+
parsedCronCache.delete(oldest);
|
|
498
|
+
}
|
|
499
|
+
parsedCronCache.set(expr, parsed);
|
|
500
|
+
return parsed;
|
|
501
|
+
}
|
|
502
|
+
function cronMatches(cron, date) {
|
|
503
|
+
if (!cron.minute.has(date.getMinutes()))
|
|
504
|
+
return false;
|
|
505
|
+
if (!cron.hour.has(date.getHours()))
|
|
506
|
+
return false;
|
|
507
|
+
if (!cron.month.has(date.getMonth() + 1))
|
|
508
|
+
return false;
|
|
509
|
+
const domOk = cron.dom.has(date.getDate());
|
|
510
|
+
const dowOk = cron.dow.has(date.getDay());
|
|
511
|
+
if (cron.domRestricted && cron.dowRestricted)
|
|
512
|
+
return domOk || dowOk;
|
|
513
|
+
if (cron.domRestricted)
|
|
514
|
+
return domOk;
|
|
515
|
+
if (cron.dowRestricted)
|
|
516
|
+
return dowOk;
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
function nextCronRun(expr, from) {
|
|
520
|
+
const cron = parseCron(expr);
|
|
521
|
+
const date = new Date(from.getTime());
|
|
522
|
+
date.setSeconds(0, 0);
|
|
523
|
+
date.setMinutes(date.getMinutes() + 1);
|
|
524
|
+
const limit = from.getTime() + 366 * 24 * 60 * 60 * 1000;
|
|
525
|
+
while (date.getTime() <= limit) {
|
|
526
|
+
if (cronMatches(cron, date))
|
|
527
|
+
return date;
|
|
528
|
+
date.setMinutes(date.getMinutes() + 1);
|
|
529
|
+
}
|
|
530
|
+
throw new Error(`no cron match within one year for: ${expr}`);
|
|
531
|
+
}
|
|
532
|
+
function initialNextRun(schedule, from = new Date) {
|
|
533
|
+
switch (schedule.type) {
|
|
534
|
+
case "once":
|
|
535
|
+
return assertDate(schedule.at, "schedule.at").toISOString();
|
|
536
|
+
case "interval":
|
|
537
|
+
if (!Number.isFinite(schedule.everyMs) || schedule.everyMs <= 0)
|
|
538
|
+
throw new Error("interval everyMs must be > 0");
|
|
539
|
+
return new Date(from.getTime() + schedule.everyMs).toISOString();
|
|
540
|
+
case "cron":
|
|
541
|
+
return nextCronRun(schedule.expression, from).toISOString();
|
|
542
|
+
case "dynamic":
|
|
543
|
+
return new Date(from.getTime() + (schedule.minIntervalMs ?? 60000)).toISOString();
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function computeNextAfter(schedule, scheduledFor, finishedAt) {
|
|
547
|
+
switch (schedule.type) {
|
|
548
|
+
case "once":
|
|
549
|
+
return;
|
|
550
|
+
case "interval": {
|
|
551
|
+
const anchor = schedule.anchor ?? "fixed_rate";
|
|
552
|
+
const base = anchor === "fixed_delay" ? finishedAt : scheduledFor;
|
|
553
|
+
let next = new Date(base.getTime() + schedule.everyMs);
|
|
554
|
+
while (next.getTime() <= finishedAt.getTime())
|
|
555
|
+
next = new Date(next.getTime() + schedule.everyMs);
|
|
556
|
+
return next.toISOString();
|
|
557
|
+
}
|
|
558
|
+
case "cron": {
|
|
559
|
+
let next = nextCronRun(schedule.expression, scheduledFor);
|
|
560
|
+
while (next.getTime() <= finishedAt.getTime())
|
|
561
|
+
next = nextCronRun(schedule.expression, next);
|
|
562
|
+
return next.toISOString();
|
|
563
|
+
}
|
|
564
|
+
case "dynamic":
|
|
565
|
+
return new Date(finishedAt.getTime() + (schedule.minIntervalMs ?? 60000)).toISOString();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function latestIntervalSlot(first, now, everyMs) {
|
|
569
|
+
if (first.getTime() > now.getTime())
|
|
570
|
+
return first;
|
|
571
|
+
const steps = Math.floor((now.getTime() - first.getTime()) / everyMs);
|
|
572
|
+
return new Date(first.getTime() + steps * everyMs);
|
|
573
|
+
}
|
|
574
|
+
function floorToMinute(date) {
|
|
575
|
+
const floored = new Date(date.getTime());
|
|
576
|
+
floored.setSeconds(0, 0);
|
|
577
|
+
return floored;
|
|
578
|
+
}
|
|
579
|
+
var LATEST_CRON_SCAN_LIMIT_MINUTES = 366 * 24 * 60;
|
|
580
|
+
function latestDailyCronSlot(cron, now) {
|
|
581
|
+
const hoursDesc = [...cron.hour].sort((a, b) => b - a);
|
|
582
|
+
const minutesDesc = [...cron.minute].sort((a, b) => b - a);
|
|
583
|
+
const candidate = floorToMinute(now);
|
|
584
|
+
for (let dayOffset = 0;dayOffset < 2; dayOffset += 1) {
|
|
585
|
+
const isToday = dayOffset === 0;
|
|
586
|
+
for (const hour of hoursDesc) {
|
|
587
|
+
if (isToday && hour > now.getHours())
|
|
588
|
+
continue;
|
|
589
|
+
const minuteCap = isToday && hour === now.getHours() ? now.getMinutes() : 59;
|
|
590
|
+
const minute = minutesDesc.find((value) => value <= minuteCap);
|
|
591
|
+
if (minute === undefined)
|
|
592
|
+
continue;
|
|
593
|
+
candidate.setHours(hour, minute, 0, 0);
|
|
594
|
+
return candidate;
|
|
595
|
+
}
|
|
596
|
+
candidate.setDate(candidate.getDate() - 1);
|
|
597
|
+
}
|
|
598
|
+
throw new Error("unreachable: daily cron pattern must match within two days");
|
|
599
|
+
}
|
|
600
|
+
function latestCronSlot(first, now, expression) {
|
|
601
|
+
if (first.getTime() > now.getTime())
|
|
602
|
+
return first;
|
|
603
|
+
const cron = parseCron(expression);
|
|
604
|
+
if (!cron.domRestricted && !cron.dowRestricted && cron.month.size === 12) {
|
|
605
|
+
const latest = latestDailyCronSlot(cron, now);
|
|
606
|
+
return latest.getTime() >= first.getTime() ? latest : first;
|
|
607
|
+
}
|
|
608
|
+
const scanFloorMs = Math.max(first.getTime(), now.getTime() - LATEST_CRON_SCAN_LIMIT_MINUTES * MINUTE_MS);
|
|
609
|
+
const cursor = floorToMinute(now);
|
|
610
|
+
while (cursor.getTime() >= scanFloorMs) {
|
|
611
|
+
if (cronMatches(cron, cursor))
|
|
612
|
+
return cursor;
|
|
613
|
+
cursor.setMinutes(cursor.getMinutes() - 1);
|
|
614
|
+
}
|
|
615
|
+
if (first.getTime() < scanFloorMs) {
|
|
616
|
+
warnOnce(`latest-cron:${expression}`, `latestCronSlot: no match for "${expression}" within the ${LATEST_CRON_SCAN_LIMIT_MINUTES}-minute scan window; using the stored next run as the latest slot`);
|
|
617
|
+
}
|
|
618
|
+
return first;
|
|
619
|
+
}
|
|
620
|
+
var MAX_CATCH_UP_SLOTS = 1000;
|
|
621
|
+
function catchUpSlotLimit(loop) {
|
|
622
|
+
if (loop.catchUpLimit > MAX_CATCH_UP_SLOTS) {
|
|
623
|
+
warnOnce(`catch-up-limit:${loop.id}`, `dueSlots: loop ${loop.id} catchUpLimit ${loop.catchUpLimit} exceeds the per-plan cap; using ${MAX_CATCH_UP_SLOTS}`);
|
|
624
|
+
return MAX_CATCH_UP_SLOTS;
|
|
625
|
+
}
|
|
626
|
+
return loop.catchUpLimit;
|
|
627
|
+
}
|
|
628
|
+
function dueSlots(loop, now) {
|
|
629
|
+
if (!loop.nextRunAt || loop.status !== "active")
|
|
630
|
+
return { slots: [] };
|
|
631
|
+
if (loop.expiresAt && new Date(loop.expiresAt).getTime() <= now.getTime())
|
|
632
|
+
return { slots: [] };
|
|
633
|
+
const next = assertDate(loop.nextRunAt, "loop.nextRunAt");
|
|
634
|
+
if (next.getTime() > now.getTime())
|
|
635
|
+
return { slots: [] };
|
|
636
|
+
if (loop.retryScheduledFor)
|
|
637
|
+
return { slots: [loop.retryScheduledFor] };
|
|
638
|
+
const catchUp = loop.catchUp;
|
|
639
|
+
switch (loop.schedule.type) {
|
|
640
|
+
case "once":
|
|
641
|
+
case "dynamic":
|
|
642
|
+
return { slots: [next.toISOString()] };
|
|
643
|
+
case "interval": {
|
|
644
|
+
if (catchUp === "all") {
|
|
645
|
+
const limit = catchUpSlotLimit(loop);
|
|
646
|
+
const slots = [];
|
|
647
|
+
let cursor = next;
|
|
648
|
+
while (cursor.getTime() <= now.getTime() && slots.length < limit) {
|
|
649
|
+
slots.push(cursor.toISOString());
|
|
650
|
+
cursor = new Date(cursor.getTime() + loop.schedule.everyMs);
|
|
651
|
+
}
|
|
652
|
+
return { slots };
|
|
653
|
+
}
|
|
654
|
+
if (catchUp === "latest")
|
|
655
|
+
return { slots: [latestIntervalSlot(next, now, loop.schedule.everyMs).toISOString()] };
|
|
656
|
+
return { slots: [next.toISOString()] };
|
|
657
|
+
}
|
|
658
|
+
case "cron": {
|
|
659
|
+
if (catchUp === "all") {
|
|
660
|
+
const limit = catchUpSlotLimit(loop);
|
|
661
|
+
const slots = [];
|
|
662
|
+
let cursor = next;
|
|
663
|
+
while (cursor.getTime() <= now.getTime() && slots.length < limit) {
|
|
664
|
+
slots.push(cursor.toISOString());
|
|
665
|
+
cursor = nextCronRun(loop.schedule.expression, cursor);
|
|
666
|
+
}
|
|
667
|
+
return { slots };
|
|
668
|
+
}
|
|
669
|
+
if (catchUp === "latest")
|
|
670
|
+
return { slots: [latestCronSlot(next, now, loop.schedule.expression).toISOString()] };
|
|
671
|
+
return { slots: [next.toISOString()] };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function parseDuration(input) {
|
|
676
|
+
const match = input.trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/);
|
|
677
|
+
if (!match)
|
|
678
|
+
throw new Error(`invalid duration: ${input}`);
|
|
679
|
+
const value = Number(match[1]);
|
|
680
|
+
const unit = match[2] ?? "ms";
|
|
681
|
+
const multiplier = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : 86400000;
|
|
682
|
+
return Math.round(value * multiplier);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// src/lib/redact.ts
|
|
686
|
+
var SCRUBBED = "[SCRUBBED]";
|
|
687
|
+
var TOKEN_PATTERNS = [
|
|
688
|
+
/\bsk-ant-[A-Za-z0-9_-]{8,}/g,
|
|
689
|
+
/\bsk-proj-[A-Za-z0-9_-]{8,}/g,
|
|
690
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
691
|
+
/\bghp_[A-Za-z0-9]{16,}\b/g,
|
|
692
|
+
/\bgithub_pat_[A-Za-z0-9_]{16,}\b/g,
|
|
693
|
+
/\bxox[a-z]-[A-Za-z0-9-]{8,}/g,
|
|
694
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{10,}\b/g
|
|
695
|
+
];
|
|
696
|
+
var AUTHORIZATION_PATTERN = /(\bAuthorization(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?(?:Bearer|Basic)\s+)[A-Za-z0-9._~+/=-]{16,}/gi;
|
|
697
|
+
var PEM_PATTERN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g;
|
|
698
|
+
var ASSIGNMENT_PATTERN = /([A-Za-z0-9_.-]{0,64}(?:key|token|secret|passwd|password|passphrase|credentials?)(?:\\?["'])?\s*[:=]\s*)(\\"([^"\\\r\n]{12,})\\"|"((?:[^"\\\r\n]|\\[^\r\n]){12,})"|'([^'\\\r\n]{12,})'|([A-Za-z0-9+/_.=-]{16,}))/gi;
|
|
699
|
+
var BENIGN_KEY_NAME_PATTERN = /(?:^|[._-])(?:idempotency|dedupe|route)[_-]?key$/i;
|
|
700
|
+
var ASSIGNMENT_HINTS = ["key", "token", "secret", "pass", "credential"];
|
|
701
|
+
function mayContainAssignment(text) {
|
|
702
|
+
const lowered = text.toLowerCase();
|
|
703
|
+
return ASSIGNMENT_HINTS.some((hint) => lowered.includes(hint));
|
|
704
|
+
}
|
|
705
|
+
function shannonEntropy(value) {
|
|
706
|
+
const counts = new Map;
|
|
707
|
+
for (const char of value)
|
|
708
|
+
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
709
|
+
let entropy = 0;
|
|
710
|
+
for (const count of counts.values()) {
|
|
711
|
+
const probability = count / value.length;
|
|
712
|
+
entropy -= probability * Math.log2(probability);
|
|
713
|
+
}
|
|
714
|
+
return entropy;
|
|
715
|
+
}
|
|
716
|
+
function isLikelySecretValue(value) {
|
|
717
|
+
if (value.length < 12)
|
|
718
|
+
return false;
|
|
719
|
+
if (value.includes(SCRUBBED))
|
|
720
|
+
return false;
|
|
721
|
+
if (value.startsWith("/") || value.startsWith("~") || value.startsWith("./"))
|
|
722
|
+
return false;
|
|
723
|
+
return shannonEntropy(value) >= 3.2;
|
|
724
|
+
}
|
|
725
|
+
function scrubSecrets(text) {
|
|
726
|
+
if (!text)
|
|
727
|
+
return text;
|
|
728
|
+
let scrubbed = text;
|
|
729
|
+
for (const pattern of TOKEN_PATTERNS)
|
|
730
|
+
scrubbed = scrubbed.replace(pattern, SCRUBBED);
|
|
731
|
+
scrubbed = scrubbed.replace(AUTHORIZATION_PATTERN, `$1${SCRUBBED}`);
|
|
732
|
+
scrubbed = scrubbed.replace(PEM_PATTERN, SCRUBBED);
|
|
733
|
+
if (!mayContainAssignment(scrubbed))
|
|
734
|
+
return scrubbed;
|
|
735
|
+
scrubbed = scrubbed.replace(ASSIGNMENT_PATTERN, (match, prefix, quotedValue, escapedQuoted, doubleQuoted, singleQuoted, bare) => {
|
|
736
|
+
const value = escapedQuoted ?? doubleQuoted ?? singleQuoted ?? bare ?? "";
|
|
737
|
+
const keyName = /^[A-Za-z0-9_.-]*/.exec(prefix)?.[0] ?? "";
|
|
738
|
+
if (BENIGN_KEY_NAME_PATTERN.test(keyName))
|
|
739
|
+
return match;
|
|
740
|
+
if (!isLikelySecretValue(value))
|
|
741
|
+
return match;
|
|
742
|
+
const quote = quotedValue.startsWith("\\\"") ? "\\\"" : quotedValue.startsWith('"') ? '"' : quotedValue.startsWith("'") ? "'" : "";
|
|
743
|
+
return `${prefix}${quote}${SCRUBBED}${quote}`;
|
|
744
|
+
});
|
|
745
|
+
return scrubbed;
|
|
746
|
+
}
|
|
747
|
+
function scrubSecretsDeep(value) {
|
|
748
|
+
if (typeof value === "string")
|
|
749
|
+
return scrubSecrets(value);
|
|
750
|
+
if (Array.isArray(value))
|
|
751
|
+
return value.map((entry) => scrubSecretsDeep(entry));
|
|
752
|
+
if (value !== null && typeof value === "object") {
|
|
753
|
+
const toJSON = value.toJSON;
|
|
754
|
+
if (typeof toJSON === "function")
|
|
755
|
+
return scrubSecretsDeep(toJSON.call(value));
|
|
756
|
+
const scrubbed = {};
|
|
757
|
+
for (const [key, entry] of Object.entries(value))
|
|
758
|
+
scrubbed[key] = scrubSecretsDeep(entry);
|
|
759
|
+
return scrubbed;
|
|
760
|
+
}
|
|
761
|
+
return value;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// src/api/index.ts
|
|
257
765
|
var program = new Command;
|
|
766
|
+
var DEFAULT_BODY_LIMIT_BYTES = 64 * 1024;
|
|
767
|
+
var DEFAULT_EVIDENCE_LIMIT_BYTES = 256 * 1024;
|
|
768
|
+
var MIN_RUNNER_LEASE_MS = 1000;
|
|
258
769
|
program.name("loops-api").description("OpenLoops self-hosted control-plane API foundation").version(packageVersion()).option("-j, --json", "print JSON");
|
|
259
770
|
function wantsJson(opts) {
|
|
260
771
|
return Boolean(program.opts().json || opts?.json);
|
|
@@ -272,6 +783,12 @@ function configuredAuthToken() {
|
|
|
272
783
|
function isLocalBind(host) {
|
|
273
784
|
return ["127.0.0.1", "localhost", "::1"].includes(host);
|
|
274
785
|
}
|
|
786
|
+
function bearerTokenMatches(authorization, token) {
|
|
787
|
+
const expected = `Bearer ${token}`;
|
|
788
|
+
const a = new TextEncoder().encode(authorization);
|
|
789
|
+
const b = new TextEncoder().encode(expected);
|
|
790
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
791
|
+
}
|
|
275
792
|
function authorizeRequest(request, host) {
|
|
276
793
|
if (isLocalBind(host))
|
|
277
794
|
return;
|
|
@@ -279,7 +796,13 @@ function authorizeRequest(request, host) {
|
|
|
279
796
|
if (!token)
|
|
280
797
|
return Response.json({ ok: false, error: "auth_required" }, { status: 401 });
|
|
281
798
|
const authorization = request.headers.get("authorization") ?? "";
|
|
282
|
-
return authorization
|
|
799
|
+
return bearerTokenMatches(authorization, token) ? undefined : Response.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
|
800
|
+
}
|
|
801
|
+
function ok(payload = {}, init) {
|
|
802
|
+
return Response.json({ ok: true, ...payload }, init);
|
|
803
|
+
}
|
|
804
|
+
function fail(error, status, details) {
|
|
805
|
+
return Response.json({ ok: false, error, ...details }, { status });
|
|
283
806
|
}
|
|
284
807
|
function apiStatus() {
|
|
285
808
|
return {
|
|
@@ -305,10 +828,444 @@ function createLoopsApiServer(opts = {}) {
|
|
|
305
828
|
if (url.pathname === "/health" || url.pathname === "/status") {
|
|
306
829
|
return Response.json(apiStatus());
|
|
307
830
|
}
|
|
308
|
-
return
|
|
831
|
+
return handleV1Request({
|
|
832
|
+
request,
|
|
833
|
+
url,
|
|
834
|
+
storage: opts.storage,
|
|
835
|
+
bodyLimitBytes: opts.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES,
|
|
836
|
+
evidenceLimitBytes: opts.evidenceLimitBytes ?? DEFAULT_EVIDENCE_LIMIT_BYTES,
|
|
837
|
+
now: opts.now ?? (() => new Date)
|
|
838
|
+
});
|
|
309
839
|
}
|
|
310
840
|
});
|
|
311
841
|
}
|
|
842
|
+
async function handleV1Request(ctx) {
|
|
843
|
+
const segments = ctx.url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
844
|
+
if (segments[0] !== "v1")
|
|
845
|
+
return fail("not_found", 404);
|
|
846
|
+
if (ctx.request.method === "GET" && segments.length === 1)
|
|
847
|
+
return ok({ service: "loops-api", version: "v1" });
|
|
848
|
+
if (ctx.request.method === "GET" && segments[1] === "status")
|
|
849
|
+
return Response.json(apiStatus());
|
|
850
|
+
try {
|
|
851
|
+
if (segments[1] === "loops")
|
|
852
|
+
return await handleLoopsRequest(ctx, segments.slice(2));
|
|
853
|
+
if (segments[1] === "runs")
|
|
854
|
+
return await handleRunsRequest(ctx, segments.slice(2));
|
|
855
|
+
if (segments[1] === "runners")
|
|
856
|
+
return await handleRunnerRequest(ctx, segments.slice(2));
|
|
857
|
+
if (segments[1] === "leases" && segments[2] === "recover" && ctx.request.method === "POST") {
|
|
858
|
+
return runnerProtocolPending("lease recovery is implemented in the runner protocol stage");
|
|
859
|
+
}
|
|
860
|
+
return fail("not_found", 404);
|
|
861
|
+
} catch (error) {
|
|
862
|
+
return errorResponse(error);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
async function handleLoopsRequest(ctx, segments) {
|
|
866
|
+
const storage = requireStorage(ctx.storage);
|
|
867
|
+
if (segments.length === 0 && ctx.request.method === "GET") {
|
|
868
|
+
const loops = await storage.listLoops({
|
|
869
|
+
status: optionalEnum(ctx.url.searchParams.get("status"), ["active", "paused", "stopped", "expired"]),
|
|
870
|
+
limit: optionalLimit(ctx.url.searchParams.get("limit")),
|
|
871
|
+
includeArchived: optionalBoolean(ctx.url.searchParams.get("includeArchived")),
|
|
872
|
+
archived: optionalBoolean(ctx.url.searchParams.get("archived"))
|
|
873
|
+
});
|
|
874
|
+
return ok({ loops: loops.map(publicLoop) });
|
|
875
|
+
}
|
|
876
|
+
if (segments.length === 0 && ctx.request.method === "POST") {
|
|
877
|
+
const body = await readJsonBody(ctx.request, ctx.bodyLimitBytes);
|
|
878
|
+
const loop = await storage.createLoop(body);
|
|
879
|
+
return ok({ loop: publicLoop(loop) }, { status: 201 });
|
|
880
|
+
}
|
|
881
|
+
const id = segments[0];
|
|
882
|
+
if (!id)
|
|
883
|
+
return fail("not_found", 404);
|
|
884
|
+
if (segments.length === 1 && ctx.request.method === "GET") {
|
|
885
|
+
const loop = await storage.getLoop(id);
|
|
886
|
+
if (!loop)
|
|
887
|
+
throw new LoopNotFoundError(id);
|
|
888
|
+
return ok({ loop: publicLoop(loop) });
|
|
889
|
+
}
|
|
890
|
+
if (segments.length === 1 && ctx.request.method === "PATCH") {
|
|
891
|
+
const body = await readJsonBody(ctx.request, ctx.bodyLimitBytes);
|
|
892
|
+
const loop = await storage.updateLoop(id, {
|
|
893
|
+
status: body.status,
|
|
894
|
+
nextRunAt: body.nextRunAt === null ? undefined : body.nextRunAt,
|
|
895
|
+
retryScheduledFor: body.retryScheduledFor === null ? undefined : body.retryScheduledFor,
|
|
896
|
+
expiresAt: body.expiresAt === null ? undefined : body.expiresAt
|
|
897
|
+
});
|
|
898
|
+
return ok({ loop: publicLoop(loop) });
|
|
899
|
+
}
|
|
900
|
+
if (segments.length === 1 && ctx.request.method === "DELETE") {
|
|
901
|
+
return ok({ deleted: await storage.deleteLoop(id) });
|
|
902
|
+
}
|
|
903
|
+
if (segments.length === 2 && segments[1] === "archive" && ctx.request.method === "POST") {
|
|
904
|
+
return ok({ loop: publicLoop(await storage.archiveLoop(id)) });
|
|
905
|
+
}
|
|
906
|
+
if (segments.length === 2 && segments[1] === "unarchive" && ctx.request.method === "POST") {
|
|
907
|
+
return ok({ loop: publicLoop(await storage.unarchiveLoop(id)) });
|
|
908
|
+
}
|
|
909
|
+
return fail("not_found", 404);
|
|
910
|
+
}
|
|
911
|
+
async function handleRunsRequest(ctx, segments) {
|
|
912
|
+
const id = segments[0];
|
|
913
|
+
if (segments.length === 2 && id && ["heartbeat", "finalize", "evidence"].includes(segments[1] ?? "") && ctx.request.method === "POST") {
|
|
914
|
+
const storage2 = requireStorage(ctx.storage);
|
|
915
|
+
const action = segments[1];
|
|
916
|
+
const now = ctx.now();
|
|
917
|
+
if (action === "heartbeat")
|
|
918
|
+
return heartbeatRun(storage2, id, await readJsonBody(ctx.request, ctx.bodyLimitBytes), now);
|
|
919
|
+
if (action === "finalize")
|
|
920
|
+
return finalizeRun(storage2, id, await readJsonBody(ctx.request, ctx.bodyLimitBytes), now);
|
|
921
|
+
return acceptRunEvidence(storage2, id, await readJsonBody(ctx.request, ctx.evidenceLimitBytes), now);
|
|
922
|
+
}
|
|
923
|
+
if (segments.length === 2 && id && segments[1] === "recover" && ctx.request.method === "POST") {
|
|
924
|
+
const storage2 = requireStorage(ctx.storage);
|
|
925
|
+
const now = ctx.now();
|
|
926
|
+
const recovered = await storage2.recoverExpiredRunLeasesDetailed(now);
|
|
927
|
+
for (const run of recovered.abandoned) {
|
|
928
|
+
const loop = await storage2.getLoop(run.loopId);
|
|
929
|
+
if (loop)
|
|
930
|
+
await advanceLoopAfterRun(storage2, loop, run, new Date(run.finishedAt ?? now), false);
|
|
931
|
+
}
|
|
932
|
+
return ok({
|
|
933
|
+
abandoned: recovered.abandoned.map((run) => publicRun(run, false, { redactError: true })),
|
|
934
|
+
deferred: recovered.deferred.map((run) => publicRun(run, false, { redactError: true }))
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
const storage = requireStorage(ctx.storage);
|
|
938
|
+
const showOutput = optionalBoolean(ctx.url.searchParams.get("showOutput")) ?? false;
|
|
939
|
+
if (segments.length === 0 && ctx.request.method === "GET") {
|
|
940
|
+
const runs = await storage.listRuns({
|
|
941
|
+
loopId: ctx.url.searchParams.get("loopId") ?? undefined,
|
|
942
|
+
status: optionalEnum(ctx.url.searchParams.get("status"), ["running", "succeeded", "failed", "timed_out", "abandoned", "skipped"]),
|
|
943
|
+
limit: optionalLimit(ctx.url.searchParams.get("limit"))
|
|
944
|
+
});
|
|
945
|
+
return ok({ runs: runs.map((run) => publicRun(run, showOutput, { redactError: true })) });
|
|
946
|
+
}
|
|
947
|
+
if (!id)
|
|
948
|
+
return fail("not_found", 404);
|
|
949
|
+
if (segments.length === 1 && ctx.request.method === "GET") {
|
|
950
|
+
const run = await storage.getRun(id);
|
|
951
|
+
if (!run)
|
|
952
|
+
return fail("run_not_found", 404);
|
|
953
|
+
return ok({ run: publicRun(run, showOutput, { redactError: true }) });
|
|
954
|
+
}
|
|
955
|
+
return fail("not_found", 404);
|
|
956
|
+
}
|
|
957
|
+
async function handleRunnerRequest(ctx, segments) {
|
|
958
|
+
if (ctx.request.method !== "POST")
|
|
959
|
+
return fail("not_found", 404);
|
|
960
|
+
const action = segments.length === 1 ? segments[0] : segments[1];
|
|
961
|
+
if (action === "register" || action === "heartbeat") {
|
|
962
|
+
const body = await readJsonBody(ctx.request, ctx.bodyLimitBytes);
|
|
963
|
+
return ok({ runner: runnerRecord(body) });
|
|
964
|
+
}
|
|
965
|
+
if (action === "poll" || action === "claim") {
|
|
966
|
+
const storage = requireStorage(ctx.storage);
|
|
967
|
+
const body = await readJsonBody(ctx.request, ctx.bodyLimitBytes);
|
|
968
|
+
const runner = runnerRecord(body);
|
|
969
|
+
const claims = await claimRuns(storage, runner, {
|
|
970
|
+
now: ctx.now(),
|
|
971
|
+
maxClaims: optionalPositiveInteger(body.maxClaims, 1, 100) ?? 1
|
|
972
|
+
});
|
|
973
|
+
return ok({ runner, claims });
|
|
974
|
+
}
|
|
975
|
+
return fail("not_found", 404);
|
|
976
|
+
}
|
|
977
|
+
function runnerRecord(body) {
|
|
978
|
+
const machineId = optionalString(body.machineId);
|
|
979
|
+
const hostname = optionalString(body.hostname);
|
|
980
|
+
const id = optionalString(body.runnerId) ?? machineId ?? hostname;
|
|
981
|
+
if (!id)
|
|
982
|
+
throw Object.assign(new Error("runner_id_required"), { status: 422 });
|
|
983
|
+
return {
|
|
984
|
+
id,
|
|
985
|
+
machineId,
|
|
986
|
+
hostname,
|
|
987
|
+
labels: stringRecord(body.labels),
|
|
988
|
+
capabilities: objectRecord(body.capabilities),
|
|
989
|
+
lastSeenAt: new Date().toISOString()
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
async function claimRuns(storage, runner, opts) {
|
|
993
|
+
const claims = [];
|
|
994
|
+
for (const loop of await storage.dueLoops(opts.now)) {
|
|
995
|
+
if (claims.length >= opts.maxClaims)
|
|
996
|
+
break;
|
|
997
|
+
if (!runnerMatchesLoop(loop.machine, runner))
|
|
998
|
+
continue;
|
|
999
|
+
if (loop.target.type === "workflow")
|
|
1000
|
+
continue;
|
|
1001
|
+
if (loop.overlap === "skip" && (await storage.listRuns({ loopId: loop.id, status: "running", limit: 1 })).length > 0)
|
|
1002
|
+
continue;
|
|
1003
|
+
for (const slot of dueSlots(loop, opts.now).slots) {
|
|
1004
|
+
if (claims.length >= opts.maxClaims)
|
|
1005
|
+
break;
|
|
1006
|
+
const claim = await storage.claimRun(loop, slot, runner.id, opts.now);
|
|
1007
|
+
if (!claim)
|
|
1008
|
+
continue;
|
|
1009
|
+
const run = await storage.heartbeatRunLease(claim.run.id, runner.id, runnerLeaseMs(claim.loop.leaseMs), opts.now, { claimToken: claim.claimToken }) ?? claim.run;
|
|
1010
|
+
claims.push({
|
|
1011
|
+
loop: publicLoop(claim.loop),
|
|
1012
|
+
run: publicRun(run, false, { redactError: true }),
|
|
1013
|
+
claimToken: claim.claimToken
|
|
1014
|
+
});
|
|
1015
|
+
if (loop.overlap === "skip")
|
|
1016
|
+
break;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return claims;
|
|
1020
|
+
}
|
|
1021
|
+
function runnerMatchesLoop(machine, runner) {
|
|
1022
|
+
if (!machine)
|
|
1023
|
+
return true;
|
|
1024
|
+
const candidates = new Set([runner.id, runner.machineId, runner.hostname].filter(Boolean));
|
|
1025
|
+
return candidates.has(machine.id) || (machine.requestedId ? candidates.has(machine.requestedId) : false);
|
|
1026
|
+
}
|
|
1027
|
+
async function heartbeatRun(storage, runId, body, now) {
|
|
1028
|
+
const claimToken = requiredString(body.claimToken, "claimToken");
|
|
1029
|
+
const run = await storage.getRun(runId);
|
|
1030
|
+
if (!run)
|
|
1031
|
+
return fail("run_not_found", 404);
|
|
1032
|
+
if (run.status !== "running" || !run.claimedBy)
|
|
1033
|
+
return fail("run_not_running", 409);
|
|
1034
|
+
const loop = await storage.getLoop(run.loopId);
|
|
1035
|
+
if (!loop)
|
|
1036
|
+
return fail("loop_not_found", 404);
|
|
1037
|
+
const heartbeat = await storage.heartbeatRunLease(run.id, run.claimedBy, runnerLeaseMs(optionalPositiveInteger(body.leaseMs, 1, 24 * 60 * 60000) ?? loop.leaseMs), now, { claimToken });
|
|
1038
|
+
if (!heartbeat)
|
|
1039
|
+
return fail("stale_claim", 409);
|
|
1040
|
+
return ok({ run: publicRun(heartbeat, false, { redactError: true }) });
|
|
1041
|
+
}
|
|
1042
|
+
async function finalizeRun(storage, runId, body, now) {
|
|
1043
|
+
const claimToken = requiredString(body.claimToken, "claimToken");
|
|
1044
|
+
const status = optionalEnum(optionalString(body.status) ?? null, ["succeeded", "failed", "timed_out"]);
|
|
1045
|
+
if (!status)
|
|
1046
|
+
throw Object.assign(new Error("status_required"), { status: 422 });
|
|
1047
|
+
const existing = await storage.getRun(runId);
|
|
1048
|
+
if (!existing)
|
|
1049
|
+
return fail("run_not_found", 404);
|
|
1050
|
+
if (existing.status !== "running" || !existing.claimedBy)
|
|
1051
|
+
return fail("run_not_running", 409);
|
|
1052
|
+
const loop = await storage.getLoop(existing.loopId);
|
|
1053
|
+
if (!loop)
|
|
1054
|
+
return fail("loop_not_found", 404);
|
|
1055
|
+
const finishedAt = optionalIsoString(body.finishedAt) ?? new Date().toISOString();
|
|
1056
|
+
const durationMs = optionalPositiveInteger(body.durationMs, 0, Number.MAX_SAFE_INTEGER) ?? Math.max(0, new Date(finishedAt).getTime() - new Date(existing.startedAt ?? existing.createdAt).getTime());
|
|
1057
|
+
const finalized = await storage.finalizeRun(runId, {
|
|
1058
|
+
status,
|
|
1059
|
+
finishedAt,
|
|
1060
|
+
durationMs,
|
|
1061
|
+
stdout: optionalText(body.stdout) ?? "",
|
|
1062
|
+
stderr: optionalText(body.stderr) ?? "",
|
|
1063
|
+
error: optionalText(body.error),
|
|
1064
|
+
exitCode: optionalInteger(body.exitCode),
|
|
1065
|
+
pid: optionalInteger(body.pid)
|
|
1066
|
+
}, { claimedBy: existing.claimedBy, claimToken, now });
|
|
1067
|
+
if (finalized.status === "running")
|
|
1068
|
+
return fail("stale_claim", 409);
|
|
1069
|
+
await advanceLoopAfterRun(storage, loop, finalized, new Date(finalized.finishedAt ?? finishedAt), finalized.status === "succeeded");
|
|
1070
|
+
return ok({ run: publicRun(finalized, false, { redactError: true }) });
|
|
1071
|
+
}
|
|
1072
|
+
async function acceptRunEvidence(storage, runId, body, now) {
|
|
1073
|
+
const heartbeat = await heartbeatRun(storage, runId, body, now);
|
|
1074
|
+
if (!heartbeat.ok)
|
|
1075
|
+
return heartbeat;
|
|
1076
|
+
return ok({ accepted: true, evidence: scrubSecretsDeep(body.evidence ?? body) });
|
|
1077
|
+
}
|
|
1078
|
+
async function advanceLoopAfterRun(storage, loop, run, finishedAt, succeeded) {
|
|
1079
|
+
if (run.status === "running")
|
|
1080
|
+
return;
|
|
1081
|
+
const current = await storage.getLoop(loop.id);
|
|
1082
|
+
if (!current || current.status !== "active" || current.archivedAt)
|
|
1083
|
+
return;
|
|
1084
|
+
if (current.retryScheduledFor && current.retryScheduledFor !== run.scheduledFor)
|
|
1085
|
+
return;
|
|
1086
|
+
if (!succeeded && run.attempt < current.maxAttempts) {
|
|
1087
|
+
await storage.updateLoop(current.id, {
|
|
1088
|
+
status: "active",
|
|
1089
|
+
nextRunAt: new Date(finishedAt.getTime() + retryDelayMs(current, run)).toISOString(),
|
|
1090
|
+
retryScheduledFor: run.scheduledFor
|
|
1091
|
+
});
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const nextRunAt = computeNextAfter(current.schedule, new Date(run.scheduledFor), finishedAt);
|
|
1095
|
+
await storage.updateLoop(current.id, {
|
|
1096
|
+
status: nextRunAt ? "active" : "stopped",
|
|
1097
|
+
nextRunAt,
|
|
1098
|
+
retryScheduledFor: undefined
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
function retryDelayMs(loop, run) {
|
|
1102
|
+
const growth = 2 ** Math.min(Math.max(1, run.attempt) - 1, 20);
|
|
1103
|
+
return Math.min(6 * 60 * 60000, loop.retryDelayMs * growth);
|
|
1104
|
+
}
|
|
1105
|
+
function runnerLeaseMs(leaseMs) {
|
|
1106
|
+
return Math.max(MIN_RUNNER_LEASE_MS, leaseMs);
|
|
1107
|
+
}
|
|
1108
|
+
function requireStorage(storage) {
|
|
1109
|
+
if (!storage)
|
|
1110
|
+
throw Object.assign(new Error("storage_unconfigured"), { status: 503, code: "storage_unconfigured" });
|
|
1111
|
+
return storage;
|
|
1112
|
+
}
|
|
1113
|
+
async function readJsonBody(request, limitBytes) {
|
|
1114
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
1115
|
+
if (!isJsonContentType(contentType))
|
|
1116
|
+
throw Object.assign(new Error("unsupported_media_type"), { status: 415 });
|
|
1117
|
+
const text = await readBodyText(request, limitBytes);
|
|
1118
|
+
try {
|
|
1119
|
+
return JSON.parse(text || "{}");
|
|
1120
|
+
} catch {
|
|
1121
|
+
throw Object.assign(new Error("invalid_json"), { status: 400 });
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
function isJsonContentType(contentType) {
|
|
1125
|
+
const mediaType = contentType.split(";")[0]?.trim().toLowerCase();
|
|
1126
|
+
return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
|
|
1127
|
+
}
|
|
1128
|
+
async function readBodyText(request, limitBytes) {
|
|
1129
|
+
const contentLength = request.headers.get("content-length");
|
|
1130
|
+
if (contentLength) {
|
|
1131
|
+
const declaredBytes = Number(contentLength);
|
|
1132
|
+
if (!Number.isFinite(declaredBytes) || declaredBytes < 0)
|
|
1133
|
+
throw Object.assign(new Error("invalid_content_length"), { status: 400 });
|
|
1134
|
+
if (declaredBytes > limitBytes)
|
|
1135
|
+
throw Object.assign(new Error("body_too_large"), { status: 413 });
|
|
1136
|
+
}
|
|
1137
|
+
if (!request.body)
|
|
1138
|
+
return "";
|
|
1139
|
+
const reader = request.body.getReader();
|
|
1140
|
+
const chunks = [];
|
|
1141
|
+
let receivedBytes = 0;
|
|
1142
|
+
while (true) {
|
|
1143
|
+
const { done, value } = await reader.read();
|
|
1144
|
+
if (done)
|
|
1145
|
+
break;
|
|
1146
|
+
if (!value)
|
|
1147
|
+
continue;
|
|
1148
|
+
receivedBytes += value.byteLength;
|
|
1149
|
+
if (receivedBytes > limitBytes) {
|
|
1150
|
+
await reader.cancel().catch(() => {
|
|
1151
|
+
return;
|
|
1152
|
+
});
|
|
1153
|
+
throw Object.assign(new Error("body_too_large"), { status: 413 });
|
|
1154
|
+
}
|
|
1155
|
+
chunks.push(value);
|
|
1156
|
+
}
|
|
1157
|
+
const bytes = new Uint8Array(receivedBytes);
|
|
1158
|
+
let offset = 0;
|
|
1159
|
+
for (const chunk of chunks) {
|
|
1160
|
+
bytes.set(chunk, offset);
|
|
1161
|
+
offset += chunk.byteLength;
|
|
1162
|
+
}
|
|
1163
|
+
return new TextDecoder().decode(bytes);
|
|
1164
|
+
}
|
|
1165
|
+
function runnerProtocolPending(message) {
|
|
1166
|
+
return fail("runner_protocol_pending", 501, { message });
|
|
1167
|
+
}
|
|
1168
|
+
function optionalLimit(value) {
|
|
1169
|
+
if (value == null || value === "")
|
|
1170
|
+
return;
|
|
1171
|
+
const limit = Number(value);
|
|
1172
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
|
|
1173
|
+
throw Object.assign(new Error("invalid_limit"), { status: 422 });
|
|
1174
|
+
return limit;
|
|
1175
|
+
}
|
|
1176
|
+
function optionalString(value) {
|
|
1177
|
+
if (value === undefined || value === null)
|
|
1178
|
+
return;
|
|
1179
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
1180
|
+
throw Object.assign(new Error("invalid_string"), { status: 422 });
|
|
1181
|
+
return value.trim();
|
|
1182
|
+
}
|
|
1183
|
+
function requiredString(value, name) {
|
|
1184
|
+
const result = optionalString(value);
|
|
1185
|
+
if (!result)
|
|
1186
|
+
throw Object.assign(new Error(`${name}_required`), { status: 422 });
|
|
1187
|
+
return result;
|
|
1188
|
+
}
|
|
1189
|
+
function optionalText(value) {
|
|
1190
|
+
if (value === undefined || value === null)
|
|
1191
|
+
return;
|
|
1192
|
+
if (typeof value !== "string")
|
|
1193
|
+
throw Object.assign(new Error("invalid_string"), { status: 422 });
|
|
1194
|
+
return value;
|
|
1195
|
+
}
|
|
1196
|
+
function optionalInteger(value) {
|
|
1197
|
+
if (value === undefined || value === null || value === "")
|
|
1198
|
+
return;
|
|
1199
|
+
const result = Number(value);
|
|
1200
|
+
if (!Number.isInteger(result))
|
|
1201
|
+
throw Object.assign(new Error("invalid_integer"), { status: 422 });
|
|
1202
|
+
return result;
|
|
1203
|
+
}
|
|
1204
|
+
function optionalPositiveInteger(value, min, max) {
|
|
1205
|
+
const result = optionalInteger(value);
|
|
1206
|
+
if (result === undefined)
|
|
1207
|
+
return;
|
|
1208
|
+
if (result < min || result > max)
|
|
1209
|
+
throw Object.assign(new Error("invalid_integer_range"), { status: 422 });
|
|
1210
|
+
return result;
|
|
1211
|
+
}
|
|
1212
|
+
function optionalIsoString(value) {
|
|
1213
|
+
const text = optionalString(value);
|
|
1214
|
+
if (!text)
|
|
1215
|
+
return;
|
|
1216
|
+
const parsed = new Date(text);
|
|
1217
|
+
if (Number.isNaN(parsed.getTime()))
|
|
1218
|
+
throw Object.assign(new Error("invalid_datetime"), { status: 422 });
|
|
1219
|
+
return parsed.toISOString();
|
|
1220
|
+
}
|
|
1221
|
+
function stringRecord(value) {
|
|
1222
|
+
if (value === undefined || value === null)
|
|
1223
|
+
return {};
|
|
1224
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1225
|
+
throw Object.assign(new Error("invalid_string_record"), { status: 422 });
|
|
1226
|
+
const result = {};
|
|
1227
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1228
|
+
if (typeof entry !== "string")
|
|
1229
|
+
throw Object.assign(new Error("invalid_string_record"), { status: 422 });
|
|
1230
|
+
result[key] = entry;
|
|
1231
|
+
}
|
|
1232
|
+
return result;
|
|
1233
|
+
}
|
|
1234
|
+
function objectRecord(value) {
|
|
1235
|
+
if (value === undefined || value === null)
|
|
1236
|
+
return {};
|
|
1237
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1238
|
+
throw Object.assign(new Error("invalid_object"), { status: 422 });
|
|
1239
|
+
return value;
|
|
1240
|
+
}
|
|
1241
|
+
function optionalBoolean(value) {
|
|
1242
|
+
if (value == null || value === "")
|
|
1243
|
+
return;
|
|
1244
|
+
if (["1", "true", "yes"].includes(value.toLowerCase()))
|
|
1245
|
+
return true;
|
|
1246
|
+
if (["0", "false", "no"].includes(value.toLowerCase()))
|
|
1247
|
+
return false;
|
|
1248
|
+
throw Object.assign(new Error("invalid_boolean"), { status: 422 });
|
|
1249
|
+
}
|
|
1250
|
+
function optionalEnum(value, allowed) {
|
|
1251
|
+
if (value == null || value === "")
|
|
1252
|
+
return;
|
|
1253
|
+
if (allowed.includes(value))
|
|
1254
|
+
return value;
|
|
1255
|
+
throw Object.assign(new Error("invalid_filter"), { status: 422 });
|
|
1256
|
+
}
|
|
1257
|
+
function errorResponse(error) {
|
|
1258
|
+
if (error instanceof LoopNotFoundError)
|
|
1259
|
+
return fail("loop_not_found", 404, { message: error.message });
|
|
1260
|
+
if (error instanceof LoopArchivedError)
|
|
1261
|
+
return fail("loop_archived", 409, { message: error.message });
|
|
1262
|
+
if (error instanceof ValidationError)
|
|
1263
|
+
return fail("validation_failed", 422, { message: error.message });
|
|
1264
|
+
const status = typeof error === "object" && error && "status" in error && typeof error.status === "number" ? error.status : 500;
|
|
1265
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1266
|
+
const code = typeof error === "object" && error && "code" in error && typeof error.code === "string" ? error.code : status === 500 ? "internal_error" : message;
|
|
1267
|
+
return fail(code, status, status === 500 ? { message: redact(message, 240) } : undefined);
|
|
1268
|
+
}
|
|
312
1269
|
async function main(argv = process.argv) {
|
|
313
1270
|
await program.parseAsync(argv);
|
|
314
1271
|
}
|