@hasna/loops 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -236,6 +236,100 @@ function parseDuration(input) {
236
236
  return Math.round(value * multiplier);
237
237
  }
238
238
 
239
+ // src/lib/workflow-spec.ts
240
+ function assertObject(value, label) {
241
+ if (!value || typeof value !== "object" || Array.isArray(value))
242
+ throw new Error(`${label} must be an object`);
243
+ }
244
+ function assertString(value, label) {
245
+ if (typeof value !== "string" || value.trim() === "")
246
+ throw new Error(`${label} must be a non-empty string`);
247
+ }
248
+ function validateTarget(value, label) {
249
+ assertObject(value, label);
250
+ if (value.type === "command") {
251
+ assertString(value.command, `${label}.command`);
252
+ return value;
253
+ }
254
+ if (value.type === "agent") {
255
+ assertString(value.provider, `${label}.provider`);
256
+ assertString(value.prompt, `${label}.prompt`);
257
+ const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
258
+ if (!providers.includes(value.provider))
259
+ throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
260
+ return value;
261
+ }
262
+ throw new Error(`${label}.type must be command or agent`);
263
+ }
264
+ function normalizeCreateWorkflowInput(input) {
265
+ assertString(input.name, "workflow.name");
266
+ if (!Array.isArray(input.steps) || input.steps.length === 0)
267
+ throw new Error("workflow.steps must contain at least one step");
268
+ const seen = new Set;
269
+ const steps = input.steps.map((step, index) => {
270
+ assertObject(step, `workflow.steps[${index}]`);
271
+ assertString(step.id, `workflow.steps[${index}].id`);
272
+ if (seen.has(step.id))
273
+ throw new Error(`duplicate workflow step id: ${step.id}`);
274
+ seen.add(step.id);
275
+ return {
276
+ ...step,
277
+ id: step.id,
278
+ target: validateTarget(step.target, `workflow.steps[${index}].target`),
279
+ dependsOn: step.dependsOn ?? [],
280
+ continueOnFailure: step.continueOnFailure ?? false
281
+ };
282
+ });
283
+ for (const step of steps) {
284
+ for (const dependency of step.dependsOn ?? []) {
285
+ if (!seen.has(dependency))
286
+ throw new Error(`step ${step.id} depends on missing step ${dependency}`);
287
+ if (dependency === step.id)
288
+ throw new Error(`step ${step.id} cannot depend on itself`);
289
+ }
290
+ }
291
+ workflowExecutionOrder({ steps });
292
+ return { ...input, name: input.name.trim(), version: input.version ?? 1, steps };
293
+ }
294
+ function workflowExecutionOrder(workflow) {
295
+ const byId = new Map(workflow.steps.map((step) => [step.id, step]));
296
+ const visiting = new Set;
297
+ const visited = new Set;
298
+ const order = [];
299
+ function visit(step) {
300
+ if (visited.has(step.id))
301
+ return;
302
+ if (visiting.has(step.id))
303
+ throw new Error(`workflow dependency cycle includes step ${step.id}`);
304
+ visiting.add(step.id);
305
+ for (const dependencyId of step.dependsOn ?? []) {
306
+ const dependency = byId.get(dependencyId);
307
+ if (!dependency)
308
+ throw new Error(`step ${step.id} depends on missing step ${dependencyId}`);
309
+ visit(dependency);
310
+ }
311
+ visiting.delete(step.id);
312
+ visited.add(step.id);
313
+ order.push(step);
314
+ }
315
+ for (const step of workflow.steps)
316
+ visit(step);
317
+ return order;
318
+ }
319
+ function workflowBodyFromJson(value, fallbackName) {
320
+ assertObject(value, "workflow file");
321
+ const rawName = fallbackName ?? value.name;
322
+ assertString(rawName, "workflow.name");
323
+ if (!Array.isArray(value.steps))
324
+ throw new Error("workflow.steps must be an array");
325
+ return normalizeCreateWorkflowInput({
326
+ name: rawName,
327
+ description: typeof value.description === "string" ? value.description : undefined,
328
+ version: typeof value.version === "number" ? value.version : undefined,
329
+ steps: value.steps
330
+ });
331
+ }
332
+
239
333
  // src/lib/store.ts
240
334
  function rowToLoop(row) {
241
335
  return {
@@ -280,6 +374,67 @@ function rowToRun(row) {
280
374
  updatedAt: row.updated_at
281
375
  };
282
376
  }
377
+ function rowToWorkflow(row) {
378
+ return {
379
+ id: row.id,
380
+ name: row.name,
381
+ description: row.description ?? undefined,
382
+ version: row.version,
383
+ status: row.status,
384
+ steps: JSON.parse(row.steps_json),
385
+ createdAt: row.created_at,
386
+ updatedAt: row.updated_at
387
+ };
388
+ }
389
+ function rowToWorkflowRun(row) {
390
+ return {
391
+ id: row.id,
392
+ workflowId: row.workflow_id,
393
+ workflowName: row.workflow_name,
394
+ loopId: row.loop_id ?? undefined,
395
+ loopRunId: row.loop_run_id ?? undefined,
396
+ scheduledFor: row.scheduled_for ?? undefined,
397
+ idempotencyKey: row.idempotency_key ?? undefined,
398
+ status: row.status,
399
+ startedAt: row.started_at ?? undefined,
400
+ finishedAt: row.finished_at ?? undefined,
401
+ durationMs: row.duration_ms ?? undefined,
402
+ error: row.error ?? undefined,
403
+ createdAt: row.created_at,
404
+ updatedAt: row.updated_at
405
+ };
406
+ }
407
+ function rowToWorkflowStepRun(row) {
408
+ return {
409
+ id: row.id,
410
+ workflowRunId: row.workflow_run_id,
411
+ stepId: row.step_id,
412
+ sequence: row.sequence,
413
+ status: row.status,
414
+ startedAt: row.started_at ?? undefined,
415
+ finishedAt: row.finished_at ?? undefined,
416
+ exitCode: row.exit_code ?? undefined,
417
+ durationMs: row.duration_ms ?? undefined,
418
+ stdout: row.stdout ?? undefined,
419
+ stderr: row.stderr ?? undefined,
420
+ error: row.error ?? undefined,
421
+ accountProfile: row.account_profile ?? undefined,
422
+ accountTool: row.account_tool ?? undefined,
423
+ createdAt: row.created_at,
424
+ updatedAt: row.updated_at
425
+ };
426
+ }
427
+ function rowToWorkflowEvent(row) {
428
+ return {
429
+ id: row.id,
430
+ workflowRunId: row.workflow_run_id,
431
+ sequence: row.sequence,
432
+ eventType: row.event_type,
433
+ stepId: row.step_id ?? undefined,
434
+ payload: row.payload_json ? JSON.parse(row.payload_json) : undefined,
435
+ createdAt: row.created_at
436
+ };
437
+ }
283
438
  function rowToLease(row) {
284
439
  return {
285
440
  id: row.id,
@@ -305,6 +460,11 @@ class Store {
305
460
  }
306
461
  migrate() {
307
462
  this.db.exec(`
463
+ CREATE TABLE IF NOT EXISTS schema_migrations (
464
+ id TEXT PRIMARY KEY,
465
+ applied_at TEXT NOT NULL
466
+ );
467
+
308
468
  CREATE TABLE IF NOT EXISTS loops (
309
469
  id TEXT PRIMARY KEY,
310
470
  name TEXT NOT NULL,
@@ -361,7 +521,78 @@ class Store {
361
521
  created_at TEXT NOT NULL,
362
522
  updated_at TEXT NOT NULL
363
523
  );
524
+
525
+ CREATE TABLE IF NOT EXISTS workflow_specs (
526
+ id TEXT PRIMARY KEY,
527
+ name TEXT NOT NULL,
528
+ description TEXT,
529
+ version INTEGER NOT NULL,
530
+ status TEXT NOT NULL,
531
+ steps_json TEXT NOT NULL,
532
+ created_at TEXT NOT NULL,
533
+ updated_at TEXT NOT NULL
534
+ );
535
+ CREATE INDEX IF NOT EXISTS idx_workflows_status_name ON workflow_specs(status, name);
536
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_name_active ON workflow_specs(name) WHERE status = 'active';
537
+
538
+ CREATE TABLE IF NOT EXISTS workflow_runs (
539
+ id TEXT PRIMARY KEY,
540
+ workflow_id TEXT NOT NULL REFERENCES workflow_specs(id) ON DELETE CASCADE,
541
+ workflow_name TEXT NOT NULL,
542
+ loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
543
+ loop_run_id TEXT REFERENCES loop_runs(id) ON DELETE SET NULL,
544
+ scheduled_for TEXT,
545
+ idempotency_key TEXT,
546
+ status TEXT NOT NULL,
547
+ started_at TEXT,
548
+ finished_at TEXT,
549
+ duration_ms INTEGER,
550
+ error TEXT,
551
+ created_at TEXT NOT NULL,
552
+ updated_at TEXT NOT NULL
553
+ );
554
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_runs_idempotency
555
+ ON workflow_runs(workflow_id, idempotency_key)
556
+ WHERE idempotency_key IS NOT NULL;
557
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created ON workflow_runs(workflow_id, created_at DESC);
558
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
559
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
560
+
561
+ CREATE TABLE IF NOT EXISTS workflow_step_runs (
562
+ id TEXT PRIMARY KEY,
563
+ workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
564
+ step_id TEXT NOT NULL,
565
+ sequence INTEGER NOT NULL,
566
+ status TEXT NOT NULL,
567
+ started_at TEXT,
568
+ finished_at TEXT,
569
+ exit_code INTEGER,
570
+ duration_ms INTEGER,
571
+ stdout TEXT,
572
+ stderr TEXT,
573
+ error TEXT,
574
+ account_profile TEXT,
575
+ account_tool TEXT,
576
+ created_at TEXT NOT NULL,
577
+ updated_at TEXT NOT NULL,
578
+ UNIQUE(workflow_run_id, step_id)
579
+ );
580
+ CREATE INDEX IF NOT EXISTS idx_workflow_step_runs_run_sequence ON workflow_step_runs(workflow_run_id, sequence);
581
+ CREATE INDEX IF NOT EXISTS idx_workflow_step_runs_status ON workflow_step_runs(status);
582
+
583
+ CREATE TABLE IF NOT EXISTS workflow_events (
584
+ id TEXT PRIMARY KEY,
585
+ workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
586
+ sequence INTEGER NOT NULL,
587
+ event_type TEXT NOT NULL,
588
+ step_id TEXT,
589
+ payload_json TEXT,
590
+ created_at TEXT NOT NULL,
591
+ UNIQUE(workflow_run_id, sequence)
592
+ );
593
+ CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
364
594
  `);
595
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
365
596
  }
366
597
  createLoop(input, from = new Date) {
367
598
  const now = nowIso();
@@ -453,6 +684,244 @@ class Store {
453
684
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
454
685
  return res.changes > 0;
455
686
  }
687
+ createWorkflow(input) {
688
+ const normalized = normalizeCreateWorkflowInput(input);
689
+ const now = nowIso();
690
+ const workflow = {
691
+ id: genId(),
692
+ name: normalized.name,
693
+ description: normalized.description,
694
+ version: normalized.version ?? 1,
695
+ status: "active",
696
+ steps: normalized.steps,
697
+ createdAt: now,
698
+ updatedAt: now
699
+ };
700
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, steps_json, created_at, updated_at)
701
+ VALUES ($id, $name, $description, $version, $status, $steps, $created, $updated)`).run({
702
+ $id: workflow.id,
703
+ $name: workflow.name,
704
+ $description: workflow.description ?? null,
705
+ $version: workflow.version,
706
+ $status: workflow.status,
707
+ $steps: JSON.stringify(workflow.steps),
708
+ $created: workflow.createdAt,
709
+ $updated: workflow.updatedAt
710
+ });
711
+ return workflow;
712
+ }
713
+ getWorkflow(id) {
714
+ const row = this.db.query("SELECT * FROM workflow_specs WHERE id = ?").get(id);
715
+ return row ? rowToWorkflow(row) : undefined;
716
+ }
717
+ findWorkflowByName(name) {
718
+ const row = this.db.query("SELECT * FROM workflow_specs WHERE name = ? AND status = 'active' ORDER BY updated_at DESC LIMIT 1").get(name);
719
+ return row ? rowToWorkflow(row) : undefined;
720
+ }
721
+ requireWorkflow(idOrName) {
722
+ return this.getWorkflow(idOrName) ?? this.findWorkflowByName(idOrName) ?? (() => {
723
+ throw new Error(`workflow not found: ${idOrName}`);
724
+ })();
725
+ }
726
+ listWorkflows(opts = {}) {
727
+ const limit = opts.limit ?? 200;
728
+ 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);
729
+ return rows.map(rowToWorkflow);
730
+ }
731
+ archiveWorkflow(idOrName) {
732
+ const workflow = this.requireWorkflow(idOrName);
733
+ const updated = nowIso();
734
+ this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=?").run(updated, workflow.id);
735
+ const archived = this.getWorkflow(workflow.id);
736
+ if (!archived)
737
+ throw new Error(`workflow not found after archive: ${workflow.id}`);
738
+ return archived;
739
+ }
740
+ createWorkflowRun(input) {
741
+ const now = nowIso();
742
+ if (input.idempotencyKey) {
743
+ const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
744
+ if (existing)
745
+ return rowToWorkflowRun(existing);
746
+ }
747
+ this.db.exec("BEGIN IMMEDIATE");
748
+ try {
749
+ if (input.idempotencyKey) {
750
+ const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
751
+ if (existing) {
752
+ this.db.exec("COMMIT");
753
+ return rowToWorkflowRun(existing);
754
+ }
755
+ }
756
+ const runId = genId();
757
+ this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, scheduled_for, idempotency_key,
758
+ status, started_at, finished_at, duration_ms, error, created_at, updated_at)
759
+ VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $scheduledFor, $idempotencyKey,
760
+ 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
761
+ $id: runId,
762
+ $workflowId: input.workflow.id,
763
+ $workflowName: input.workflow.name,
764
+ $loopId: input.loop?.id ?? null,
765
+ $loopRunId: input.loopRun?.id ?? null,
766
+ $scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
767
+ $idempotencyKey: input.idempotencyKey ?? null,
768
+ $started: now,
769
+ $created: now,
770
+ $updated: now
771
+ });
772
+ input.workflow.steps.forEach((step, sequence) => {
773
+ const account = step.account ?? step.target.account;
774
+ this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
775
+ exit_code, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
776
+ VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL,
777
+ $accountProfile, $accountTool, $created, $updated)`).run({
778
+ $id: genId(),
779
+ $workflowRunId: runId,
780
+ $stepId: step.id,
781
+ $sequence: sequence,
782
+ $accountProfile: account?.profile ?? null,
783
+ $accountTool: account?.tool ?? null,
784
+ $created: now,
785
+ $updated: now
786
+ });
787
+ });
788
+ this.db.query(`INSERT INTO workflow_events (id, workflow_run_id, sequence, event_type, step_id, payload_json, created_at)
789
+ VALUES ($id, $workflowRunId, 1, 'created', NULL, $payload, $created)`).run({
790
+ $id: genId(),
791
+ $workflowRunId: runId,
792
+ $payload: JSON.stringify({
793
+ workflowId: input.workflow.id,
794
+ workflowName: input.workflow.name,
795
+ stepCount: input.workflow.steps.length,
796
+ loopId: input.loop?.id,
797
+ loopRunId: input.loopRun?.id
798
+ }),
799
+ $created: now
800
+ });
801
+ this.db.exec("COMMIT");
802
+ const run = this.getWorkflowRun(runId);
803
+ if (!run)
804
+ throw new Error(`workflow run not found after create: ${runId}`);
805
+ return run;
806
+ } catch (error) {
807
+ try {
808
+ this.db.exec("ROLLBACK");
809
+ } catch {}
810
+ throw error;
811
+ }
812
+ }
813
+ getWorkflowRun(id) {
814
+ const row = this.db.query("SELECT * FROM workflow_runs WHERE id = ?").get(id);
815
+ return row ? rowToWorkflowRun(row) : undefined;
816
+ }
817
+ listWorkflowRuns(opts = {}) {
818
+ const limit = opts.limit ?? 100;
819
+ let rows;
820
+ if (opts.workflowId) {
821
+ rows = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.workflowId, limit);
822
+ } else if (opts.loopRunId) {
823
+ rows = this.db.query("SELECT * FROM workflow_runs WHERE loop_run_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.loopRunId, limit);
824
+ } else {
825
+ rows = this.db.query("SELECT * FROM workflow_runs ORDER BY created_at DESC LIMIT ?").all(limit);
826
+ }
827
+ return rows.map(rowToWorkflowRun);
828
+ }
829
+ listWorkflowStepRuns(workflowRunId) {
830
+ const rows = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? ORDER BY sequence ASC").all(workflowRunId);
831
+ return rows.map(rowToWorkflowStepRun);
832
+ }
833
+ getWorkflowStepRun(workflowRunId, stepId) {
834
+ const row = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? AND step_id = ?").get(workflowRunId, stepId);
835
+ return row ? rowToWorkflowStepRun(row) : undefined;
836
+ }
837
+ startWorkflowStepRun(workflowRunId, stepId) {
838
+ const now = nowIso();
839
+ this.db.query(`UPDATE workflow_step_runs
840
+ SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
841
+ stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
842
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId AND status IN ('pending', 'running', 'failed', 'timed_out')`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $started: now, $updated: now });
843
+ this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
844
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
845
+ if (!run)
846
+ throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
847
+ return run;
848
+ }
849
+ finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
850
+ const finishedAt = patch.finishedAt ?? nowIso();
851
+ this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
852
+ stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
853
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId`).run({
854
+ $workflowRunId: workflowRunId,
855
+ $stepId: stepId,
856
+ $status: patch.status,
857
+ $finished: finishedAt,
858
+ $exitCode: patch.exitCode ?? null,
859
+ $durationMs: patch.durationMs ?? null,
860
+ $stdout: patch.stdout ?? null,
861
+ $stderr: patch.stderr ?? null,
862
+ $error: patch.error ?? null,
863
+ $updated: finishedAt
864
+ });
865
+ this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
866
+ exitCode: patch.exitCode,
867
+ error: patch.error
868
+ });
869
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
870
+ if (!run)
871
+ throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
872
+ return run;
873
+ }
874
+ skipWorkflowStepRun(workflowRunId, stepId, reason) {
875
+ const now = nowIso();
876
+ this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, error=$error, updated_at=$updated
877
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $finished: now, $error: reason, $updated: now });
878
+ this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
879
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
880
+ if (!run)
881
+ throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
882
+ return run;
883
+ }
884
+ finalizeWorkflowRun(workflowRunId, status, patch = {}) {
885
+ const finishedAt = patch.finishedAt ?? nowIso();
886
+ this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
887
+ WHERE id=$id`).run({
888
+ $id: workflowRunId,
889
+ $status: status,
890
+ $finished: finishedAt,
891
+ $durationMs: patch.durationMs ?? null,
892
+ $error: patch.error ?? null,
893
+ $updated: finishedAt
894
+ });
895
+ this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
896
+ const run = this.getWorkflowRun(workflowRunId);
897
+ if (!run)
898
+ throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
899
+ return run;
900
+ }
901
+ appendWorkflowEvent(workflowRunId, eventType, stepId, payload) {
902
+ const now = nowIso();
903
+ const current = this.db.query("SELECT MAX(sequence) AS sequence FROM workflow_events WHERE workflow_run_id = ?").get(workflowRunId);
904
+ const sequence = (current?.sequence ?? 0) + 1;
905
+ const id = genId();
906
+ this.db.query(`INSERT INTO workflow_events (id, workflow_run_id, sequence, event_type, step_id, payload_json, created_at)
907
+ VALUES ($id, $workflowRunId, $sequence, $eventType, $stepId, $payload, $created)`).run({
908
+ $id: id,
909
+ $workflowRunId: workflowRunId,
910
+ $sequence: sequence,
911
+ $eventType: eventType,
912
+ $stepId: stepId ?? null,
913
+ $payload: payload ? JSON.stringify(payload) : null,
914
+ $created: now
915
+ });
916
+ const event = this.db.query("SELECT * FROM workflow_events WHERE id = ?").get(id);
917
+ if (!event)
918
+ throw new Error(`workflow event not found after append: ${id}`);
919
+ return rowToWorkflowEvent(event);
920
+ }
921
+ listWorkflowEvents(workflowRunId, limit = 200) {
922
+ const rows = this.db.query("SELECT * FROM workflow_events WHERE workflow_run_id = ? ORDER BY sequence ASC LIMIT ?").all(workflowRunId, limit);
923
+ return rows.map(rowToWorkflowEvent);
924
+ }
456
925
  hasRunningRun(loopId) {
457
926
  const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
458
927
  return (row?.count ?? 0) > 0;
@@ -573,10 +1042,9 @@ class Store {
573
1042
  throw error;
574
1043
  }
575
1044
  }
576
- finalizeRun(id, patch) {
1045
+ finalizeRun(id, patch, opts = {}) {
577
1046
  const finishedAt = patch.finishedAt ?? nowIso();
578
- this.db.query(`UPDATE loop_runs SET status=$status, finished_at=$finished, lease_expires_at=NULL, pid=$pid, exit_code=$exitCode,
579
- duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run({
1047
+ const params = {
580
1048
  $id: id,
581
1049
  $status: patch.status,
582
1050
  $finished: finishedAt,
@@ -586,13 +1054,29 @@ class Store {
586
1054
  $stdout: patch.stdout ?? null,
587
1055
  $stderr: patch.stderr ?? null,
588
1056
  $error: patch.error ?? null,
589
- $updated: finishedAt
590
- });
1057
+ $updated: finishedAt,
1058
+ $claimedBy: opts.claimedBy ?? null,
1059
+ $now: (opts.now ?? new Date).toISOString()
1060
+ };
1061
+ 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,
1062
+ duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
1063
+ 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,
1064
+ duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
591
1065
  const run = this.getRun(id);
592
1066
  if (!run)
593
1067
  throw new Error(`run not found after finalize: ${id}`);
1068
+ if (opts.claimedBy && res.changes !== 1)
1069
+ return run;
594
1070
  return run;
595
1071
  }
1072
+ heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
1073
+ const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
1074
+ const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
1075
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy`).run({ $id: id, $claimedBy: claimedBy, $expires: expiresAt, $updated: now.toISOString() });
1076
+ if (res.changes !== 1)
1077
+ return;
1078
+ return this.getRun(id);
1079
+ }
596
1080
  listRuns(opts = {}) {
597
1081
  const limit = opts.limit ?? 100;
598
1082
  let rows;
@@ -694,8 +1178,103 @@ import { spawn as spawn2 } from "child_process";
694
1178
  // src/lib/executor.ts
695
1179
  import { spawn } from "child_process";
696
1180
  import { once } from "events";
1181
+
1182
+ // src/lib/accounts.ts
1183
+ import { spawnSync } from "child_process";
1184
+ import { existsSync } from "fs";
1185
+ var EXPORT_RE = /^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
1186
+ function accountToolForProvider(provider) {
1187
+ switch (provider) {
1188
+ case "claude":
1189
+ return "claude";
1190
+ case "cursor":
1191
+ return "cursor";
1192
+ case "codewith":
1193
+ return "codewith";
1194
+ case "aicopilot":
1195
+ return "aicopilot";
1196
+ case "opencode":
1197
+ return "opencode";
1198
+ case "codex":
1199
+ return "codex";
1200
+ }
1201
+ }
1202
+ function parseExportValue(raw) {
1203
+ try {
1204
+ return JSON.parse(raw);
1205
+ } catch {
1206
+ return raw.replace(/^['"]|['"]$/g, "");
1207
+ }
1208
+ }
1209
+ function parseAccountExportLines(output) {
1210
+ const env = {};
1211
+ for (const line of output.split(/\r?\n/)) {
1212
+ const match = EXPORT_RE.exec(line.trim());
1213
+ if (!match)
1214
+ continue;
1215
+ env[match[1]] = parseExportValue(match[2]);
1216
+ }
1217
+ return env;
1218
+ }
1219
+ function primaryAccountDir(output) {
1220
+ for (const line of output.split(/\r?\n/)) {
1221
+ const match = EXPORT_RE.exec(line.trim());
1222
+ if (!match)
1223
+ continue;
1224
+ return parseExportValue(match[2]);
1225
+ }
1226
+ return;
1227
+ }
1228
+ function resolveAccountEnv(account, toolHint, env) {
1229
+ if (!account)
1230
+ return {};
1231
+ const tool = account.tool ?? toolHint;
1232
+ if (!tool)
1233
+ throw new Error("account.tool is required when no provider tool can be inferred");
1234
+ const result = spawnSync("accounts", ["env", account.profile, "--tool", tool], {
1235
+ encoding: "utf8",
1236
+ env,
1237
+ stdio: ["ignore", "pipe", "pipe"]
1238
+ });
1239
+ if (result.error) {
1240
+ throw new Error(`failed to run accounts env for ${account.profile}/${tool}: ${result.error.message}`);
1241
+ }
1242
+ if ((result.status ?? 0) !== 0) {
1243
+ const stderr = result.stderr.trim();
1244
+ throw new Error(`accounts env failed for ${account.profile}/${tool}${stderr ? `: ${stderr}` : ""}`);
1245
+ }
1246
+ const profileDir = primaryAccountDir(result.stdout);
1247
+ if (!profileDir)
1248
+ throw new Error(`accounts env returned no profile directory for ${account.profile}/${tool}`);
1249
+ if (!existsSync(profileDir))
1250
+ throw new Error(`account profile directory does not exist for ${account.profile}/${tool}: ${profileDir}`);
1251
+ return {
1252
+ ...parseAccountExportLines(result.stdout),
1253
+ LOOPS_ACCOUNT_PROFILE: account.profile,
1254
+ LOOPS_ACCOUNT_TOOL: tool
1255
+ };
1256
+ }
1257
+
1258
+ // src/lib/executor.ts
697
1259
  var DEFAULT_TIMEOUT_MS = 30 * 60000;
698
1260
  var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
1261
+ var AUTH_ENV_KEYS = [
1262
+ "CLAUDE_CONFIG_DIR",
1263
+ "CODEWITH_HOME",
1264
+ "CODEX_HOME",
1265
+ "CURSOR_CONFIG_DIR",
1266
+ "OPENCODE_CONFIG_DIR",
1267
+ "AICOPILOT_CONFIG_DIR",
1268
+ "ANTHROPIC_API_KEY",
1269
+ "OPENAI_API_KEY",
1270
+ "OPENROUTER_API_KEY",
1271
+ "GITHUB_TOKEN",
1272
+ "GH_TOKEN",
1273
+ "XDG_CONFIG_HOME",
1274
+ "XDG_DATA_HOME",
1275
+ "XDG_STATE_HOME",
1276
+ "XDG_CACHE_HOME"
1277
+ ];
699
1278
  function appendBounded(current, chunk, maxBytes) {
700
1279
  const next = current + chunk.toString("utf8");
701
1280
  if (Buffer.byteLength(next, "utf8") <= maxBytes)
@@ -734,6 +1313,8 @@ function providerCommand(provider) {
734
1313
  return "aicopilot";
735
1314
  case "opencode":
736
1315
  return "opencode";
1316
+ case "codex":
1317
+ return "codex";
737
1318
  }
738
1319
  }
739
1320
  function agentArgs(target) {
@@ -770,6 +1351,16 @@ function agentArgs(target) {
770
1351
  args.push("--agent", target.agent);
771
1352
  args.push(...target.extraArgs ?? [], target.prompt);
772
1353
  return args;
1354
+ case "codex":
1355
+ args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
1356
+ if (isolation === "safe")
1357
+ args.push("--ignore-rules");
1358
+ if (target.cwd)
1359
+ args.push("--cd", target.cwd);
1360
+ if (target.model)
1361
+ args.push("--model", target.model);
1362
+ args.push(...target.extraArgs ?? [], target.prompt);
1363
+ return args;
773
1364
  case "aicopilot":
774
1365
  args.push("run", "--format", "json");
775
1366
  if (isolation === "safe")
@@ -796,8 +1387,7 @@ function agentArgs(target) {
796
1387
  return args;
797
1388
  }
798
1389
  }
799
- function commandSpec(loop) {
800
- const target = loop.target;
1390
+ function commandSpec(target) {
801
1391
  if (target.type === "command") {
802
1392
  const commandTarget = target;
803
1393
  return {
@@ -806,7 +1396,9 @@ function commandSpec(loop) {
806
1396
  cwd: commandTarget.cwd,
807
1397
  shell: commandTarget.shell,
808
1398
  env: commandTarget.env,
809
- timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS
1399
+ timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
1400
+ account: commandTarget.account,
1401
+ accountTool: commandTarget.account?.tool
810
1402
  };
811
1403
  }
812
1404
  const agentTarget = target;
@@ -814,11 +1406,40 @@ function commandSpec(loop) {
814
1406
  command: providerCommand(agentTarget.provider),
815
1407
  args: agentArgs(agentTarget),
816
1408
  cwd: agentTarget.cwd,
817
- timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS
1409
+ timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
1410
+ account: agentTarget.account,
1411
+ accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider)
818
1412
  };
819
1413
  }
820
- async function executeLoop(loop, run, opts = {}) {
821
- const spec = commandSpec(loop);
1414
+ function executionEnv(spec, metadata, opts) {
1415
+ const env = { ...opts.env ?? process.env };
1416
+ if (spec.account) {
1417
+ const accountEnv = resolveAccountEnv(spec.account, spec.accountTool, env);
1418
+ for (const key of AUTH_ENV_KEYS)
1419
+ delete env[key];
1420
+ Object.assign(env, accountEnv);
1421
+ }
1422
+ Object.assign(env, spec.env ?? {});
1423
+ if (metadata.loopId)
1424
+ env.LOOPS_LOOP_ID = metadata.loopId;
1425
+ if (metadata.loopName)
1426
+ env.LOOPS_LOOP_NAME = metadata.loopName;
1427
+ if (metadata.runId)
1428
+ env.LOOPS_RUN_ID = metadata.runId;
1429
+ if (metadata.scheduledFor)
1430
+ env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
1431
+ if (metadata.workflowId)
1432
+ env.LOOPS_WORKFLOW_ID = metadata.workflowId;
1433
+ if (metadata.workflowName)
1434
+ env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
1435
+ if (metadata.workflowRunId)
1436
+ env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
1437
+ if (metadata.workflowStepId)
1438
+ env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1439
+ return env;
1440
+ }
1441
+ async function executeTarget(target, metadata = {}, opts = {}) {
1442
+ const spec = commandSpec(target);
822
1443
  const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
823
1444
  const startedAt = nowIso();
824
1445
  let stdout = "";
@@ -826,14 +1447,7 @@ async function executeLoop(loop, run, opts = {}) {
826
1447
  let timedOut = false;
827
1448
  let exitCode;
828
1449
  let error;
829
- const env = {
830
- ...opts.env ?? process.env,
831
- ...spec.env ?? {},
832
- LOOPS_LOOP_ID: loop.id,
833
- LOOPS_LOOP_NAME: loop.name,
834
- LOOPS_RUN_ID: run.id,
835
- LOOPS_SCHEDULED_FOR: run.scheduledFor
836
- };
1450
+ const env = executionEnv(spec, metadata, opts);
837
1451
  const child = spawn(spec.command, spec.args, {
838
1452
  cwd: spec.cwd,
839
1453
  env,
@@ -903,12 +1517,137 @@ async function executeLoop(loop, run, opts = {}) {
903
1517
  durationMs
904
1518
  };
905
1519
  }
1520
+ async function executeLoop(loop, run, opts = {}) {
1521
+ if (loop.target.type === "workflow") {
1522
+ throw new Error("workflow loop targets must be executed with executeLoopTarget");
1523
+ }
1524
+ return executeTarget(loop.target, {
1525
+ loopId: loop.id,
1526
+ loopName: loop.name,
1527
+ runId: run.id,
1528
+ scheduledFor: run.scheduledFor
1529
+ }, opts);
1530
+ }
1531
+
1532
+ // src/lib/workflow-runner.ts
1533
+ function targetWithStepAccount(step) {
1534
+ const account = step.account ?? step.target.account;
1535
+ if (!account)
1536
+ return step.target;
1537
+ return { ...step.target, account };
1538
+ }
1539
+ function workflowResult(workflowRun, status, startedAt, finishedAt, stdout, error) {
1540
+ return {
1541
+ status,
1542
+ exitCode: status === "succeeded" ? 0 : 1,
1543
+ stdout,
1544
+ stderr: "",
1545
+ error,
1546
+ startedAt,
1547
+ finishedAt,
1548
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime()
1549
+ };
1550
+ }
1551
+ async function executeWorkflow(store, workflow, opts = {}) {
1552
+ const run = store.createWorkflowRun({
1553
+ workflow,
1554
+ loop: opts.loop,
1555
+ loopRun: opts.loopRun,
1556
+ scheduledFor: opts.scheduledFor,
1557
+ idempotencyKey: opts.idempotencyKey
1558
+ });
1559
+ const startedAt = run.startedAt ?? nowIso();
1560
+ if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out") {
1561
+ const steps2 = store.listWorkflowStepRuns(run.id);
1562
+ return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), JSON.stringify({ workflowRun: run, steps: steps2 }, null, 2), run.error);
1563
+ }
1564
+ const ordered = workflowExecutionOrder(workflow);
1565
+ const byId = new Map(workflow.steps.map((step) => [step.id, step]));
1566
+ let blockingError;
1567
+ let terminalStatus = "succeeded";
1568
+ for (const step of ordered) {
1569
+ const existing = store.getWorkflowStepRun(run.id, step.id);
1570
+ if (existing?.status === "succeeded" || existing?.status === "skipped")
1571
+ continue;
1572
+ const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
1573
+ const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
1574
+ const dependencyStep = byId.get(dependencyId);
1575
+ if (dependencyRun?.status === "succeeded")
1576
+ return false;
1577
+ return !dependencyStep?.continueOnFailure;
1578
+ });
1579
+ if (blockedBy) {
1580
+ store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`);
1581
+ blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
1582
+ terminalStatus = "failed";
1583
+ continue;
1584
+ }
1585
+ store.startWorkflowStepRun(run.id, step.id);
1586
+ const result = await executeTarget(targetWithStepAccount(step), {
1587
+ loopId: opts.loop?.id,
1588
+ loopName: opts.loop?.name,
1589
+ runId: opts.loopRun?.id,
1590
+ scheduledFor: opts.loopRun?.scheduledFor ?? opts.scheduledFor,
1591
+ workflowId: workflow.id,
1592
+ workflowName: workflow.name,
1593
+ workflowRunId: run.id,
1594
+ workflowStepId: step.id
1595
+ }, opts);
1596
+ store.finalizeWorkflowStepRun(run.id, step.id, {
1597
+ status: result.status,
1598
+ finishedAt: result.finishedAt,
1599
+ durationMs: result.durationMs,
1600
+ stdout: result.stdout,
1601
+ stderr: result.stderr,
1602
+ exitCode: result.exitCode,
1603
+ error: result.error
1604
+ });
1605
+ if (result.status !== "succeeded" && !step.continueOnFailure) {
1606
+ terminalStatus = result.status;
1607
+ blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
1608
+ break;
1609
+ }
1610
+ }
1611
+ if (terminalStatus !== "succeeded") {
1612
+ for (const step of ordered) {
1613
+ const existing = store.getWorkflowStepRun(run.id, step.id);
1614
+ if (existing?.status === "pending" || existing?.status === "running") {
1615
+ store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run");
1616
+ }
1617
+ }
1618
+ }
1619
+ const finishedAt = nowIso();
1620
+ const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
1621
+ finishedAt,
1622
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
1623
+ error: blockingError
1624
+ });
1625
+ const steps = store.listWorkflowStepRuns(run.id);
1626
+ return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
1627
+ }
1628
+ async function executeLoopTarget(store, loop, run, opts = {}) {
1629
+ if (loop.target.type !== "workflow")
1630
+ return executeLoop(loop, run, opts);
1631
+ const workflow = store.requireWorkflow(loop.target.workflowId);
1632
+ return executeWorkflow(store, workflow, {
1633
+ ...opts,
1634
+ loop,
1635
+ loopRun: run,
1636
+ scheduledFor: run.scheduledFor,
1637
+ idempotencyKey: `${loop.id}:${run.scheduledFor}`
1638
+ });
1639
+ }
906
1640
 
907
1641
  // src/lib/scheduler.ts
908
1642
  function nextAfterRetry(loop, now) {
909
1643
  return new Date(now.getTime() + loop.retryDelayMs).toISOString();
910
1644
  }
911
1645
  function advanceLoop(store, loop, run, finishedAt, succeeded) {
1646
+ if (run.status === "running")
1647
+ return;
1648
+ const current = store.getLoop(loop.id);
1649
+ if (!current || current.status !== "active")
1650
+ return;
912
1651
  const shouldRetry = !succeeded && run.attempt < loop.maxAttempts;
913
1652
  if (shouldRetry) {
914
1653
  store.updateLoop(loop.id, {
@@ -937,8 +1676,14 @@ async function runSlot(deps, loop, scheduledFor) {
937
1676
  if (!claim)
938
1677
  return;
939
1678
  deps.onRun?.(claim.run);
1679
+ let heartbeat;
1680
+ const heartbeatEveryMs = Math.max(1000, Math.min(60000, Math.floor(claim.loop.leaseMs / 3)));
1681
+ heartbeat = setInterval(() => {
1682
+ deps.store.heartbeatRunLease(claim.run.id, deps.runnerId, claim.loop.leaseMs);
1683
+ }, heartbeatEveryMs);
1684
+ heartbeat.unref();
940
1685
  try {
941
- const result = await (deps.execute ?? executeLoop)(claim.loop, claim.run);
1686
+ const result = await (deps.execute ?? ((loop2, run) => executeLoopTarget(deps.store, loop2, run)))(claim.loop, claim.run);
942
1687
  const finalRun = deps.store.finalizeRun(claim.run.id, {
943
1688
  status: result.status,
944
1689
  finishedAt: result.finishedAt,
@@ -948,8 +1693,11 @@ async function runSlot(deps, loop, scheduledFor) {
948
1693
  exitCode: result.exitCode,
949
1694
  error: result.error,
950
1695
  pid: result.pid
1696
+ }, {
1697
+ claimedBy: deps.runnerId,
1698
+ now: deps.now?.() ?? new Date(result.finishedAt)
951
1699
  });
952
- advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), result.status === "succeeded");
1700
+ advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), finalRun.status === "succeeded");
953
1701
  deps.onRun?.(finalRun);
954
1702
  return finalRun;
955
1703
  } catch (err) {
@@ -962,10 +1710,16 @@ async function runSlot(deps, loop, scheduledFor) {
962
1710
  stdout: "",
963
1711
  stderr: "",
964
1712
  error: err instanceof Error ? err.message : String(err)
1713
+ }, {
1714
+ claimedBy: deps.runnerId,
1715
+ now: deps.now?.() ?? finishedAt
965
1716
  });
966
1717
  advanceLoop(deps.store, claim.loop, finalRun, finishedAt, false);
967
1718
  deps.onRun?.(finalRun);
968
1719
  return finalRun;
1720
+ } finally {
1721
+ if (heartbeat)
1722
+ clearInterval(heartbeat);
969
1723
  }
970
1724
  }
971
1725
  async function tick(deps) {
@@ -992,13 +1746,15 @@ async function tick(deps) {
992
1746
  skipped.push(run);
993
1747
  else
994
1748
  completed.push(run);
1749
+ if (["failed", "timed_out", "abandoned"].includes(run.status) && run.attempt < loop.maxAttempts)
1750
+ break;
995
1751
  }
996
1752
  }
997
1753
  return { claimed, completed, skipped, recovered, expired };
998
1754
  }
999
1755
 
1000
1756
  // src/daemon/control.ts
1001
- import { existsSync, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
1757
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, rmSync, writeFileSync } from "fs";
1002
1758
  import { hostname } from "os";
1003
1759
  import { dirname as dirname2 } from "path";
1004
1760
 
@@ -1026,7 +1782,7 @@ async function runLoop(opts) {
1026
1782
 
1027
1783
  // src/daemon/control.ts
1028
1784
  function readPid(path = pidFilePath()) {
1029
- if (!existsSync(path))
1785
+ if (!existsSync2(path))
1030
1786
  return;
1031
1787
  try {
1032
1788
  const pid = Number(readFileSync(path, "utf8").trim());