@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.
package/dist/sdk/index.js CHANGED
@@ -234,6 +234,100 @@ 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
+ return value;
251
+ }
252
+ if (value.type === "agent") {
253
+ assertString(value.provider, `${label}.provider`);
254
+ assertString(value.prompt, `${label}.prompt`);
255
+ const providers = ["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"];
256
+ if (!providers.includes(value.provider))
257
+ throw new Error(`${label}.provider must be one of ${providers.join(", ")}`);
258
+ return value;
259
+ }
260
+ throw new Error(`${label}.type must be command or agent`);
261
+ }
262
+ function normalizeCreateWorkflowInput(input) {
263
+ assertString(input.name, "workflow.name");
264
+ if (!Array.isArray(input.steps) || input.steps.length === 0)
265
+ throw new Error("workflow.steps must contain at least one step");
266
+ const seen = new Set;
267
+ const steps = input.steps.map((step, index) => {
268
+ assertObject(step, `workflow.steps[${index}]`);
269
+ assertString(step.id, `workflow.steps[${index}].id`);
270
+ if (seen.has(step.id))
271
+ throw new Error(`duplicate workflow step id: ${step.id}`);
272
+ seen.add(step.id);
273
+ return {
274
+ ...step,
275
+ id: step.id,
276
+ target: validateTarget(step.target, `workflow.steps[${index}].target`),
277
+ dependsOn: step.dependsOn ?? [],
278
+ continueOnFailure: step.continueOnFailure ?? false
279
+ };
280
+ });
281
+ for (const step of steps) {
282
+ for (const dependency of step.dependsOn ?? []) {
283
+ if (!seen.has(dependency))
284
+ throw new Error(`step ${step.id} depends on missing step ${dependency}`);
285
+ if (dependency === step.id)
286
+ throw new Error(`step ${step.id} cannot depend on itself`);
287
+ }
288
+ }
289
+ workflowExecutionOrder({ steps });
290
+ return { ...input, name: input.name.trim(), version: input.version ?? 1, steps };
291
+ }
292
+ function workflowExecutionOrder(workflow) {
293
+ const byId = new Map(workflow.steps.map((step) => [step.id, step]));
294
+ const visiting = new Set;
295
+ const visited = new Set;
296
+ const order = [];
297
+ function visit(step) {
298
+ if (visited.has(step.id))
299
+ return;
300
+ if (visiting.has(step.id))
301
+ throw new Error(`workflow dependency cycle includes step ${step.id}`);
302
+ visiting.add(step.id);
303
+ for (const dependencyId of step.dependsOn ?? []) {
304
+ const dependency = byId.get(dependencyId);
305
+ if (!dependency)
306
+ throw new Error(`step ${step.id} depends on missing step ${dependencyId}`);
307
+ visit(dependency);
308
+ }
309
+ visiting.delete(step.id);
310
+ visited.add(step.id);
311
+ order.push(step);
312
+ }
313
+ for (const step of workflow.steps)
314
+ visit(step);
315
+ return order;
316
+ }
317
+ function workflowBodyFromJson(value, fallbackName) {
318
+ assertObject(value, "workflow file");
319
+ const rawName = fallbackName ?? value.name;
320
+ assertString(rawName, "workflow.name");
321
+ if (!Array.isArray(value.steps))
322
+ throw new Error("workflow.steps must be an array");
323
+ return normalizeCreateWorkflowInput({
324
+ name: rawName,
325
+ description: typeof value.description === "string" ? value.description : undefined,
326
+ version: typeof value.version === "number" ? value.version : undefined,
327
+ steps: value.steps
328
+ });
329
+ }
330
+
237
331
  // src/lib/store.ts
238
332
  function rowToLoop(row) {
239
333
  return {
@@ -278,6 +372,67 @@ function rowToRun(row) {
278
372
  updatedAt: row.updated_at
279
373
  };
280
374
  }
375
+ function rowToWorkflow(row) {
376
+ return {
377
+ id: row.id,
378
+ name: row.name,
379
+ description: row.description ?? undefined,
380
+ version: row.version,
381
+ status: row.status,
382
+ steps: JSON.parse(row.steps_json),
383
+ createdAt: row.created_at,
384
+ updatedAt: row.updated_at
385
+ };
386
+ }
387
+ function rowToWorkflowRun(row) {
388
+ return {
389
+ id: row.id,
390
+ workflowId: row.workflow_id,
391
+ workflowName: row.workflow_name,
392
+ loopId: row.loop_id ?? undefined,
393
+ loopRunId: row.loop_run_id ?? undefined,
394
+ scheduledFor: row.scheduled_for ?? undefined,
395
+ idempotencyKey: row.idempotency_key ?? undefined,
396
+ status: row.status,
397
+ startedAt: row.started_at ?? undefined,
398
+ finishedAt: row.finished_at ?? undefined,
399
+ durationMs: row.duration_ms ?? undefined,
400
+ error: row.error ?? undefined,
401
+ createdAt: row.created_at,
402
+ updatedAt: row.updated_at
403
+ };
404
+ }
405
+ function rowToWorkflowStepRun(row) {
406
+ return {
407
+ id: row.id,
408
+ workflowRunId: row.workflow_run_id,
409
+ stepId: row.step_id,
410
+ sequence: row.sequence,
411
+ status: row.status,
412
+ startedAt: row.started_at ?? undefined,
413
+ finishedAt: row.finished_at ?? undefined,
414
+ exitCode: row.exit_code ?? undefined,
415
+ durationMs: row.duration_ms ?? undefined,
416
+ stdout: row.stdout ?? undefined,
417
+ stderr: row.stderr ?? undefined,
418
+ error: row.error ?? undefined,
419
+ accountProfile: row.account_profile ?? undefined,
420
+ accountTool: row.account_tool ?? undefined,
421
+ createdAt: row.created_at,
422
+ updatedAt: row.updated_at
423
+ };
424
+ }
425
+ function rowToWorkflowEvent(row) {
426
+ return {
427
+ id: row.id,
428
+ workflowRunId: row.workflow_run_id,
429
+ sequence: row.sequence,
430
+ eventType: row.event_type,
431
+ stepId: row.step_id ?? undefined,
432
+ payload: row.payload_json ? JSON.parse(row.payload_json) : undefined,
433
+ createdAt: row.created_at
434
+ };
435
+ }
281
436
  function rowToLease(row) {
282
437
  return {
283
438
  id: row.id,
@@ -303,6 +458,11 @@ class Store {
303
458
  }
304
459
  migrate() {
305
460
  this.db.exec(`
461
+ CREATE TABLE IF NOT EXISTS schema_migrations (
462
+ id TEXT PRIMARY KEY,
463
+ applied_at TEXT NOT NULL
464
+ );
465
+
306
466
  CREATE TABLE IF NOT EXISTS loops (
307
467
  id TEXT PRIMARY KEY,
308
468
  name TEXT NOT NULL,
@@ -359,7 +519,78 @@ class Store {
359
519
  created_at TEXT NOT NULL,
360
520
  updated_at TEXT NOT NULL
361
521
  );
522
+
523
+ CREATE TABLE IF NOT EXISTS workflow_specs (
524
+ id TEXT PRIMARY KEY,
525
+ name TEXT NOT NULL,
526
+ description TEXT,
527
+ version INTEGER NOT NULL,
528
+ status TEXT NOT NULL,
529
+ steps_json TEXT NOT NULL,
530
+ created_at TEXT NOT NULL,
531
+ updated_at TEXT NOT NULL
532
+ );
533
+ CREATE INDEX IF NOT EXISTS idx_workflows_status_name ON workflow_specs(status, name);
534
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflows_name_active ON workflow_specs(name) WHERE status = 'active';
535
+
536
+ CREATE TABLE IF NOT EXISTS workflow_runs (
537
+ id TEXT PRIMARY KEY,
538
+ workflow_id TEXT NOT NULL REFERENCES workflow_specs(id) ON DELETE CASCADE,
539
+ workflow_name TEXT NOT NULL,
540
+ loop_id TEXT REFERENCES loops(id) ON DELETE SET NULL,
541
+ loop_run_id TEXT REFERENCES loop_runs(id) ON DELETE SET NULL,
542
+ scheduled_for TEXT,
543
+ idempotency_key TEXT,
544
+ status TEXT NOT NULL,
545
+ started_at TEXT,
546
+ finished_at TEXT,
547
+ duration_ms INTEGER,
548
+ error TEXT,
549
+ created_at TEXT NOT NULL,
550
+ updated_at TEXT NOT NULL
551
+ );
552
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_runs_idempotency
553
+ ON workflow_runs(workflow_id, idempotency_key)
554
+ WHERE idempotency_key IS NOT NULL;
555
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow_created ON workflow_runs(workflow_id, created_at DESC);
556
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_loop_run ON workflow_runs(loop_run_id);
557
+ CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
558
+
559
+ CREATE TABLE IF NOT EXISTS workflow_step_runs (
560
+ id TEXT PRIMARY KEY,
561
+ workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
562
+ step_id TEXT NOT NULL,
563
+ sequence INTEGER NOT NULL,
564
+ status TEXT NOT NULL,
565
+ started_at TEXT,
566
+ finished_at TEXT,
567
+ exit_code INTEGER,
568
+ duration_ms INTEGER,
569
+ stdout TEXT,
570
+ stderr TEXT,
571
+ error TEXT,
572
+ account_profile TEXT,
573
+ account_tool TEXT,
574
+ created_at TEXT NOT NULL,
575
+ updated_at TEXT NOT NULL,
576
+ UNIQUE(workflow_run_id, step_id)
577
+ );
578
+ CREATE INDEX IF NOT EXISTS idx_workflow_step_runs_run_sequence ON workflow_step_runs(workflow_run_id, sequence);
579
+ CREATE INDEX IF NOT EXISTS idx_workflow_step_runs_status ON workflow_step_runs(status);
580
+
581
+ CREATE TABLE IF NOT EXISTS workflow_events (
582
+ id TEXT PRIMARY KEY,
583
+ workflow_run_id TEXT NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
584
+ sequence INTEGER NOT NULL,
585
+ event_type TEXT NOT NULL,
586
+ step_id TEXT,
587
+ payload_json TEXT,
588
+ created_at TEXT NOT NULL,
589
+ UNIQUE(workflow_run_id, sequence)
590
+ );
591
+ CREATE INDEX IF NOT EXISTS idx_workflow_events_run_sequence ON workflow_events(workflow_run_id, sequence);
362
592
  `);
593
+ this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run("0001_initial_and_workflows", nowIso());
363
594
  }
364
595
  createLoop(input, from = new Date) {
365
596
  const now = nowIso();
@@ -451,6 +682,244 @@ class Store {
451
682
  const res = this.db.query("DELETE FROM loops WHERE id = ?").run(loop.id);
452
683
  return res.changes > 0;
453
684
  }
685
+ createWorkflow(input) {
686
+ const normalized = normalizeCreateWorkflowInput(input);
687
+ const now = nowIso();
688
+ const workflow = {
689
+ id: genId(),
690
+ name: normalized.name,
691
+ description: normalized.description,
692
+ version: normalized.version ?? 1,
693
+ status: "active",
694
+ steps: normalized.steps,
695
+ createdAt: now,
696
+ updatedAt: now
697
+ };
698
+ this.db.query(`INSERT INTO workflow_specs (id, name, description, version, status, steps_json, created_at, updated_at)
699
+ VALUES ($id, $name, $description, $version, $status, $steps, $created, $updated)`).run({
700
+ $id: workflow.id,
701
+ $name: workflow.name,
702
+ $description: workflow.description ?? null,
703
+ $version: workflow.version,
704
+ $status: workflow.status,
705
+ $steps: JSON.stringify(workflow.steps),
706
+ $created: workflow.createdAt,
707
+ $updated: workflow.updatedAt
708
+ });
709
+ return workflow;
710
+ }
711
+ getWorkflow(id) {
712
+ const row = this.db.query("SELECT * FROM workflow_specs WHERE id = ?").get(id);
713
+ return row ? rowToWorkflow(row) : undefined;
714
+ }
715
+ findWorkflowByName(name) {
716
+ const row = this.db.query("SELECT * FROM workflow_specs WHERE name = ? AND status = 'active' ORDER BY updated_at DESC LIMIT 1").get(name);
717
+ return row ? rowToWorkflow(row) : undefined;
718
+ }
719
+ requireWorkflow(idOrName) {
720
+ return this.getWorkflow(idOrName) ?? this.findWorkflowByName(idOrName) ?? (() => {
721
+ throw new Error(`workflow not found: ${idOrName}`);
722
+ })();
723
+ }
724
+ listWorkflows(opts = {}) {
725
+ const limit = opts.limit ?? 200;
726
+ 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);
727
+ return rows.map(rowToWorkflow);
728
+ }
729
+ archiveWorkflow(idOrName) {
730
+ const workflow = this.requireWorkflow(idOrName);
731
+ const updated = nowIso();
732
+ this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=?").run(updated, workflow.id);
733
+ const archived = this.getWorkflow(workflow.id);
734
+ if (!archived)
735
+ throw new Error(`workflow not found after archive: ${workflow.id}`);
736
+ return archived;
737
+ }
738
+ createWorkflowRun(input) {
739
+ const now = nowIso();
740
+ if (input.idempotencyKey) {
741
+ const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
742
+ if (existing)
743
+ return rowToWorkflowRun(existing);
744
+ }
745
+ this.db.exec("BEGIN IMMEDIATE");
746
+ try {
747
+ if (input.idempotencyKey) {
748
+ const existing = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? AND idempotency_key = ? LIMIT 1").get(input.workflow.id, input.idempotencyKey);
749
+ if (existing) {
750
+ this.db.exec("COMMIT");
751
+ return rowToWorkflowRun(existing);
752
+ }
753
+ }
754
+ const runId = genId();
755
+ this.db.query(`INSERT INTO workflow_runs (id, workflow_id, workflow_name, loop_id, loop_run_id, scheduled_for, idempotency_key,
756
+ status, started_at, finished_at, duration_ms, error, created_at, updated_at)
757
+ VALUES ($id, $workflowId, $workflowName, $loopId, $loopRunId, $scheduledFor, $idempotencyKey,
758
+ 'running', $started, NULL, NULL, NULL, $created, $updated)`).run({
759
+ $id: runId,
760
+ $workflowId: input.workflow.id,
761
+ $workflowName: input.workflow.name,
762
+ $loopId: input.loop?.id ?? null,
763
+ $loopRunId: input.loopRun?.id ?? null,
764
+ $scheduledFor: input.scheduledFor ?? input.loopRun?.scheduledFor ?? null,
765
+ $idempotencyKey: input.idempotencyKey ?? null,
766
+ $started: now,
767
+ $created: now,
768
+ $updated: now
769
+ });
770
+ input.workflow.steps.forEach((step, sequence) => {
771
+ const account = step.account ?? step.target.account;
772
+ this.db.query(`INSERT INTO workflow_step_runs (id, workflow_run_id, step_id, sequence, status, started_at, finished_at,
773
+ exit_code, duration_ms, stdout, stderr, error, account_profile, account_tool, created_at, updated_at)
774
+ VALUES ($id, $workflowRunId, $stepId, $sequence, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, NULL,
775
+ $accountProfile, $accountTool, $created, $updated)`).run({
776
+ $id: genId(),
777
+ $workflowRunId: runId,
778
+ $stepId: step.id,
779
+ $sequence: sequence,
780
+ $accountProfile: account?.profile ?? null,
781
+ $accountTool: account?.tool ?? null,
782
+ $created: now,
783
+ $updated: now
784
+ });
785
+ });
786
+ this.db.query(`INSERT INTO workflow_events (id, workflow_run_id, sequence, event_type, step_id, payload_json, created_at)
787
+ VALUES ($id, $workflowRunId, 1, 'created', NULL, $payload, $created)`).run({
788
+ $id: genId(),
789
+ $workflowRunId: runId,
790
+ $payload: JSON.stringify({
791
+ workflowId: input.workflow.id,
792
+ workflowName: input.workflow.name,
793
+ stepCount: input.workflow.steps.length,
794
+ loopId: input.loop?.id,
795
+ loopRunId: input.loopRun?.id
796
+ }),
797
+ $created: now
798
+ });
799
+ this.db.exec("COMMIT");
800
+ const run = this.getWorkflowRun(runId);
801
+ if (!run)
802
+ throw new Error(`workflow run not found after create: ${runId}`);
803
+ return run;
804
+ } catch (error) {
805
+ try {
806
+ this.db.exec("ROLLBACK");
807
+ } catch {}
808
+ throw error;
809
+ }
810
+ }
811
+ getWorkflowRun(id) {
812
+ const row = this.db.query("SELECT * FROM workflow_runs WHERE id = ?").get(id);
813
+ return row ? rowToWorkflowRun(row) : undefined;
814
+ }
815
+ listWorkflowRuns(opts = {}) {
816
+ const limit = opts.limit ?? 100;
817
+ let rows;
818
+ if (opts.workflowId) {
819
+ rows = this.db.query("SELECT * FROM workflow_runs WHERE workflow_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.workflowId, limit);
820
+ } else if (opts.loopRunId) {
821
+ rows = this.db.query("SELECT * FROM workflow_runs WHERE loop_run_id = ? ORDER BY created_at DESC LIMIT ?").all(opts.loopRunId, limit);
822
+ } else {
823
+ rows = this.db.query("SELECT * FROM workflow_runs ORDER BY created_at DESC LIMIT ?").all(limit);
824
+ }
825
+ return rows.map(rowToWorkflowRun);
826
+ }
827
+ listWorkflowStepRuns(workflowRunId) {
828
+ const rows = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? ORDER BY sequence ASC").all(workflowRunId);
829
+ return rows.map(rowToWorkflowStepRun);
830
+ }
831
+ getWorkflowStepRun(workflowRunId, stepId) {
832
+ const row = this.db.query("SELECT * FROM workflow_step_runs WHERE workflow_run_id = ? AND step_id = ?").get(workflowRunId, stepId);
833
+ return row ? rowToWorkflowStepRun(row) : undefined;
834
+ }
835
+ startWorkflowStepRun(workflowRunId, stepId) {
836
+ const now = nowIso();
837
+ this.db.query(`UPDATE workflow_step_runs
838
+ SET status='running', started_at=$started, finished_at=NULL, exit_code=NULL, duration_ms=NULL,
839
+ stdout=NULL, stderr=NULL, error=NULL, updated_at=$updated
840
+ 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 });
841
+ this.appendWorkflowEvent(workflowRunId, "step_started", stepId);
842
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
843
+ if (!run)
844
+ throw new Error(`workflow step run not found: ${workflowRunId}/${stepId}`);
845
+ return run;
846
+ }
847
+ finalizeWorkflowStepRun(workflowRunId, stepId, patch) {
848
+ const finishedAt = patch.finishedAt ?? nowIso();
849
+ this.db.query(`UPDATE workflow_step_runs SET status=$status, finished_at=$finished, exit_code=$exitCode, duration_ms=$durationMs,
850
+ stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
851
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId`).run({
852
+ $workflowRunId: workflowRunId,
853
+ $stepId: stepId,
854
+ $status: patch.status,
855
+ $finished: finishedAt,
856
+ $exitCode: patch.exitCode ?? null,
857
+ $durationMs: patch.durationMs ?? null,
858
+ $stdout: patch.stdout ?? null,
859
+ $stderr: patch.stderr ?? null,
860
+ $error: patch.error ?? null,
861
+ $updated: finishedAt
862
+ });
863
+ this.appendWorkflowEvent(workflowRunId, `step_${patch.status}`, stepId, {
864
+ exitCode: patch.exitCode,
865
+ error: patch.error
866
+ });
867
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
868
+ if (!run)
869
+ throw new Error(`workflow step run not found after finalize: ${workflowRunId}/${stepId}`);
870
+ return run;
871
+ }
872
+ skipWorkflowStepRun(workflowRunId, stepId, reason) {
873
+ const now = nowIso();
874
+ this.db.query(`UPDATE workflow_step_runs SET status='skipped', finished_at=$finished, error=$error, updated_at=$updated
875
+ WHERE workflow_run_id=$workflowRunId AND step_id=$stepId`).run({ $workflowRunId: workflowRunId, $stepId: stepId, $finished: now, $error: reason, $updated: now });
876
+ this.appendWorkflowEvent(workflowRunId, "step_skipped", stepId, { reason });
877
+ const run = this.getWorkflowStepRun(workflowRunId, stepId);
878
+ if (!run)
879
+ throw new Error(`workflow step run not found after skip: ${workflowRunId}/${stepId}`);
880
+ return run;
881
+ }
882
+ finalizeWorkflowRun(workflowRunId, status, patch = {}) {
883
+ const finishedAt = patch.finishedAt ?? nowIso();
884
+ this.db.query(`UPDATE workflow_runs SET status=$status, finished_at=$finished, duration_ms=$durationMs, error=$error, updated_at=$updated
885
+ WHERE id=$id`).run({
886
+ $id: workflowRunId,
887
+ $status: status,
888
+ $finished: finishedAt,
889
+ $durationMs: patch.durationMs ?? null,
890
+ $error: patch.error ?? null,
891
+ $updated: finishedAt
892
+ });
893
+ this.appendWorkflowEvent(workflowRunId, status, undefined, { error: patch.error });
894
+ const run = this.getWorkflowRun(workflowRunId);
895
+ if (!run)
896
+ throw new Error(`workflow run not found after finalize: ${workflowRunId}`);
897
+ return run;
898
+ }
899
+ appendWorkflowEvent(workflowRunId, eventType, stepId, payload) {
900
+ const now = nowIso();
901
+ const current = this.db.query("SELECT MAX(sequence) AS sequence FROM workflow_events WHERE workflow_run_id = ?").get(workflowRunId);
902
+ const sequence = (current?.sequence ?? 0) + 1;
903
+ const id = genId();
904
+ this.db.query(`INSERT INTO workflow_events (id, workflow_run_id, sequence, event_type, step_id, payload_json, created_at)
905
+ VALUES ($id, $workflowRunId, $sequence, $eventType, $stepId, $payload, $created)`).run({
906
+ $id: id,
907
+ $workflowRunId: workflowRunId,
908
+ $sequence: sequence,
909
+ $eventType: eventType,
910
+ $stepId: stepId ?? null,
911
+ $payload: payload ? JSON.stringify(payload) : null,
912
+ $created: now
913
+ });
914
+ const event = this.db.query("SELECT * FROM workflow_events WHERE id = ?").get(id);
915
+ if (!event)
916
+ throw new Error(`workflow event not found after append: ${id}`);
917
+ return rowToWorkflowEvent(event);
918
+ }
919
+ listWorkflowEvents(workflowRunId, limit = 200) {
920
+ const rows = this.db.query("SELECT * FROM workflow_events WHERE workflow_run_id = ? ORDER BY sequence ASC LIMIT ?").all(workflowRunId, limit);
921
+ return rows.map(rowToWorkflowEvent);
922
+ }
454
923
  hasRunningRun(loopId) {
455
924
  const row = this.db.query("SELECT COUNT(*) AS count FROM loop_runs WHERE loop_id = ? AND status = 'running'").get(loopId);
456
925
  return (row?.count ?? 0) > 0;
@@ -571,10 +1040,9 @@ class Store {
571
1040
  throw error;
572
1041
  }
573
1042
  }
574
- finalizeRun(id, patch) {
1043
+ finalizeRun(id, patch, opts = {}) {
575
1044
  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({
1045
+ const params = {
578
1046
  $id: id,
579
1047
  $status: patch.status,
580
1048
  $finished: finishedAt,
@@ -584,13 +1052,29 @@ class Store {
584
1052
  $stdout: patch.stdout ?? null,
585
1053
  $stderr: patch.stderr ?? null,
586
1054
  $error: patch.error ?? null,
587
- $updated: finishedAt
588
- });
1055
+ $updated: finishedAt,
1056
+ $claimedBy: opts.claimedBy ?? null,
1057
+ $now: (opts.now ?? new Date).toISOString()
1058
+ };
1059
+ 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,
1060
+ duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated
1061
+ 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,
1062
+ duration_ms=$durationMs, stdout=$stdout, stderr=$stderr, error=$error, updated_at=$updated WHERE id=$id`).run(params);
589
1063
  const run = this.getRun(id);
590
1064
  if (!run)
591
1065
  throw new Error(`run not found after finalize: ${id}`);
1066
+ if (opts.claimedBy && res.changes !== 1)
1067
+ return run;
592
1068
  return run;
593
1069
  }
1070
+ heartbeatRunLease(id, claimedBy, leaseMs, now = new Date) {
1071
+ const expiresAt = new Date(now.getTime() + leaseMs).toISOString();
1072
+ const res = this.db.query(`UPDATE loop_runs SET lease_expires_at=$expires, updated_at=$updated
1073
+ WHERE id=$id AND status='running' AND claimed_by=$claimedBy`).run({ $id: id, $claimedBy: claimedBy, $expires: expiresAt, $updated: now.toISOString() });
1074
+ if (res.changes !== 1)
1075
+ return;
1076
+ return this.getRun(id);
1077
+ }
594
1078
  listRuns(opts = {}) {
595
1079
  const limit = opts.limit ?? 100;
596
1080
  let rows;
@@ -684,8 +1168,103 @@ class Store {
684
1168
  // src/lib/executor.ts
685
1169
  import { spawn } from "child_process";
686
1170
  import { once } from "events";
1171
+
1172
+ // src/lib/accounts.ts
1173
+ import { spawnSync } from "child_process";
1174
+ import { existsSync } from "fs";
1175
+ var EXPORT_RE = /^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
1176
+ function accountToolForProvider(provider) {
1177
+ switch (provider) {
1178
+ case "claude":
1179
+ return "claude";
1180
+ case "cursor":
1181
+ return "cursor";
1182
+ case "codewith":
1183
+ return "codewith";
1184
+ case "aicopilot":
1185
+ return "aicopilot";
1186
+ case "opencode":
1187
+ return "opencode";
1188
+ case "codex":
1189
+ return "codex";
1190
+ }
1191
+ }
1192
+ function parseExportValue(raw) {
1193
+ try {
1194
+ return JSON.parse(raw);
1195
+ } catch {
1196
+ return raw.replace(/^['"]|['"]$/g, "");
1197
+ }
1198
+ }
1199
+ function parseAccountExportLines(output) {
1200
+ const env = {};
1201
+ for (const line of output.split(/\r?\n/)) {
1202
+ const match = EXPORT_RE.exec(line.trim());
1203
+ if (!match)
1204
+ continue;
1205
+ env[match[1]] = parseExportValue(match[2]);
1206
+ }
1207
+ return env;
1208
+ }
1209
+ function primaryAccountDir(output) {
1210
+ for (const line of output.split(/\r?\n/)) {
1211
+ const match = EXPORT_RE.exec(line.trim());
1212
+ if (!match)
1213
+ continue;
1214
+ return parseExportValue(match[2]);
1215
+ }
1216
+ return;
1217
+ }
1218
+ function resolveAccountEnv(account, toolHint, env) {
1219
+ if (!account)
1220
+ return {};
1221
+ const tool = account.tool ?? toolHint;
1222
+ if (!tool)
1223
+ throw new Error("account.tool is required when no provider tool can be inferred");
1224
+ const result = spawnSync("accounts", ["env", account.profile, "--tool", tool], {
1225
+ encoding: "utf8",
1226
+ env,
1227
+ stdio: ["ignore", "pipe", "pipe"]
1228
+ });
1229
+ if (result.error) {
1230
+ throw new Error(`failed to run accounts env for ${account.profile}/${tool}: ${result.error.message}`);
1231
+ }
1232
+ if ((result.status ?? 0) !== 0) {
1233
+ const stderr = result.stderr.trim();
1234
+ throw new Error(`accounts env failed for ${account.profile}/${tool}${stderr ? `: ${stderr}` : ""}`);
1235
+ }
1236
+ const profileDir = primaryAccountDir(result.stdout);
1237
+ if (!profileDir)
1238
+ throw new Error(`accounts env returned no profile directory for ${account.profile}/${tool}`);
1239
+ if (!existsSync(profileDir))
1240
+ throw new Error(`account profile directory does not exist for ${account.profile}/${tool}: ${profileDir}`);
1241
+ return {
1242
+ ...parseAccountExportLines(result.stdout),
1243
+ LOOPS_ACCOUNT_PROFILE: account.profile,
1244
+ LOOPS_ACCOUNT_TOOL: tool
1245
+ };
1246
+ }
1247
+
1248
+ // src/lib/executor.ts
687
1249
  var DEFAULT_TIMEOUT_MS = 30 * 60000;
688
1250
  var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
1251
+ var AUTH_ENV_KEYS = [
1252
+ "CLAUDE_CONFIG_DIR",
1253
+ "CODEWITH_HOME",
1254
+ "CODEX_HOME",
1255
+ "CURSOR_CONFIG_DIR",
1256
+ "OPENCODE_CONFIG_DIR",
1257
+ "AICOPILOT_CONFIG_DIR",
1258
+ "ANTHROPIC_API_KEY",
1259
+ "OPENAI_API_KEY",
1260
+ "OPENROUTER_API_KEY",
1261
+ "GITHUB_TOKEN",
1262
+ "GH_TOKEN",
1263
+ "XDG_CONFIG_HOME",
1264
+ "XDG_DATA_HOME",
1265
+ "XDG_STATE_HOME",
1266
+ "XDG_CACHE_HOME"
1267
+ ];
689
1268
  function appendBounded(current, chunk, maxBytes) {
690
1269
  const next = current + chunk.toString("utf8");
691
1270
  if (Buffer.byteLength(next, "utf8") <= maxBytes)
@@ -724,6 +1303,8 @@ function providerCommand(provider) {
724
1303
  return "aicopilot";
725
1304
  case "opencode":
726
1305
  return "opencode";
1306
+ case "codex":
1307
+ return "codex";
727
1308
  }
728
1309
  }
729
1310
  function agentArgs(target) {
@@ -760,6 +1341,16 @@ function agentArgs(target) {
760
1341
  args.push("--agent", target.agent);
761
1342
  args.push(...target.extraArgs ?? [], target.prompt);
762
1343
  return args;
1344
+ case "codex":
1345
+ args.push("exec", "--json", "--ephemeral", "--ask-for-approval", "never", "--sandbox", "workspace-write");
1346
+ if (isolation === "safe")
1347
+ args.push("--ignore-rules");
1348
+ if (target.cwd)
1349
+ args.push("--cd", target.cwd);
1350
+ if (target.model)
1351
+ args.push("--model", target.model);
1352
+ args.push(...target.extraArgs ?? [], target.prompt);
1353
+ return args;
763
1354
  case "aicopilot":
764
1355
  args.push("run", "--format", "json");
765
1356
  if (isolation === "safe")
@@ -786,8 +1377,7 @@ function agentArgs(target) {
786
1377
  return args;
787
1378
  }
788
1379
  }
789
- function commandSpec(loop) {
790
- const target = loop.target;
1380
+ function commandSpec(target) {
791
1381
  if (target.type === "command") {
792
1382
  const commandTarget = target;
793
1383
  return {
@@ -796,7 +1386,9 @@ function commandSpec(loop) {
796
1386
  cwd: commandTarget.cwd,
797
1387
  shell: commandTarget.shell,
798
1388
  env: commandTarget.env,
799
- timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS
1389
+ timeoutMs: commandTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
1390
+ account: commandTarget.account,
1391
+ accountTool: commandTarget.account?.tool
800
1392
  };
801
1393
  }
802
1394
  const agentTarget = target;
@@ -804,11 +1396,40 @@ function commandSpec(loop) {
804
1396
  command: providerCommand(agentTarget.provider),
805
1397
  args: agentArgs(agentTarget),
806
1398
  cwd: agentTarget.cwd,
807
- timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS
1399
+ timeoutMs: agentTarget.timeoutMs ?? DEFAULT_TIMEOUT_MS,
1400
+ account: agentTarget.account,
1401
+ accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider)
808
1402
  };
809
1403
  }
810
- async function executeLoop(loop, run, opts = {}) {
811
- const spec = commandSpec(loop);
1404
+ function executionEnv(spec, metadata, opts) {
1405
+ const env = { ...opts.env ?? process.env };
1406
+ if (spec.account) {
1407
+ const accountEnv = resolveAccountEnv(spec.account, spec.accountTool, env);
1408
+ for (const key of AUTH_ENV_KEYS)
1409
+ delete env[key];
1410
+ Object.assign(env, accountEnv);
1411
+ }
1412
+ Object.assign(env, spec.env ?? {});
1413
+ if (metadata.loopId)
1414
+ env.LOOPS_LOOP_ID = metadata.loopId;
1415
+ if (metadata.loopName)
1416
+ env.LOOPS_LOOP_NAME = metadata.loopName;
1417
+ if (metadata.runId)
1418
+ env.LOOPS_RUN_ID = metadata.runId;
1419
+ if (metadata.scheduledFor)
1420
+ env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
1421
+ if (metadata.workflowId)
1422
+ env.LOOPS_WORKFLOW_ID = metadata.workflowId;
1423
+ if (metadata.workflowName)
1424
+ env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
1425
+ if (metadata.workflowRunId)
1426
+ env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
1427
+ if (metadata.workflowStepId)
1428
+ env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
1429
+ return env;
1430
+ }
1431
+ async function executeTarget(target, metadata = {}, opts = {}) {
1432
+ const spec = commandSpec(target);
812
1433
  const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
813
1434
  const startedAt = nowIso();
814
1435
  let stdout = "";
@@ -816,14 +1437,7 @@ async function executeLoop(loop, run, opts = {}) {
816
1437
  let timedOut = false;
817
1438
  let exitCode;
818
1439
  let error;
819
- const env = {
820
- ...opts.env ?? process.env,
821
- ...spec.env ?? {},
822
- LOOPS_LOOP_ID: loop.id,
823
- LOOPS_LOOP_NAME: loop.name,
824
- LOOPS_RUN_ID: run.id,
825
- LOOPS_SCHEDULED_FOR: run.scheduledFor
826
- };
1440
+ const env = executionEnv(spec, metadata, opts);
827
1441
  const child = spawn(spec.command, spec.args, {
828
1442
  cwd: spec.cwd,
829
1443
  env,
@@ -893,12 +1507,137 @@ async function executeLoop(loop, run, opts = {}) {
893
1507
  durationMs
894
1508
  };
895
1509
  }
1510
+ async function executeLoop(loop, run, opts = {}) {
1511
+ if (loop.target.type === "workflow") {
1512
+ throw new Error("workflow loop targets must be executed with executeLoopTarget");
1513
+ }
1514
+ return executeTarget(loop.target, {
1515
+ loopId: loop.id,
1516
+ loopName: loop.name,
1517
+ runId: run.id,
1518
+ scheduledFor: run.scheduledFor
1519
+ }, opts);
1520
+ }
1521
+
1522
+ // src/lib/workflow-runner.ts
1523
+ function targetWithStepAccount(step) {
1524
+ const account = step.account ?? step.target.account;
1525
+ if (!account)
1526
+ return step.target;
1527
+ return { ...step.target, account };
1528
+ }
1529
+ function workflowResult(workflowRun, status, startedAt, finishedAt, stdout, error) {
1530
+ return {
1531
+ status,
1532
+ exitCode: status === "succeeded" ? 0 : 1,
1533
+ stdout,
1534
+ stderr: "",
1535
+ error,
1536
+ startedAt,
1537
+ finishedAt,
1538
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime()
1539
+ };
1540
+ }
1541
+ async function executeWorkflow(store, workflow, opts = {}) {
1542
+ const run = store.createWorkflowRun({
1543
+ workflow,
1544
+ loop: opts.loop,
1545
+ loopRun: opts.loopRun,
1546
+ scheduledFor: opts.scheduledFor,
1547
+ idempotencyKey: opts.idempotencyKey
1548
+ });
1549
+ const startedAt = run.startedAt ?? nowIso();
1550
+ if (run.status === "succeeded" || run.status === "failed" || run.status === "timed_out") {
1551
+ const steps2 = store.listWorkflowStepRuns(run.id);
1552
+ return workflowResult(run, run.status, startedAt, run.finishedAt ?? nowIso(), JSON.stringify({ workflowRun: run, steps: steps2 }, null, 2), run.error);
1553
+ }
1554
+ const ordered = workflowExecutionOrder(workflow);
1555
+ const byId = new Map(workflow.steps.map((step) => [step.id, step]));
1556
+ let blockingError;
1557
+ let terminalStatus = "succeeded";
1558
+ for (const step of ordered) {
1559
+ const existing = store.getWorkflowStepRun(run.id, step.id);
1560
+ if (existing?.status === "succeeded" || existing?.status === "skipped")
1561
+ continue;
1562
+ const blockedBy = (step.dependsOn ?? []).find((dependencyId) => {
1563
+ const dependencyRun = store.getWorkflowStepRun(run.id, dependencyId);
1564
+ const dependencyStep = byId.get(dependencyId);
1565
+ if (dependencyRun?.status === "succeeded")
1566
+ return false;
1567
+ return !dependencyStep?.continueOnFailure;
1568
+ });
1569
+ if (blockedBy) {
1570
+ store.skipWorkflowStepRun(run.id, step.id, `dependency did not succeed: ${blockedBy}`);
1571
+ blockingError ??= `step ${step.id} blocked by dependency ${blockedBy}`;
1572
+ terminalStatus = "failed";
1573
+ continue;
1574
+ }
1575
+ store.startWorkflowStepRun(run.id, step.id);
1576
+ const result = await executeTarget(targetWithStepAccount(step), {
1577
+ loopId: opts.loop?.id,
1578
+ loopName: opts.loop?.name,
1579
+ runId: opts.loopRun?.id,
1580
+ scheduledFor: opts.loopRun?.scheduledFor ?? opts.scheduledFor,
1581
+ workflowId: workflow.id,
1582
+ workflowName: workflow.name,
1583
+ workflowRunId: run.id,
1584
+ workflowStepId: step.id
1585
+ }, opts);
1586
+ store.finalizeWorkflowStepRun(run.id, step.id, {
1587
+ status: result.status,
1588
+ finishedAt: result.finishedAt,
1589
+ durationMs: result.durationMs,
1590
+ stdout: result.stdout,
1591
+ stderr: result.stderr,
1592
+ exitCode: result.exitCode,
1593
+ error: result.error
1594
+ });
1595
+ if (result.status !== "succeeded" && !step.continueOnFailure) {
1596
+ terminalStatus = result.status;
1597
+ blockingError = `step ${step.id} ${result.status}${result.error ? `: ${result.error}` : ""}`;
1598
+ break;
1599
+ }
1600
+ }
1601
+ if (terminalStatus !== "succeeded") {
1602
+ for (const step of ordered) {
1603
+ const existing = store.getWorkflowStepRun(run.id, step.id);
1604
+ if (existing?.status === "pending" || existing?.status === "running") {
1605
+ store.skipWorkflowStepRun(run.id, step.id, blockingError ?? "workflow stopped before step could run");
1606
+ }
1607
+ }
1608
+ }
1609
+ const finishedAt = nowIso();
1610
+ const finalRun = store.finalizeWorkflowRun(run.id, terminalStatus, {
1611
+ finishedAt,
1612
+ durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
1613
+ error: blockingError
1614
+ });
1615
+ const steps = store.listWorkflowStepRuns(run.id);
1616
+ return workflowResult(finalRun, terminalStatus, startedAt, finishedAt, JSON.stringify({ workflowRun: finalRun, steps }, null, 2), blockingError);
1617
+ }
1618
+ async function executeLoopTarget(store, loop, run, opts = {}) {
1619
+ if (loop.target.type !== "workflow")
1620
+ return executeLoop(loop, run, opts);
1621
+ const workflow = store.requireWorkflow(loop.target.workflowId);
1622
+ return executeWorkflow(store, workflow, {
1623
+ ...opts,
1624
+ loop,
1625
+ loopRun: run,
1626
+ scheduledFor: run.scheduledFor,
1627
+ idempotencyKey: `${loop.id}:${run.scheduledFor}`
1628
+ });
1629
+ }
896
1630
 
897
1631
  // src/lib/scheduler.ts
898
1632
  function nextAfterRetry(loop, now) {
899
1633
  return new Date(now.getTime() + loop.retryDelayMs).toISOString();
900
1634
  }
901
1635
  function advanceLoop(store, loop, run, finishedAt, succeeded) {
1636
+ if (run.status === "running")
1637
+ return;
1638
+ const current = store.getLoop(loop.id);
1639
+ if (!current || current.status !== "active")
1640
+ return;
902
1641
  const shouldRetry = !succeeded && run.attempt < loop.maxAttempts;
903
1642
  if (shouldRetry) {
904
1643
  store.updateLoop(loop.id, {
@@ -927,8 +1666,14 @@ async function runSlot(deps, loop, scheduledFor) {
927
1666
  if (!claim)
928
1667
  return;
929
1668
  deps.onRun?.(claim.run);
1669
+ let heartbeat;
1670
+ const heartbeatEveryMs = Math.max(1000, Math.min(60000, Math.floor(claim.loop.leaseMs / 3)));
1671
+ heartbeat = setInterval(() => {
1672
+ deps.store.heartbeatRunLease(claim.run.id, deps.runnerId, claim.loop.leaseMs);
1673
+ }, heartbeatEveryMs);
1674
+ heartbeat.unref();
930
1675
  try {
931
- const result = await (deps.execute ?? executeLoop)(claim.loop, claim.run);
1676
+ const result = await (deps.execute ?? ((loop2, run) => executeLoopTarget(deps.store, loop2, run)))(claim.loop, claim.run);
932
1677
  const finalRun = deps.store.finalizeRun(claim.run.id, {
933
1678
  status: result.status,
934
1679
  finishedAt: result.finishedAt,
@@ -938,8 +1683,11 @@ async function runSlot(deps, loop, scheduledFor) {
938
1683
  exitCode: result.exitCode,
939
1684
  error: result.error,
940
1685
  pid: result.pid
1686
+ }, {
1687
+ claimedBy: deps.runnerId,
1688
+ now: deps.now?.() ?? new Date(result.finishedAt)
941
1689
  });
942
- advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), result.status === "succeeded");
1690
+ advanceLoop(deps.store, claim.loop, finalRun, new Date(result.finishedAt), finalRun.status === "succeeded");
943
1691
  deps.onRun?.(finalRun);
944
1692
  return finalRun;
945
1693
  } catch (err) {
@@ -952,10 +1700,16 @@ async function runSlot(deps, loop, scheduledFor) {
952
1700
  stdout: "",
953
1701
  stderr: "",
954
1702
  error: err instanceof Error ? err.message : String(err)
1703
+ }, {
1704
+ claimedBy: deps.runnerId,
1705
+ now: deps.now?.() ?? finishedAt
955
1706
  });
956
1707
  advanceLoop(deps.store, claim.loop, finalRun, finishedAt, false);
957
1708
  deps.onRun?.(finalRun);
958
1709
  return finalRun;
1710
+ } finally {
1711
+ if (heartbeat)
1712
+ clearInterval(heartbeat);
959
1713
  }
960
1714
  }
961
1715
  async function tick(deps) {
@@ -982,6 +1736,8 @@ async function tick(deps) {
982
1736
  skipped.push(run);
983
1737
  else
984
1738
  completed.push(run);
1739
+ if (["failed", "timed_out", "abandoned"].includes(run.status) && run.attempt < loop.maxAttempts)
1740
+ break;
985
1741
  }
986
1742
  }
987
1743
  return { claimed, completed, skipped, recovered, expired };
@@ -1033,7 +1789,7 @@ class LoopsClient {
1033
1789
  const claim = this.store.claimRun(loop, scheduledFor, this.runnerId);
1034
1790
  if (!claim)
1035
1791
  throw new Error(`could not claim manual run for ${idOrName}`);
1036
- const result = await executeLoop(loop, claim.run);
1792
+ const result = await executeLoopTarget(this.store, loop, claim.run);
1037
1793
  return this.store.finalizeRun(claim.run.id, {
1038
1794
  status: result.status,
1039
1795
  finishedAt: result.finishedAt,
@@ -1043,6 +1799,8 @@ class LoopsClient {
1043
1799
  exitCode: result.exitCode,
1044
1800
  error: result.error,
1045
1801
  pid: result.pid
1802
+ }, {
1803
+ claimedBy: claim.run.claimedBy
1046
1804
  });
1047
1805
  }
1048
1806
  close() {