@hasna/loops 0.4.0 → 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.
@@ -0,0 +1,1290 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+ // package.json
4
+ var package_default = {
5
+ name: "@hasna/loops",
6
+ version: "0.4.2",
7
+ description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
8
+ type: "module",
9
+ main: "dist/index.js",
10
+ types: "dist/index.d.ts",
11
+ bin: {
12
+ loops: "dist/cli/index.js",
13
+ "loops-daemon": "dist/daemon/index.js",
14
+ "loops-api": "dist/api/index.js",
15
+ "loops-runner": "dist/runner/index.js",
16
+ "loops-mcp": "dist/mcp/index.js"
17
+ },
18
+ exports: {
19
+ ".": {
20
+ types: "./dist/index.d.ts",
21
+ import: "./dist/index.js"
22
+ },
23
+ "./sdk": {
24
+ types: "./dist/sdk/index.d.ts",
25
+ import: "./dist/sdk/index.js"
26
+ },
27
+ "./mcp": {
28
+ types: "./dist/mcp/index.d.ts",
29
+ import: "./dist/mcp/index.js"
30
+ },
31
+ "./api": {
32
+ types: "./dist/api/index.d.ts",
33
+ import: "./dist/api/index.js"
34
+ },
35
+ "./runner": {
36
+ types: "./dist/runner/index.d.ts",
37
+ import: "./dist/runner/index.js"
38
+ },
39
+ "./mode": {
40
+ types: "./dist/lib/mode.d.ts",
41
+ import: "./dist/lib/mode.js"
42
+ },
43
+ "./storage": {
44
+ types: "./dist/lib/store.d.ts",
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"
62
+ }
63
+ },
64
+ files: [
65
+ "dist",
66
+ "README.md",
67
+ "CHANGELOG.md",
68
+ "docs",
69
+ "LICENSE"
70
+ ],
71
+ scripts: {
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",
73
+ "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
74
+ typecheck: "tsc --noEmit",
75
+ test: "bun test",
76
+ "test:boundary": "bun run scripts/no-private-cloud-boundary.mjs",
77
+ prepare: "test -d dist || bun run build",
78
+ "dev:cli": "bun run src/cli/index.ts",
79
+ "dev:daemon": "bun run src/daemon/index.ts",
80
+ prepublishOnly: "bun run build && bun run test:boundary"
81
+ },
82
+ keywords: [
83
+ "loops",
84
+ "scheduler",
85
+ "daemon",
86
+ "agents",
87
+ "claude",
88
+ "cursor",
89
+ "codewith",
90
+ "opencode",
91
+ "cli"
92
+ ],
93
+ repository: {
94
+ type: "git",
95
+ url: "git+https://github.com/hasna/loops.git"
96
+ },
97
+ homepage: "https://github.com/hasna/loops#readme",
98
+ bugs: {
99
+ url: "https://github.com/hasna/loops/issues"
100
+ },
101
+ author: "Hasna <andrei@hasna.com>",
102
+ license: "Apache-2.0",
103
+ engines: {
104
+ bun: ">=1.0.0"
105
+ },
106
+ dependencies: {
107
+ "@hasna/events": "^0.1.9",
108
+ "@modelcontextprotocol/sdk": "^1.29.0",
109
+ "@openrouter/ai-sdk-provider": "2.9.1",
110
+ ai: "6.0.204",
111
+ commander: "^13.1.0",
112
+ zod: "4.4.3"
113
+ },
114
+ optionalDependencies: {
115
+ "@hasna/machines": "0.0.49"
116
+ },
117
+ devDependencies: {
118
+ "@types/bun": "latest",
119
+ typescript: "^5.7.3"
120
+ },
121
+ publishConfig: {
122
+ registry: "https://registry.npmjs.org",
123
+ access: "public"
124
+ }
125
+ };
126
+
127
+ // src/lib/version.ts
128
+ function packageVersion() {
129
+ if (typeof package_default.version !== "string" || package_default.version.trim() === "")
130
+ throw new Error("package.json version is missing");
131
+ return package_default.version;
132
+ }
133
+
134
+ // src/lib/mode.ts
135
+ var LOOP_DEPLOYMENT_MODES = ["local", "self_hosted", "cloud"];
136
+ var MODE_ENV_KEYS = ["LOOPS_MODE", "HASNA_LOOPS_MODE"];
137
+ var API_URL_ENV_KEYS = ["LOOPS_API_URL", "HASNA_LOOPS_API_URL"];
138
+ var CLOUD_API_URL_ENV_KEYS = ["LOOPS_CLOUD_API_URL", "HASNA_LOOPS_CLOUD_API_URL"];
139
+ var DATABASE_URL_ENV_KEYS = ["LOOPS_DATABASE_URL", "HASNA_LOOPS_DATABASE_URL"];
140
+ var API_TOKEN_ENV_KEYS = ["LOOPS_API_TOKEN", "HASNA_LOOPS_API_TOKEN"];
141
+ var CLOUD_TOKEN_ENV_KEYS = ["LOOPS_CLOUD_TOKEN", "HASNA_LOOPS_CLOUD_TOKEN"];
142
+ var TOKEN_ENV_KEYS = [...API_TOKEN_ENV_KEYS, ...CLOUD_TOKEN_ENV_KEYS];
143
+ function envValue(env, keys) {
144
+ for (const key of keys) {
145
+ const value = env[key]?.trim();
146
+ if (value)
147
+ return { key, value };
148
+ }
149
+ return;
150
+ }
151
+ function normalizeLoopDeploymentMode(value) {
152
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
153
+ if (normalized === "local")
154
+ return "local";
155
+ if (normalized === "self_hosted" || normalized === "selfhosted")
156
+ return "self_hosted";
157
+ if (normalized === "cloud" || normalized === "saas")
158
+ return "cloud";
159
+ throw new Error(`unsupported OpenLoops deployment mode "${value}"; expected local, self_hosted, or cloud`);
160
+ }
161
+ function resolveLoopDeploymentMode(env = process.env) {
162
+ const explicitMode = envValue(env, MODE_ENV_KEYS);
163
+ if (explicitMode) {
164
+ return {
165
+ deploymentMode: normalizeLoopDeploymentMode(explicitMode.value),
166
+ source: explicitMode.key
167
+ };
168
+ }
169
+ const cloudApiUrl = envValue(env, CLOUD_API_URL_ENV_KEYS);
170
+ if (cloudApiUrl)
171
+ return { deploymentMode: "cloud", source: cloudApiUrl.key };
172
+ const apiUrl = envValue(env, API_URL_ENV_KEYS);
173
+ if (apiUrl)
174
+ return { deploymentMode: "self_hosted", source: apiUrl.key };
175
+ const databaseUrl = envValue(env, DATABASE_URL_ENV_KEYS);
176
+ if (databaseUrl)
177
+ return { deploymentMode: "self_hosted", source: databaseUrl.key };
178
+ return { deploymentMode: "local", source: "default" };
179
+ }
180
+ function loopControlPlaneConfig(env = process.env) {
181
+ return {
182
+ apiUrl: envValue(env, API_URL_ENV_KEYS)?.value,
183
+ cloudApiUrl: envValue(env, CLOUD_API_URL_ENV_KEYS)?.value,
184
+ databaseUrlPresent: Boolean(envValue(env, DATABASE_URL_ENV_KEYS)),
185
+ apiAuthTokenPresent: Boolean(envValue(env, API_TOKEN_ENV_KEYS)),
186
+ cloudAuthTokenPresent: Boolean(envValue(env, CLOUD_TOKEN_ENV_KEYS)),
187
+ authTokenPresent: Boolean(envValue(env, TOKEN_ENV_KEYS))
188
+ };
189
+ }
190
+ function sourceOfTruthForMode(mode) {
191
+ if (mode === "local")
192
+ return "local_sqlite";
193
+ if (mode === "self_hosted")
194
+ return "self_hosted_control_plane";
195
+ return "cloud_control_plane";
196
+ }
197
+ function displayControlPlaneUrl(value) {
198
+ if (!value)
199
+ return;
200
+ try {
201
+ const url = new URL(value);
202
+ const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/g, "");
203
+ return `${url.origin}${path}`;
204
+ } catch {
205
+ return "[invalid-url]";
206
+ }
207
+ }
208
+ function buildDeploymentStatus(opts = {}) {
209
+ const env = opts.env ?? process.env;
210
+ const active = resolveLoopDeploymentMode(env);
211
+ const deploymentMode = opts.perspective ?? active.deploymentMode;
212
+ const config = loopControlPlaneConfig(env);
213
+ const rawApiUrl = deploymentMode === "cloud" ? config.cloudApiUrl : config.apiUrl;
214
+ const apiUrl = displayControlPlaneUrl(rawApiUrl);
215
+ const controlPlaneKind = deploymentMode === "local" ? "none" : deploymentMode;
216
+ const deploymentAuthTokenPresent = deploymentMode === "cloud" ? config.cloudAuthTokenPresent : deploymentMode === "self_hosted" ? config.apiAuthTokenPresent : false;
217
+ const controlPlaneConfigured = deploymentMode === "local" ? false : deploymentMode === "self_hosted" ? Boolean(config.apiUrl || config.databaseUrlPresent) : Boolean(config.cloudApiUrl && deploymentAuthTokenPresent);
218
+ const warnings = [];
219
+ if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
220
+ warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
221
+ }
222
+ if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
223
+ warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
224
+ }
225
+ if (deploymentMode === "cloud" && !config.cloudApiUrl) {
226
+ warnings.push("cloud mode is a public contract until LOOPS_CLOUD_API_URL is configured");
227
+ }
228
+ if (deploymentMode === "cloud" && config.cloudApiUrl && !deploymentAuthTokenPresent) {
229
+ warnings.push("cloud mode needs LOOPS_CLOUD_TOKEN or HASNA_LOOPS_CLOUD_TOKEN before it can become ready");
230
+ }
231
+ if (opts.perspective && opts.perspective !== active.deploymentMode) {
232
+ warnings.push(`active deployment mode is ${active.deploymentMode}; this is the ${opts.perspective} perspective`);
233
+ }
234
+ return {
235
+ packageVersion: packageVersion(),
236
+ deploymentMode,
237
+ activeDeploymentMode: active.deploymentMode,
238
+ active: deploymentMode === active.deploymentMode,
239
+ deploymentModeSource: active.source,
240
+ sourceOfTruth: sourceOfTruthForMode(deploymentMode),
241
+ localStore: {
242
+ role: deploymentMode === "local" ? "authoritative" : "cache_and_spool"
243
+ },
244
+ controlPlane: {
245
+ kind: controlPlaneKind,
246
+ configured: controlPlaneConfigured,
247
+ apiUrl,
248
+ databaseUrlPresent: config.databaseUrlPresent,
249
+ authTokenPresent: deploymentAuthTokenPresent
250
+ },
251
+ runner: {
252
+ required: deploymentMode !== "local",
253
+ role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
254
+ },
255
+ warnings
256
+ };
257
+ }
258
+ function deploymentStatusLine(status) {
259
+ const configured = status.controlPlane.kind === "none" ? "none" : String(status.controlPlane.configured);
260
+ const active = status.active ? "active" : `active=${status.activeDeploymentMode}`;
261
+ return [
262
+ `deploymentMode=${status.deploymentMode}`,
263
+ active,
264
+ `source=${status.deploymentModeSource}`,
265
+ `truth=${status.sourceOfTruth}`,
266
+ `local=${status.localStore.role}`,
267
+ `control_plane=${configured}`
268
+ ].join(" ");
269
+ }
270
+
271
+ // src/api/index.ts
272
+ import { timingSafeEqual } from "crypto";
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
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;
769
+ program.name("loops-api").description("OpenLoops self-hosted control-plane API foundation").version(packageVersion()).option("-j, --json", "print JSON");
770
+ function wantsJson(opts) {
771
+ return Boolean(program.opts().json || opts?.json);
772
+ }
773
+ function printStatus(opts) {
774
+ const status = buildDeploymentStatus({ perspective: "self_hosted" });
775
+ if (wantsJson(opts))
776
+ console.log(JSON.stringify(apiStatus(), null, 2));
777
+ else
778
+ console.log(deploymentStatusLine(status));
779
+ }
780
+ function configuredAuthToken() {
781
+ return process.env.LOOPS_API_TOKEN?.trim() || process.env.HASNA_LOOPS_API_TOKEN?.trim();
782
+ }
783
+ function isLocalBind(host) {
784
+ return ["127.0.0.1", "localhost", "::1"].includes(host);
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
+ }
792
+ function authorizeRequest(request, host) {
793
+ if (isLocalBind(host))
794
+ return;
795
+ const token = configuredAuthToken();
796
+ if (!token)
797
+ return Response.json({ ok: false, error: "auth_required" }, { status: 401 });
798
+ const authorization = request.headers.get("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 });
806
+ }
807
+ function apiStatus() {
808
+ return {
809
+ ok: true,
810
+ service: "loops-api",
811
+ status: buildDeploymentStatus({ perspective: "self_hosted" })
812
+ };
813
+ }
814
+ function createLoopsApiServer(opts = {}) {
815
+ const host = opts.host ?? "127.0.0.1";
816
+ const port = opts.port ?? 8787;
817
+ if (!isLocalBind(host) && !configuredAuthToken()) {
818
+ throw new Error("non-local loops-api binds require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
819
+ }
820
+ return Bun.serve({
821
+ hostname: host,
822
+ port,
823
+ fetch(request) {
824
+ const unauthorized = authorizeRequest(request, host);
825
+ if (unauthorized)
826
+ return unauthorized;
827
+ const url = new URL(request.url);
828
+ if (url.pathname === "/health" || url.pathname === "/status") {
829
+ return Response.json(apiStatus());
830
+ }
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
+ });
839
+ }
840
+ });
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
+ }
1269
+ async function main(argv = process.argv) {
1270
+ await program.parseAsync(argv);
1271
+ }
1272
+ program.action(() => printStatus());
1273
+ program.command("status").option("-j, --json", "print JSON").action((opts) => printStatus(opts));
1274
+ program.command("serve").description("serve the foundation health/status endpoints").option("--host <host>", "host", "127.0.0.1").option("--port <port>", "port", (value) => Number(value), 8787).action((opts) => {
1275
+ const host = String(opts.host);
1276
+ const port = Number(opts.port);
1277
+ const server = createLoopsApiServer({ host, port });
1278
+ console.log(`loops-api listening on http://${server.hostname}:${server.port}`);
1279
+ });
1280
+ if (import.meta.main) {
1281
+ main().catch((error) => {
1282
+ console.error(error instanceof Error ? error.message : String(error));
1283
+ process.exit(1);
1284
+ });
1285
+ }
1286
+ export {
1287
+ main,
1288
+ createLoopsApiServer,
1289
+ apiStatus
1290
+ };