@osolmaz/pi-workflows 0.11.1 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -129,6 +129,11 @@ Then, from any pi conversation:
129
129
  /workflow echo summarize this repository
130
130
  ```
131
131
 
132
+ A model-started workflow is saved before the tool reports it as queued. The returned run ID works
133
+ with `workflow status` and `workflow cancel` before execution starts. Pi Workflows waits for the
134
+ current agent turn to settle before activation. If activation fails, it saves the failure and sends
135
+ one follow-up turn so the model can correct the cause and start a new run.
136
+
132
137
  `/workflow` with no arguments lists discovered workflows. `/workflow pause`
133
138
  lets the current step finish and then holds the run before the next node. This
134
139
  is useful when you want to interject in the conversation mid-workflow.
@@ -1,5 +1,6 @@
1
1
  import { type ControllerStore, type EffectReservation, type QueueItem, type QueueRequeueOptions, type WorkflowRecordUpdate, type WorkflowReservation } from "./store.js";
2
2
  import type { ChildWorkflowRecord, ControllerEvent, ControllerQueueClaim, ControllerResource, ControllerResourceRef, ControllerResourceStatus, EffectRecord, JsonObject } from "./types.js";
3
+ export type WorkflowRunLaunchStatus = "queued" | "starting" | "running" | "parked" | "done" | "failed" | "cancelled";
3
4
  /** A user-started workflow run tracked by the durable run queue. */
4
5
  export type WorkflowRunQueueRecord = {
5
6
  runId: string;
@@ -7,8 +8,11 @@ export type WorkflowRunQueueRecord = {
7
8
  workflowName: string;
8
9
  /** Canonical source reference used to reopen the run. */
9
10
  workflowSourceRef: string;
11
+ workflowSource: unknown;
12
+ definitionDigest: string;
10
13
  input: unknown;
11
- status: "claimed" | "parked" | "done";
14
+ launchOptions: unknown;
15
+ status: WorkflowRunLaunchStatus;
12
16
  runnerId: string | null;
13
17
  claimToken: string | null;
14
18
  claimExpiresAt: string | null;
@@ -16,8 +20,12 @@ export type WorkflowRunQueueRecord = {
16
20
  /** Pi session that owns delivery and interactive execution, or null for detached runs. */
17
21
  originSessionId: string | null;
18
22
  parentRunId: string | null;
23
+ errorCode: string | null;
24
+ errorMessage: string | null;
19
25
  createdAt: string;
20
26
  updatedAt: string;
27
+ startedAt: string | null;
28
+ finishedAt: string | null;
21
29
  };
22
30
  export type WorkflowNotificationRecord = {
23
31
  notificationId: string;
@@ -26,7 +34,7 @@ export type WorkflowNotificationRecord = {
26
34
  attemptId: string;
27
35
  notificationIndex: number;
28
36
  targetSessionId: string;
29
- kind: "progress" | "final";
37
+ kind: "progress" | "final" | "launch_failure";
30
38
  content: string;
31
39
  createdAt: string;
32
40
  deliveryClaimExpiresAt: string | null;
@@ -129,6 +137,7 @@ export declare class SqliteControllerStore implements ControllerStore {
129
137
  }): ControllerEvent[];
130
138
  private configure;
131
139
  private initializeSchema;
140
+ private assertAlphaSchemaLayout;
132
141
  private transaction;
133
142
  private resourceRow;
134
143
  private requireResource;
@@ -139,15 +148,29 @@ export declare class SqliteControllerStore implements ControllerStore {
139
148
  private requireEffect;
140
149
  private workflowRow;
141
150
  private requireWorkflow;
142
- /**
143
- * Insert a user-started run and claim it in one statement, so the
144
- * originating runner owns the run from birth (origin affinity).
145
- */
151
+ /** Reserve a user-started run before the initiating agent turn settles. */
152
+ reserveWorkflowRun(options: {
153
+ runId: string;
154
+ workflowName: string;
155
+ workflowSourceRef: string;
156
+ workflowSource: unknown;
157
+ definitionDigest: string;
158
+ input: unknown;
159
+ launchOptions?: unknown;
160
+ runnerId: string;
161
+ originSessionId: string;
162
+ parentRunId?: string;
163
+ now?: string;
164
+ }): WorkflowRunQueueRecord;
165
+ /** Insert a run that will start immediately outside a live model turn. */
146
166
  enqueueWorkflowRun(options: {
147
167
  runId: string;
148
168
  workflowName: string;
149
169
  workflowSourceRef: string;
170
+ workflowSource?: unknown;
171
+ definitionDigest?: string;
150
172
  input: unknown;
173
+ launchOptions?: unknown;
151
174
  runnerId: string;
152
175
  claimToken: string;
153
176
  leaseMs: number;
@@ -160,6 +183,19 @@ export declare class SqliteControllerStore implements ControllerStore {
160
183
  listWorkflowRuns(options?: {
161
184
  status?: WorkflowRunQueueRecord["status"];
162
185
  }): WorkflowRunQueueRecord[];
186
+ findSessionReservation(sessionId: string): WorkflowRunQueueRecord | undefined;
187
+ claimWorkflowRun(options: {
188
+ runId: string;
189
+ runnerId: string;
190
+ claimToken: string;
191
+ leaseMs: number;
192
+ now?: string;
193
+ }): WorkflowRunQueueRecord | undefined;
194
+ markWorkflowRunRunning(options: {
195
+ runId: string;
196
+ claimToken: string;
197
+ now?: string;
198
+ }): boolean;
163
199
  /**
164
200
  * Claim the oldest claimable run, preferring runs with affinity to this
165
201
  * runner. Parked rows are claimable immediately; claimed rows become
@@ -194,6 +230,18 @@ export declare class SqliteControllerStore implements ControllerStore {
194
230
  claimToken: string;
195
231
  now?: string;
196
232
  }): boolean;
233
+ failWorkflowRun(options: {
234
+ runId: string;
235
+ claimToken?: string;
236
+ errorCode: string;
237
+ errorMessage: string;
238
+ now?: string;
239
+ }): boolean;
240
+ cancelWorkflowRun(options: {
241
+ runId: string;
242
+ claimToken?: string;
243
+ now?: string;
244
+ }): boolean;
197
245
  /**
198
246
  * Delete a claimed row. Used when a continuation fails before its bundle
199
247
  * exists, so the parent's one-continuation slot is not consumed by a run
@@ -249,7 +297,7 @@ export declare class SqliteControllerStore implements ControllerStore {
249
297
  attemptId: string;
250
298
  notificationIndex: number;
251
299
  targetSessionId: string;
252
- kind: "progress" | "final";
300
+ kind: "progress" | "final" | "launch_failure";
253
301
  content: string;
254
302
  notificationId?: string;
255
303
  now?: string;
@@ -467,19 +467,50 @@ export class SqliteControllerStore {
467
467
  if (row === undefined) {
468
468
  throw new Error("Controller store has no schema identifier");
469
469
  }
470
- return;
471
470
  }
472
- this.transaction(() => {
473
- this.database
474
- .prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
475
- .run(CONTROLLER_STORE_SCHEMA);
476
- this.database.exec(SCHEMA_SQL);
477
- const queueColumns = this.database.pragma("table_info(workflow_run_queue)");
478
- if (!queueColumns.some((column) => column.name === "origin_session_id")) {
479
- this.database.exec("ALTER TABLE workflow_run_queue ADD COLUMN origin_session_id TEXT");
480
- }
481
- this.database.exec("DROP TABLE IF EXISTS session_watermarks");
482
- });
471
+ else {
472
+ const existingQueue = this.database
473
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'workflow_run_queue'")
474
+ .get();
475
+ if (existingQueue !== undefined)
476
+ this.assertAlphaSchemaLayout();
477
+ this.transaction(() => {
478
+ this.database
479
+ .prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
480
+ .run(CONTROLLER_STORE_SCHEMA);
481
+ this.database.exec(SCHEMA_SQL);
482
+ });
483
+ }
484
+ this.assertAlphaSchemaLayout();
485
+ }
486
+ assertAlphaSchemaLayout() {
487
+ const requiredQueueColumns = new Set([
488
+ "run_id",
489
+ "workflow_ref",
490
+ "workflow_path",
491
+ "workflow_source_json",
492
+ "definition_digest",
493
+ "input_json",
494
+ "launch_options_json",
495
+ "status",
496
+ "runner_id",
497
+ "claim_token",
498
+ "claim_expires_at",
499
+ "affinity_runner_id",
500
+ "origin_session_id",
501
+ "parent_run_id",
502
+ "error_code",
503
+ "error_message",
504
+ "created_at",
505
+ "updated_at",
506
+ "started_at",
507
+ "finished_at",
508
+ ]);
509
+ const actual = new Set(this.database.pragma("table_info(workflow_run_queue)").map((column) => column.name));
510
+ const missing = [...requiredQueueColumns].filter((column) => !actual.has(column));
511
+ if (missing.length > 0) {
512
+ throw new Error("Controller store uses an incompatible alpha layout. Preserve needed run bundles, then reset the project controller store.");
513
+ }
483
514
  }
484
515
  transaction(task) {
485
516
  this.database.exec("BEGIN IMMEDIATE");
@@ -551,10 +582,32 @@ export class SqliteControllerStore {
551
582
  }
552
583
  return workflow;
553
584
  }
554
- /**
555
- * Insert a user-started run and claim it in one statement, so the
556
- * originating runner owns the run from birth (origin affinity).
557
- */
585
+ /** Reserve a user-started run before the initiating agent turn settles. */
586
+ reserveWorkflowRun(options) {
587
+ validateRunId(options.runId);
588
+ validateKey(options.workflowName, "workflow name");
589
+ validateKey(options.workflowSourceRef, "workflow source ref");
590
+ validateKey(options.definitionDigest, "workflow definition digest");
591
+ validateKey(options.runnerId, "runner id");
592
+ validateKey(options.originSessionId, "origin session id");
593
+ const inputJson = canonicalJson(options.input ?? null, "workflow run input");
594
+ const sourceJson = canonicalJson(options.workflowSource, "workflow run source");
595
+ const launchJson = canonicalJson(options.launchOptions ?? {}, "workflow launch options");
596
+ validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
597
+ validateJsonSize(sourceJson, "Workflow run source", MAX_EVENT_BYTES);
598
+ validateJsonSize(launchJson, "Workflow launch options", MAX_EVENT_BYTES);
599
+ const now = validTimestamp(options.now);
600
+ this.database
601
+ .prepare(`INSERT INTO workflow_run_queue (
602
+ run_id, workflow_ref, workflow_path, workflow_source_json, definition_digest,
603
+ input_json, launch_options_json, status, runner_id, claim_token, claim_expires_at,
604
+ affinity_runner_id, origin_session_id, parent_run_id, error_code, error_message,
605
+ created_at, updated_at, started_at, finished_at
606
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', NULL, NULL, NULL, ?, ?, ?, NULL, NULL, ?, ?, NULL, NULL)`)
607
+ .run(options.runId, options.workflowName, options.workflowSourceRef, sourceJson, options.definitionDigest, inputJson, launchJson, options.runnerId, options.originSessionId, options.parentRunId ?? null, now, now);
608
+ return this.requireWorkflowRun(options.runId);
609
+ }
610
+ /** Insert a run that will start immediately outside a live model turn. */
558
611
  enqueueWorkflowRun(options) {
559
612
  validateRunId(options.runId);
560
613
  validateKey(options.workflowName, "workflow name");
@@ -566,16 +619,19 @@ export class SqliteControllerStore {
566
619
  validateKey(options.originSessionId, "origin session id");
567
620
  }
568
621
  const inputJson = canonicalJson(options.input ?? null, "workflow run input");
622
+ const sourceJson = canonicalJson(options.workflowSource ?? { ref: options.workflowSourceRef }, "workflow run source");
623
+ const launchJson = canonicalJson(options.launchOptions ?? {}, "workflow launch options");
569
624
  validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
570
625
  const now = validTimestamp(options.now);
571
626
  const expiresAt = epoch(now) + options.leaseMs;
572
627
  this.database
573
628
  .prepare(`INSERT INTO workflow_run_queue (
574
- run_id, workflow_ref, workflow_path, input_json, status,
575
- runner_id, claim_token, claim_expires_at, affinity_runner_id,
576
- origin_session_id, parent_run_id, created_at, updated_at
577
- ) VALUES (?, ?, ?, ?, 'claimed', ?, ?, ?, ?, ?, ?, ?, ?)`)
578
- .run(options.runId, options.workflowName, options.workflowSourceRef, inputJson, options.runnerId, options.claimToken, expiresAt, options.affinityRunnerId ?? options.runnerId, options.originSessionId ?? null, options.parentRunId ?? null, now, now);
629
+ run_id, workflow_ref, workflow_path, workflow_source_json, definition_digest,
630
+ input_json, launch_options_json, status, runner_id, claim_token, claim_expires_at,
631
+ affinity_runner_id, origin_session_id, parent_run_id, error_code, error_message,
632
+ created_at, updated_at, started_at, finished_at
633
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL)`)
634
+ .run(options.runId, options.workflowName, options.workflowSourceRef, sourceJson, options.definitionDigest ?? "unavailable", inputJson, launchJson, options.runnerId, options.claimToken, expiresAt, options.affinityRunnerId ?? options.runnerId, options.originSessionId ?? null, options.parentRunId ?? null, now, now, now);
579
635
  return this.requireWorkflowRun(options.runId);
580
636
  }
581
637
  getWorkflowRun(runId) {
@@ -595,6 +651,43 @@ export class SqliteControllerStore {
595
651
  .all(options.status);
596
652
  return rows.map(workflowRunFromRow);
597
653
  }
654
+ findSessionReservation(sessionId) {
655
+ validateKey(sessionId, "session id");
656
+ const row = this.database
657
+ .prepare(`SELECT * FROM workflow_run_queue
658
+ WHERE origin_session_id = ? AND status IN ('queued', 'starting', 'running')
659
+ ORDER BY created_at LIMIT 1`)
660
+ .get(sessionId);
661
+ return row === undefined ? undefined : workflowRunFromRow(row);
662
+ }
663
+ claimWorkflowRun(options) {
664
+ validateRunId(options.runId);
665
+ validateKey(options.runnerId, "runner id");
666
+ validateKey(options.claimToken, "claim token");
667
+ validateDuration(options.leaseMs, "leaseMs");
668
+ const now = validTimestamp(options.now);
669
+ const nowMs = epoch(now);
670
+ const expiresAt = nowMs + options.leaseMs;
671
+ const result = this.database
672
+ .prepare(`UPDATE workflow_run_queue
673
+ SET status = 'starting', runner_id = ?, claim_token = ?, claim_expires_at = ?,
674
+ started_at = COALESCE(started_at, ?), updated_at = ?
675
+ WHERE run_id = ? AND (
676
+ status IN ('queued', 'parked')
677
+ OR (status = 'starting' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
678
+ )`)
679
+ .run(options.runnerId, options.claimToken, expiresAt, now, now, options.runId, nowMs);
680
+ return result.changes === 1 ? this.requireWorkflowRun(options.runId) : undefined;
681
+ }
682
+ markWorkflowRunRunning(options) {
683
+ validateRunId(options.runId);
684
+ const now = validTimestamp(options.now);
685
+ const result = this.database
686
+ .prepare(`UPDATE workflow_run_queue SET status = 'running', updated_at = ?
687
+ WHERE run_id = ? AND claim_token = ? AND status = 'starting'`)
688
+ .run(now, options.runId, options.claimToken);
689
+ return result.changes === 1;
690
+ }
598
691
  /**
599
692
  * Claim the oldest claimable run, preferring runs with affinity to this
600
693
  * runner. Parked rows are claimable immediately; claimed rows become
@@ -616,11 +709,11 @@ export class SqliteControllerStore {
616
709
  }
617
710
  const exclusion = excluded.length === 0 ? "" : `AND run_id NOT IN (${excluded.map(() => "?").join(", ")})`;
618
711
  const sessionFilter = options.sessionId === undefined
619
- ? ""
712
+ ? "AND status != 'queued' AND (status != 'starting' OR origin_session_id IS NULL)"
620
713
  : "AND (origin_session_id IS NULL OR origin_session_id = ?)";
621
714
  const claimable = `(
622
- status = 'parked'
623
- OR (status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
715
+ status IN ('queued', 'parked')
716
+ OR (status IN ('starting', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
624
717
  )`;
625
718
  return this.transaction(() => {
626
719
  const candidate = this.database
@@ -636,13 +729,13 @@ export class SqliteControllerStore {
636
729
  }
637
730
  const result = this.database
638
731
  .prepare(`UPDATE workflow_run_queue
639
- SET status = 'claimed', runner_id = ?, claim_token = ?,
640
- claim_expires_at = ?, updated_at = ?
732
+ SET status = 'starting', runner_id = ?, claim_token = ?,
733
+ claim_expires_at = ?, started_at = COALESCE(started_at, ?), updated_at = ?
641
734
  WHERE run_id = ? AND (
642
- status = 'parked'
643
- OR (status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
735
+ status IN ('queued', 'parked')
736
+ OR (status IN ('starting', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
644
737
  )`)
645
- .run(options.runnerId, options.claimToken, expiresAt, now, candidate.run_id, nowMs);
738
+ .run(options.runnerId, options.claimToken, expiresAt, now, now, candidate.run_id, nowMs);
646
739
  return result.changes === 1 ? this.requireWorkflowRun(candidate.run_id) : undefined;
647
740
  });
648
741
  }
@@ -654,7 +747,7 @@ export class SqliteControllerStore {
654
747
  const expiresAt = nowMs + options.leaseMs;
655
748
  const result = this.database
656
749
  .prepare(`UPDATE workflow_run_queue SET claim_expires_at = ?
657
- WHERE run_id = ? AND claim_token = ? AND status = 'claimed'
750
+ WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')
658
751
  AND claim_expires_at > ?`)
659
752
  .run(expiresAt, options.runId, options.claimToken, nowMs);
660
753
  return result.changes === 1;
@@ -665,7 +758,7 @@ export class SqliteControllerStore {
665
758
  const nowMs = epoch(validTimestamp(options.now));
666
759
  const row = this.database
667
760
  .prepare(`SELECT 1 AS live FROM workflow_run_queue
668
- WHERE run_id = ? AND claim_token = ? AND status = 'claimed'
761
+ WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')
669
762
  AND claim_expires_at > ?`)
670
763
  .get(options.runId, options.claimToken, nowMs);
671
764
  return row !== undefined;
@@ -678,10 +771,42 @@ export class SqliteControllerStore {
678
771
  .prepare(`UPDATE workflow_run_queue
679
772
  SET status = 'parked', runner_id = NULL, claim_token = NULL,
680
773
  claim_expires_at = NULL, updated_at = ?
681
- WHERE run_id = ? AND claim_token = ? AND status = 'claimed'`)
774
+ WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`)
682
775
  .run(now, options.runId, options.claimToken);
683
776
  return result.changes === 1;
684
777
  }
778
+ failWorkflowRun(options) {
779
+ validateRunId(options.runId);
780
+ validateKey(options.errorCode, "workflow launch error code");
781
+ const message = options.errorMessage.trim();
782
+ if (message.length === 0 || Buffer.byteLength(message) > 2_048) {
783
+ throw new Error("Workflow launch error message must be between 1 and 2048 bytes");
784
+ }
785
+ const now = validTimestamp(options.now);
786
+ const claimFilter = options.claimToken === undefined ? "" : "AND claim_token = ?";
787
+ const result = this.database
788
+ .prepare(`UPDATE workflow_run_queue
789
+ SET status = 'failed', runner_id = NULL, claim_token = NULL,
790
+ claim_expires_at = NULL, parent_run_id = NULL, input_json = 'null',
791
+ launch_options_json = '{}', error_code = ?, error_message = ?,
792
+ finished_at = ?, updated_at = ?
793
+ WHERE run_id = ? AND status IN ('queued', 'starting', 'running') ${claimFilter}`)
794
+ .run(options.errorCode, message, now, now, options.runId, ...(options.claimToken === undefined ? [] : [options.claimToken]));
795
+ return result.changes === 1;
796
+ }
797
+ cancelWorkflowRun(options) {
798
+ validateRunId(options.runId);
799
+ const now = validTimestamp(options.now);
800
+ const claimFilter = options.claimToken === undefined ? "" : "AND claim_token = ?";
801
+ const result = this.database
802
+ .prepare(`UPDATE workflow_run_queue
803
+ SET status = 'cancelled', runner_id = NULL, claim_token = NULL,
804
+ claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
805
+ finished_at = ?, updated_at = ?
806
+ WHERE run_id = ? AND status IN ('queued', 'starting') ${claimFilter}`)
807
+ .run(now, now, options.runId, ...(options.claimToken === undefined ? [] : [options.claimToken]));
808
+ return result.changes === 1;
809
+ }
685
810
  /**
686
811
  * Delete a claimed row. Used when a continuation fails before its bundle
687
812
  * exists, so the parent's one-continuation slot is not consumed by a run
@@ -701,9 +826,10 @@ export class SqliteControllerStore {
701
826
  const result = this.database
702
827
  .prepare(`UPDATE workflow_run_queue
703
828
  SET status = 'done', runner_id = NULL, claim_token = NULL,
704
- claim_expires_at = NULL, updated_at = ?
705
- WHERE run_id = ? AND claim_token = ? AND status = 'claimed'`)
706
- .run(now, options.runId, options.claimToken);
829
+ claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
830
+ finished_at = ?, updated_at = ?
831
+ WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`)
832
+ .run(now, now, options.runId, options.claimToken);
707
833
  return result.changes === 1;
708
834
  }
709
835
  /** Repair a canonical bundle's queue source and claim it only when needed. */
@@ -721,26 +847,27 @@ export class SqliteControllerStore {
721
847
  const row = this.database
722
848
  .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
723
849
  .get(options.runId);
724
- if (row === undefined || row.status === "done")
850
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
725
851
  return false;
852
+ }
726
853
  if (row.workflow_path === options.workflowSourceRef && row.status === "parked") {
727
854
  return "unchanged";
728
855
  }
729
856
  const claimable = row.status === "parked" ||
730
- (row.status === "claimed" &&
857
+ (row.status === "starting" &&
731
858
  row.claim_token === options.claimToken &&
732
859
  row.claim_expires_at !== null &&
733
860
  row.claim_expires_at > nowMs) ||
734
- (row.status === "claimed" &&
861
+ (row.status === "starting" &&
735
862
  row.claim_expires_at !== null &&
736
863
  row.claim_expires_at <= nowMs);
737
864
  if (!claimable)
738
865
  return false;
739
866
  const result = this.database
740
867
  .prepare(`UPDATE workflow_run_queue
741
- SET workflow_ref = ?, workflow_path = ?, status = 'claimed', runner_id = ?,
868
+ SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
742
869
  claim_token = ?, claim_expires_at = ?, updated_at = ?
743
- WHERE run_id = ? AND status != 'done'`)
870
+ WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`)
744
871
  .run(options.workflowName, options.workflowSourceRef, options.runnerId, options.claimToken, expiresAt, now, options.runId);
745
872
  return result.changes === 1 ? "claimed" : false;
746
873
  });
@@ -761,25 +888,26 @@ export class SqliteControllerStore {
761
888
  const row = this.database
762
889
  .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
763
890
  .get(options.runId);
764
- if (row === undefined || row.status === "done")
891
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
765
892
  return false;
893
+ }
766
894
  const sourceMatches = row.workflow_path === options.oldWorkflowPath ||
767
895
  row.workflow_path === options.workflowSourceRef;
768
896
  const claimable = row.status === "parked" ||
769
- (row.status === "claimed" &&
897
+ (row.status === "starting" &&
770
898
  row.claim_token === options.claimToken &&
771
899
  row.claim_expires_at !== null &&
772
900
  row.claim_expires_at > nowMs) ||
773
- (row.status === "claimed" &&
901
+ (row.status === "starting" &&
774
902
  row.claim_expires_at !== null &&
775
903
  row.claim_expires_at <= nowMs);
776
904
  if (!sourceMatches || !claimable)
777
905
  return false;
778
906
  const result = this.database
779
907
  .prepare(`UPDATE workflow_run_queue
780
- SET workflow_ref = ?, workflow_path = ?, status = 'claimed', runner_id = ?,
908
+ SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
781
909
  claim_token = ?, claim_expires_at = ?, updated_at = ?
782
- WHERE run_id = ? AND status != 'done'`)
910
+ WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`)
783
911
  .run(options.workflowName, options.workflowSourceRef, options.runnerId, options.claimToken, expiresAt, now, options.runId);
784
912
  return result.changes === 1;
785
913
  });
@@ -945,19 +1073,31 @@ const SCHEMA_SQL = `
945
1073
  run_id TEXT PRIMARY KEY,
946
1074
  workflow_ref TEXT NOT NULL,
947
1075
  workflow_path TEXT NOT NULL,
1076
+ workflow_source_json TEXT NOT NULL,
1077
+ definition_digest TEXT NOT NULL,
948
1078
  input_json TEXT NOT NULL,
949
- status TEXT NOT NULL CHECK (status IN ('claimed', 'parked', 'done')),
1079
+ launch_options_json TEXT NOT NULL,
1080
+ status TEXT NOT NULL CHECK (
1081
+ status IN ('queued', 'starting', 'running', 'parked', 'done', 'failed', 'cancelled')
1082
+ ),
950
1083
  runner_id TEXT,
951
1084
  claim_token TEXT,
952
1085
  claim_expires_at INTEGER,
953
1086
  affinity_runner_id TEXT,
954
1087
  origin_session_id TEXT,
955
1088
  parent_run_id TEXT,
1089
+ error_code TEXT,
1090
+ error_message TEXT,
956
1091
  created_at TEXT NOT NULL,
957
- updated_at TEXT NOT NULL
1092
+ updated_at TEXT NOT NULL,
1093
+ started_at TEXT,
1094
+ finished_at TEXT
958
1095
  );
959
1096
  CREATE INDEX IF NOT EXISTS workflow_run_queue_claimable
960
1097
  ON workflow_run_queue(status, claim_expires_at);
1098
+ CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_session_reservation
1099
+ ON workflow_run_queue(origin_session_id)
1100
+ WHERE origin_session_id IS NOT NULL AND status IN ('queued', 'starting', 'running');
961
1101
  -- A checkpointed parent admits exactly one continuation run, across
962
1102
  -- sessions and processes.
963
1103
  CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_parent
@@ -981,7 +1121,7 @@ const SCHEMA_SQL = `
981
1121
  attempt_id TEXT NOT NULL,
982
1122
  notification_index INTEGER NOT NULL CHECK (notification_index > 0),
983
1123
  target_session_id TEXT NOT NULL,
984
- kind TEXT NOT NULL CHECK (kind IN ('progress', 'final')),
1124
+ kind TEXT NOT NULL CHECK (kind IN ('progress', 'final', 'launch_failure')),
985
1125
  content TEXT NOT NULL,
986
1126
  created_at TEXT NOT NULL,
987
1127
  delivery_claim_token TEXT,
@@ -1099,7 +1239,10 @@ function workflowRunFromRow(row) {
1099
1239
  runId: row.run_id,
1100
1240
  workflowName: row.workflow_ref,
1101
1241
  workflowSourceRef: row.workflow_path,
1242
+ workflowSource: parseStoredJson(row.workflow_source_json, "workflow run source"),
1243
+ definitionDigest: row.definition_digest,
1102
1244
  input: parseStoredJson(row.input_json, "workflow run input"),
1245
+ launchOptions: parseStoredJson(row.launch_options_json, "workflow launch options"),
1103
1246
  status: row.status,
1104
1247
  runnerId: row.runner_id,
1105
1248
  claimToken: row.claim_token,
@@ -1107,8 +1250,12 @@ function workflowRunFromRow(row) {
1107
1250
  affinityRunnerId: row.affinity_runner_id,
1108
1251
  originSessionId: row.origin_session_id,
1109
1252
  parentRunId: row.parent_run_id,
1253
+ errorCode: row.error_code,
1254
+ errorMessage: row.error_message,
1110
1255
  createdAt: row.created_at,
1111
1256
  updatedAt: row.updated_at,
1257
+ startedAt: row.started_at,
1258
+ finishedAt: row.finished_at,
1112
1259
  };
1113
1260
  }
1114
1261
  function validateRunId(runId) {