@hasna/loops 0.4.13 → 0.4.22

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +99 -3
  2. package/README.md +171 -39
  3. package/dist/api/index.d.ts +36 -0
  4. package/dist/api/index.js +1518 -15
  5. package/dist/cli/index.js +7280 -3213
  6. package/dist/cli/ui.d.ts +44 -0
  7. package/dist/daemon/index.js +1433 -303
  8. package/dist/generated/storage-kit/health.d.ts +19 -0
  9. package/dist/generated/storage-kit/index.d.ts +7 -0
  10. package/dist/generated/storage-kit/migrations.d.ts +47 -0
  11. package/dist/generated/storage-kit/mode.d.ts +47 -0
  12. package/dist/generated/storage-kit/pool.d.ts +33 -0
  13. package/dist/generated/storage-kit/query.d.ts +35 -0
  14. package/dist/generated/storage-kit/tls.d.ts +25 -0
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +8689 -4837
  17. package/dist/lib/cloud/mode.d.ts +17 -0
  18. package/dist/lib/cloud/resolve.d.ts +16 -0
  19. package/dist/lib/cloud/storage.d.ts +82 -0
  20. package/dist/lib/cloud/transport.d.ts +148 -0
  21. package/dist/lib/format.d.ts +2 -1
  22. package/dist/lib/health.d.ts +83 -3
  23. package/dist/lib/migration.d.ts +40 -0
  24. package/dist/lib/mode.js +15 -3
  25. package/dist/lib/route/index.d.ts +2 -0
  26. package/dist/lib/route/policies.d.ts +51 -0
  27. package/dist/lib/route/provider-admission.d.ts +37 -0
  28. package/dist/lib/route/throttle.d.ts +4 -0
  29. package/dist/lib/route/types.d.ts +8 -0
  30. package/dist/lib/run-receipts.d.ts +14 -0
  31. package/dist/lib/scheduler.d.ts +5 -2
  32. package/dist/lib/storage/contract.d.ts +7 -1
  33. package/dist/lib/storage/index.d.ts +1 -0
  34. package/dist/lib/storage/index.js +2028 -231
  35. package/dist/lib/storage/pg-executor.d.ts +27 -0
  36. package/dist/lib/storage/pg-runner-claim.d.ts +40 -0
  37. package/dist/lib/storage/postgres-loop-storage.d.ts +120 -0
  38. package/dist/lib/storage/postgres-schema.js +29 -0
  39. package/dist/lib/storage/postgres.js +29 -0
  40. package/dist/lib/storage/sqlite.d.ts +6 -0
  41. package/dist/lib/storage/sqlite.js +748 -26
  42. package/dist/lib/store/index.d.ts +268 -0
  43. package/dist/lib/store.d.ts +282 -1
  44. package/dist/lib/store.js +746 -26
  45. package/dist/lib/template-kit.d.ts +25 -0
  46. package/dist/lib/templates.d.ts +16 -1
  47. package/dist/mcp/http.d.ts +16 -0
  48. package/dist/mcp/index.js +2885 -634
  49. package/dist/runner/index.js +198 -32
  50. package/dist/sdk/http.d.ts +182 -0
  51. package/dist/sdk/http.js +166 -0
  52. package/dist/sdk/index.d.ts +55 -18
  53. package/dist/sdk/index.js +2734 -617
  54. package/dist/serve/index.d.ts +4 -0
  55. package/dist/serve/index.js +9095 -0
  56. package/dist/types.d.ts +52 -0
  57. package/docs/AUTOMATION_RUNTIME_DESIGN.md +442 -1
  58. package/docs/CUTOVER-RUNBOOK.md +78 -0
  59. package/docs/DEPLOYMENT_MODES.md +35 -18
  60. package/docs/RUNTIME_BOUNDARY.md +203 -0
  61. package/docs/USAGE.md +195 -38
  62. package/package.json +15 -3
package/dist/api/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // package.json
4
4
  var package_default = {
5
5
  name: "@hasna/loops",
6
- version: "0.4.13",
6
+ version: "0.4.22",
7
7
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
8
8
  type: "module",
9
9
  main: "dist/index.js",
@@ -12,6 +12,7 @@ var package_default = {
12
12
  loops: "dist/cli/index.js",
13
13
  "loops-daemon": "dist/daemon/index.js",
14
14
  "loops-api": "dist/api/index.js",
15
+ "loops-serve": "dist/serve/index.js",
15
16
  "loops-runner": "dist/runner/index.js",
16
17
  "loops-mcp": "dist/mcp/index.js"
17
18
  },
@@ -24,6 +25,14 @@ var package_default = {
24
25
  types: "./dist/sdk/index.d.ts",
25
26
  import: "./dist/sdk/index.js"
26
27
  },
28
+ "./sdk/http": {
29
+ types: "./dist/sdk/http.d.ts",
30
+ import: "./dist/sdk/http.js"
31
+ },
32
+ "./serve": {
33
+ types: "./dist/serve/index.d.ts",
34
+ import: "./dist/serve/index.js"
35
+ },
27
36
  "./mcp": {
28
37
  types: "./dist/mcp/index.d.ts",
29
38
  import: "./dist/mcp/index.js"
@@ -69,7 +78,7 @@ var package_default = {
69
78
  "LICENSE"
70
79
  ],
71
80
  scripts: {
72
- build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts src/lib/mode.ts src/lib/storage/index.ts src/lib/storage/contract.ts src/lib/storage/sqlite.ts src/lib/storage/postgres.ts src/lib/storage/postgres-schema.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
81
+ build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/serve/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/sdk/http.ts src/lib/store.ts src/lib/mode.ts src/lib/storage/index.ts src/lib/storage/contract.ts src/lib/storage/sqlite.ts src/lib/storage/postgres.ts src/lib/storage/postgres-schema.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/serve/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
73
82
  "build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
74
83
  typecheck: "tsc --noEmit",
75
84
  test: "bun test",
@@ -109,13 +118,16 @@ var package_default = {
109
118
  "@openrouter/ai-sdk-provider": "2.9.1",
110
119
  ai: "6.0.204",
111
120
  commander: "^13.1.0",
121
+ pg: "^8.13.1",
112
122
  zod: "4.4.3"
113
123
  },
114
124
  optionalDependencies: {
115
- "@hasna/machines": "0.0.49"
125
+ "@hasna/machines": "0.0.49",
126
+ "@hasna/contracts": "^0.4.2"
116
127
  },
117
128
  devDependencies: {
118
129
  "@types/bun": "latest",
130
+ "@types/pg": "^8.11.10",
119
131
  typescript: "^5.7.3"
120
132
  },
121
133
  publishConfig: {
@@ -426,6 +438,9 @@ function publicRun(run, showOutput = false, opts = {}) {
426
438
  error: opts.redactError ? redact(run.error) : run.error
427
439
  };
428
440
  }
441
+ function publicRunReceipt(receipt) {
442
+ return { ...receipt };
443
+ }
429
444
  function publicExecutorResult(result, showOutput = false) {
430
445
  return {
431
446
  ...result,
@@ -815,11 +830,1194 @@ function scrubSecretsDeep(value) {
815
830
  }
816
831
  return value;
817
832
  }
833
+ // openapi/loops.json
834
+ var loops_default = {
835
+ openapi: "3.1.0",
836
+ info: {
837
+ title: "OpenLoops",
838
+ version: "0.4.14",
839
+ description: "OpenLoops self-hosted control-plane HTTP API (loops-serve). RDS-direct (Amendment A1). All /v1 routes require an API key (x-api-key or Authorization: Bearer) with a loops:* scope."
840
+ },
841
+ servers: [
842
+ {
843
+ url: "https://loops.hasna.xyz"
844
+ }
845
+ ],
846
+ paths: {
847
+ "/health": {
848
+ get: {
849
+ operationId: "healthCheck",
850
+ summary: "Liveness probe",
851
+ responses: {
852
+ "200": {
853
+ description: "alive",
854
+ content: {
855
+ "application/json": {
856
+ schema: {
857
+ $ref: "#/components/schemas/Foundation"
858
+ }
859
+ }
860
+ }
861
+ }
862
+ }
863
+ }
864
+ },
865
+ "/ready": {
866
+ get: {
867
+ operationId: "readyCheck",
868
+ summary: "Readiness probe (storage reachable + migrated)",
869
+ responses: {
870
+ "200": {
871
+ description: "ready",
872
+ content: {
873
+ "application/json": {
874
+ schema: {
875
+ $ref: "#/components/schemas/Foundation"
876
+ }
877
+ }
878
+ }
879
+ },
880
+ "503": {
881
+ description: "not ready",
882
+ content: {
883
+ "application/json": {
884
+ schema: {
885
+ $ref: "#/components/schemas/Foundation"
886
+ }
887
+ }
888
+ }
889
+ }
890
+ }
891
+ }
892
+ },
893
+ "/version": {
894
+ get: {
895
+ operationId: "getVersion",
896
+ summary: "Service version",
897
+ responses: {
898
+ "200": {
899
+ description: "version",
900
+ content: {
901
+ "application/json": {
902
+ schema: {
903
+ $ref: "#/components/schemas/Foundation"
904
+ }
905
+ }
906
+ }
907
+ }
908
+ }
909
+ }
910
+ },
911
+ "/v1/loops": {
912
+ get: {
913
+ operationId: "listLoops",
914
+ summary: "List loops",
915
+ parameters: [
916
+ {
917
+ name: "status",
918
+ in: "query",
919
+ required: false,
920
+ schema: {
921
+ type: "string",
922
+ enum: [
923
+ "active",
924
+ "paused",
925
+ "stopped",
926
+ "expired"
927
+ ]
928
+ }
929
+ },
930
+ {
931
+ name: "limit",
932
+ in: "query",
933
+ required: false,
934
+ schema: {
935
+ type: "integer"
936
+ }
937
+ },
938
+ {
939
+ name: "includeArchived",
940
+ in: "query",
941
+ required: false,
942
+ schema: {
943
+ type: "boolean"
944
+ }
945
+ },
946
+ {
947
+ name: "archived",
948
+ in: "query",
949
+ required: false,
950
+ schema: {
951
+ type: "boolean"
952
+ }
953
+ }
954
+ ],
955
+ responses: {
956
+ "200": {
957
+ description: "loops",
958
+ content: {
959
+ "application/json": {
960
+ schema: {
961
+ $ref: "#/components/schemas/LoopListResponse"
962
+ }
963
+ }
964
+ }
965
+ }
966
+ }
967
+ },
968
+ post: {
969
+ operationId: "createLoop",
970
+ summary: "Create a loop",
971
+ requestBody: {
972
+ required: true,
973
+ content: {
974
+ "application/json": {
975
+ schema: {
976
+ $ref: "#/components/schemas/CreateLoopInput"
977
+ }
978
+ }
979
+ }
980
+ },
981
+ responses: {
982
+ "201": {
983
+ description: "created",
984
+ content: {
985
+ "application/json": {
986
+ schema: {
987
+ $ref: "#/components/schemas/LoopResponse"
988
+ }
989
+ }
990
+ }
991
+ }
992
+ }
993
+ }
994
+ },
995
+ "/v1/loops/count": {
996
+ get: {
997
+ operationId: "countLoops",
998
+ summary: "Count loops (total-row verification)",
999
+ parameters: [
1000
+ {
1001
+ name: "status",
1002
+ in: "query",
1003
+ required: false,
1004
+ schema: {
1005
+ type: "string"
1006
+ }
1007
+ },
1008
+ {
1009
+ name: "includeArchived",
1010
+ in: "query",
1011
+ required: false,
1012
+ schema: {
1013
+ type: "boolean"
1014
+ }
1015
+ },
1016
+ {
1017
+ name: "archived",
1018
+ in: "query",
1019
+ required: false,
1020
+ schema: {
1021
+ type: "boolean"
1022
+ }
1023
+ }
1024
+ ],
1025
+ responses: {
1026
+ "200": {
1027
+ description: "count",
1028
+ content: {
1029
+ "application/json": {
1030
+ schema: {
1031
+ $ref: "#/components/schemas/CountResponse"
1032
+ }
1033
+ }
1034
+ }
1035
+ }
1036
+ }
1037
+ }
1038
+ },
1039
+ "/v1/loops/{id}": {
1040
+ get: {
1041
+ operationId: "getLoop",
1042
+ summary: "Get a loop by id",
1043
+ parameters: [
1044
+ {
1045
+ name: "id",
1046
+ in: "path",
1047
+ required: true,
1048
+ schema: {
1049
+ type: "string"
1050
+ }
1051
+ }
1052
+ ],
1053
+ responses: {
1054
+ "200": {
1055
+ description: "loop",
1056
+ content: {
1057
+ "application/json": {
1058
+ schema: {
1059
+ $ref: "#/components/schemas/LoopResponse"
1060
+ }
1061
+ }
1062
+ }
1063
+ }
1064
+ }
1065
+ },
1066
+ patch: {
1067
+ operationId: "updateLoop",
1068
+ summary: "Update a loop (status / schedule fields)",
1069
+ parameters: [
1070
+ {
1071
+ name: "id",
1072
+ in: "path",
1073
+ required: true,
1074
+ schema: {
1075
+ type: "string"
1076
+ }
1077
+ }
1078
+ ],
1079
+ requestBody: {
1080
+ required: true,
1081
+ content: {
1082
+ "application/json": {
1083
+ schema: {
1084
+ $ref: "#/components/schemas/UpdateLoopInput"
1085
+ }
1086
+ }
1087
+ }
1088
+ },
1089
+ responses: {
1090
+ "200": {
1091
+ description: "loop",
1092
+ content: {
1093
+ "application/json": {
1094
+ schema: {
1095
+ $ref: "#/components/schemas/LoopResponse"
1096
+ }
1097
+ }
1098
+ }
1099
+ }
1100
+ }
1101
+ },
1102
+ delete: {
1103
+ operationId: "deleteLoop",
1104
+ summary: "Delete a loop",
1105
+ parameters: [
1106
+ {
1107
+ name: "id",
1108
+ in: "path",
1109
+ required: true,
1110
+ schema: {
1111
+ type: "string"
1112
+ }
1113
+ }
1114
+ ],
1115
+ responses: {
1116
+ "200": {
1117
+ description: "deleted",
1118
+ content: {
1119
+ "application/json": {
1120
+ schema: {
1121
+ $ref: "#/components/schemas/DeleteResponse"
1122
+ }
1123
+ }
1124
+ }
1125
+ }
1126
+ }
1127
+ }
1128
+ },
1129
+ "/v1/loops/{id}/archive": {
1130
+ post: {
1131
+ operationId: "archiveLoop",
1132
+ summary: "Archive a loop",
1133
+ parameters: [
1134
+ {
1135
+ name: "id",
1136
+ in: "path",
1137
+ required: true,
1138
+ schema: {
1139
+ type: "string"
1140
+ }
1141
+ }
1142
+ ],
1143
+ responses: {
1144
+ "200": {
1145
+ description: "loop",
1146
+ content: {
1147
+ "application/json": {
1148
+ schema: {
1149
+ $ref: "#/components/schemas/LoopResponse"
1150
+ }
1151
+ }
1152
+ }
1153
+ }
1154
+ }
1155
+ }
1156
+ },
1157
+ "/v1/loops/{id}/unarchive": {
1158
+ post: {
1159
+ operationId: "unarchiveLoop",
1160
+ summary: "Unarchive a loop",
1161
+ parameters: [
1162
+ {
1163
+ name: "id",
1164
+ in: "path",
1165
+ required: true,
1166
+ schema: {
1167
+ type: "string"
1168
+ }
1169
+ }
1170
+ ],
1171
+ responses: {
1172
+ "200": {
1173
+ description: "loop",
1174
+ content: {
1175
+ "application/json": {
1176
+ schema: {
1177
+ $ref: "#/components/schemas/LoopResponse"
1178
+ }
1179
+ }
1180
+ }
1181
+ }
1182
+ }
1183
+ }
1184
+ },
1185
+ "/v1/runs": {
1186
+ get: {
1187
+ operationId: "listRuns",
1188
+ summary: "List runs",
1189
+ parameters: [
1190
+ {
1191
+ name: "loopId",
1192
+ in: "query",
1193
+ required: false,
1194
+ schema: {
1195
+ type: "string"
1196
+ }
1197
+ },
1198
+ {
1199
+ name: "status",
1200
+ in: "query",
1201
+ required: false,
1202
+ schema: {
1203
+ type: "string"
1204
+ }
1205
+ },
1206
+ {
1207
+ name: "limit",
1208
+ in: "query",
1209
+ required: false,
1210
+ schema: {
1211
+ type: "integer"
1212
+ }
1213
+ }
1214
+ ],
1215
+ responses: {
1216
+ "200": {
1217
+ description: "runs",
1218
+ content: {
1219
+ "application/json": {
1220
+ schema: {
1221
+ $ref: "#/components/schemas/RunListResponse"
1222
+ }
1223
+ }
1224
+ }
1225
+ }
1226
+ }
1227
+ }
1228
+ },
1229
+ "/v1/runs/count": {
1230
+ get: {
1231
+ operationId: "countRuns",
1232
+ summary: "Count runs (total-row verification)",
1233
+ parameters: [
1234
+ {
1235
+ name: "status",
1236
+ in: "query",
1237
+ required: false,
1238
+ schema: {
1239
+ type: "string"
1240
+ }
1241
+ }
1242
+ ],
1243
+ responses: {
1244
+ "200": {
1245
+ description: "count",
1246
+ content: {
1247
+ "application/json": {
1248
+ schema: {
1249
+ $ref: "#/components/schemas/CountResponse"
1250
+ }
1251
+ }
1252
+ }
1253
+ }
1254
+ }
1255
+ }
1256
+ },
1257
+ "/v1/runs/{id}": {
1258
+ get: {
1259
+ operationId: "getRun",
1260
+ summary: "Get a run by id",
1261
+ parameters: [
1262
+ {
1263
+ name: "id",
1264
+ in: "path",
1265
+ required: true,
1266
+ schema: {
1267
+ type: "string"
1268
+ }
1269
+ }
1270
+ ],
1271
+ responses: {
1272
+ "200": {
1273
+ description: "run",
1274
+ content: {
1275
+ "application/json": {
1276
+ schema: {
1277
+ $ref: "#/components/schemas/RunResponse"
1278
+ }
1279
+ }
1280
+ }
1281
+ }
1282
+ }
1283
+ }
1284
+ },
1285
+ "/v1/import": {
1286
+ post: {
1287
+ operationId: "importRows",
1288
+ summary: "Bulk id-preserving import (self-hosted backfill)",
1289
+ 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.",
1290
+ requestBody: {
1291
+ required: true,
1292
+ content: {
1293
+ "application/json": {
1294
+ schema: {
1295
+ $ref: "#/components/schemas/ImportInput"
1296
+ }
1297
+ }
1298
+ }
1299
+ },
1300
+ responses: {
1301
+ "200": {
1302
+ description: "import counts",
1303
+ content: {
1304
+ "application/json": {
1305
+ schema: {
1306
+ $ref: "#/components/schemas/ImportResponse"
1307
+ }
1308
+ }
1309
+ }
1310
+ }
1311
+ }
1312
+ }
1313
+ },
1314
+ "/v1/receipts": {
1315
+ get: {
1316
+ operationId: "listRunReceipts",
1317
+ summary: "List run receipts",
1318
+ parameters: [
1319
+ {
1320
+ name: "loopId",
1321
+ in: "query",
1322
+ required: false,
1323
+ schema: {
1324
+ type: "string"
1325
+ }
1326
+ },
1327
+ {
1328
+ name: "repo",
1329
+ in: "query",
1330
+ required: false,
1331
+ schema: {
1332
+ type: "string"
1333
+ }
1334
+ },
1335
+ {
1336
+ name: "taskId",
1337
+ in: "query",
1338
+ required: false,
1339
+ schema: {
1340
+ type: "string"
1341
+ }
1342
+ },
1343
+ {
1344
+ name: "knowledgeId",
1345
+ in: "query",
1346
+ required: false,
1347
+ schema: {
1348
+ type: "string"
1349
+ }
1350
+ },
1351
+ {
1352
+ name: "status",
1353
+ in: "query",
1354
+ required: false,
1355
+ schema: {
1356
+ type: "string"
1357
+ }
1358
+ },
1359
+ {
1360
+ name: "limit",
1361
+ in: "query",
1362
+ required: false,
1363
+ schema: {
1364
+ type: "integer"
1365
+ }
1366
+ }
1367
+ ],
1368
+ responses: {
1369
+ "200": {
1370
+ description: "run receipts",
1371
+ content: {
1372
+ "application/json": {
1373
+ schema: {
1374
+ $ref: "#/components/schemas/RunReceiptListResponse"
1375
+ }
1376
+ }
1377
+ }
1378
+ }
1379
+ }
1380
+ },
1381
+ post: {
1382
+ operationId: "writeRunReceipt",
1383
+ summary: "Write a run receipt",
1384
+ requestBody: {
1385
+ required: true,
1386
+ content: {
1387
+ "application/json": {
1388
+ schema: {
1389
+ $ref: "#/components/schemas/WriteRunReceiptInput"
1390
+ }
1391
+ }
1392
+ }
1393
+ },
1394
+ responses: {
1395
+ "201": {
1396
+ description: "run receipt",
1397
+ content: {
1398
+ "application/json": {
1399
+ schema: {
1400
+ $ref: "#/components/schemas/RunReceiptResponse"
1401
+ }
1402
+ }
1403
+ }
1404
+ }
1405
+ }
1406
+ }
1407
+ },
1408
+ "/v1/receipts/{runId}": {
1409
+ get: {
1410
+ operationId: "getRunReceipt",
1411
+ summary: "Get a run receipt by run id",
1412
+ parameters: [
1413
+ {
1414
+ name: "runId",
1415
+ in: "path",
1416
+ required: true,
1417
+ schema: {
1418
+ type: "string"
1419
+ }
1420
+ }
1421
+ ],
1422
+ responses: {
1423
+ "200": {
1424
+ description: "run receipt",
1425
+ content: {
1426
+ "application/json": {
1427
+ schema: {
1428
+ $ref: "#/components/schemas/RunReceiptResponse"
1429
+ }
1430
+ }
1431
+ }
1432
+ }
1433
+ }
1434
+ }
1435
+ }
1436
+ },
1437
+ components: {
1438
+ schemas: {
1439
+ Foundation: {
1440
+ type: "object",
1441
+ properties: {
1442
+ status: {
1443
+ type: "string"
1444
+ },
1445
+ version: {
1446
+ type: "string"
1447
+ },
1448
+ mode: {
1449
+ type: "string"
1450
+ },
1451
+ service: {
1452
+ type: "string"
1453
+ },
1454
+ detail: {
1455
+ type: "string"
1456
+ }
1457
+ },
1458
+ required: [
1459
+ "status",
1460
+ "version",
1461
+ "mode"
1462
+ ]
1463
+ },
1464
+ Loop: {
1465
+ type: "object",
1466
+ properties: {
1467
+ id: {
1468
+ type: "string"
1469
+ },
1470
+ name: {
1471
+ type: "string"
1472
+ },
1473
+ description: {
1474
+ type: "string",
1475
+ nullable: true
1476
+ },
1477
+ status: {
1478
+ type: "string"
1479
+ },
1480
+ schedule: {
1481
+ type: "object",
1482
+ additionalProperties: true
1483
+ },
1484
+ target: {
1485
+ type: "object",
1486
+ additionalProperties: true
1487
+ },
1488
+ nextRunAt: {
1489
+ type: "string",
1490
+ nullable: true
1491
+ },
1492
+ createdAt: {
1493
+ type: "string"
1494
+ },
1495
+ updatedAt: {
1496
+ type: "string"
1497
+ }
1498
+ },
1499
+ required: [
1500
+ "id",
1501
+ "name",
1502
+ "status"
1503
+ ]
1504
+ },
1505
+ CreateLoopInput: {
1506
+ type: "object",
1507
+ properties: {
1508
+ name: {
1509
+ type: "string"
1510
+ },
1511
+ description: {
1512
+ type: "string"
1513
+ },
1514
+ schedule: {
1515
+ type: "object",
1516
+ additionalProperties: true
1517
+ },
1518
+ target: {
1519
+ type: "object",
1520
+ additionalProperties: true
1521
+ }
1522
+ },
1523
+ required: [
1524
+ "name",
1525
+ "schedule",
1526
+ "target"
1527
+ ]
1528
+ },
1529
+ UpdateLoopInput: {
1530
+ type: "object",
1531
+ properties: {
1532
+ status: {
1533
+ type: "string",
1534
+ enum: [
1535
+ "active",
1536
+ "paused",
1537
+ "stopped",
1538
+ "expired"
1539
+ ]
1540
+ },
1541
+ nextRunAt: {
1542
+ type: "string",
1543
+ nullable: true
1544
+ },
1545
+ retryScheduledFor: {
1546
+ type: "string",
1547
+ nullable: true
1548
+ },
1549
+ expiresAt: {
1550
+ type: "string",
1551
+ nullable: true
1552
+ }
1553
+ }
1554
+ },
1555
+ Run: {
1556
+ type: "object",
1557
+ properties: {
1558
+ id: {
1559
+ type: "string"
1560
+ },
1561
+ loopId: {
1562
+ type: "string"
1563
+ },
1564
+ status: {
1565
+ type: "string"
1566
+ },
1567
+ attempt: {
1568
+ type: "integer"
1569
+ },
1570
+ scheduledFor: {
1571
+ type: "string"
1572
+ },
1573
+ startedAt: {
1574
+ type: "string",
1575
+ nullable: true
1576
+ },
1577
+ finishedAt: {
1578
+ type: "string",
1579
+ nullable: true
1580
+ }
1581
+ },
1582
+ required: [
1583
+ "id",
1584
+ "loopId",
1585
+ "status"
1586
+ ]
1587
+ },
1588
+ LoopResponse: {
1589
+ type: "object",
1590
+ properties: {
1591
+ ok: {
1592
+ type: "boolean"
1593
+ },
1594
+ loop: {
1595
+ $ref: "#/components/schemas/Loop"
1596
+ }
1597
+ },
1598
+ required: [
1599
+ "ok",
1600
+ "loop"
1601
+ ]
1602
+ },
1603
+ LoopListResponse: {
1604
+ type: "object",
1605
+ properties: {
1606
+ ok: {
1607
+ type: "boolean"
1608
+ },
1609
+ loops: {
1610
+ type: "array",
1611
+ items: {
1612
+ $ref: "#/components/schemas/Loop"
1613
+ }
1614
+ }
1615
+ },
1616
+ required: [
1617
+ "ok",
1618
+ "loops"
1619
+ ]
1620
+ },
1621
+ RunResponse: {
1622
+ type: "object",
1623
+ properties: {
1624
+ ok: {
1625
+ type: "boolean"
1626
+ },
1627
+ run: {
1628
+ $ref: "#/components/schemas/Run"
1629
+ }
1630
+ },
1631
+ required: [
1632
+ "ok",
1633
+ "run"
1634
+ ]
1635
+ },
1636
+ RunListResponse: {
1637
+ type: "object",
1638
+ properties: {
1639
+ ok: {
1640
+ type: "boolean"
1641
+ },
1642
+ runs: {
1643
+ type: "array",
1644
+ items: {
1645
+ $ref: "#/components/schemas/Run"
1646
+ }
1647
+ }
1648
+ },
1649
+ required: [
1650
+ "ok",
1651
+ "runs"
1652
+ ]
1653
+ },
1654
+ DeleteResponse: {
1655
+ type: "object",
1656
+ properties: {
1657
+ ok: {
1658
+ type: "boolean"
1659
+ },
1660
+ deleted: {
1661
+ type: "boolean"
1662
+ }
1663
+ },
1664
+ required: [
1665
+ "ok",
1666
+ "deleted"
1667
+ ]
1668
+ },
1669
+ RunReceiptMachine: {
1670
+ oneOf: [
1671
+ {
1672
+ type: "string"
1673
+ },
1674
+ {
1675
+ type: "object",
1676
+ additionalProperties: true
1677
+ }
1678
+ ]
1679
+ },
1680
+ RunReceiptSummary: {
1681
+ type: "object",
1682
+ required: [
1683
+ "stdout_bytes",
1684
+ "stderr_bytes"
1685
+ ],
1686
+ properties: {
1687
+ text: {
1688
+ type: "string"
1689
+ },
1690
+ stdout_bytes: {
1691
+ type: "integer",
1692
+ minimum: 0
1693
+ },
1694
+ stderr_bytes: {
1695
+ type: "integer",
1696
+ minimum: 0
1697
+ },
1698
+ stdout_excerpt: {
1699
+ type: "string"
1700
+ },
1701
+ stderr_excerpt: {
1702
+ type: "string"
1703
+ },
1704
+ error: {
1705
+ type: "string"
1706
+ },
1707
+ duration_ms: {
1708
+ type: "integer",
1709
+ minimum: 0
1710
+ }
1711
+ },
1712
+ additionalProperties: false
1713
+ },
1714
+ RunReceipt: {
1715
+ type: "object",
1716
+ required: [
1717
+ "loop_id",
1718
+ "run_id",
1719
+ "machine",
1720
+ "repo",
1721
+ "task_ids",
1722
+ "knowledge_ids",
1723
+ "digest_id",
1724
+ "started_at",
1725
+ "finished_at",
1726
+ "status",
1727
+ "exit_code",
1728
+ "summary",
1729
+ "evidence_paths",
1730
+ "created_at",
1731
+ "updated_at"
1732
+ ],
1733
+ properties: {
1734
+ loop_id: {
1735
+ type: "string"
1736
+ },
1737
+ run_id: {
1738
+ type: "string"
1739
+ },
1740
+ machine: {
1741
+ $ref: "#/components/schemas/RunReceiptMachine"
1742
+ },
1743
+ repo: {
1744
+ type: "string"
1745
+ },
1746
+ task_ids: {
1747
+ type: "array",
1748
+ items: {
1749
+ type: "string"
1750
+ }
1751
+ },
1752
+ knowledge_ids: {
1753
+ type: "array",
1754
+ items: {
1755
+ type: "string"
1756
+ }
1757
+ },
1758
+ digest_id: {
1759
+ type: "string"
1760
+ },
1761
+ started_at: {
1762
+ type: "string",
1763
+ format: "date-time",
1764
+ nullable: true
1765
+ },
1766
+ finished_at: {
1767
+ type: "string",
1768
+ format: "date-time",
1769
+ nullable: true
1770
+ },
1771
+ status: {
1772
+ type: "string"
1773
+ },
1774
+ exit_code: {
1775
+ type: "integer",
1776
+ nullable: true
1777
+ },
1778
+ summary: {
1779
+ $ref: "#/components/schemas/RunReceiptSummary"
1780
+ },
1781
+ evidence_paths: {
1782
+ type: "array",
1783
+ items: {
1784
+ type: "string"
1785
+ }
1786
+ },
1787
+ created_at: {
1788
+ type: "string",
1789
+ format: "date-time"
1790
+ },
1791
+ updated_at: {
1792
+ type: "string",
1793
+ format: "date-time"
1794
+ }
1795
+ },
1796
+ additionalProperties: false
1797
+ },
1798
+ WriteRunReceiptInput: {
1799
+ type: "object",
1800
+ required: [
1801
+ "run_id"
1802
+ ],
1803
+ properties: {
1804
+ loop_id: {
1805
+ type: "string"
1806
+ },
1807
+ run_id: {
1808
+ type: "string"
1809
+ },
1810
+ machine: {
1811
+ $ref: "#/components/schemas/RunReceiptMachine"
1812
+ },
1813
+ repo: {
1814
+ type: "string"
1815
+ },
1816
+ task_ids: {
1817
+ type: "array",
1818
+ items: {
1819
+ type: "string"
1820
+ }
1821
+ },
1822
+ knowledge_ids: {
1823
+ type: "array",
1824
+ items: {
1825
+ type: "string"
1826
+ }
1827
+ },
1828
+ digest_id: {
1829
+ type: "string"
1830
+ },
1831
+ started_at: {
1832
+ type: "string",
1833
+ format: "date-time",
1834
+ nullable: true
1835
+ },
1836
+ finished_at: {
1837
+ type: "string",
1838
+ format: "date-time",
1839
+ nullable: true
1840
+ },
1841
+ status: {
1842
+ type: "string"
1843
+ },
1844
+ exit_code: {
1845
+ type: "integer",
1846
+ nullable: true
1847
+ },
1848
+ summary: {
1849
+ oneOf: [
1850
+ {
1851
+ type: "string"
1852
+ },
1853
+ {
1854
+ $ref: "#/components/schemas/RunReceiptSummary"
1855
+ },
1856
+ {
1857
+ type: "null"
1858
+ }
1859
+ ]
1860
+ },
1861
+ evidence_paths: {
1862
+ type: "array",
1863
+ items: {
1864
+ type: "string"
1865
+ }
1866
+ },
1867
+ stdout: {
1868
+ type: "string"
1869
+ },
1870
+ stderr: {
1871
+ type: "string"
1872
+ },
1873
+ error: {
1874
+ type: "string"
1875
+ },
1876
+ duration_ms: {
1877
+ type: "integer",
1878
+ minimum: 0
1879
+ }
1880
+ },
1881
+ additionalProperties: false
1882
+ },
1883
+ RunReceiptResponse: {
1884
+ type: "object",
1885
+ properties: {
1886
+ ok: {
1887
+ type: "boolean"
1888
+ },
1889
+ receipt: {
1890
+ $ref: "#/components/schemas/RunReceipt"
1891
+ }
1892
+ },
1893
+ required: [
1894
+ "ok",
1895
+ "receipt"
1896
+ ]
1897
+ },
1898
+ RunReceiptListResponse: {
1899
+ type: "object",
1900
+ properties: {
1901
+ ok: {
1902
+ type: "boolean"
1903
+ },
1904
+ receipts: {
1905
+ type: "array",
1906
+ items: {
1907
+ $ref: "#/components/schemas/RunReceipt"
1908
+ }
1909
+ }
1910
+ },
1911
+ required: [
1912
+ "ok",
1913
+ "receipts"
1914
+ ]
1915
+ },
1916
+ ImportInput: {
1917
+ type: "object",
1918
+ description: "Batches of full rows to upsert by id. Any array may be omitted.",
1919
+ properties: {
1920
+ workflows: {
1921
+ type: "array",
1922
+ items: {
1923
+ type: "object",
1924
+ additionalProperties: true
1925
+ }
1926
+ },
1927
+ loops: {
1928
+ type: "array",
1929
+ items: {
1930
+ type: "object",
1931
+ additionalProperties: true
1932
+ }
1933
+ },
1934
+ runs: {
1935
+ type: "array",
1936
+ items: {
1937
+ type: "object",
1938
+ additionalProperties: true
1939
+ }
1940
+ },
1941
+ replace: {
1942
+ type: "boolean",
1943
+ description: "Update existing rows whose id matches (default false: existing rows are left unchanged)."
1944
+ }
1945
+ }
1946
+ },
1947
+ ImportResponse: {
1948
+ type: "object",
1949
+ properties: {
1950
+ ok: {
1951
+ type: "boolean"
1952
+ },
1953
+ imported: {
1954
+ type: "object",
1955
+ properties: {
1956
+ workflows: {
1957
+ type: "integer"
1958
+ },
1959
+ loops: {
1960
+ type: "integer"
1961
+ },
1962
+ runs: {
1963
+ type: "integer"
1964
+ }
1965
+ },
1966
+ required: [
1967
+ "workflows",
1968
+ "loops",
1969
+ "runs"
1970
+ ]
1971
+ },
1972
+ skippedRunning: {
1973
+ type: "integer"
1974
+ }
1975
+ },
1976
+ required: [
1977
+ "ok",
1978
+ "imported",
1979
+ "skippedRunning"
1980
+ ]
1981
+ },
1982
+ CountResponse: {
1983
+ type: "object",
1984
+ properties: {
1985
+ ok: {
1986
+ type: "boolean"
1987
+ },
1988
+ count: {
1989
+ type: "integer"
1990
+ }
1991
+ },
1992
+ required: [
1993
+ "ok",
1994
+ "count"
1995
+ ]
1996
+ }
1997
+ },
1998
+ securitySchemes: {
1999
+ apiKey: {
2000
+ type: "apiKey",
2001
+ in: "header",
2002
+ name: "x-api-key"
2003
+ }
2004
+ }
2005
+ },
2006
+ security: [
2007
+ {
2008
+ apiKey: []
2009
+ }
2010
+ ]
2011
+ };
818
2012
 
819
2013
  // src/api/index.ts
2014
+ function openApiDocument() {
2015
+ return { ...loops_default, info: { ...loops_default.info, version: packageVersion() } };
2016
+ }
820
2017
  var program = new Command;
821
2018
  var DEFAULT_BODY_LIMIT_BYTES = 64 * 1024;
822
2019
  var DEFAULT_EVIDENCE_LIMIT_BYTES = 256 * 1024;
2020
+ var DEFAULT_IMPORT_LIMIT_BYTES = 32 * 1024 * 1024;
823
2021
  var MIN_RUNNER_LEASE_MS = 1000;
824
2022
  program.name("loops-api").description("OpenLoops self-hosted control-plane API foundation").version(packageVersion()).option("-j, --json", "print JSON");
825
2023
  function wantsJson(opts) {
@@ -866,21 +2064,62 @@ function apiStatus() {
866
2064
  status: buildDeploymentStatus({ perspective: "self_hosted" })
867
2065
  };
868
2066
  }
2067
+ function foundationMode() {
2068
+ return buildDeploymentStatus({}).activeDeploymentMode;
2069
+ }
2070
+ function foundationEnvelope(status, extra = {}) {
2071
+ return { status, version: packageVersion(), mode: foundationMode(), service: "loops", ...extra };
2072
+ }
869
2073
  function createLoopsApiServer(opts = {}) {
870
2074
  const host = opts.host ?? "127.0.0.1";
871
2075
  const port = opts.port ?? 8787;
872
- if (!isLocalBind(host) && !configuredAuthToken()) {
873
- throw new Error("non-local loops-api binds require LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
2076
+ if (!isLocalBind(host) && !opts.authenticator && !configuredAuthToken()) {
2077
+ throw new Error("non-local loops-serve binds require an API-key authenticator or LOOPS_API_TOKEN");
874
2078
  }
2079
+ const defaultReady = async () => {
2080
+ if (!opts.storage)
2081
+ return { ready: false, detail: "storage_unconfigured" };
2082
+ try {
2083
+ await opts.storage.listLoops({ limit: 1 });
2084
+ return { ready: true };
2085
+ } catch (error) {
2086
+ return { ready: false, detail: error instanceof Error ? error.message : "storage_unreachable" };
2087
+ }
2088
+ };
2089
+ const readyCheck = opts.readyCheck ?? defaultReady;
875
2090
  return Bun.serve({
876
2091
  hostname: host,
877
2092
  port,
878
- fetch(request) {
879
- const unauthorized = authorizeRequest(request, host);
880
- if (unauthorized)
881
- return unauthorized;
2093
+ idleTimeout: 60,
2094
+ async fetch(request) {
882
2095
  const url = new URL(request.url);
883
- if (url.pathname === "/health" || url.pathname === "/status") {
2096
+ if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/healthz")) {
2097
+ return Response.json(foundationEnvelope("ok"));
2098
+ }
2099
+ if (request.method === "GET" && (url.pathname === "/version" || url.pathname === "/v1/version")) {
2100
+ return Response.json(foundationEnvelope("ok"));
2101
+ }
2102
+ if (request.method === "GET" && (url.pathname === "/ready" || url.pathname === "/readyz")) {
2103
+ const result = await readyCheck();
2104
+ return Response.json(foundationEnvelope(result.ready ? "ready" : "not_ready", result.detail ? { detail: result.detail } : {}), { status: result.ready ? 200 : 503 });
2105
+ }
2106
+ if (request.method === "GET" && url.pathname === "/openapi.json") {
2107
+ return Response.json(openApiDocument());
2108
+ }
2109
+ if (opts.authenticator) {
2110
+ const decision = await opts.authenticator.authenticate(request.headers, {
2111
+ method: request.method,
2112
+ path: url.pathname
2113
+ });
2114
+ if (!decision.ok) {
2115
+ return Response.json({ ok: false, error: decision.reason ?? "unauthorized", message: decision.message }, { status: decision.status || 401 });
2116
+ }
2117
+ } else {
2118
+ const unauthorized = authorizeRequest(request, host);
2119
+ if (unauthorized)
2120
+ return unauthorized;
2121
+ }
2122
+ if (request.method === "GET" && url.pathname === "/status") {
884
2123
  return Response.json(apiStatus());
885
2124
  }
886
2125
  return handleV1Request({
@@ -889,6 +2128,7 @@ function createLoopsApiServer(opts = {}) {
889
2128
  storage: opts.storage,
890
2129
  bodyLimitBytes: opts.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES,
891
2130
  evidenceLimitBytes: opts.evidenceLimitBytes ?? DEFAULT_EVIDENCE_LIMIT_BYTES,
2131
+ importLimitBytes: opts.importLimitBytes ?? DEFAULT_IMPORT_LIMIT_BYTES,
892
2132
  now: opts.now ?? (() => new Date)
893
2133
  });
894
2134
  }
@@ -903,10 +2143,28 @@ async function handleV1Request(ctx) {
903
2143
  if (ctx.request.method === "GET" && segments[1] === "status")
904
2144
  return Response.json(apiStatus());
905
2145
  try {
2146
+ if (segments[1] === "import")
2147
+ return await handleImportRequest(ctx, segments.slice(2));
906
2148
  if (segments[1] === "loops")
907
2149
  return await handleLoopsRequest(ctx, segments.slice(2));
908
2150
  if (segments[1] === "runs")
909
2151
  return await handleRunsRequest(ctx, segments.slice(2));
2152
+ if (segments[1] === "receipts")
2153
+ return await handleReceiptsRequest(ctx, segments.slice(2));
2154
+ if (segments[1] === "workflows")
2155
+ return await handleWorkflowsRequest(ctx, segments.slice(2));
2156
+ if (segments[1] === "workflow-runs")
2157
+ return await handleWorkflowRunsRequest(ctx, segments.slice(2));
2158
+ if (segments[1] === "work-items")
2159
+ return await handleWorkItemsRequest(ctx, segments.slice(2));
2160
+ if (segments[1] === "invocations")
2161
+ return await handleInvocationsRequest(ctx, segments.slice(2));
2162
+ if (segments[1] === "goals")
2163
+ return await handleGoalsRequest(ctx, segments.slice(2));
2164
+ if (segments[1] === "goal-runs")
2165
+ return await handleGoalRunsRequest(ctx, segments.slice(2));
2166
+ if (segments[1] === "history")
2167
+ return await handleHistoryRequest(ctx, segments.slice(2));
910
2168
  if (segments[1] === "runners")
911
2169
  return await handleRunnerRequest(ctx, segments.slice(2));
912
2170
  if (segments[1] === "leases" && segments[2] === "recover" && ctx.request.method === "POST") {
@@ -917,14 +2175,45 @@ async function handleV1Request(ctx) {
917
2175
  return errorResponse(error);
918
2176
  }
919
2177
  }
2178
+ async function handleImportRequest(ctx, segments) {
2179
+ if (segments.length !== 0 || ctx.request.method !== "POST")
2180
+ return fail("not_found", 404);
2181
+ const storage = requireStorage(ctx.storage);
2182
+ const body = await readJsonBody(ctx.request, ctx.importLimitBytes);
2183
+ const replace = body.replace === true;
2184
+ const workflows = Array.isArray(body.workflows) ? body.workflows : [];
2185
+ const loops = Array.isArray(body.loops) ? body.loops : [];
2186
+ const runs = Array.isArray(body.runs) ? body.runs : [];
2187
+ const imported = { workflows: 0, loops: 0, runs: 0 };
2188
+ let skippedRunning = 0;
2189
+ for (const workflow of workflows) {
2190
+ await storage.upsertMigrationWorkflow(workflow, { replace });
2191
+ imported.workflows += 1;
2192
+ }
2193
+ for (const loop of loops) {
2194
+ await storage.upsertMigrationLoop(loop, { replace });
2195
+ imported.loops += 1;
2196
+ }
2197
+ for (const run of runs) {
2198
+ if (run.status === "running") {
2199
+ skippedRunning += 1;
2200
+ continue;
2201
+ }
2202
+ await storage.upsertMigrationRun(run, { replace });
2203
+ imported.runs += 1;
2204
+ }
2205
+ return ok({ imported, skippedRunning });
2206
+ }
920
2207
  async function handleLoopsRequest(ctx, segments) {
921
2208
  const storage = requireStorage(ctx.storage);
922
2209
  if (segments.length === 0 && ctx.request.method === "GET") {
923
2210
  const loops = await storage.listLoops({
924
2211
  status: optionalEnum(ctx.url.searchParams.get("status"), ["active", "paused", "stopped", "expired"]),
925
2212
  limit: optionalLimit(ctx.url.searchParams.get("limit")),
2213
+ offset: optionalOffset(ctx.url.searchParams.get("offset")),
926
2214
  includeArchived: optionalBoolean(ctx.url.searchParams.get("includeArchived")),
927
- archived: optionalBoolean(ctx.url.searchParams.get("archived"))
2215
+ archived: optionalBoolean(ctx.url.searchParams.get("archived")),
2216
+ name: optionalString(ctx.url.searchParams.get("name"))
928
2217
  });
929
2218
  return ok({ loops: loops.map(publicLoop) });
930
2219
  }
@@ -933,6 +2222,13 @@ async function handleLoopsRequest(ctx, segments) {
933
2222
  const loop = await storage.createLoop(body);
934
2223
  return ok({ loop: publicLoop(loop) }, { status: 201 });
935
2224
  }
2225
+ if (segments.length === 1 && segments[0] === "count" && ctx.request.method === "GET") {
2226
+ const count = await storage.countLoops(optionalEnum(ctx.url.searchParams.get("status"), ["active", "paused", "stopped", "expired"]), {
2227
+ includeArchived: optionalBoolean(ctx.url.searchParams.get("includeArchived")),
2228
+ archived: optionalBoolean(ctx.url.searchParams.get("archived"))
2229
+ });
2230
+ return ok({ count });
2231
+ }
936
2232
  const id = segments[0];
937
2233
  if (!id)
938
2234
  return fail("not_found", 404);
@@ -965,6 +2261,170 @@ async function handleLoopsRequest(ctx, segments) {
965
2261
  if (segments.length === 2 && segments[1] === "unarchive" && ctx.request.method === "POST") {
966
2262
  return ok({ loop: publicLoop(await storage.unarchiveLoop(id)) });
967
2263
  }
2264
+ if (segments.length === 2 && segments[1] === "rename" && ctx.request.method === "POST") {
2265
+ const body = await readJsonBody(ctx.request, ctx.bodyLimitBytes);
2266
+ const name = requiredString(body.name, "name");
2267
+ return ok({ loop: publicLoop(await storage.renameLoop(id, name)) });
2268
+ }
2269
+ return fail("not_found", 404);
2270
+ }
2271
+ async function handleWorkflowsRequest(ctx, segments) {
2272
+ const storage = requireStorage(ctx.storage);
2273
+ if (segments.length === 0 && ctx.request.method === "GET") {
2274
+ const workflows = await storage.listWorkflows({
2275
+ status: optionalEnum(ctx.url.searchParams.get("status"), ["active", "archived"]),
2276
+ limit: optionalLimit(ctx.url.searchParams.get("limit")),
2277
+ offset: optionalOffset(ctx.url.searchParams.get("offset"))
2278
+ });
2279
+ return ok({ workflows: workflows.map(publicWorkflow) });
2280
+ }
2281
+ if (segments.length === 0 && ctx.request.method === "POST") {
2282
+ const body = await readJsonBody(ctx.request, ctx.bodyLimitBytes);
2283
+ return ok({ workflow: publicWorkflow(await storage.createWorkflow(body)) }, { status: 201 });
2284
+ }
2285
+ if (segments.length === 1 && segments[0] === "count" && ctx.request.method === "GET") {
2286
+ const count = await storage.countWorkflows({
2287
+ status: optionalEnum(ctx.url.searchParams.get("status"), ["active", "archived"])
2288
+ });
2289
+ return ok({ count });
2290
+ }
2291
+ const id = segments[0];
2292
+ if (!id)
2293
+ return fail("not_found", 404);
2294
+ if (segments.length === 1 && ctx.request.method === "GET") {
2295
+ const workflow = await storage.getWorkflow(id);
2296
+ if (!workflow)
2297
+ return fail("workflow_not_found", 404);
2298
+ return ok({ workflow: publicWorkflow(workflow) });
2299
+ }
2300
+ if (segments.length === 2 && segments[1] === "archive" && ctx.request.method === "POST") {
2301
+ return ok({ workflow: publicWorkflow(await storage.archiveWorkflow(id)) });
2302
+ }
2303
+ return fail("not_found", 404);
2304
+ }
2305
+ async function handleWorkflowRunsRequest(ctx, segments) {
2306
+ const storage = requireStorage(ctx.storage);
2307
+ if (segments.length === 0 && ctx.request.method === "GET") {
2308
+ const runs = await storage.listWorkflowRuns({
2309
+ workflowId: ctx.url.searchParams.get("workflowId") ?? undefined,
2310
+ loopRunId: ctx.url.searchParams.get("loopRunId") ?? undefined,
2311
+ limit: optionalLimit(ctx.url.searchParams.get("limit"))
2312
+ });
2313
+ return ok({ workflowRuns: runs.map(publicWorkflowRun) });
2314
+ }
2315
+ const id = segments[0];
2316
+ if (!id)
2317
+ return fail("not_found", 404);
2318
+ if (segments.length === 1 && ctx.request.method === "GET") {
2319
+ const run = await storage.getWorkflowRun(id);
2320
+ if (!run)
2321
+ return fail("workflow_run_not_found", 404);
2322
+ return ok({ workflowRun: publicWorkflowRun(run) });
2323
+ }
2324
+ if (segments.length === 2 && segments[1] === "steps" && ctx.request.method === "GET") {
2325
+ const steps = await storage.listWorkflowStepRuns(id);
2326
+ return ok({ steps: steps.map((step) => publicWorkflowStepRun(step)) });
2327
+ }
2328
+ if (segments.length === 2 && segments[1] === "events" && ctx.request.method === "GET") {
2329
+ const events = await storage.listWorkflowEvents(id, optionalLimit(ctx.url.searchParams.get("limit")) ?? 200);
2330
+ return ok({ events: events.map(publicWorkflowEvent) });
2331
+ }
2332
+ if (segments.length === 2 && segments[1] === "recover" && ctx.request.method === "POST") {
2333
+ const body = await readJsonBody(ctx.request, ctx.bodyLimitBytes);
2334
+ const reason = optionalText(body.reason);
2335
+ const result = reason === undefined ? await storage.recoverWorkflowRun(id) : await storage.recoverWorkflowRun(id, reason);
2336
+ return ok({
2337
+ workflowRun: publicWorkflowRun(result.run),
2338
+ recoveredSteps: result.recoveredSteps.map((step) => publicWorkflowStepRun(step))
2339
+ });
2340
+ }
2341
+ return fail("not_found", 404);
2342
+ }
2343
+ async function handleWorkItemsRequest(ctx, segments) {
2344
+ const storage = requireStorage(ctx.storage);
2345
+ if (segments.length === 0 && ctx.request.method === "GET") {
2346
+ const items = await storage.listWorkflowWorkItems({
2347
+ status: optionalString(ctx.url.searchParams.get("status")),
2348
+ routeKey: ctx.url.searchParams.get("routeKey") ?? undefined,
2349
+ limit: optionalLimit(ctx.url.searchParams.get("limit"))
2350
+ });
2351
+ return ok({ workItems: items.map(publicWorkflowWorkItem) });
2352
+ }
2353
+ const id = segments[0];
2354
+ if (!id)
2355
+ return fail("not_found", 404);
2356
+ if (segments.length === 1 && ctx.request.method === "GET") {
2357
+ const item = await storage.getWorkflowWorkItem(id);
2358
+ if (!item)
2359
+ return fail("work_item_not_found", 404);
2360
+ return ok({ workItem: publicWorkflowWorkItem(item) });
2361
+ }
2362
+ return fail("not_found", 404);
2363
+ }
2364
+ async function handleInvocationsRequest(ctx, segments) {
2365
+ const storage = requireStorage(ctx.storage);
2366
+ if (segments.length === 0 && ctx.request.method === "GET") {
2367
+ const invocations = await storage.listWorkflowInvocations({ limit: optionalLimit(ctx.url.searchParams.get("limit")) });
2368
+ return ok({ invocations: invocations.map(publicWorkflowInvocation) });
2369
+ }
2370
+ const id = segments[0];
2371
+ if (!id)
2372
+ return fail("not_found", 404);
2373
+ if (segments.length === 1 && ctx.request.method === "GET") {
2374
+ const invocation = await storage.getWorkflowInvocation(id);
2375
+ if (!invocation)
2376
+ return fail("invocation_not_found", 404);
2377
+ return ok({ invocation: publicWorkflowInvocation(invocation) });
2378
+ }
2379
+ return fail("not_found", 404);
2380
+ }
2381
+ async function handleGoalsRequest(ctx, segments) {
2382
+ const storage = requireStorage(ctx.storage);
2383
+ if (segments.length === 0 && ctx.request.method === "GET") {
2384
+ const goals = await storage.listGoals({
2385
+ status: optionalString(ctx.url.searchParams.get("status")),
2386
+ limit: optionalLimit(ctx.url.searchParams.get("limit"))
2387
+ });
2388
+ return ok({ goals: goals.map(publicGoal) });
2389
+ }
2390
+ const id = segments[0];
2391
+ if (!id)
2392
+ return fail("not_found", 404);
2393
+ if (segments.length === 1 && ctx.request.method === "GET") {
2394
+ const goal = await storage.getGoal(id);
2395
+ if (!goal)
2396
+ return fail("goal_not_found", 404);
2397
+ return ok({ goal: publicGoal(goal) });
2398
+ }
2399
+ if (segments.length === 2 && segments[1] === "plan-nodes" && ctx.request.method === "GET") {
2400
+ const nodes = await storage.listGoalPlanNodes(id);
2401
+ return ok({ nodes });
2402
+ }
2403
+ return fail("not_found", 404);
2404
+ }
2405
+ async function handleGoalRunsRequest(ctx, segments) {
2406
+ const storage = requireStorage(ctx.storage);
2407
+ if (segments.length === 0 && ctx.request.method === "GET") {
2408
+ const runs = await storage.listGoalRuns({
2409
+ goalId: ctx.url.searchParams.get("goalId") ?? undefined,
2410
+ runId: ctx.url.searchParams.get("runId") ?? undefined,
2411
+ limit: optionalLimit(ctx.url.searchParams.get("limit"))
2412
+ });
2413
+ return ok({ goalRuns: runs.map(publicGoalRun) });
2414
+ }
2415
+ return fail("not_found", 404);
2416
+ }
2417
+ async function handleHistoryRequest(ctx, segments) {
2418
+ const storage = requireStorage(ctx.storage);
2419
+ if (segments.length === 1 && segments[0] === "prune" && ctx.request.method === "POST") {
2420
+ const body = await readJsonBody(ctx.request, ctx.bodyLimitBytes);
2421
+ const history = await storage.pruneHistory({
2422
+ maxAgeDays: optionalInteger(body.maxAgeDays),
2423
+ keepPerLoop: optionalInteger(body.keepPerLoop),
2424
+ dryRun: body.dryRun === undefined ? undefined : Boolean(body.dryRun)
2425
+ });
2426
+ return ok({ history });
2427
+ }
968
2428
  return fail("not_found", 404);
969
2429
  }
970
2430
  async function handleRunsRequest(ctx, segments) {
@@ -1000,11 +2460,16 @@ async function handleRunsRequest(ctx, segments) {
1000
2460
  }
1001
2461
  const storage = requireStorage(ctx.storage);
1002
2462
  const showOutput = optionalBoolean(ctx.url.searchParams.get("showOutput")) ?? false;
2463
+ if (segments.length === 1 && id === "count" && ctx.request.method === "GET") {
2464
+ const count = await storage.countRuns(optionalEnum(ctx.url.searchParams.get("status"), ["running", "succeeded", "failed", "timed_out", "abandoned", "skipped"]));
2465
+ return ok({ count });
2466
+ }
1003
2467
  if (segments.length === 0 && ctx.request.method === "GET") {
1004
2468
  const runs = await storage.listRuns({
1005
2469
  loopId: ctx.url.searchParams.get("loopId") ?? undefined,
1006
2470
  status: optionalEnum(ctx.url.searchParams.get("status"), ["running", "succeeded", "failed", "timed_out", "abandoned", "skipped"]),
1007
- limit: optionalLimit(ctx.url.searchParams.get("limit"))
2471
+ limit: optionalLimit(ctx.url.searchParams.get("limit")),
2472
+ offset: optionalOffset(ctx.url.searchParams.get("offset"))
1008
2473
  });
1009
2474
  return ok({ runs: runs.map((run) => publicRun(run, showOutput, { redactError: true })) });
1010
2475
  }
@@ -1018,6 +2483,34 @@ async function handleRunsRequest(ctx, segments) {
1018
2483
  }
1019
2484
  return fail("not_found", 404);
1020
2485
  }
2486
+ async function handleReceiptsRequest(ctx, segments) {
2487
+ const storage = requireStorage(ctx.storage);
2488
+ const id = segments[0];
2489
+ if (segments.length === 0 && ctx.request.method === "GET") {
2490
+ const receipts = await storage.listRunReceipts({
2491
+ loopId: ctx.url.searchParams.get("loopId") ?? undefined,
2492
+ repo: ctx.url.searchParams.get("repo") ?? undefined,
2493
+ taskId: ctx.url.searchParams.get("taskId") ?? undefined,
2494
+ knowledgeId: ctx.url.searchParams.get("knowledgeId") ?? undefined,
2495
+ status: ctx.url.searchParams.get("status") ?? undefined,
2496
+ limit: optionalLimit(ctx.url.searchParams.get("limit"))
2497
+ });
2498
+ return ok({ receipts: receipts.map(publicRunReceipt) });
2499
+ }
2500
+ if (segments.length === 0 && ctx.request.method === "POST") {
2501
+ const body = await readJsonBody(ctx.request, ctx.evidenceLimitBytes);
2502
+ return ok({ receipt: publicRunReceipt(await storage.writeRunReceipt(body)) }, { status: 201 });
2503
+ }
2504
+ if (!id)
2505
+ return fail("not_found", 404);
2506
+ if (segments.length === 1 && ctx.request.method === "GET") {
2507
+ const receipt = await storage.getRunReceipt(id);
2508
+ if (!receipt)
2509
+ return fail("run_receipt_not_found", 404);
2510
+ return ok({ receipt: publicRunReceipt(receipt) });
2511
+ }
2512
+ return fail("not_found", 404);
2513
+ }
1021
2514
  async function handleRunnerRequest(ctx, segments) {
1022
2515
  if (ctx.request.method !== "POST")
1023
2516
  return fail("not_found", 404);
@@ -1229,13 +2722,22 @@ async function readBodyText(request, limitBytes) {
1229
2722
  function runnerProtocolPending(message) {
1230
2723
  return fail("runner_protocol_pending", 501, { message });
1231
2724
  }
2725
+ var MAX_PAGE_LIMIT = 1000;
1232
2726
  function optionalLimit(value) {
1233
2727
  if (value == null || value === "")
1234
2728
  return;
1235
2729
  const limit = Number(value);
1236
- if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
2730
+ if (!Number.isInteger(limit) || limit < 1)
1237
2731
  throw Object.assign(new Error("invalid_limit"), { status: 422 });
1238
- return limit;
2732
+ return Math.min(limit, MAX_PAGE_LIMIT);
2733
+ }
2734
+ function optionalOffset(value) {
2735
+ if (value == null || value === "")
2736
+ return;
2737
+ const offset = Number(value);
2738
+ if (!Number.isInteger(offset) || offset < 0)
2739
+ throw Object.assign(new Error("invalid_offset"), { status: 422 });
2740
+ return offset;
1239
2741
  }
1240
2742
  function optionalString(value) {
1241
2743
  if (value === undefined || value === null)
@@ -1341,13 +2843,14 @@ program.command("serve").description("serve the foundation health/status endpoin
1341
2843
  const server = createLoopsApiServer({ host, port });
1342
2844
  console.log(`loops-api listening on http://${server.hostname}:${server.port}`);
1343
2845
  });
1344
- if (import.meta.main) {
2846
+ if (import.meta.main && (import.meta.url.endsWith("api/index.ts") || import.meta.url.endsWith("api/index.js"))) {
1345
2847
  main().catch((error) => {
1346
2848
  console.error(error instanceof Error ? error.message : String(error));
1347
2849
  process.exit(1);
1348
2850
  });
1349
2851
  }
1350
2852
  export {
2853
+ openApiDocument,
1351
2854
  main,
1352
2855
  createLoopsApiServer,
1353
2856
  apiStatus