@hasna/loops 0.4.27 → 0.4.28

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.
@@ -1571,6 +1571,7 @@ var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
1571
1571
  var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
1572
1572
  var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
1573
1573
  var SCHEMA_USER_VERSION = 8;
1574
+ var BREAKING_SCHEMA_FLOOR = 7;
1574
1575
  var TERMINAL_RUN_STATUSES = ["succeeded", "failed", "timed_out", "abandoned", "skipped"];
1575
1576
  var PRUNE_BATCH_SIZE = 400;
1576
1577
  var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "task-lifecycle", "event-worker-verifier"]);
@@ -1713,6 +1714,7 @@ function rowToWorkflowWorkItem(row) {
1713
1714
  priority: row.priority,
1714
1715
  status: row.status,
1715
1716
  attempts: row.attempts,
1717
+ gateDeaths: row.gate_deaths ?? 0,
1716
1718
  nextAttemptAt: row.next_attempt_at ?? undefined,
1717
1719
  leaseExpiresAt: row.lease_expires_at ?? undefined,
1718
1720
  workflowId: row.workflow_id ?? undefined,
@@ -1862,6 +1864,23 @@ function workItemStatusForLoopRun(status, attempt, maxAttempts) {
1862
1864
  }
1863
1865
  return;
1864
1866
  }
1867
+ var WORK_ITEM_TEMPFAIL_EXIT_CODE = 75;
1868
+ var GATE_STEP_IDS = new Set(["triage", "planner", "plan"]);
1869
+ var GATE_DEATH_MAX_DURATION_MS = 60000;
1870
+ var GATE_DEATH_CEILING = 20;
1871
+ function classifyNonProductiveStepFailure(steps) {
1872
+ const failing = [...steps].reverse().find((step) => step.status === "failed" || step.status === "timed_out");
1873
+ if (!failing)
1874
+ return;
1875
+ if (failing.exitCode === WORK_ITEM_TEMPFAIL_EXIT_CODE)
1876
+ return "tempfail";
1877
+ if (typeof failing.error === "string" && failing.error.includes("worktree preparation failed"))
1878
+ return "gate-death";
1879
+ const fast = failing.durationMs === undefined || failing.durationMs < GATE_DEATH_MAX_DURATION_MS;
1880
+ if (GATE_STEP_IDS.has(failing.stepId) && fast)
1881
+ return "gate-death";
1882
+ return;
1883
+ }
1865
1884
  function scrubbedOrNull(value) {
1866
1885
  return value == null ? null : scrubSecrets(value);
1867
1886
  }
@@ -1962,11 +1981,21 @@ class Store {
1962
1981
  id TEXT PRIMARY KEY,
1963
1982
  applied_at TEXT NOT NULL
1964
1983
  );
1984
+ CREATE TABLE IF NOT EXISTS schema_compat (
1985
+ id INTEGER PRIMARY KEY CHECK (id = 1),
1986
+ min_compatible_user_version INTEGER NOT NULL
1987
+ );
1965
1988
  `);
1966
1989
  const versionRow = this.db.query("PRAGMA user_version").get();
1967
1990
  const userVersion = versionRow?.user_version ?? 0;
1968
1991
  if (userVersion > SCHEMA_USER_VERSION) {
1969
- throw new Error(`loops database schema version ${userVersion} is newer than this binary supports (${SCHEMA_USER_VERSION}); upgrade open-loops before opening this database`);
1992
+ const floorRow = this.db.query("SELECT min_compatible_user_version FROM schema_compat WHERE id = 1").get();
1993
+ if (!floorRow) {
1994
+ throw new Error(`loops database schema version ${userVersion} is newer than this binary supports (${SCHEMA_USER_VERSION}) and carries no compatibility floor; upgrade open-loops before opening this database`);
1995
+ }
1996
+ if (SCHEMA_USER_VERSION < floorRow.min_compatible_user_version) {
1997
+ throw new Error(`loops database schema version ${userVersion} requires a binary with schema support >= ${floorRow.min_compatible_user_version} (this binary supports ${SCHEMA_USER_VERSION}); upgrade open-loops before opening this database`);
1998
+ }
1970
1999
  }
1971
2000
  const applied = new Set(this.db.query("SELECT id FROM schema_migrations").all().map((row) => row.id));
1972
2001
  for (const migration of this.migrations()) {
@@ -1977,8 +2006,10 @@ class Store {
1977
2006
  this.db.query("INSERT OR IGNORE INTO schema_migrations (id, applied_at) VALUES (?, ?)").run(migration.id, nowIso());
1978
2007
  }
1979
2008
  }
1980
- if (userVersion !== SCHEMA_USER_VERSION)
2009
+ if (userVersion < SCHEMA_USER_VERSION)
1981
2010
  this.db.exec(`PRAGMA user_version = ${SCHEMA_USER_VERSION}`);
2011
+ this.db.query(`INSERT INTO schema_compat (id, min_compatible_user_version) VALUES (1, ?)
2012
+ ON CONFLICT(id) DO UPDATE SET min_compatible_user_version = MAX(min_compatible_user_version, excluded.min_compatible_user_version)`).run(BREAKING_SCHEMA_FLOOR);
1982
2013
  }
1983
2014
  migrations() {
1984
2015
  return [
@@ -2045,6 +2076,12 @@ class Store {
2045
2076
  this.addColumnIfMissing("workflow_work_items", "machine_id", "TEXT");
2046
2077
  this.db.exec("CREATE INDEX IF NOT EXISTS idx_workflow_work_items_machine ON workflow_work_items(machine_id, status)");
2047
2078
  }
2079
+ },
2080
+ {
2081
+ id: "0011_work_item_gate_deaths",
2082
+ apply: () => {
2083
+ this.addColumnIfMissing("workflow_work_items", "gate_deaths", "INTEGER NOT NULL DEFAULT 0");
2084
+ }
2048
2085
  }
2049
2086
  ];
2050
2087
  }
@@ -3175,7 +3212,7 @@ class Store {
3175
3212
  }
3176
3213
  upsertWorkflowWorkItem(input) {
3177
3214
  const now = nowIso();
3178
- const id = genId();
3215
+ const id = input.id ?? genId();
3179
3216
  const status = input.status ?? "queued";
3180
3217
  this.db.query(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
3181
3218
  subject_ref, project_key, project_group, machine_id, route_scope, priority, status, attempts, next_attempt_at, lease_expires_at,
@@ -3303,6 +3340,7 @@ class Store {
3303
3340
  const placeholders = requeueableStatuses.map(() => "?").join(",");
3304
3341
  const res = this.db.query(`UPDATE workflow_work_items
3305
3342
  SET status='queued', workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
3343
+ ${patch.resetAttempts ? "attempts=0, gate_deaths=0," : ""}
3306
3344
  next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
3307
3345
  WHERE id=? AND status IN (${placeholders})`).run(reason, now, id, ...requeueableStatuses);
3308
3346
  const item = this.getWorkflowWorkItem(id);
@@ -3312,6 +3350,46 @@ class Store {
3312
3350
  throw new Error(`workflow work item was not requeued: ${id} status=${item.status}`);
3313
3351
  return item;
3314
3352
  }
3353
+ deadLetterWorkflowWorkItem(id, patch = {}) {
3354
+ const now = nowIso();
3355
+ const reason = patch.reason?.trim() || "redispatch cap reached; dead-lettered";
3356
+ this.db.query(`UPDATE workflow_work_items
3357
+ SET status='dead_letter', next_attempt_at=NULL, lease_expires_at=NULL, last_reason=?, updated_at=?
3358
+ WHERE id=? AND status IN ('succeeded','failed','cancelled')`).run(reason, now, id);
3359
+ const item = this.getWorkflowWorkItem(id);
3360
+ if (!item)
3361
+ throw new Error(`workflow work item not found after dead-letter: ${id}`);
3362
+ return item;
3363
+ }
3364
+ demoteNonProductiveWorkItems(workflowRunId, finishedAt) {
3365
+ const kind = classifyNonProductiveStepFailure(this.listWorkflowStepRuns(workflowRunId));
3366
+ if (!kind) {
3367
+ this.db.query("UPDATE workflow_work_items SET gate_deaths=0, updated_at=? WHERE workflow_run_id=? AND status='failed' AND gate_deaths > 0").run(finishedAt, workflowRunId);
3368
+ return;
3369
+ }
3370
+ if (kind === "tempfail") {
3371
+ this.db.query(`UPDATE workflow_work_items
3372
+ SET status='queued', attempts=CASE WHEN attempts > 0 THEN attempts - 1 ELSE 0 END,
3373
+ gate_deaths=0,
3374
+ workflow_id=NULL, loop_id=NULL, workflow_run_id=NULL,
3375
+ next_attempt_at=NULL, lease_expires_at=NULL,
3376
+ last_reason='worker exited 75 (tempfail): requeued for retry; attempt refunded (does not count toward redispatch cap)',
3377
+ updated_at=?
3378
+ WHERE workflow_run_id=? AND status='failed'`).run(finishedAt, workflowRunId);
3379
+ return;
3380
+ }
3381
+ this.db.query(`UPDATE workflow_work_items
3382
+ SET attempts=CASE WHEN attempts > 0 THEN attempts - 1 ELSE 0 END,
3383
+ gate_deaths=gate_deaths + 1,
3384
+ status=CASE WHEN gate_deaths + 1 >= ${GATE_DEATH_CEILING} THEN 'dead_letter' ELSE status END,
3385
+ last_reason=CASE
3386
+ WHEN gate_deaths + 1 >= ${GATE_DEATH_CEILING}
3387
+ THEN 'gate-death ceiling reached (' || (gate_deaths + 1) || '/${GATE_DEATH_CEILING} consecutive runs died at worktree prep / triage / planner without reaching the worker): dead-lettered \u2014 the infrastructure fault needs an operator; ''loops routes requeue'' resets and retries'
3388
+ ELSE 'gate death before real work (worktree prep / triage / planner): attempt refunded (does not count toward redispatch cap); consecutive gate deaths: ' || (gate_deaths + 1) || '/${GATE_DEATH_CEILING}'
3389
+ END,
3390
+ updated_at=?
3391
+ WHERE workflow_run_id=? AND status='failed'`).run(finishedAt, workflowRunId);
3392
+ }
3315
3393
  admitWorkflowWorkItem(id, patch) {
3316
3394
  const now = nowIso();
3317
3395
  const res = this.db.query(`UPDATE workflow_work_items
@@ -4023,6 +4101,8 @@ class Store {
4023
4101
  if (changed) {
4024
4102
  const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
4025
4103
  this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, error, finishedAt);
4104
+ if (itemStatus === "failed")
4105
+ this.demoteNonProductiveWorkItems(workflowRunId, finishedAt);
4026
4106
  this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
4027
4107
  }
4028
4108
  this.db.exec("COMMIT");
@@ -4972,7 +5052,7 @@ class Store {
4972
5052
  // package.json
4973
5053
  var package_default = {
4974
5054
  name: "@hasna/loops",
4975
- version: "0.4.27",
5055
+ version: "0.4.28",
4976
5056
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
4977
5057
  type: "module",
4978
5058
  main: "dist/index.js",
@@ -5082,6 +5162,7 @@ var package_default = {
5082
5162
  bun: ">=1.0.0"
5083
5163
  },
5084
5164
  dependencies: {
5165
+ "@hasna/contracts": "^0.5.1",
5085
5166
  "@hasna/events": "^0.1.9",
5086
5167
  "@modelcontextprotocol/sdk": "^1.29.0",
5087
5168
  "@openrouter/ai-sdk-provider": "2.9.1",
@@ -5091,8 +5172,7 @@ var package_default = {
5091
5172
  zod: "4.4.3"
5092
5173
  },
5093
5174
  optionalDependencies: {
5094
- "@hasna/machines": "0.0.49",
5095
- "@hasna/contracts": "^0.4.2"
5175
+ "@hasna/machines": "0.0.49"
5096
5176
  },
5097
5177
  devDependencies: {
5098
5178
  "@types/bun": "latest",
@@ -5412,6 +5492,14 @@ var loops_default = {
5412
5492
  type: "integer"
5413
5493
  }
5414
5494
  },
5495
+ {
5496
+ name: "offset",
5497
+ in: "query",
5498
+ required: false,
5499
+ schema: {
5500
+ type: "integer"
5501
+ }
5502
+ },
5415
5503
  {
5416
5504
  name: "includeArchived",
5417
5505
  in: "query",
@@ -5687,6 +5775,22 @@ var loops_default = {
5687
5775
  schema: {
5688
5776
  type: "integer"
5689
5777
  }
5778
+ },
5779
+ {
5780
+ name: "offset",
5781
+ in: "query",
5782
+ required: false,
5783
+ schema: {
5784
+ type: "integer"
5785
+ }
5786
+ },
5787
+ {
5788
+ name: "showOutput",
5789
+ in: "query",
5790
+ required: false,
5791
+ schema: {
5792
+ type: "boolean"
5793
+ }
5690
5794
  }
5691
5795
  ],
5692
5796
  responses: {
@@ -5743,6 +5847,14 @@ var loops_default = {
5743
5847
  schema: {
5744
5848
  type: "string"
5745
5849
  }
5850
+ },
5851
+ {
5852
+ name: "showOutput",
5853
+ in: "query",
5854
+ required: false,
5855
+ schema: {
5856
+ type: "boolean"
5857
+ }
5746
5858
  }
5747
5859
  ],
5748
5860
  responses: {
@@ -5763,7 +5875,7 @@ var loops_default = {
5763
5875
  post: {
5764
5876
  operationId: "importRows",
5765
5877
  summary: "Bulk id-preserving import (self-hosted backfill)",
5766
- description: "Upsert full workflow/loop/run rows by id (idempotent; ON CONFLICT(id) DO UPDATE). Preserves id, status, archived state, and timestamps. Applied FK-safe: workflows, then loops, then runs. Volatile running runs are skipped and reported.",
5878
+ description: "Upsert full workflow/loop/run rows by id (idempotent; ON CONFLICT(id) DO UPDATE). Preserves ids, definitions, and timestamps while applying safe defaults: imported workflows are archived, imported loops are paused, and nextRunAt/retryScheduledFor are cleared by default so backfills never schedule immediately. Set preserveWorkflowActivation=true or preserveLoopScheduling=true only for intentional activation. Applied FK-safe: workflows, then loops, then runs. Volatile running runs are skipped and reported.",
5767
5879
  requestBody: {
5768
5880
  required: true,
5769
5881
  content: {
@@ -5788,51 +5900,141 @@ var loops_default = {
5788
5900
  }
5789
5901
  }
5790
5902
  },
5791
- "/v1/receipts": {
5903
+ "/v1/workflows": {
5792
5904
  get: {
5793
- operationId: "listRunReceipts",
5794
- summary: "List run receipts",
5905
+ operationId: "listWorkflows",
5906
+ summary: "List workflow specs",
5795
5907
  parameters: [
5796
5908
  {
5797
- name: "loopId",
5909
+ name: "status",
5798
5910
  in: "query",
5799
5911
  required: false,
5800
5912
  schema: {
5801
- type: "string"
5913
+ type: "string",
5914
+ enum: [
5915
+ "active",
5916
+ "archived"
5917
+ ]
5802
5918
  }
5803
5919
  },
5804
5920
  {
5805
- name: "repo",
5921
+ name: "limit",
5806
5922
  in: "query",
5807
5923
  required: false,
5808
5924
  schema: {
5809
- type: "string"
5925
+ type: "integer"
5810
5926
  }
5811
5927
  },
5812
5928
  {
5813
- name: "taskId",
5929
+ name: "offset",
5814
5930
  in: "query",
5815
5931
  required: false,
5816
5932
  schema: {
5817
- type: "string"
5933
+ type: "integer"
5818
5934
  }
5819
- },
5935
+ }
5936
+ ],
5937
+ responses: {
5938
+ "200": {
5939
+ description: "workflow specs",
5940
+ content: {
5941
+ "application/json": {
5942
+ schema: {
5943
+ $ref: "#/components/schemas/WorkflowListResponse"
5944
+ }
5945
+ }
5946
+ }
5947
+ }
5948
+ }
5949
+ },
5950
+ post: {
5951
+ operationId: "createWorkflow",
5952
+ summary: "Create a workflow spec",
5953
+ requestBody: {
5954
+ required: true,
5955
+ content: {
5956
+ "application/json": {
5957
+ schema: {
5958
+ $ref: "#/components/schemas/CreateWorkflowInput"
5959
+ }
5960
+ }
5961
+ }
5962
+ },
5963
+ responses: {
5964
+ "201": {
5965
+ description: "workflow",
5966
+ content: {
5967
+ "application/json": {
5968
+ schema: {
5969
+ $ref: "#/components/schemas/WorkflowResponse"
5970
+ }
5971
+ }
5972
+ }
5973
+ }
5974
+ }
5975
+ }
5976
+ },
5977
+ "/v1/workflows/count": {
5978
+ get: {
5979
+ operationId: "countWorkflows",
5980
+ summary: "Count workflow specs",
5981
+ parameters: [
5820
5982
  {
5821
- name: "knowledgeId",
5983
+ name: "status",
5822
5984
  in: "query",
5823
5985
  required: false,
5824
5986
  schema: {
5825
5987
  type: "string"
5826
5988
  }
5827
- },
5989
+ }
5990
+ ],
5991
+ responses: {
5992
+ "200": {
5993
+ description: "count",
5994
+ content: {
5995
+ "application/json": {
5996
+ schema: {
5997
+ $ref: "#/components/schemas/CountResponse"
5998
+ }
5999
+ }
6000
+ }
6001
+ }
6002
+ }
6003
+ }
6004
+ },
6005
+ "/v1/workflows/{id}": {
6006
+ get: {
6007
+ operationId: "getWorkflow",
6008
+ summary: "Get a workflow spec by id",
6009
+ parameters: [
5828
6010
  {
5829
- name: "status",
5830
- in: "query",
5831
- required: false,
6011
+ name: "id",
6012
+ in: "path",
6013
+ required: true,
5832
6014
  schema: {
5833
6015
  type: "string"
5834
6016
  }
5835
- },
6017
+ }
6018
+ ],
6019
+ responses: {
6020
+ "200": {
6021
+ description: "workflow",
6022
+ content: {
6023
+ "application/json": {
6024
+ schema: {
6025
+ $ref: "#/components/schemas/WorkflowResponse"
6026
+ }
6027
+ }
6028
+ }
6029
+ }
6030
+ }
6031
+ }
6032
+ },
6033
+ "/v1/invocations": {
6034
+ get: {
6035
+ operationId: "listWorkflowInvocations",
6036
+ summary: "List workflow route invocations",
6037
+ parameters: [
5836
6038
  {
5837
6039
  name: "limit",
5838
6040
  in: "query",
@@ -5844,11 +6046,11 @@ var loops_default = {
5844
6046
  ],
5845
6047
  responses: {
5846
6048
  "200": {
5847
- description: "run receipts",
6049
+ description: "workflow invocations",
5848
6050
  content: {
5849
6051
  "application/json": {
5850
6052
  schema: {
5851
- $ref: "#/components/schemas/RunReceiptListResponse"
6053
+ $ref: "#/components/schemas/WorkflowInvocationListResponse"
5852
6054
  }
5853
6055
  }
5854
6056
  }
@@ -5856,25 +6058,25 @@ var loops_default = {
5856
6058
  }
5857
6059
  },
5858
6060
  post: {
5859
- operationId: "writeRunReceipt",
5860
- summary: "Write a run receipt",
6061
+ operationId: "createWorkflowInvocation",
6062
+ summary: "Create an id-preserving workflow route invocation",
5861
6063
  requestBody: {
5862
6064
  required: true,
5863
6065
  content: {
5864
6066
  "application/json": {
5865
6067
  schema: {
5866
- $ref: "#/components/schemas/WriteRunReceiptInput"
6068
+ $ref: "#/components/schemas/CreateWorkflowInvocationInput"
5867
6069
  }
5868
6070
  }
5869
6071
  }
5870
6072
  },
5871
6073
  responses: {
5872
6074
  "201": {
5873
- description: "run receipt",
6075
+ description: "workflow invocation",
5874
6076
  content: {
5875
6077
  "application/json": {
5876
6078
  schema: {
5877
- $ref: "#/components/schemas/RunReceiptResponse"
6079
+ $ref: "#/components/schemas/WorkflowInvocationResponse"
5878
6080
  }
5879
6081
  }
5880
6082
  }
@@ -5882,51 +6084,215 @@ var loops_default = {
5882
6084
  }
5883
6085
  }
5884
6086
  },
5885
- "/v1/receipts/{runId}": {
6087
+ "/v1/work-items": {
5886
6088
  get: {
5887
- operationId: "getRunReceipt",
5888
- summary: "Get a run receipt by run id",
6089
+ operationId: "listWorkflowWorkItems",
6090
+ summary: "List workflow route work items",
5889
6091
  parameters: [
5890
6092
  {
5891
- name: "runId",
5892
- in: "path",
5893
- required: true,
6093
+ name: "status",
6094
+ in: "query",
6095
+ required: false,
6096
+ schema: {
6097
+ type: "string"
6098
+ }
6099
+ },
6100
+ {
6101
+ name: "routeKey",
6102
+ in: "query",
6103
+ required: false,
5894
6104
  schema: {
5895
6105
  type: "string"
5896
6106
  }
6107
+ },
6108
+ {
6109
+ name: "limit",
6110
+ in: "query",
6111
+ required: false,
6112
+ schema: {
6113
+ type: "integer"
6114
+ }
5897
6115
  }
5898
6116
  ],
5899
6117
  responses: {
5900
6118
  "200": {
5901
- description: "run receipt",
6119
+ description: "workflow work items",
5902
6120
  content: {
5903
6121
  "application/json": {
5904
6122
  schema: {
5905
- $ref: "#/components/schemas/RunReceiptResponse"
6123
+ $ref: "#/components/schemas/WorkflowWorkItemListResponse"
6124
+ }
6125
+ }
6126
+ }
6127
+ }
6128
+ }
6129
+ },
6130
+ post: {
6131
+ operationId: "upsertWorkflowWorkItem",
6132
+ summary: "Upsert an id-preserving workflow route work item",
6133
+ requestBody: {
6134
+ required: true,
6135
+ content: {
6136
+ "application/json": {
6137
+ schema: {
6138
+ $ref: "#/components/schemas/UpsertWorkflowWorkItemInput"
6139
+ }
6140
+ }
6141
+ }
6142
+ },
6143
+ responses: {
6144
+ "201": {
6145
+ description: "workflow work item",
6146
+ content: {
6147
+ "application/json": {
6148
+ schema: {
6149
+ $ref: "#/components/schemas/WorkflowWorkItemResponse"
5906
6150
  }
5907
6151
  }
5908
6152
  }
5909
6153
  }
5910
6154
  }
5911
6155
  }
5912
- }
5913
- },
5914
- components: {
5915
- schemas: {
5916
- Foundation: {
5917
- type: "object",
5918
- properties: {
5919
- status: {
5920
- type: "string"
6156
+ },
6157
+ "/v1/receipts": {
6158
+ get: {
6159
+ operationId: "listRunReceipts",
6160
+ summary: "List run receipts",
6161
+ parameters: [
6162
+ {
6163
+ name: "loopId",
6164
+ in: "query",
6165
+ required: false,
6166
+ schema: {
6167
+ type: "string"
6168
+ }
5921
6169
  },
5922
- version: {
5923
- type: "string"
6170
+ {
6171
+ name: "repo",
6172
+ in: "query",
6173
+ required: false,
6174
+ schema: {
6175
+ type: "string"
6176
+ }
5924
6177
  },
5925
- mode: {
5926
- type: "string"
6178
+ {
6179
+ name: "taskId",
6180
+ in: "query",
6181
+ required: false,
6182
+ schema: {
6183
+ type: "string"
6184
+ }
5927
6185
  },
5928
- service: {
5929
- type: "string"
6186
+ {
6187
+ name: "knowledgeId",
6188
+ in: "query",
6189
+ required: false,
6190
+ schema: {
6191
+ type: "string"
6192
+ }
6193
+ },
6194
+ {
6195
+ name: "status",
6196
+ in: "query",
6197
+ required: false,
6198
+ schema: {
6199
+ type: "string"
6200
+ }
6201
+ },
6202
+ {
6203
+ name: "limit",
6204
+ in: "query",
6205
+ required: false,
6206
+ schema: {
6207
+ type: "integer"
6208
+ }
6209
+ }
6210
+ ],
6211
+ responses: {
6212
+ "200": {
6213
+ description: "run receipts",
6214
+ content: {
6215
+ "application/json": {
6216
+ schema: {
6217
+ $ref: "#/components/schemas/RunReceiptListResponse"
6218
+ }
6219
+ }
6220
+ }
6221
+ }
6222
+ }
6223
+ },
6224
+ post: {
6225
+ operationId: "writeRunReceipt",
6226
+ summary: "Write a run receipt",
6227
+ requestBody: {
6228
+ required: true,
6229
+ content: {
6230
+ "application/json": {
6231
+ schema: {
6232
+ $ref: "#/components/schemas/WriteRunReceiptInput"
6233
+ }
6234
+ }
6235
+ }
6236
+ },
6237
+ responses: {
6238
+ "201": {
6239
+ description: "run receipt",
6240
+ content: {
6241
+ "application/json": {
6242
+ schema: {
6243
+ $ref: "#/components/schemas/RunReceiptResponse"
6244
+ }
6245
+ }
6246
+ }
6247
+ }
6248
+ }
6249
+ }
6250
+ },
6251
+ "/v1/receipts/{runId}": {
6252
+ get: {
6253
+ operationId: "getRunReceipt",
6254
+ summary: "Get a run receipt by run id",
6255
+ parameters: [
6256
+ {
6257
+ name: "runId",
6258
+ in: "path",
6259
+ required: true,
6260
+ schema: {
6261
+ type: "string"
6262
+ }
6263
+ }
6264
+ ],
6265
+ responses: {
6266
+ "200": {
6267
+ description: "run receipt",
6268
+ content: {
6269
+ "application/json": {
6270
+ schema: {
6271
+ $ref: "#/components/schemas/RunReceiptResponse"
6272
+ }
6273
+ }
6274
+ }
6275
+ }
6276
+ }
6277
+ }
6278
+ }
6279
+ },
6280
+ components: {
6281
+ schemas: {
6282
+ Foundation: {
6283
+ type: "object",
6284
+ properties: {
6285
+ status: {
6286
+ type: "string"
6287
+ },
6288
+ version: {
6289
+ type: "string"
6290
+ },
6291
+ mode: {
6292
+ type: "string"
6293
+ },
6294
+ service: {
6295
+ type: "string"
5930
6296
  },
5931
6297
  detail: {
5932
6298
  type: "string"
@@ -6390,6 +6756,352 @@ var loops_default = {
6390
6756
  "receipts"
6391
6757
  ]
6392
6758
  },
6759
+ Workflow: {
6760
+ type: "object",
6761
+ properties: {
6762
+ id: {
6763
+ type: "string"
6764
+ },
6765
+ name: {
6766
+ type: "string"
6767
+ },
6768
+ description: {
6769
+ type: "string",
6770
+ nullable: true
6771
+ },
6772
+ version: {
6773
+ type: "integer"
6774
+ },
6775
+ status: {
6776
+ type: "string"
6777
+ },
6778
+ steps: {
6779
+ type: "array",
6780
+ items: {
6781
+ type: "object",
6782
+ additionalProperties: true
6783
+ }
6784
+ },
6785
+ goal: {
6786
+ type: "object",
6787
+ additionalProperties: true
6788
+ },
6789
+ createdAt: {
6790
+ type: "string"
6791
+ },
6792
+ updatedAt: {
6793
+ type: "string"
6794
+ }
6795
+ },
6796
+ required: [
6797
+ "id",
6798
+ "name",
6799
+ "version",
6800
+ "status",
6801
+ "steps"
6802
+ ]
6803
+ },
6804
+ CreateWorkflowInput: {
6805
+ type: "object",
6806
+ properties: {
6807
+ name: {
6808
+ type: "string"
6809
+ },
6810
+ description: {
6811
+ type: "string"
6812
+ },
6813
+ steps: {
6814
+ type: "array",
6815
+ items: {
6816
+ type: "object",
6817
+ additionalProperties: true
6818
+ }
6819
+ },
6820
+ goal: {
6821
+ type: "object",
6822
+ additionalProperties: true
6823
+ }
6824
+ },
6825
+ required: [
6826
+ "name",
6827
+ "steps"
6828
+ ]
6829
+ },
6830
+ WorkflowResponse: {
6831
+ type: "object",
6832
+ properties: {
6833
+ ok: {
6834
+ type: "boolean"
6835
+ },
6836
+ workflow: {
6837
+ $ref: "#/components/schemas/Workflow"
6838
+ }
6839
+ },
6840
+ required: [
6841
+ "ok",
6842
+ "workflow"
6843
+ ]
6844
+ },
6845
+ WorkflowListResponse: {
6846
+ type: "object",
6847
+ properties: {
6848
+ ok: {
6849
+ type: "boolean"
6850
+ },
6851
+ workflows: {
6852
+ type: "array",
6853
+ items: {
6854
+ $ref: "#/components/schemas/Workflow"
6855
+ }
6856
+ }
6857
+ },
6858
+ required: [
6859
+ "ok",
6860
+ "workflows"
6861
+ ]
6862
+ },
6863
+ CreateWorkflowInvocationInput: {
6864
+ type: "object",
6865
+ properties: {
6866
+ id: {
6867
+ type: "string"
6868
+ },
6869
+ workflowId: {
6870
+ type: "string"
6871
+ },
6872
+ templateId: {
6873
+ type: "string"
6874
+ },
6875
+ sourceRef: {
6876
+ type: "object",
6877
+ additionalProperties: true
6878
+ },
6879
+ subjectRef: {
6880
+ type: "object",
6881
+ additionalProperties: true
6882
+ },
6883
+ intent: {
6884
+ type: "string"
6885
+ },
6886
+ scope: {
6887
+ type: "object",
6888
+ additionalProperties: true
6889
+ },
6890
+ outputPolicy: {
6891
+ type: "object",
6892
+ additionalProperties: true
6893
+ }
6894
+ },
6895
+ required: [
6896
+ "sourceRef",
6897
+ "subjectRef",
6898
+ "intent"
6899
+ ]
6900
+ },
6901
+ WorkflowInvocation: {
6902
+ allOf: [
6903
+ {
6904
+ $ref: "#/components/schemas/CreateWorkflowInvocationInput"
6905
+ },
6906
+ {
6907
+ type: "object",
6908
+ properties: {
6909
+ id: {
6910
+ type: "string"
6911
+ },
6912
+ createdAt: {
6913
+ type: "string"
6914
+ },
6915
+ updatedAt: {
6916
+ type: "string"
6917
+ }
6918
+ },
6919
+ required: [
6920
+ "id",
6921
+ "createdAt",
6922
+ "updatedAt"
6923
+ ]
6924
+ }
6925
+ ]
6926
+ },
6927
+ WorkflowInvocationResponse: {
6928
+ type: "object",
6929
+ properties: {
6930
+ ok: {
6931
+ type: "boolean"
6932
+ },
6933
+ invocation: {
6934
+ $ref: "#/components/schemas/WorkflowInvocation"
6935
+ }
6936
+ },
6937
+ required: [
6938
+ "ok",
6939
+ "invocation"
6940
+ ]
6941
+ },
6942
+ WorkflowInvocationListResponse: {
6943
+ type: "object",
6944
+ properties: {
6945
+ ok: {
6946
+ type: "boolean"
6947
+ },
6948
+ invocations: {
6949
+ type: "array",
6950
+ items: {
6951
+ $ref: "#/components/schemas/WorkflowInvocation"
6952
+ }
6953
+ }
6954
+ },
6955
+ required: [
6956
+ "ok",
6957
+ "invocations"
6958
+ ]
6959
+ },
6960
+ WorkflowWorkItem: {
6961
+ type: "object",
6962
+ additionalProperties: true,
6963
+ properties: {
6964
+ id: {
6965
+ type: "string"
6966
+ },
6967
+ routeKey: {
6968
+ type: "string"
6969
+ },
6970
+ idempotencyKey: {
6971
+ type: "string"
6972
+ },
6973
+ invocationId: {
6974
+ type: "string"
6975
+ },
6976
+ sourceType: {
6977
+ type: "string"
6978
+ },
6979
+ sourceRef: {
6980
+ type: "string"
6981
+ },
6982
+ subjectRef: {
6983
+ type: "string"
6984
+ },
6985
+ status: {
6986
+ type: "string"
6987
+ },
6988
+ priority: {
6989
+ type: "integer"
6990
+ },
6991
+ createdAt: {
6992
+ type: "string"
6993
+ },
6994
+ updatedAt: {
6995
+ type: "string"
6996
+ }
6997
+ },
6998
+ required: [
6999
+ "id",
7000
+ "routeKey",
7001
+ "idempotencyKey",
7002
+ "invocationId",
7003
+ "sourceType",
7004
+ "sourceRef",
7005
+ "subjectRef",
7006
+ "status",
7007
+ "priority"
7008
+ ]
7009
+ },
7010
+ UpsertWorkflowWorkItemInput: {
7011
+ type: "object",
7012
+ properties: {
7013
+ id: {
7014
+ type: "string"
7015
+ },
7016
+ routeKey: {
7017
+ type: "string"
7018
+ },
7019
+ idempotencyKey: {
7020
+ type: "string"
7021
+ },
7022
+ invocationId: {
7023
+ type: "string"
7024
+ },
7025
+ sourceType: {
7026
+ type: "string"
7027
+ },
7028
+ sourceRef: {
7029
+ type: "string"
7030
+ },
7031
+ subjectRef: {
7032
+ type: "string"
7033
+ },
7034
+ projectKey: {
7035
+ type: "string"
7036
+ },
7037
+ projectGroup: {
7038
+ type: "string"
7039
+ },
7040
+ machineId: {
7041
+ type: "string"
7042
+ },
7043
+ routeScope: {
7044
+ type: "string"
7045
+ },
7046
+ priority: {
7047
+ type: "integer"
7048
+ },
7049
+ status: {
7050
+ type: "string",
7051
+ enum: [
7052
+ "queued",
7053
+ "deferred"
7054
+ ]
7055
+ },
7056
+ nextAttemptAt: {
7057
+ type: "string"
7058
+ },
7059
+ lastReason: {
7060
+ type: "string"
7061
+ }
7062
+ },
7063
+ required: [
7064
+ "routeKey",
7065
+ "idempotencyKey",
7066
+ "invocationId",
7067
+ "sourceType",
7068
+ "sourceRef",
7069
+ "subjectRef"
7070
+ ]
7071
+ },
7072
+ WorkflowWorkItemResponse: {
7073
+ type: "object",
7074
+ properties: {
7075
+ ok: {
7076
+ type: "boolean"
7077
+ },
7078
+ workItem: {
7079
+ $ref: "#/components/schemas/WorkflowWorkItem"
7080
+ }
7081
+ },
7082
+ required: [
7083
+ "ok",
7084
+ "workItem"
7085
+ ]
7086
+ },
7087
+ WorkflowWorkItemListResponse: {
7088
+ type: "object",
7089
+ properties: {
7090
+ ok: {
7091
+ type: "boolean"
7092
+ },
7093
+ workItems: {
7094
+ type: "array",
7095
+ items: {
7096
+ $ref: "#/components/schemas/WorkflowWorkItem"
7097
+ }
7098
+ }
7099
+ },
7100
+ required: [
7101
+ "ok",
7102
+ "workItems"
7103
+ ]
7104
+ },
6393
7105
  ImportInput: {
6394
7106
  type: "object",
6395
7107
  description: "Batches of full rows to upsert by id. Any array may be omitted.",
@@ -6417,7 +7129,15 @@ var loops_default = {
6417
7129
  },
6418
7130
  replace: {
6419
7131
  type: "boolean",
6420
- description: "Update existing rows whose id matches (default false: existing rows are left unchanged)."
7132
+ description: "Update existing rows whose id matches. Default safe imports may still archive/pause same-id workflows/loops when preserve flags are false."
7133
+ },
7134
+ preserveLoopScheduling: {
7135
+ type: "boolean",
7136
+ description: "Default false. When false, imported loops are written paused with nextRunAt and retryScheduledFor cleared so backfills never schedule immediately."
7137
+ },
7138
+ preserveWorkflowActivation: {
7139
+ type: "boolean",
7140
+ description: "Default false. When false, imported workflows are written archived, including same-id active rows, so backfills cannot activate workflow execution unless explicitly requested."
6421
7141
  }
6422
7142
  }
6423
7143
  },
@@ -6652,6 +7372,21 @@ async function handleV1Request(ctx) {
6652
7372
  return errorResponse(error);
6653
7373
  }
6654
7374
  }
7375
+ function safeImportedWorkflow(workflow, opts) {
7376
+ if (opts.preserveWorkflowActivation)
7377
+ return workflow;
7378
+ return { ...workflow, status: "archived" };
7379
+ }
7380
+ function safeImportedLoop(loop, opts) {
7381
+ if (opts.preserveLoopScheduling)
7382
+ return loop;
7383
+ return {
7384
+ ...loop,
7385
+ status: "paused",
7386
+ nextRunAt: undefined,
7387
+ retryScheduledFor: undefined
7388
+ };
7389
+ }
6655
7390
  async function handleImportRequest(ctx, segments) {
6656
7391
  if (segments.length !== 0 || ctx.request.method !== "POST")
6657
7392
  return fail("not_found", 404);
@@ -6661,14 +7396,20 @@ async function handleImportRequest(ctx, segments) {
6661
7396
  const workflows = Array.isArray(body.workflows) ? body.workflows : [];
6662
7397
  const loops = Array.isArray(body.loops) ? body.loops : [];
6663
7398
  const runs = Array.isArray(body.runs) ? body.runs : [];
7399
+ const preserveWorkflowActivation = body.preserveWorkflowActivation === true;
7400
+ const preserveLoopScheduling = body.preserveLoopScheduling === true;
6664
7401
  const imported = { workflows: 0, loops: 0, runs: 0 };
6665
7402
  let skippedRunning = 0;
6666
7403
  for (const workflow of workflows) {
6667
- await storage.upsertMigrationWorkflow(workflow, { replace });
7404
+ await storage.upsertMigrationWorkflow(safeImportedWorkflow(workflow, { preserveWorkflowActivation }), {
7405
+ replace: replace || !preserveWorkflowActivation
7406
+ });
6668
7407
  imported.workflows += 1;
6669
7408
  }
6670
7409
  for (const loop of loops) {
6671
- await storage.upsertMigrationLoop(loop, { replace });
7410
+ await storage.upsertMigrationLoop(safeImportedLoop(loop, { preserveLoopScheduling }), {
7411
+ replace: replace || !preserveLoopScheduling
7412
+ });
6672
7413
  imported.loops += 1;
6673
7414
  }
6674
7415
  for (const run of runs) {
@@ -6827,6 +7568,10 @@ async function handleWorkItemsRequest(ctx, segments) {
6827
7568
  });
6828
7569
  return ok({ workItems: items.map(publicWorkflowWorkItem) });
6829
7570
  }
7571
+ if (segments.length === 0 && ctx.request.method === "POST") {
7572
+ const body = await readJsonBody(ctx.request, ctx.bodyLimitBytes);
7573
+ return ok({ workItem: publicWorkflowWorkItem(await storage.upsertWorkflowWorkItem(body)) }, { status: 201 });
7574
+ }
6830
7575
  const id = segments[0];
6831
7576
  if (!id)
6832
7577
  return fail("not_found", 404);
@@ -6844,6 +7589,10 @@ async function handleInvocationsRequest(ctx, segments) {
6844
7589
  const invocations = await storage.listWorkflowInvocations({ limit: optionalLimit(ctx.url.searchParams.get("limit")) });
6845
7590
  return ok({ invocations: invocations.map(publicWorkflowInvocation) });
6846
7591
  }
7592
+ if (segments.length === 0 && ctx.request.method === "POST") {
7593
+ const body = await readJsonBody(ctx.request, ctx.bodyLimitBytes);
7594
+ return ok({ invocation: publicWorkflowInvocation(await storage.createWorkflowInvocation(body)) }, { status: 201 });
7595
+ }
6847
7596
  const id = segments[0];
6848
7597
  if (!id)
6849
7598
  return fail("not_found", 404);
@@ -7680,6 +8429,9 @@ CREATE INDEX IF NOT EXISTS idx_run_receipts_knowledge_ids ON run_receipts USING
7680
8429
  migration("0006_work_item_machine_id", `
7681
8430
  ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS machine_id TEXT;
7682
8431
  CREATE INDEX IF NOT EXISTS idx_workflow_work_items_machine ON workflow_work_items(machine_id, status);
8432
+ `),
8433
+ migration("0007_work_item_gate_deaths", `
8434
+ ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS gate_deaths INTEGER NOT NULL DEFAULT 0;
7683
8435
  `)
7684
8436
  ]);
7685
8437
 
@@ -8972,11 +9724,118 @@ class PostgresLoopStorage {
8972
9724
  throw new Error(`workflow not found after archive: ${existing.id}`);
8973
9725
  return archived;
8974
9726
  }
8975
- createWorkflowInvocation() {
8976
- throw new NotImplementedError("createWorkflowInvocation");
9727
+ async createWorkflowInvocation(...args) {
9728
+ const [input] = args;
9729
+ const now = nowIso();
9730
+ const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
9731
+ if (sourceDedupeKey) {
9732
+ const existing = await this.client.get("SELECT * FROM workflow_invocations WHERE source_kind = $1 AND source_dedupe_key = $2 LIMIT 1", [input.sourceRef.kind, sourceDedupeKey]);
9733
+ if (existing)
9734
+ return rowToWorkflowInvocation(existing);
9735
+ }
9736
+ const id = input.id ?? genId();
9737
+ await this.client.execute(`INSERT INTO workflow_invocations (id, workflow_id, template_id, source_kind, source_id, source_dedupe_key,
9738
+ source_json, subject_kind, subject_id, subject_path, subject_url, subject_json, intent, scope_json,
9739
+ output_policy_json, created_at, updated_at)
9740
+ VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10,$11,$12::jsonb,$13,$14::jsonb,$15::jsonb,$16,$17)`, [
9741
+ id,
9742
+ input.workflowId ?? null,
9743
+ input.templateId ?? null,
9744
+ input.sourceRef.kind,
9745
+ input.sourceRef.id ?? null,
9746
+ sourceDedupeKey ?? null,
9747
+ JSON.stringify(input.sourceRef),
9748
+ input.subjectRef.kind,
9749
+ input.subjectRef.id ?? null,
9750
+ input.subjectRef.path ?? null,
9751
+ input.subjectRef.url ?? null,
9752
+ JSON.stringify(input.subjectRef),
9753
+ input.intent,
9754
+ input.scope ? JSON.stringify(input.scope) : null,
9755
+ input.outputPolicy ? JSON.stringify(input.outputPolicy) : null,
9756
+ now,
9757
+ now
9758
+ ]);
9759
+ const row = await this.client.get("SELECT * FROM workflow_invocations WHERE id = $1", [id]);
9760
+ if (!row)
9761
+ throw new Error(`workflow invocation not found after create: ${id}`);
9762
+ return rowToWorkflowInvocation(row);
8977
9763
  }
8978
- upsertWorkflowWorkItem() {
8979
- throw new NotImplementedError("upsertWorkflowWorkItem");
9764
+ async upsertWorkflowWorkItem(...args) {
9765
+ const [input] = args;
9766
+ const now = nowIso();
9767
+ const id = input.id ?? genId();
9768
+ const status = input.status ?? "queued";
9769
+ await this.client.execute(`INSERT INTO workflow_work_items (id, route_key, idempotency_key, invocation_id, source_type, source_ref,
9770
+ subject_ref, project_key, project_group, machine_id, route_scope, priority, status, attempts, next_attempt_at,
9771
+ lease_expires_at, workflow_id, loop_id, workflow_run_id, last_reason, created_at, updated_at)
9772
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,0,$14,NULL,NULL,NULL,NULL,$15,$16,$17)
9773
+ ON CONFLICT(route_key, idempotency_key) DO UPDATE SET
9774
+ invocation_id=excluded.invocation_id,
9775
+ source_type=excluded.source_type,
9776
+ source_ref=excluded.source_ref,
9777
+ subject_ref=excluded.subject_ref,
9778
+ project_key=excluded.project_key,
9779
+ project_group=excluded.project_group,
9780
+ machine_id=CASE
9781
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.machine_id
9782
+ ELSE excluded.machine_id
9783
+ END,
9784
+ route_scope=excluded.route_scope,
9785
+ priority=excluded.priority,
9786
+ status=CASE
9787
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled')
9788
+ THEN workflow_work_items.status
9789
+ ELSE excluded.status
9790
+ END,
9791
+ workflow_id=CASE
9792
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_id
9793
+ ELSE NULL
9794
+ END,
9795
+ loop_id=CASE
9796
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.loop_id
9797
+ ELSE NULL
9798
+ END,
9799
+ workflow_run_id=CASE
9800
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.workflow_run_id
9801
+ ELSE NULL
9802
+ END,
9803
+ lease_expires_at=CASE
9804
+ WHEN workflow_work_items.status IN ('succeeded', 'admitted', 'running', 'failed', 'dead_letter', 'cancelled') THEN workflow_work_items.lease_expires_at
9805
+ ELSE NULL
9806
+ END,
9807
+ next_attempt_at=excluded.next_attempt_at,
9808
+ last_reason=CASE
9809
+ WHEN workflow_work_items.attempts > 0
9810
+ AND workflow_work_items.status IN ('queued', 'deferred')
9811
+ AND workflow_work_items.last_reason IS NOT NULL
9812
+ AND excluded.last_reason IS NOT NULL
9813
+ THEN workflow_work_items.last_reason || '; ' || excluded.last_reason
9814
+ ELSE COALESCE(excluded.last_reason, workflow_work_items.last_reason)
9815
+ END,
9816
+ updated_at=excluded.updated_at`, [
9817
+ id,
9818
+ input.routeKey,
9819
+ input.idempotencyKey,
9820
+ input.invocationId,
9821
+ input.sourceType,
9822
+ input.sourceRef,
9823
+ input.subjectRef,
9824
+ input.projectKey ?? null,
9825
+ input.projectGroup ?? null,
9826
+ input.machineId ?? null,
9827
+ input.routeScope ?? null,
9828
+ input.priority ?? 0,
9829
+ status,
9830
+ input.nextAttemptAt ?? null,
9831
+ input.lastReason ?? null,
9832
+ now,
9833
+ now
9834
+ ]);
9835
+ const row = await this.client.get("SELECT * FROM workflow_work_items WHERE route_key = $1 AND idempotency_key = $2 LIMIT 1", [input.routeKey, input.idempotencyKey]);
9836
+ if (!row)
9837
+ throw new Error(`workflow work item not found after upsert: ${input.routeKey}/${input.idempotencyKey}`);
9838
+ return rowToWorkflowWorkItem(row);
8980
9839
  }
8981
9840
  admitWorkflowWorkItem() {
8982
9841
  throw new NotImplementedError("admitWorkflowWorkItem");