@hasna/loops 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -3
- package/dist/cli/index.js +1479 -75
- package/dist/daemon/daemon.d.ts +1 -0
- package/dist/daemon/index.js +1156 -52
- package/dist/daemon/install.d.ts +8 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1335 -57
- package/dist/lib/accounts.d.ts +4 -0
- package/dist/lib/doctor.d.ts +13 -0
- package/dist/lib/executor.d.ts +20 -1
- package/dist/lib/format.d.ts +5 -1
- package/dist/lib/scheduler.d.ts +12 -0
- package/dist/lib/store.d.ts +47 -2
- package/dist/lib/store.js +627 -6
- package/dist/lib/workflow-runner.d.ts +14 -0
- package/dist/lib/workflow-spec.d.ts +5 -0
- package/dist/sdk/index.js +1126 -57
- package/dist/types.d.ts +88 -2
- package/docs/USAGE.md +89 -4
- package/package.json +2 -2
package/dist/sdk/index.js
CHANGED
|
@@ -234,6 +234,103 @@ function parseDuration(input) {
|
|
|
234
234
|
return Math.round(value * multiplier);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
// src/lib/workflow-spec.ts
|
|
238
|
+
function assertObject(value, label) {
|
|
239
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
240
|
+
throw new Error(`${label} must be an object`);
|
|
241
|
+
}
|
|
242
|
+
function assertString(value, label) {
|
|
243
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
244
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
245
|
+
}
|
|
246
|
+
function validateTarget(value, label) {
|
|
247
|
+
assertObject(value, label);
|
|
248
|
+
if (value.type === "command") {
|
|
249
|
+
assertString(value.command, `${label}.command`);
|
|
250
|
+
if (value.shell !== true && /\s/.test(value.command.trim())) {
|
|
251
|
+
throw new Error(`${label}.command must be an executable without spaces when shell is false; put flags in args or set shell true`);
|
|
252
|
+
}
|
|
253
|
+
return value;
|
|
254
|
+
}
|
|
255
|
+
if (value.type === "agent") {
|
|
256
|
+
assertString(value.provider, `${label}.provider`);
|
|
257
|
+
assertString(value.prompt, `${label}.prompt`);
|
|
258
|
+
const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
|
|
259
|
+
if (!providers.includes(value.provider))
|
|
260
|
+
throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
|
|
261
|
+
return value;
|
|
262
|
+
}
|
|
263
|
+
throw new Error(`${label}.type must be command or agent`);
|
|
264
|
+
}
|
|
265
|
+
function normalizeCreateWorkflowInput(input) {
|
|
266
|
+
assertString(input.name, "workflow.name");
|
|
267
|
+
if (!Array.isArray(input.steps) || input.steps.length === 0)
|
|
268
|
+
throw new Error("workflow.steps must contain at least one step");
|
|
269
|
+
const seen = new Set;
|
|
270
|
+
const steps = input.steps.map((step, index) => {
|
|
271
|
+
assertObject(step, `workflow.steps[${index}]`);
|
|
272
|
+
assertString(step.id, `workflow.steps[${index}].id`);
|
|
273
|
+
if (seen.has(step.id))
|
|
274
|
+
throw new Error(`duplicate workflow step id: ${step.id}`);
|
|
275
|
+
seen.add(step.id);
|
|
276
|
+
return {
|
|
277
|
+
...step,
|
|
278
|
+
id: step.id,
|
|
279
|
+
target: validateTarget(step.target, `workflow.steps[${index}].target`),
|
|
280
|
+
dependsOn: step.dependsOn ?? [],
|
|
281
|
+
continueOnFailure: step.continueOnFailure ?? false
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
for (const step of steps) {
|
|
285
|
+
for (const dependency of step.dependsOn ?? []) {
|
|
286
|
+
if (!seen.has(dependency))
|
|
287
|
+
throw new Error(`step ${step.id} depends on missing step ${dependency}`);
|
|
288
|
+
if (dependency === step.id)
|
|
289
|
+
throw new Error(`step ${step.id} cannot depend on itself`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
workflowExecutionOrder({ steps });
|
|
293
|
+
return { ...input, name: input.name.trim(), version: input.version ?? 1, steps };
|
|
294
|
+
}
|
|
295
|
+
function workflowExecutionOrder(workflow) {
|
|
296
|
+
const byId = new Map(workflow.steps.map((step) => [step.id, step]));
|
|
297
|
+
const visiting = new Set;
|
|
298
|
+
const visited = new Set;
|
|
299
|
+
const order = [];
|
|
300
|
+
function visit(step) {
|
|
301
|
+
if (visited.has(step.id))
|
|
302
|
+
return;
|
|
303
|
+
if (visiting.has(step.id))
|
|
304
|
+
throw new Error(`workflow dependency cycle includes step ${step.id}`);
|
|
305
|
+
visiting.add(step.id);
|
|
306
|
+
for (const dependencyId of step.dependsOn ?? []) {
|
|
307
|
+
const dependency = byId.get(dependencyId);
|
|
308
|
+
if (!dependency)
|
|
309
|
+
throw new Error(`step ${step.id} depends on missing step ${dependencyId}`);
|
|
310
|
+
visit(dependency);
|
|
311
|
+
}
|
|
312
|
+
visiting.delete(step.id);
|
|
313
|
+
visited.add(step.id);
|
|
314
|
+
order.push(step);
|
|
315
|
+
}
|
|
316
|
+
for (const step of workflow.steps)
|
|
317
|
+
visit(step);
|
|
318
|
+
return order;
|
|
319
|
+
}
|
|
320
|
+
function workflowBodyFromJson(value, fallbackName) {
|
|
321
|
+
assertObject(value, "workflow file");
|
|
322
|
+
const rawName = fallbackName ?? value.name;
|
|
323
|
+
assertString(rawName, "workflow.name");
|
|
324
|
+
if (!Array.isArray(value.steps))
|
|
325
|
+
throw new Error("workflow.steps must be an array");
|
|
326
|
+
return normalizeCreateWorkflowInput({
|
|
327
|
+
name: rawName,
|
|
328
|
+
description: typeof value.description === "string" ? value.description : undefined,
|
|
329
|
+
version: typeof value.version === "number" ? value.version : undefined,
|
|
330
|
+
steps: value.steps
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
237
334
|
// src/lib/store.ts
|
|
238
335
|
function rowToLoop(row) {
|
|
239
336
|
return {
|
|
@@ -278,6 +375,76 @@ function rowToRun(row) {
|
|
|
278
375
|
updatedAt: row.updated_at
|
|
279
376
|
};
|
|
280
377
|
}
|
|
378
|
+
function rowToWorkflow(row) {
|
|
379
|
+
return {
|
|
380
|
+
id: row.id,
|
|
381
|
+
name: row.name,
|
|
382
|
+
description: row.description ?? undefined,
|
|
383
|
+
version: row.version,
|
|
384
|
+
status: row.status,
|
|
385
|
+
steps: JSON.parse(row.steps_json),
|
|
386
|
+
createdAt: row.created_at,
|
|
387
|
+
updatedAt: row.updated_at
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function rowToWorkflowRun(row) {
|
|
391
|
+
return {
|
|
392
|
+
id: row.id,
|
|
393
|
+
workflowId: row.workflow_id,
|
|
394
|
+
workflowName: row.workflow_name,
|
|
395
|
+
loopId: row.loop_id ?? undefined,
|
|
396
|
+
loopRunId: row.loop_run_id ?? undefined,
|
|
397
|
+
scheduledFor: row.scheduled_for ?? undefined,
|
|
398
|
+
idempotencyKey: row.idempotency_key ?? undefined,
|
|
399
|
+
status: row.status,
|
|
400
|
+
startedAt: row.started_at ?? undefined,
|
|
401
|
+
finishedAt: row.finished_at ?? undefined,
|
|
402
|
+
durationMs: row.duration_ms ?? undefined,
|
|
403
|
+
error: row.error ?? undefined,
|
|
404
|
+
createdAt: row.created_at,
|
|
405
|
+
updatedAt: row.updated_at
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function rowToWorkflowStepRun(row) {
|
|
409
|
+
return {
|
|
410
|
+
id: row.id,
|
|
411
|
+
workflowRunId: row.workflow_run_id,
|
|
412
|
+
stepId: row.step_id,
|
|
413
|
+
sequence: row.sequence,
|
|
414
|
+
status: row.status,
|
|
415
|
+
startedAt: row.started_at ?? undefined,
|
|
416
|
+
finishedAt: row.finished_at ?? undefined,
|
|
417
|
+
exitCode: row.exit_code ?? undefined,
|
|
418
|
+
pid: row.pid ?? undefined,
|
|
419
|
+
durationMs: row.duration_ms ?? undefined,
|
|
420
|
+
stdout: row.stdout ?? undefined,
|
|
421
|
+
stderr: row.stderr ?? undefined,
|
|
422
|
+
error: row.error ?? undefined,
|
|
423
|
+
accountProfile: row.account_profile ?? undefined,
|
|
424
|
+
accountTool: row.account_tool ?? undefined,
|
|
425
|
+
createdAt: row.created_at,
|
|
426
|
+
updatedAt: row.updated_at
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function rowToWorkflowEvent(row) {
|
|
430
|
+
return {
|
|
431
|
+
id: row.id,
|
|
432
|
+
workflowRunId: row.workflow_run_id,
|
|
433
|
+
sequence: row.sequence,
|
|
434
|
+
eventType: row.event_type,
|
|
435
|
+
stepId: row.step_id ?? undefined,
|
|
436
|
+
payload: row.payload_json ? JSON.parse(row.payload_json) : undefined,
|
|
437
|
+
createdAt: row.created_at
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
function isProcessAlive(pid) {
|
|
441
|
+
try {
|
|
442
|
+
process.kill(pid, 0);
|
|
443
|
+
return true;
|
|
444
|
+
} catch {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
281
448
|
function rowToLease(row) {
|
|
282
449
|
return {
|
|
283
450
|
id: row.id,
|
|
@@ -303,6 +470,11 @@ class Store {
|
|
|
303
470
|
}
|
|
304
471
|
migrate() {
|
|
305
472
|
this.db.exec(`
|
|
473
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
474
|
+
id TEXT PRIMARY KEY,
|
|
475
|
+
applied_at TEXT NOT NULL
|
|
476
|
+
);
|
|
477
|
+
|
|
306
478
|
CREATE TABLE IF NOT EXISTS loops (
|
|
307
479
|
id TEXT PRIMARY KEY,
|
|
308
480
|
name TEXT NOT NULL,
|
|
@@ -359,7 +531,82 @@ class Store {
|
|
|
359
531
|
created_at TEXT NOT NULL,
|
|
360
532
|
updated_at TEXT NOT NULL
|
|
361
533
|
);
|
|
534
|
+
|
|
535
|
+
CREATE TABLE IF NOT EXISTS workflow_specs (
|
|
536
|
+
id TEXT PRIMARY KEY,
|
|
537
|
+
name TEXT NOT NULL,
|
|
538
|
+
description TEXT,
|
|
539
|
+
version INTEGER NOT NULL,
|
|
540
|
+
status TEXT NOT NULL,
|
|
541
|
+
steps_json TEXT NOT NULL,
|
|
542
|
+
created_at TEXT NOT NULL,
|
|
543
|
+
updated_at TEXT NOT NULL
|
|
544
|
+
);
|
|
545
|
+
CREATE INDEX IF NOT EXISTS idx_workflows_status_name ON workflow_specs(status, name);
|
|
546
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_name_active ON workflow_specs(name) WHERE status = 'active';
|
|
547
|
+
|
|
548
|
+
CREATE TABLE IF NOT EXISTS workflow_runs (
|
|
549
|
+
id TEXT PRIMARY KEY,
|
|
550
|
+
workflow_id TEXT NOT NULL REFERENCES workflow_specs(id) ON DELETE CASCADE,
|
|
551
|
+
workflow_name TEXT NOT NULL,
|
|
552
|
+
loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
|
|
553
|
+
loop_run_id TEXT REFERENCES loop_runs(id) ON DELETE SET NULL,
|
|
554
|
+
scheduled_for TEXT,
|
|
555
|
+
idempotency_key TEXT,
|
|
556
|
+
status TEXT NOT NULL,
|
|
557
|
+
started_at TEXT,
|
|
558
|
+
finished_at TEXT,
|
|
559
|
+
duration_ms INTEGER,
|
|
560
|
+
error TEXT,
|
|
561
|
+
created_at TEXT NOT NULL,
|
|
562
|
+
updated_at TEXT NOT NULL
|
|
563
|
+
);
|
|
564
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_runs_idempotency
|
|
565
|
+
ON workflow_runs(workflow_id, idempotency_key)
|
|
566
|
+
WHERE idempotency_key IS NOT NULL;
|
|
567
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created ON workflow_runs(workflow_id, created_at DESC);
|
|
568
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
|
|
569
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
|
570
|
+
|
|
571
|
+
CREATE TABLE IF NOT EXISTS workflow_step_runs (
|
|
572
|
+
id TEXT PRIMARY KEY,
|
|
573
|
+
workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
|
574
|
+
step_id TEXT NOT NULL,
|
|
575
|
+
sequence INTEGER NOT NULL,
|
|
576
|
+
status TEXT NOT NULL,
|
|
577
|
+
started_at TEXT,
|
|
578
|
+
finished_at TEXT,
|
|
579
|
+
exit_code INTEGER,
|
|
580
|
+
pid INTEGER,
|
|
581
|
+
duration_ms INTEGER,
|
|
582
|
+
stdout TEXT,
|
|
583
|
+
stderr TEXT,
|
|
584
|
+
error TEXT,
|
|
585
|
+
account_profile TEXT,
|
|
586
|
+
account_tool TEXT,
|
|
587
|
+
created_at TEXT NOT NULL,
|
|
588
|
+
updated_at TEXT NOT NULL,
|
|
589
|
+
UNIQUE(workflow_run_id, step_id)
|
|
590
|
+
);
|
|
591
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_step_runs_run_sequence ON workflow_step_runs(workflow_run_id, sequence);
|
|
592
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_step_runs_status ON workflow_step_runs(status);
|
|
593
|
+
|
|
594
|
+
CREATE TABLE IF NOT EXISTS workflow_events (
|
|
595
|
+
id TEXT PRIMARY KEY,
|
|
596
|
+
workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
|
|
597
|
+
sequence INTEGER NOT NULL,
|
|
598
|
+
event_type TEXT NOT NULL,
|
|
599
|
+
step_id TEXT,
|
|
600
|
+
payload_json TEXT,
|
|
601
|
+
created_at TEXT NOT NULL,
|
|
602
|
+
UNIQUE(workflow_run_id, sequence)
|
|
603
|
+
);
|
|
604
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
|
|
362
605
|
`);
|
|
606
|
+
try {
|
|
607
|
+
this.db.query("ALTER TABLE workflow_step_runs ADD COLUMN pid INTEGER").run();
|
|
608
|
+
} catch {}
|
|
609
|
+
this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
|
|
363
610
|
}
|
|
364
611
|
createLoop(input, from = new Date) {
|
|
365
612
|
const now = nowIso();
|
|
@@ -451,10 +698,343 @@ class Store {
|
|
|
451
698
|
const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
|
|
452
699
|
return res.changes > 0;
|
|
453
700
|
}
|
|
701
|
+
createWorkflow(input) {
|
|
702
|
+
const normalized = normalizeCreateWorkflowInput(input);
|
|
703
|
+
const now = nowIso();
|
|
704
|
+
const workflow = {
|
|
705
|
+
id: genId(),
|
|
706
|
+
name: normalized.name,
|
|
707
|
+
description: normalized.description,
|
|
708
|
+
version: normalized.version ?? 1,
|
|
709
|
+
status: "active",
|
|
710
|
+
steps: normalized.steps,
|
|
711
|
+
createdAt: now,
|
|
712
|
+
updatedAt: now
|
|
713
|
+
};
|
|
714
|
+
this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, steps_json, created_at, updated_at)
|
|
715
|
+
VALUES ($id, $name, $description, $version, $status, $steps, $created, $updated)`).run({
|
|
716
|
+
$id: workflow.id,
|
|
717
|
+
$name: workflow.name,
|
|
718
|
+
$description: workflow.description ?? null,
|
|
719
|
+
$version: workflow.version,
|
|
720
|
+
$status: workflow.status,
|
|
721
|
+
$steps: JSON.stringify(workflow.steps),
|
|
722
|
+
$created: workflow.createdAt,
|
|
723
|
+
$updated: workflow.updatedAt
|
|
724
|
+
});
|
|
725
|
+
return workflow;
|
|
726
|
+
}
|
|
727
|
+
getWorkflow(id) {
|
|
728
|
+
const row = this.db.query("SELECT * FROM workflow_specs WHERE id = ?").get(id);
|
|
729
|
+
return row ? rowToWorkflow(row) : undefined;
|
|
730
|
+
}
|
|
731
|
+
findWorkflowByName(name) {
|
|
732
|
+
const row = this.db.query("SELECT * FROM workflow_specs WHERE name = ? AND status = 'active' ORDER BY updated_at DESC LIMIT 1").get(name);
|
|
733
|
+
return row ? rowToWorkflow(row) : undefined;
|
|
734
|
+
}
|
|
735
|
+
requireWorkflow(idOrName) {
|
|
736
|
+
return this.getWorkflow(idOrName) ?? this.findWorkflowByName(idOrName) ?? (() => {
|
|
737
|
+
throw new Error(`workflow not found: ${idOrName}`);
|
|
738
|
+
})();
|
|
739
|
+
}
|
|
740
|
+
listWorkflows(opts = {}) {
|
|
741
|
+
const limit = opts.limit ?? 200;
|
|
742
|
+
const rows = opts.status ? this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ?").all(opts.status, limit) : this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ?").all(limit);
|
|
743
|
+
return rows.map(rowToWorkflow);
|
|
744
|
+
}
|
|
745
|
+
archiveWorkflow(idOrName) {
|
|
746
|
+
const workflow = this.requireWorkflow(idOrName);
|
|
747
|
+
const updated = nowIso();
|
|
748
|
+
this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=?").run(updated, workflow.id);
|
|
749
|
+
const archived = this.getWorkflow(workflow.id);
|
|
750
|
+
if (!archived)
|
|
751
|
+
throw new Error(`workflow not found after archive: ${workflow.id}`);
|
|
752
|
+
return archived;
|
|
753
|
+
}
|
|
754
|
+
createWorkflowRun(input) {
|
|
755
|
+
const now = nowIso();
|
|
756
|
+
if (input.idempotencyKey) {
|
|
757
|
+
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
758
|
+
if (existing)
|
|
759
|
+
return rowToWorkflowRun(existing);
|
|
760
|
+
}
|
|
761
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
762
|
+
try {
|
|
763
|
+
if (input.idempotencyKey) {
|
|
764
|
+
const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
|
|
765
|
+
if (existing) {
|
|
766
|
+
this.db.exec("COMMIT");
|
|
767
|
+
return rowToWorkflowRun(existing);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
const runId = genId();
|
|
771
|
+
this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, scheduled_for, idempotency_key,
|
|
772
|
+
status, started_at, finished_at, duration_ms, error, created_at, updated_at)
|
|
773
|
+
VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $scheduledFor, $idempotencyKey,
|
|
774
|
+
'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
|
|
775
|
+
$id: runId,
|
|
776
|
+
$workflowId: input.workflow.id,
|
|
777
|
+
$workflowName: input.workflow.name,
|
|
778
|
+
$loopId: input.loop?.id ?? null,
|
|
779
|
+
$loopRunId: input.loopRun?.id ?? null,
|
|
780
|
+
$scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
|
|
781
|
+
$idempotencyKey: input.idempotencyKey ?? null,
|
|
782
|
+
$started: now,
|
|
783
|
+
$created: now,
|
|
784
|
+
$updated: now
|
|
785
|
+
});
|
|
786
|
+
input.workflow.steps.forEach((step, sequence) => {
|
|
787
|
+
const account = step.account ?? step.target.account;
|
|
788
|
+
this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
|
|
789
|
+
exit_code, pid, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
|
|
790
|
+
VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
791
|
+
$accountProfile, $accountTool, $created, $updated)`).run({
|
|
792
|
+
$id: genId(),
|
|
793
|
+
$workflowRunId: runId,
|
|
794
|
+
$stepId: step.id,
|
|
795
|
+
$sequence: sequence,
|
|
796
|
+
$accountProfile: account?.profile ?? null,
|
|
797
|
+
$accountTool: account?.tool ?? null,
|
|
798
|
+
$created: now,
|
|
799
|
+
$updated: now
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
this.db.query(`INSERT INTO workflow_events (id, workflow_run_id, sequence, event_type, step_id, payload_json, created_at)
|
|
803
|
+
VALUES ($id, $workflowRunId, 1, 'created', NULL, $payload, $created)`).run({
|
|
804
|
+
$id: genId(),
|
|
805
|
+
$workflowRunId: runId,
|
|
806
|
+
$payload: JSON.stringify({
|
|
807
|
+
workflowId: input.workflow.id,
|
|
808
|
+
workflowName: input.workflow.name,
|
|
809
|
+
stepCount: input.workflow.steps.length,
|
|
810
|
+
loopId: input.loop?.id,
|
|
811
|
+
loopRunId: input.loopRun?.id
|
|
812
|
+
}),
|
|
813
|
+
$created: now
|
|
814
|
+
});
|
|
815
|
+
this.db.exec("COMMIT");
|
|
816
|
+
const run = this.getWorkflowRun(runId);
|
|
817
|
+
if (!run)
|
|
818
|
+
throw new Error(`workflow run not found after create: ${runId}`);
|
|
819
|
+
return run;
|
|
820
|
+
} catch (error) {
|
|
821
|
+
try {
|
|
822
|
+
this.db.exec("ROLLBACK");
|
|
823
|
+
} catch {}
|
|
824
|
+
throw error;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
getWorkflowRun(id) {
|
|
828
|
+
const row = this.db.query("SELECT * FROM workflow_runs WHERE id = ?").get(id);
|
|
829
|
+
return row ? rowToWorkflowRun(row) : undefined;
|
|
830
|
+
}
|
|
831
|
+
requireWorkflowRun(id) {
|
|
832
|
+
const run = this.getWorkflowRun(id);
|
|
833
|
+
if (!run)
|
|
834
|
+
throw new Error(`workflow run not found: ${id}`);
|
|
835
|
+
return run;
|
|
836
|
+
}
|
|
837
|
+
listWorkflowRuns(opts = {}) {
|
|
838
|
+
const limit = opts.limit ?? 100;
|
|
839
|
+
let rows;
|
|
840
|
+
if (opts.workflowId) {
|
|
841
|
+
rows = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.workflowId, limit);
|
|
842
|
+
} else if (opts.loopRunId) {
|
|
843
|
+
rows = this.db.query("SELECT * FROM workflow_runs WHERE loop_run_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.loopRunId, limit);
|
|
844
|
+
} else {
|
|
845
|
+
rows = this.db.query("SELECT * FROM workflow_runs ORDER BY created_at DESC LIMIT ?").all(limit);
|
|
846
|
+
}
|
|
847
|
+
return rows.map(rowToWorkflowRun);
|
|
848
|
+
}
|
|
849
|
+
listWorkflowStepRuns(workflowRunId) {
|
|
850
|
+
const rows = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? ORDER BY sequence ASC").all(workflowRunId);
|
|
851
|
+
return rows.map(rowToWorkflowStepRun);
|
|
852
|
+
}
|
|
853
|
+
getWorkflowStepRun(workflowRunId, stepId) {
|
|
854
|
+
const row = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? AND step_id = ?").get(workflowRunId, stepId);
|
|
855
|
+
return row ? rowToWorkflowStepRun(row) : undefined;
|
|
856
|
+
}
|
|
857
|
+
isWorkflowRunTerminal(workflowRunId) {
|
|
858
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
859
|
+
return Boolean(run && ["succeeded", "failed", "timed_out", "cancelled"].includes(run.status));
|
|
860
|
+
}
|
|
861
|
+
startWorkflowStepRun(workflowRunId, stepId) {
|
|
862
|
+
const now = nowIso();
|
|
863
|
+
const res = this.db.query(`UPDATE workflow_step_runs
|
|
864
|
+
SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
|
|
865
|
+
pid=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
866
|
+
WHERE workflow_run_id=$workflowRunId
|
|
867
|
+
AND step_id=$stepId
|
|
868
|
+
AND status IN ('pending', 'failed', 'timed_out')
|
|
869
|
+
AND EXISTS (
|
|
870
|
+
SELECT 1 FROM workflow_runs
|
|
871
|
+
WHERE id=$workflowRunId AND status='running'
|
|
872
|
+
)`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $started: now, $updated: now });
|
|
873
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
874
|
+
if (!run)
|
|
875
|
+
throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
|
|
876
|
+
if (res.changes !== 1) {
|
|
877
|
+
throw new Error(`workflow step is not claimable: ${workflowRunId}/${stepId} status=${run.status}`);
|
|
878
|
+
}
|
|
879
|
+
this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
|
|
880
|
+
return run;
|
|
881
|
+
}
|
|
882
|
+
markWorkflowStepPid(workflowRunId, stepId, pid) {
|
|
883
|
+
const now = nowIso();
|
|
884
|
+
this.db.query(`UPDATE workflow_step_runs SET pid=$pid, updated_at=$updated
|
|
885
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $pid: pid, $updated: now });
|
|
886
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
887
|
+
if (!run)
|
|
888
|
+
throw new Error(`workflow step run not found after pid update: ${workflowRunId}/${stepId}`);
|
|
889
|
+
return run;
|
|
890
|
+
}
|
|
891
|
+
recoverWorkflowRun(workflowRunId, reason = "workflow run recovered for retry") {
|
|
892
|
+
const now = nowIso();
|
|
893
|
+
const before = this.listWorkflowStepRuns(workflowRunId).filter((step) => step.status === "running");
|
|
894
|
+
const live = before.filter((step) => step.pid !== undefined && isProcessAlive(step.pid));
|
|
895
|
+
if (live.length > 0) {
|
|
896
|
+
throw new Error(`cannot recover workflow run while step processes are still alive: ${live.map((step) => `${step.stepId} pid=${step.pid}`).join(", ")}`);
|
|
897
|
+
}
|
|
898
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
899
|
+
SET status='pending', started_at=NULL, finished_at=NULL, exit_code=NULL, pid=NULL, duration_ms=NULL,
|
|
900
|
+
stdout=NULL, stderr=NULL, error=$reason, updated_at=$updated
|
|
901
|
+
WHERE workflow_run_id=$workflowRunId AND status='running'`).run({ $workflowRunId: workflowRunId, $reason: reason, $updated: now });
|
|
902
|
+
if (before.length > 0) {
|
|
903
|
+
this.appendWorkflowEvent(workflowRunId, "recovered", undefined, {
|
|
904
|
+
reason,
|
|
905
|
+
recoveredSteps: before.map((step) => step.stepId)
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return {
|
|
909
|
+
run: this.requireWorkflowRun(workflowRunId),
|
|
910
|
+
recoveredSteps: before.map((step) => this.getWorkflowStepRun(workflowRunId, step.stepId)).filter(Boolean)
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
|
|
914
|
+
const finishedAt = patch.finishedAt ?? nowIso();
|
|
915
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
|
|
916
|
+
pid=NULL, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
917
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status='running'`).run({
|
|
918
|
+
$workflowRunId: workflowRunId,
|
|
919
|
+
$stepId: stepId,
|
|
920
|
+
$status: patch.status,
|
|
921
|
+
$finished: finishedAt,
|
|
922
|
+
$exitCode: patch.exitCode ?? null,
|
|
923
|
+
$durationMs: patch.durationMs ?? null,
|
|
924
|
+
$stdout: patch.stdout ?? null,
|
|
925
|
+
$stderr: patch.stderr ?? null,
|
|
926
|
+
$error: patch.error ?? null,
|
|
927
|
+
$updated: finishedAt
|
|
928
|
+
});
|
|
929
|
+
if (res.changes === 1) {
|
|
930
|
+
this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
|
|
931
|
+
exitCode: patch.exitCode,
|
|
932
|
+
error: patch.error
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
936
|
+
if (!run)
|
|
937
|
+
throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
|
|
938
|
+
return run;
|
|
939
|
+
}
|
|
940
|
+
skipWorkflowStepRun(workflowRunId, stepId, reason) {
|
|
941
|
+
const now = nowIso();
|
|
942
|
+
const res = this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, pid=NULL, error=$error, updated_at=$updated
|
|
943
|
+
WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $finished: now, $error: reason, $updated: now });
|
|
944
|
+
if (res.changes === 1)
|
|
945
|
+
this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
|
|
946
|
+
const run = this.getWorkflowStepRun(workflowRunId, stepId);
|
|
947
|
+
if (!run)
|
|
948
|
+
throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
|
|
949
|
+
return run;
|
|
950
|
+
}
|
|
951
|
+
finalizeWorkflowRun(workflowRunId, status, patch = {}) {
|
|
952
|
+
const finishedAt = patch.finishedAt ?? nowIso();
|
|
953
|
+
const res = this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
|
|
954
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({
|
|
955
|
+
$id: workflowRunId,
|
|
956
|
+
$status: status,
|
|
957
|
+
$finished: finishedAt,
|
|
958
|
+
$durationMs: patch.durationMs ?? null,
|
|
959
|
+
$error: patch.error ?? null,
|
|
960
|
+
$updated: finishedAt
|
|
961
|
+
});
|
|
962
|
+
const run = this.getWorkflowRun(workflowRunId);
|
|
963
|
+
if (!run)
|
|
964
|
+
throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
|
|
965
|
+
if (res.changes === 1)
|
|
966
|
+
this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
|
|
967
|
+
return run;
|
|
968
|
+
}
|
|
969
|
+
cancelWorkflowRun(workflowRunId, reason = "cancelled by user") {
|
|
970
|
+
const now = nowIso();
|
|
971
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
972
|
+
try {
|
|
973
|
+
const run = this.requireWorkflowRun(workflowRunId);
|
|
974
|
+
if (!["succeeded", "failed", "timed_out", "cancelled"].includes(run.status)) {
|
|
975
|
+
this.db.query(`UPDATE workflow_runs
|
|
976
|
+
SET status='cancelled', finished_at=$finished, error=$reason, updated_at=$updated
|
|
977
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
978
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
979
|
+
SET status='cancelled', finished_at=$finished, pid=NULL, error=$reason, updated_at=$updated
|
|
980
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
|
|
981
|
+
this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
|
|
982
|
+
}
|
|
983
|
+
this.db.exec("COMMIT");
|
|
984
|
+
return this.requireWorkflowRun(workflowRunId);
|
|
985
|
+
} catch (error) {
|
|
986
|
+
try {
|
|
987
|
+
this.db.exec("ROLLBACK");
|
|
988
|
+
} catch {}
|
|
989
|
+
throw error;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
appendWorkflowEvent(workflowRunId, eventType, stepId, payload) {
|
|
993
|
+
const now = nowIso();
|
|
994
|
+
const current = this.db.query("SELECT MAX(sequence) AS sequence FROM workflow_events WHERE workflow_run_id = ?").get(workflowRunId);
|
|
995
|
+
const sequence = (current?.sequence ?? 0) + 1;
|
|
996
|
+
const id = genId();
|
|
997
|
+
this.db.query(`INSERT INTO workflow_events (id, workflow_run_id, sequence, event_type, step_id, payload_json, created_at)
|
|
998
|
+
VALUES ($id, $workflowRunId, $sequence, $eventType, $stepId, $payload, $created)`).run({
|
|
999
|
+
$id: id,
|
|
1000
|
+
$workflowRunId: workflowRunId,
|
|
1001
|
+
$sequence: sequence,
|
|
1002
|
+
$eventType: eventType,
|
|
1003
|
+
$stepId: stepId ?? null,
|
|
1004
|
+
$payload: payload ? JSON.stringify(payload) : null,
|
|
1005
|
+
$created: now
|
|
1006
|
+
});
|
|
1007
|
+
const event = this.db.query("SELECT * FROM workflow_events WHERE id = ?").get(id);
|
|
1008
|
+
if (!event)
|
|
1009
|
+
throw new Error(`workflow event not found after append: ${id}`);
|
|
1010
|
+
return rowToWorkflowEvent(event);
|
|
1011
|
+
}
|
|
1012
|
+
listWorkflowEvents(workflowRunId, limit = 200) {
|
|
1013
|
+
const rows = this.db.query("SELECT * FROM workflow_events WHERE workflow_run_id = ? ORDER BY sequence ASC LIMIT ?").all(workflowRunId, limit);
|
|
1014
|
+
return rows.map(rowToWorkflowEvent);
|
|
1015
|
+
}
|
|
454
1016
|
hasRunningRun(loopId) {
|
|
455
1017
|
const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
|
|
456
1018
|
return (row?.count ?? 0) > 0;
|
|
457
1019
|
}
|
|
1020
|
+
markRunPid(id, pid, claimedBy) {
|
|
1021
|
+
const now = nowIso();
|
|
1022
|
+
const res = claimedBy ? this.db.query(`UPDATE loop_runs SET pid=$pid, updated_at=$updated
|
|
1023
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy`).run({ $id: id, $pid: pid, $updated: now, $claimedBy: claimedBy }) : this.db.query("UPDATE loop_runs SET pid=$pid, updated_at=$updated WHERE id=$id AND status='running'").run({ $id: id, $pid: pid, $updated: now });
|
|
1024
|
+
if (res.changes !== 1)
|
|
1025
|
+
return;
|
|
1026
|
+
return this.getRun(id);
|
|
1027
|
+
}
|
|
1028
|
+
hasLiveWorkflowStepProcesses(loopRunId) {
|
|
1029
|
+
const liveWorkflowSteps = this.db.query(`SELECT wr.id AS workflow_run_id, wsr.step_id AS step_id, wsr.pid AS pid
|
|
1030
|
+
FROM workflow_runs wr
|
|
1031
|
+
JOIN workflow_step_runs wsr ON wsr.workflow_run_id = wr.id
|
|
1032
|
+
WHERE wr.loop_run_id = ?
|
|
1033
|
+
AND wr.status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')
|
|
1034
|
+
AND wsr.status = 'running'
|
|
1035
|
+
AND wsr.pid IS NOT NULL`).all(loopRunId);
|
|
1036
|
+
return liveWorkflowSteps.some((step) => isProcessAlive(step.pid));
|
|
1037
|
+
}
|
|
458
1038
|
createSkippedRun(loop, scheduledFor, reason) {
|
|
459
1039
|
const now = nowIso();
|
|
460
1040
|
const run = {
|
|
@@ -502,6 +1082,14 @@ class Store {
|
|
|
502
1082
|
const existing = this.getRunBySlot(loop.id, scheduledFor);
|
|
503
1083
|
if (existing) {
|
|
504
1084
|
if (existing.status === "running") {
|
|
1085
|
+
if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && existing.pid && isProcessAlive(existing.pid)) {
|
|
1086
|
+
this.db.exec("COMMIT");
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (existing.leaseExpiresAt && existing.leaseExpiresAt <= startedAt && this.hasLiveWorkflowStepProcesses(existing.id)) {
|
|
1090
|
+
this.db.exec("COMMIT");
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
505
1093
|
const res3 = this.db.query(`UPDATE loop_runs SET status='running', started_at=$started, finished_at=NULL,
|
|
506
1094
|
claimed_by=$claimedBy, lease_expires_at=$lease, pid=NULL, exit_code=NULL,
|
|
507
1095
|
duration_ms=NULL, stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
|
|
@@ -571,10 +1159,9 @@ class Store {
|
|
|
571
1159
|
throw error;
|
|
572
1160
|
}
|
|
573
1161
|
}
|
|
574
|
-
finalizeRun(id, patch) {
|
|
1162
|
+
finalizeRun(id, patch, opts = {}) {
|
|
575
1163
|
const finishedAt = patch.finishedAt ?? nowIso();
|
|
576
|
-
|
|
577
|
-
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run({
|
|
1164
|
+
const params = {
|
|
578
1165
|
$id: id,
|
|
579
1166
|
$status: patch.status,
|
|
580
1167
|
$finished: finishedAt,
|
|
@@ -584,13 +1171,29 @@ class Store {
|
|
|
584
1171
|
$stdout: patch.stdout ?? null,
|
|
585
1172
|
$stderr: patch.stderr ?? null,
|
|
586
1173
|
$error: patch.error ?? null,
|
|
587
|
-
$updated: finishedAt
|
|
588
|
-
|
|
1174
|
+
$updated: finishedAt,
|
|
1175
|
+
$claimedBy: opts.claimedBy ?? null,
|
|
1176
|
+
$now: (opts.now ?? new Date).toISOString()
|
|
1177
|
+
};
|
|
1178
|
+
const res = opts.claimedBy ? this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
1179
|
+
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
|
|
1180
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy AND lease_expires_at > $now`).run(params) : this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
|
|
1181
|
+
duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
|
|
589
1182
|
const run = this.getRun(id);
|
|
590
1183
|
if (!run)
|
|
591
1184
|
throw new Error(`run not found after finalize: ${id}`);
|
|
1185
|
+
if (opts.claimedBy && res.changes !== 1)
|
|
1186
|
+
return run;
|
|
592
1187
|
return run;
|
|
593
1188
|
}
|
|
1189
|
+
heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
|
|
1190
|
+
const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
|
|
1191
|
+
const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
|
|
1192
|
+
WHERE id=$id AND status='running' AND claimed_by=$claimedBy`).run({ $id: id, $claimedBy: claimedBy, $expires: expiresAt, $updated: now.toISOString() });
|
|
1193
|
+
if (res.changes !== 1)
|
|
1194
|
+
return;
|
|
1195
|
+
return this.getRun(id);
|
|
1196
|
+
}
|
|
594
1197
|
listRuns(opts = {}) {
|
|
595
1198
|
const limit = opts.limit ?? 100;
|
|
596
1199
|
let rows;
|
|
@@ -609,8 +1212,26 @@ class Store {
|
|
|
609
1212
|
const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
|
|
610
1213
|
const recovered = [];
|
|
611
1214
|
for (const row of rows) {
|
|
1215
|
+
if (row.pid && isProcessAlive(row.pid))
|
|
1216
|
+
continue;
|
|
1217
|
+
if (this.hasLiveWorkflowStepProcesses(row.id))
|
|
1218
|
+
continue;
|
|
1219
|
+
const finished = now.toISOString();
|
|
612
1220
|
this.db.query(`UPDATE loop_runs SET status='abandoned', finished_at=$finished, lease_expires_at=NULL,
|
|
613
|
-
error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished:
|
|
1221
|
+
error='run lease expired before completion', updated_at=$updated WHERE id=$id`).run({ $id: row.id, $finished: finished, $updated: finished });
|
|
1222
|
+
const workflowRows = this.db.query("SELECT * FROM workflow_runs WHERE loop_run_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')").all(row.id);
|
|
1223
|
+
for (const workflowRow of workflowRows) {
|
|
1224
|
+
this.db.query(`UPDATE workflow_runs
|
|
1225
|
+
SET status='failed', finished_at=$finished, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1226
|
+
WHERE id=$id AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).run({ $id: workflowRow.id, $finished: finished, $updated: finished });
|
|
1227
|
+
this.db.query(`UPDATE workflow_step_runs
|
|
1228
|
+
SET status='skipped', finished_at=$finished, pid=NULL, error='parent loop run lease expired before completion', updated_at=$updated
|
|
1229
|
+
WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRow.id, $finished: finished, $updated: finished });
|
|
1230
|
+
this.appendWorkflowEvent(workflowRow.id, "failed", undefined, {
|
|
1231
|
+
error: "parent loop run lease expired before completion",
|
|
1232
|
+
loopRunId: row.id
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
614
1235
|
const run = this.getRun(row.id);
|
|
615
1236
|
if (run)
|
|
616
1237
|
recovered.push(run);
|
|
@@ -682,10 +1303,126 @@ class Store {
|
|
|
682
1303
|
}
|
|
683
1304
|
|
|
684
1305
|
// src/lib/executor.ts
|
|
685
|
-
import { spawn } from "child_process";
|
|
1306
|
+
import { spawn, spawnSync as spawnSync2 } from "child_process";
|
|
686
1307
|
import { once } from "events";
|
|
1308
|
+
import { existsSync as existsSync2 } from "fs";
|
|
1309
|
+
|
|
1310
|
+
// src/lib/accounts.ts
|
|
1311
|
+
import { spawnSync } from "child_process";
|
|
1312
|
+
import { existsSync } from "fs";
|
|
1313
|
+
var EXPORT_RE = /^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
|
|
1314
|
+
function accountToolForProvider(provider) {
|
|
1315
|
+
switch (provider) {
|
|
1316
|
+
case "claude":
|
|
1317
|
+
return "claude";
|
|
1318
|
+
case "cursor":
|
|
1319
|
+
return "cursor";
|
|
1320
|
+
case "codewith":
|
|
1321
|
+
return "codewith";
|
|
1322
|
+
case "aicopilot":
|
|
1323
|
+
return "aicopilot";
|
|
1324
|
+
case "opencode":
|
|
1325
|
+
return "opencode";
|
|
1326
|
+
case "codex":
|
|
1327
|
+
return "codex";
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
function parseExportValue(raw) {
|
|
1331
|
+
try {
|
|
1332
|
+
return JSON.parse(raw);
|
|
1333
|
+
} catch {
|
|
1334
|
+
return raw.replace(/^['"]|['"]$/g, "");
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
function parseAccountExportLines(output) {
|
|
1338
|
+
const env = {};
|
|
1339
|
+
for (const line of output.split(/\r?\n/)) {
|
|
1340
|
+
const match = EXPORT_RE.exec(line.trim());
|
|
1341
|
+
if (!match)
|
|
1342
|
+
continue;
|
|
1343
|
+
env[match[1]] = parseExportValue(match[2]);
|
|
1344
|
+
}
|
|
1345
|
+
return env;
|
|
1346
|
+
}
|
|
1347
|
+
function primaryAccountDir(output) {
|
|
1348
|
+
for (const line of output.split(/\r?\n/)) {
|
|
1349
|
+
const match = EXPORT_RE.exec(line.trim());
|
|
1350
|
+
if (!match)
|
|
1351
|
+
continue;
|
|
1352
|
+
return parseExportValue(match[2]);
|
|
1353
|
+
}
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
function accountDirEnvVar(tool) {
|
|
1357
|
+
switch (tool) {
|
|
1358
|
+
case "claude":
|
|
1359
|
+
return "CLAUDE_CONFIG_DIR";
|
|
1360
|
+
case "codex":
|
|
1361
|
+
case "codex-app":
|
|
1362
|
+
return "CODEX_HOME";
|
|
1363
|
+
case "cursor":
|
|
1364
|
+
return "CURSOR_CONFIG_DIR";
|
|
1365
|
+
case "opencode":
|
|
1366
|
+
return "OPENCODE_CONFIG_DIR";
|
|
1367
|
+
case "codewith":
|
|
1368
|
+
return "CODEWITH_HOME";
|
|
1369
|
+
case "aicopilot":
|
|
1370
|
+
return "AICOPILOT_CONFIG_DIR";
|
|
1371
|
+
default:
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
function resolveAccountEnv(account, toolHint, env) {
|
|
1376
|
+
if (!account)
|
|
1377
|
+
return {};
|
|
1378
|
+
const tool = account.tool ?? toolHint;
|
|
1379
|
+
if (!tool)
|
|
1380
|
+
throw new Error("account.tool is required when no provider tool can be inferred");
|
|
1381
|
+
const result = spawnSync("accounts", ["env", account.profile, "--tool", tool], {
|
|
1382
|
+
encoding: "utf8",
|
|
1383
|
+
env,
|
|
1384
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1385
|
+
});
|
|
1386
|
+
if (result.error) {
|
|
1387
|
+
throw new Error(`failed to run accounts env for ${account.profile}/${tool}: ${result.error.message}`);
|
|
1388
|
+
}
|
|
1389
|
+
if ((result.status ?? 0) !== 0) {
|
|
1390
|
+
const stderr = result.stderr.trim();
|
|
1391
|
+
throw new Error(`accounts env failed for ${account.profile}/${tool}${stderr ? `: ${stderr}` : ""}`);
|
|
1392
|
+
}
|
|
1393
|
+
const accountEnv = parseAccountExportLines(result.stdout);
|
|
1394
|
+
const profileDir = (accountDirEnvVar(tool) ? accountEnv[accountDirEnvVar(tool)] : undefined) ?? primaryAccountDir(result.stdout);
|
|
1395
|
+
if (!profileDir)
|
|
1396
|
+
throw new Error(`accounts env returned no profile directory for ${account.profile}/${tool}`);
|
|
1397
|
+
if (!existsSync(profileDir))
|
|
1398
|
+
throw new Error(`account profile directory does not exist for ${account.profile}/${tool}: ${profileDir}`);
|
|
1399
|
+
return {
|
|
1400
|
+
...accountEnv,
|
|
1401
|
+
LOOPS_ACCOUNT_PROFILE: account.profile,
|
|
1402
|
+
LOOPS_ACCOUNT_TOOL: tool
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// src/lib/executor.ts
|
|
687
1407
|
var DEFAULT_TIMEOUT_MS = 30 * 60000;
|
|
688
1408
|
var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
1409
|
+
var AUTH_ENV_KEYS = [
|
|
1410
|
+
"CLAUDE_CONFIG_DIR",
|
|
1411
|
+
"CODEWITH_HOME",
|
|
1412
|
+
"CODEX_HOME",
|
|
1413
|
+
"CURSOR_CONFIG_DIR",
|
|
1414
|
+
"OPENCODE_CONFIG_DIR",
|
|
1415
|
+
"AICOPILOT_CONFIG_DIR",
|
|
1416
|
+
"ANTHROPIC_API_KEY",
|
|
1417
|
+
"OPENAI_API_KEY",
|
|
1418
|
+
"OPENROUTER_API_KEY",
|
|
1419
|
+
"GITHUB_TOKEN",
|
|
1420
|
+
"GH_TOKEN",
|
|
1421
|
+
"XDG_CONFIG_HOME",
|
|
1422
|
+
"XDG_DATA_HOME",
|
|
1423
|
+
"XDG_STATE_HOME",
|
|
1424
|
+
"XDG_CACHE_HOME"
|
|
1425
|
+
];
|
|
689
1426
|
function appendBounded(current, chunk, maxBytes) {
|
|
690
1427
|
const next = current + chunk.toString("utf8");
|
|
691
1428
|
if (Buffer.byteLength(next, "utf8") <= maxBytes)
|
|
@@ -724,6 +1461,8 @@ function providerCommand(provider) {
|
|
|
724
1461
|
return "aicopilot";
|
|
725
1462
|
case "opencode":
|
|
726
1463
|
return "opencode";
|
|
1464
|
+
case "codex":
|
|
1465
|
+
return "codex";
|
|
727
1466
|
}
|
|
728
1467
|
}
|
|
729
1468
|
function agentArgs(target) {
|
|
@@ -760,6 +1499,16 @@ function agentArgs(target) {
|
|
|
760
1499
|
args.push("--agent", target.agent);
|
|
761
1500
|
args.push(...target.extraArgs ?? [], target.prompt);
|
|
762
1501
|
return args;
|
|
1502
|
+
case "codex":
|
|
1503
|
+
args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
|
|
1504
|
+
if (isolation === "safe")
|
|
1505
|
+
args.push("--ignore-rules");
|
|
1506
|
+
if (target.cwd)
|
|
1507
|
+
args.push("--cd", target.cwd);
|
|
1508
|
+
if (target.model)
|
|
1509
|
+
args.push("--model", target.model);
|
|
1510
|
+
args.push(...target.extraArgs ?? [], target.prompt);
|
|
1511
|
+
return args;
|
|
763
1512
|
case "aicopilot":
|
|
764
1513
|
args.push("run", "--format", "json");
|
|
765
1514
|
if (isolation === "safe")
|
|
@@ -786,8 +1535,7 @@ function agentArgs(target) {
|
|
|
786
1535
|
return args;
|
|
787
1536
|
}
|
|
788
1537
|
}
|
|
789
|
-
function commandSpec(
|
|
790
|
-
const target = loop.target;
|
|
1538
|
+
function commandSpec(target) {
|
|
791
1539
|
if (target.type === "command") {
|
|
792
1540
|
const commandTarget = target;
|
|
793
1541
|
return {
|
|
@@ -796,7 +1544,9 @@ function commandSpec(loop) {
|
|
|
796
1544
|
cwd: commandTarget.cwd,
|
|
797
1545
|
shell: commandTarget.shell,
|
|
798
1546
|
env: commandTarget.env,
|
|
799
|
-
timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
1547
|
+
timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
1548
|
+
account: commandTarget.account,
|
|
1549
|
+
accountTool: commandTarget.account?.tool
|
|
800
1550
|
};
|
|
801
1551
|
}
|
|
802
1552
|
const agentTarget = target;
|
|
@@ -804,11 +1554,61 @@ function commandSpec(loop) {
|
|
|
804
1554
|
command: providerCommand(agentTarget.provider),
|
|
805
1555
|
args: agentArgs(agentTarget),
|
|
806
1556
|
cwd: agentTarget.cwd,
|
|
807
|
-
timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
1557
|
+
timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
1558
|
+
account: agentTarget.account,
|
|
1559
|
+
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider)
|
|
808
1560
|
};
|
|
809
1561
|
}
|
|
810
|
-
|
|
811
|
-
const
|
|
1562
|
+
function executionEnv(spec, metadata, opts) {
|
|
1563
|
+
const env = { ...opts.env ?? process.env };
|
|
1564
|
+
if (spec.account) {
|
|
1565
|
+
const accountEnv = resolveAccountEnv(spec.account, spec.accountTool, env);
|
|
1566
|
+
for (const key of AUTH_ENV_KEYS)
|
|
1567
|
+
delete env[key];
|
|
1568
|
+
Object.assign(env, accountEnv);
|
|
1569
|
+
}
|
|
1570
|
+
Object.assign(env, spec.env ?? {});
|
|
1571
|
+
if (metadata.loopId)
|
|
1572
|
+
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1573
|
+
if (metadata.loopName)
|
|
1574
|
+
env.LOOPS_LOOP_NAME = metadata.loopName;
|
|
1575
|
+
if (metadata.runId)
|
|
1576
|
+
env.LOOPS_RUN_ID = metadata.runId;
|
|
1577
|
+
if (metadata.scheduledFor)
|
|
1578
|
+
env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
|
|
1579
|
+
if (metadata.workflowId)
|
|
1580
|
+
env.LOOPS_WORKFLOW_ID = metadata.workflowId;
|
|
1581
|
+
if (metadata.workflowName)
|
|
1582
|
+
env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
|
|
1583
|
+
if (metadata.workflowRunId)
|
|
1584
|
+
env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
|
|
1585
|
+
if (metadata.workflowStepId)
|
|
1586
|
+
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1587
|
+
return env;
|
|
1588
|
+
}
|
|
1589
|
+
function commandExists(command, env) {
|
|
1590
|
+
if (command.includes("/") && existsSync2(command))
|
|
1591
|
+
return true;
|
|
1592
|
+
const result = spawnSync2("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], {
|
|
1593
|
+
env,
|
|
1594
|
+
stdio: "ignore"
|
|
1595
|
+
});
|
|
1596
|
+
return (result.status ?? 1) === 0;
|
|
1597
|
+
}
|
|
1598
|
+
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1599
|
+
const spec = commandSpec(target);
|
|
1600
|
+
const env = executionEnv(spec, metadata, opts);
|
|
1601
|
+
if (!spec.shell && !commandExists(spec.command, env)) {
|
|
1602
|
+
throw new Error(`Executable not found in PATH: ${spec.command}`);
|
|
1603
|
+
}
|
|
1604
|
+
return {
|
|
1605
|
+
command: spec.command,
|
|
1606
|
+
accountProfile: spec.account?.profile,
|
|
1607
|
+
accountTool: spec.accountTool
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
async function executeTarget(target, metadata = {}, opts = {}) {
|
|
1611
|
+
const spec = commandSpec(target);
|
|
812
1612
|
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
813
1613
|
const startedAt = nowIso();
|
|
814
1614
|
let stdout = "";
|
|
@@ -816,14 +1616,18 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
816
1616
|
let timedOut = false;
|
|
817
1617
|
let exitCode;
|
|
818
1618
|
let error;
|
|
819
|
-
const env =
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1619
|
+
const env = executionEnv(spec, metadata, opts);
|
|
1620
|
+
if (!spec.shell && !commandExists(spec.command, env)) {
|
|
1621
|
+
return {
|
|
1622
|
+
status: "failed",
|
|
1623
|
+
stdout: "",
|
|
1624
|
+
stderr: "",
|
|
1625
|
+
error: `Executable not found in PATH: ${spec.command}`,
|
|
1626
|
+
startedAt,
|
|
1627
|
+
finishedAt: nowIso(),
|
|
1628
|
+
durationMs: 0
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
827
1631
|
const child = spawn(spec.command, spec.args, {
|
|
828
1632
|
cwd: spec.cwd,
|
|
829
1633
|
env,
|
|
@@ -831,6 +1635,16 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
831
1635
|
detached: true,
|
|
832
1636
|
stdio: ["ignore", "pipe", "pipe"]
|
|
833
1637
|
});
|
|
1638
|
+
if (child.pid)
|
|
1639
|
+
opts.onSpawn?.(child.pid);
|
|
1640
|
+
const abortHandler = () => {
|
|
1641
|
+
error = "cancelled";
|
|
1642
|
+
if (child.pid)
|
|
1643
|
+
killProcessGroup(child.pid);
|
|
1644
|
+
};
|
|
1645
|
+
if (opts.signal?.aborted)
|
|
1646
|
+
abortHandler();
|
|
1647
|
+
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
834
1648
|
child.stdout.on("data", (chunk) => {
|
|
835
1649
|
stdout = appendBounded(stdout, chunk, maxOutputBytes);
|
|
836
1650
|
});
|
|
@@ -853,6 +1667,7 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
853
1667
|
error = err instanceof Error ? err.message : String(err);
|
|
854
1668
|
} finally {
|
|
855
1669
|
clearTimeout(timer);
|
|
1670
|
+
opts.signal?.removeEventListener("abort", abortHandler);
|
|
856
1671
|
}
|
|
857
1672
|
const finishedAt = nowIso();
|
|
858
1673
|
const durationMs = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
|
|
@@ -893,12 +1708,243 @@ async function executeLoop(loop, run, opts = {}) {
|
|
|
893
1708
|
durationMs
|
|
894
1709
|
};
|
|
895
1710
|
}
|
|
1711
|
+
async function executeLoop(loop, run, opts = {}) {
|
|
1712
|
+
if (loop.target.type === "workflow") {
|
|
1713
|
+
throw new Error("workflow loop targets must be executed with executeLoopTarget");
|
|
1714
|
+
}
|
|
1715
|
+
return executeTarget(loop.target, {
|
|
1716
|
+
loopId: loop.id,
|
|
1717
|
+
loopName: loop.name,
|
|
1718
|
+
runId: run.id,
|
|
1719
|
+
scheduledFor: run.scheduledFor
|
|
1720
|
+
}, opts);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// src/lib/workflow-runner.ts
|
|
1724
|
+
function targetWithStepAccount(step) {
|
|
1725
|
+
const account = step.account ?? step.target.account;
|
|
1726
|
+
const timeoutMs = step.timeoutMs ?? step.target.timeoutMs;
|
|
1727
|
+
if (!account && timeoutMs === step.target.timeoutMs)
|
|
1728
|
+
return step.target;
|
|
1729
|
+
return { ...step.target, account, timeoutMs };
|
|
1730
|
+
}
|
|
1731
|
+
function workflowResult(workflowRun, status, startedAt, finishedAt, stdout, error) {
|
|
1732
|
+
const executorStatus = status === "succeeded" ? "succeeded" : status === "timed_out" ? "timed_out" : "failed";
|
|
1733
|
+
return {
|
|
1734
|
+
status: executorStatus,
|
|
1735
|
+
exitCode: executorStatus === "succeeded" ? 0 : 1,
|
|
1736
|
+
stdout,
|
|
1737
|
+
stderr: "",
|
|
1738
|
+
error,
|
|
1739
|
+
startedAt,
|
|
1740
|
+
finishedAt,
|
|
1741
|
+
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime()
|
|
1742
|
+
};
|
|
1743
|
+
}
|
|
1744
|
+
async function executeWorkflow(store, workflow, opts = {}) {
|
|
1745
|
+
const run = store.createWorkflowRun({
|
|
1746
|
+
workflow,
|
|
1747
|
+
loop: opts.loop,
|
|
1748
|
+
loopRun: opts.loopRun,
|
|
1749
|
+
scheduledFor: opts.scheduledFor,
|
|
1750
|
+
idempotencyKey: opts.idempotencyKey
|
|
1751
|
+
});
|
|
1752
|
+
const startedAt = run.startedAt ?? nowIso();
|
|
1753
|
+
if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out" || run.status === "cancelled") {
|
|
1754
|
+
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1755
|
+
return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), JSON.stringify({ workflowRun: run, steps: steps2 }, null, 2), run.error);
|
|
1756
|
+
}
|
|
1757
|
+
const ordered = workflowExecutionOrder(workflow);
|
|
1758
|
+
const byId = new Map(workflow.steps.map((step) => [step.id, step]));
|
|
1759
|
+
let blockingError;
|
|
1760
|
+
let terminalStatus = "succeeded";
|
|
1761
|
+
for (const step of ordered) {
|
|
1762
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1763
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
1764
|
+
blockingError = "workflow run was cancelled";
|
|
1765
|
+
break;
|
|
1766
|
+
}
|
|
1767
|
+
const pendingTimeout = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
1768
|
+
if (pendingTimeout) {
|
|
1769
|
+
terminalStatus = "timed_out";
|
|
1770
|
+
blockingError = pendingTimeout;
|
|
1771
|
+
break;
|
|
1772
|
+
}
|
|
1773
|
+
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
1774
|
+
if (existing?.status === "succeeded" || existing?.status === "skipped" || existing?.status === "cancelled")
|
|
1775
|
+
continue;
|
|
1776
|
+
const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
|
|
1777
|
+
const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
|
|
1778
|
+
const dependencyStep = byId.get(dependencyId);
|
|
1779
|
+
if (dependencyRun?.status === "succeeded")
|
|
1780
|
+
return false;
|
|
1781
|
+
return !dependencyStep?.continueOnFailure;
|
|
1782
|
+
});
|
|
1783
|
+
if (blockedBy) {
|
|
1784
|
+
store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`);
|
|
1785
|
+
blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
|
|
1786
|
+
terminalStatus = "failed";
|
|
1787
|
+
continue;
|
|
1788
|
+
}
|
|
1789
|
+
const startedStep = store.startWorkflowStepRun(run.id, step.id);
|
|
1790
|
+
if (startedStep.status !== "running") {
|
|
1791
|
+
terminalStatus = "failed";
|
|
1792
|
+
blockingError = `step ${step.id} could not start because workflow is no longer running`;
|
|
1793
|
+
break;
|
|
1794
|
+
}
|
|
1795
|
+
const metadata = {
|
|
1796
|
+
loopId: opts.loop?.id,
|
|
1797
|
+
loopName: opts.loop?.name,
|
|
1798
|
+
runId: opts.loopRun?.id,
|
|
1799
|
+
scheduledFor: opts.loopRun?.scheduledFor ?? opts.scheduledFor,
|
|
1800
|
+
workflowId: workflow.id,
|
|
1801
|
+
workflowName: workflow.name,
|
|
1802
|
+
workflowRunId: run.id,
|
|
1803
|
+
workflowStepId: step.id
|
|
1804
|
+
};
|
|
1805
|
+
let result;
|
|
1806
|
+
const controller = new AbortController;
|
|
1807
|
+
const externalAbort = () => controller.abort();
|
|
1808
|
+
if (opts.signal?.aborted)
|
|
1809
|
+
controller.abort();
|
|
1810
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
1811
|
+
const cancelTimer = setInterval(() => {
|
|
1812
|
+
if (store.getWorkflowRun(run.id)?.status === "cancelled")
|
|
1813
|
+
controller.abort();
|
|
1814
|
+
}, opts.cancelPollMs ?? 500);
|
|
1815
|
+
cancelTimer.unref();
|
|
1816
|
+
try {
|
|
1817
|
+
result = await executeTarget(targetWithStepAccount(step), metadata, {
|
|
1818
|
+
...opts,
|
|
1819
|
+
signal: controller.signal,
|
|
1820
|
+
onSpawn: (pid) => {
|
|
1821
|
+
store.markWorkflowStepPid(run.id, step.id, pid);
|
|
1822
|
+
opts.onSpawn?.(pid);
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
} catch (error) {
|
|
1826
|
+
const finishedAt2 = nowIso();
|
|
1827
|
+
result = {
|
|
1828
|
+
status: "failed",
|
|
1829
|
+
stdout: "",
|
|
1830
|
+
stderr: "",
|
|
1831
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1832
|
+
startedAt: startedStep.startedAt ?? finishedAt2,
|
|
1833
|
+
finishedAt: finishedAt2,
|
|
1834
|
+
durationMs: new Date(finishedAt2).getTime() - new Date(startedStep.startedAt ?? finishedAt2).getTime()
|
|
1835
|
+
};
|
|
1836
|
+
} finally {
|
|
1837
|
+
clearInterval(cancelTimer);
|
|
1838
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
1839
|
+
}
|
|
1840
|
+
const timeoutMessage = opts.signal?.aborted ? opts.signalTimeoutMessage?.() : undefined;
|
|
1841
|
+
if (timeoutMessage && result.status === "failed") {
|
|
1842
|
+
result = { ...result, status: "timed_out", error: timeoutMessage };
|
|
1843
|
+
}
|
|
1844
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1845
|
+
terminalStatus = store.requireWorkflowRun(run.id).status;
|
|
1846
|
+
blockingError = "workflow run was cancelled";
|
|
1847
|
+
break;
|
|
1848
|
+
}
|
|
1849
|
+
store.finalizeWorkflowStepRun(run.id, step.id, {
|
|
1850
|
+
status: result.status,
|
|
1851
|
+
finishedAt: result.finishedAt,
|
|
1852
|
+
durationMs: result.durationMs,
|
|
1853
|
+
stdout: result.stdout,
|
|
1854
|
+
stderr: result.stderr,
|
|
1855
|
+
exitCode: result.exitCode,
|
|
1856
|
+
error: result.error
|
|
1857
|
+
});
|
|
1858
|
+
if (result.status !== "succeeded" && !step.continueOnFailure) {
|
|
1859
|
+
terminalStatus = result.status;
|
|
1860
|
+
blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
|
|
1861
|
+
break;
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
if (terminalStatus !== "succeeded") {
|
|
1865
|
+
for (const step of ordered) {
|
|
1866
|
+
const existing = store.getWorkflowStepRun(run.id, step.id);
|
|
1867
|
+
if (existing?.status === "pending" || existing?.status === "running") {
|
|
1868
|
+
store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run");
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
const finishedAt = nowIso();
|
|
1873
|
+
if (store.isWorkflowRunTerminal(run.id)) {
|
|
1874
|
+
const terminalRun = store.requireWorkflowRun(run.id);
|
|
1875
|
+
const steps2 = store.listWorkflowStepRuns(run.id);
|
|
1876
|
+
return workflowResult(terminalRun, terminalRun.status, startedAt, terminalRun.finishedAt ?? finishedAt, JSON.stringify({ workflowRun: terminalRun, steps: steps2 }, null, 2), terminalRun.error ?? blockingError);
|
|
1877
|
+
}
|
|
1878
|
+
const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
|
|
1879
|
+
finishedAt,
|
|
1880
|
+
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
1881
|
+
error: blockingError
|
|
1882
|
+
});
|
|
1883
|
+
const steps = store.listWorkflowStepRuns(run.id);
|
|
1884
|
+
return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
|
|
1885
|
+
}
|
|
1886
|
+
function preflightWorkflow(workflow, opts = {}) {
|
|
1887
|
+
return workflowExecutionOrder(workflow).map((step) => preflightTarget(targetWithStepAccount(step), {
|
|
1888
|
+
workflowId: workflow.id,
|
|
1889
|
+
workflowName: workflow.name,
|
|
1890
|
+
workflowStepId: step.id
|
|
1891
|
+
}, opts));
|
|
1892
|
+
}
|
|
1893
|
+
async function executeLoopTarget(store, loop, run, opts = {}) {
|
|
1894
|
+
if (loop.target.type !== "workflow")
|
|
1895
|
+
return executeLoop(loop, run, opts);
|
|
1896
|
+
const workflow = store.requireWorkflow(loop.target.workflowId);
|
|
1897
|
+
const controller = loop.target.timeoutMs ? new AbortController : undefined;
|
|
1898
|
+
let workflowTimedOut = false;
|
|
1899
|
+
const externalAbort = () => controller?.abort();
|
|
1900
|
+
if (controller && opts.signal?.aborted)
|
|
1901
|
+
controller.abort();
|
|
1902
|
+
if (controller)
|
|
1903
|
+
opts.signal?.addEventListener("abort", externalAbort, { once: true });
|
|
1904
|
+
const timer = controller ? setTimeout(() => {
|
|
1905
|
+
workflowTimedOut = true;
|
|
1906
|
+
controller.abort();
|
|
1907
|
+
}, loop.target.timeoutMs) : undefined;
|
|
1908
|
+
timer?.unref();
|
|
1909
|
+
try {
|
|
1910
|
+
return await executeWorkflow(store, workflow, {
|
|
1911
|
+
...opts,
|
|
1912
|
+
signal: controller?.signal ?? opts.signal,
|
|
1913
|
+
signalTimeoutMessage: () => workflowTimedOut && loop.target.type === "workflow" ? `workflow timed out after ${loop.target.timeoutMs}ms` : undefined,
|
|
1914
|
+
loop,
|
|
1915
|
+
loopRun: run,
|
|
1916
|
+
scheduledFor: run.scheduledFor,
|
|
1917
|
+
idempotencyKey: `${loop.id}:${run.scheduledFor}:attempt:${run.attempt}`
|
|
1918
|
+
});
|
|
1919
|
+
} finally {
|
|
1920
|
+
if (timer)
|
|
1921
|
+
clearTimeout(timer);
|
|
1922
|
+
if (controller)
|
|
1923
|
+
opts.signal?.removeEventListener("abort", externalAbort);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
896
1926
|
|
|
897
1927
|
// src/lib/scheduler.ts
|
|
1928
|
+
function manualRunScheduledFor(loop, now = new Date) {
|
|
1929
|
+
if (loop.nextRunAt && new Date(loop.nextRunAt).getTime() <= now.getTime()) {
|
|
1930
|
+
return loop.retryScheduledFor ?? loop.nextRunAt;
|
|
1931
|
+
}
|
|
1932
|
+
return now.toISOString();
|
|
1933
|
+
}
|
|
1934
|
+
function shouldAdvanceManualRun(loop, scheduledFor, now = new Date) {
|
|
1935
|
+
if (!loop.nextRunAt || new Date(loop.nextRunAt).getTime() > now.getTime())
|
|
1936
|
+
return false;
|
|
1937
|
+
return scheduledFor === (loop.retryScheduledFor ?? loop.nextRunAt);
|
|
1938
|
+
}
|
|
898
1939
|
function nextAfterRetry(loop, now) {
|
|
899
1940
|
return new Date(now.getTime() + loop.retryDelayMs).toISOString();
|
|
900
1941
|
}
|
|
901
1942
|
function advanceLoop(store, loop, run, finishedAt, succeeded) {
|
|
1943
|
+
if (run.status === "running")
|
|
1944
|
+
return;
|
|
1945
|
+
const current = store.getLoop(loop.id);
|
|
1946
|
+
if (!current || current.status !== "active")
|
|
1947
|
+
return;
|
|
902
1948
|
const shouldRetry = !succeeded && run.attempt < loop.maxAttempts;
|
|
903
1949
|
if (shouldRetry) {
|
|
904
1950
|
store.updateLoop(loop.id, {
|
|
@@ -915,21 +1961,18 @@ function advanceLoop(store, loop, run, finishedAt, succeeded) {
|
|
|
915
1961
|
retryScheduledFor: undefined
|
|
916
1962
|
});
|
|
917
1963
|
}
|
|
918
|
-
async function
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
}
|
|
926
|
-
const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
|
|
927
|
-
if (!claim)
|
|
928
|
-
return;
|
|
929
|
-
deps.onRun?.(claim.run);
|
|
1964
|
+
async function executeClaimedRun(deps) {
|
|
1965
|
+
let heartbeat;
|
|
1966
|
+
const heartbeatEveryMs = Math.max(10, Math.min(60000, Math.floor(deps.loop.leaseMs / 3)));
|
|
1967
|
+
heartbeat = setInterval(() => {
|
|
1968
|
+
deps.store.heartbeatRunLease(deps.run.id, deps.runnerId, deps.loop.leaseMs);
|
|
1969
|
+
}, heartbeatEveryMs);
|
|
1970
|
+
heartbeat.unref();
|
|
930
1971
|
try {
|
|
931
|
-
const result = await (deps.execute ??
|
|
932
|
-
|
|
1972
|
+
const result = await (deps.execute ?? ((loop, run) => executeLoopTarget(deps.store, loop, run, {
|
|
1973
|
+
onSpawn: (pid) => deps.store.markRunPid(run.id, pid, deps.runnerId)
|
|
1974
|
+
})))(deps.loop, deps.run);
|
|
1975
|
+
return deps.store.finalizeRun(deps.run.id, {
|
|
933
1976
|
status: result.status,
|
|
934
1977
|
finishedAt: result.finishedAt,
|
|
935
1978
|
durationMs: result.durationMs,
|
|
@@ -938,25 +1981,53 @@ async function runSlot(deps, loop, scheduledFor) {
|
|
|
938
1981
|
exitCode: result.exitCode,
|
|
939
1982
|
error: result.error,
|
|
940
1983
|
pid: result.pid
|
|
1984
|
+
}, {
|
|
1985
|
+
claimedBy: deps.runnerId,
|
|
1986
|
+
now: deps.now?.() ?? new Date(result.finishedAt)
|
|
941
1987
|
});
|
|
942
|
-
advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), result.status === "succeeded");
|
|
943
|
-
deps.onRun?.(finalRun);
|
|
944
|
-
return finalRun;
|
|
945
1988
|
} catch (err) {
|
|
946
|
-
deps.onError?.(
|
|
1989
|
+
deps.onError?.(deps.loop, err);
|
|
947
1990
|
const finishedAt = new Date;
|
|
948
|
-
|
|
1991
|
+
return deps.store.finalizeRun(deps.run.id, {
|
|
949
1992
|
status: "failed",
|
|
950
1993
|
finishedAt: finishedAt.toISOString(),
|
|
951
|
-
durationMs: finishedAt.getTime() - new Date(
|
|
1994
|
+
durationMs: finishedAt.getTime() - new Date(deps.run.startedAt ?? deps.run.createdAt).getTime(),
|
|
952
1995
|
stdout: "",
|
|
953
1996
|
stderr: "",
|
|
954
1997
|
error: err instanceof Error ? err.message : String(err)
|
|
1998
|
+
}, {
|
|
1999
|
+
claimedBy: deps.runnerId,
|
|
2000
|
+
now: deps.now?.() ?? finishedAt
|
|
955
2001
|
});
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
2002
|
+
} finally {
|
|
2003
|
+
if (heartbeat)
|
|
2004
|
+
clearInterval(heartbeat);
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
async function runSlot(deps, loop, scheduledFor) {
|
|
2008
|
+
const now = deps.now?.() ?? new Date;
|
|
2009
|
+
if (loop.overlap === "skip" && deps.store.hasRunningRun(loop.id)) {
|
|
2010
|
+
const skipped = deps.store.createSkippedRun(loop, scheduledFor, "previous run still active");
|
|
2011
|
+
advanceLoop(deps.store, loop, skipped, now, true);
|
|
2012
|
+
deps.onRun?.(skipped);
|
|
2013
|
+
return skipped;
|
|
959
2014
|
}
|
|
2015
|
+
const claim = deps.store.claimRun(loop, scheduledFor, deps.runnerId, now);
|
|
2016
|
+
if (!claim)
|
|
2017
|
+
return;
|
|
2018
|
+
deps.onRun?.(claim.run);
|
|
2019
|
+
const finalRun = await executeClaimedRun({
|
|
2020
|
+
store: deps.store,
|
|
2021
|
+
runnerId: deps.runnerId,
|
|
2022
|
+
loop: claim.loop,
|
|
2023
|
+
run: claim.run,
|
|
2024
|
+
now: deps.now,
|
|
2025
|
+
execute: deps.execute,
|
|
2026
|
+
onError: deps.onError
|
|
2027
|
+
});
|
|
2028
|
+
advanceLoop(deps.store, claim.loop, finalRun, new Date(finalRun.finishedAt ?? new Date), finalRun.status === "succeeded");
|
|
2029
|
+
deps.onRun?.(finalRun);
|
|
2030
|
+
return finalRun;
|
|
960
2031
|
}
|
|
961
2032
|
async function tick(deps) {
|
|
962
2033
|
const now = deps.now?.() ?? new Date;
|
|
@@ -982,6 +2053,8 @@ async function tick(deps) {
|
|
|
982
2053
|
skipped.push(run);
|
|
983
2054
|
else
|
|
984
2055
|
completed.push(run);
|
|
2056
|
+
if (["failed", "timed_out", "abandoned"].includes(run.status) && run.attempt < loop.maxAttempts)
|
|
2057
|
+
break;
|
|
985
2058
|
}
|
|
986
2059
|
}
|
|
987
2060
|
return { claimed, completed, skipped, recovered, expired };
|
|
@@ -1029,21 +2102,17 @@ class LoopsClient {
|
|
|
1029
2102
|
}
|
|
1030
2103
|
async runNow(idOrName) {
|
|
1031
2104
|
const loop = this.get(idOrName);
|
|
1032
|
-
const
|
|
1033
|
-
const
|
|
2105
|
+
const now = new Date;
|
|
2106
|
+
const scheduledFor = manualRunScheduledFor(loop, now);
|
|
2107
|
+
const shouldAdvance = shouldAdvanceManualRun(loop, scheduledFor, now);
|
|
2108
|
+
const claim = this.store.claimRun(loop, scheduledFor, this.runnerId, now);
|
|
1034
2109
|
if (!claim)
|
|
1035
2110
|
throw new Error(`could not claim manual run for ${idOrName}`);
|
|
1036
|
-
const
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
stdout: result.stdout,
|
|
1042
|
-
stderr: result.stderr,
|
|
1043
|
-
exitCode: result.exitCode,
|
|
1044
|
-
error: result.error,
|
|
1045
|
-
pid: result.pid
|
|
1046
|
-
});
|
|
2111
|
+
const run = await executeClaimedRun({ store: this.store, runnerId: this.runnerId, loop: claim.loop, run: claim.run });
|
|
2112
|
+
if (shouldAdvance) {
|
|
2113
|
+
advanceLoop(this.store, claim.loop, run, new Date(run.finishedAt ?? new Date), run.status === "succeeded");
|
|
2114
|
+
}
|
|
2115
|
+
return run;
|
|
1047
2116
|
}
|
|
1048
2117
|
close() {
|
|
1049
2118
|
if (this.ownStore)
|