@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/dist/lib/store.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
- this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
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: now.toISOString(), $updated: now.toISOString() });
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);