primitive-admin 1.1.0-alpha.8 → 1.1.0-alpha.80

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 (522) hide show
  1. package/README.md +444 -106
  2. package/assets/skill/skills/primitive-platform/SKILL.md +811 -0
  3. package/dist/bin/primitive.d.ts +2 -0
  4. package/dist/bin/primitive.js +373 -21
  5. package/dist/bin/primitive.js.map +1 -1
  6. package/dist/src/commands/admins.d.ts +2 -0
  7. package/dist/src/commands/admins.js +128 -19
  8. package/dist/src/commands/admins.js.map +1 -1
  9. package/dist/src/commands/analytics.d.ts +2 -0
  10. package/dist/src/commands/analytics.js +700 -55
  11. package/dist/src/commands/analytics.js.map +1 -1
  12. package/dist/src/commands/apps.d.ts +2 -0
  13. package/dist/src/commands/apps.js +54 -96
  14. package/dist/src/commands/apps.js.map +1 -1
  15. package/dist/src/commands/auth.d.ts +2 -0
  16. package/dist/src/commands/auth.js +196 -105
  17. package/dist/src/commands/auth.js.map +1 -1
  18. package/dist/src/commands/blob-buckets.d.ts +2 -0
  19. package/dist/src/commands/blob-buckets.js +330 -0
  20. package/dist/src/commands/blob-buckets.js.map +1 -0
  21. package/dist/src/commands/catalog.d.ts +2 -0
  22. package/dist/src/commands/catalog.js +37 -38
  23. package/dist/src/commands/catalog.js.map +1 -1
  24. package/dist/src/commands/collection-type-configs.d.ts +2 -0
  25. package/dist/src/commands/collection-type-configs.js +84 -0
  26. package/dist/src/commands/collection-type-configs.js.map +1 -0
  27. package/dist/src/commands/collections.d.ts +2 -0
  28. package/dist/src/commands/collections.js +565 -0
  29. package/dist/src/commands/collections.js.map +1 -0
  30. package/dist/src/commands/comparisons.d.ts +2 -0
  31. package/dist/src/commands/comparisons.js +6 -6
  32. package/dist/src/commands/comparisons.js.map +1 -1
  33. package/dist/src/commands/config.d.ts +46 -0
  34. package/dist/src/commands/config.js +465 -0
  35. package/dist/src/commands/config.js.map +1 -0
  36. package/dist/src/commands/connections.d.ts +2 -0
  37. package/dist/src/commands/connections.js +100 -0
  38. package/dist/src/commands/connections.js.map +1 -0
  39. package/dist/src/commands/cron-triggers.d.ts +2 -0
  40. package/dist/src/commands/cron-triggers.js +265 -0
  41. package/dist/src/commands/cron-triggers.js.map +1 -0
  42. package/dist/src/commands/database-type-configs.d.ts +2 -0
  43. package/dist/src/commands/database-type-configs.js +163 -0
  44. package/dist/src/commands/database-type-configs.js.map +1 -0
  45. package/dist/src/commands/database-types.d.ts +2 -0
  46. package/dist/src/commands/database-types.js +171 -0
  47. package/dist/src/commands/database-types.js.map +1 -0
  48. package/dist/src/commands/databases.d.ts +65 -0
  49. package/dist/src/commands/databases.js +1859 -238
  50. package/dist/src/commands/databases.js.map +1 -1
  51. package/dist/src/commands/documents.d.ts +2 -0
  52. package/dist/src/commands/documents.js +2062 -19
  53. package/dist/src/commands/documents.js.map +1 -1
  54. package/dist/src/commands/email-templates.d.ts +2 -0
  55. package/dist/src/commands/email-templates.js +174 -0
  56. package/dist/src/commands/email-templates.js.map +1 -0
  57. package/dist/src/commands/env.d.ts +23 -0
  58. package/dist/src/commands/env.js +385 -0
  59. package/dist/src/commands/env.js.map +1 -0
  60. package/dist/src/commands/feature-flags.d.ts +14 -0
  61. package/dist/src/commands/feature-flags.js +116 -0
  62. package/dist/src/commands/feature-flags.js.map +1 -0
  63. package/dist/src/commands/functions.d.ts +20 -0
  64. package/dist/src/commands/functions.js +672 -0
  65. package/dist/src/commands/functions.js.map +1 -0
  66. package/dist/src/commands/group-type-configs.d.ts +2 -0
  67. package/dist/src/commands/group-type-configs.js +86 -0
  68. package/dist/src/commands/group-type-configs.js.map +1 -0
  69. package/dist/src/commands/groups.d.ts +2 -0
  70. package/dist/src/commands/groups.js +39 -108
  71. package/dist/src/commands/groups.js.map +1 -1
  72. package/dist/src/commands/guides.d.ts +223 -0
  73. package/dist/src/commands/guides.js +617 -65
  74. package/dist/src/commands/guides.js.map +1 -1
  75. package/dist/src/commands/init.d.ts +37 -0
  76. package/dist/src/commands/init.js +1693 -208
  77. package/dist/src/commands/init.js.map +1 -1
  78. package/dist/src/commands/integrations.d.ts +2 -0
  79. package/dist/src/commands/integrations.js +402 -182
  80. package/dist/src/commands/integrations.js.map +1 -1
  81. package/dist/src/commands/llm.d.ts +2 -0
  82. package/dist/src/commands/llm.js +13 -8
  83. package/dist/src/commands/llm.js.map +1 -1
  84. package/dist/src/commands/locks.d.ts +8 -0
  85. package/dist/src/commands/locks.js +160 -0
  86. package/dist/src/commands/locks.js.map +1 -0
  87. package/dist/src/commands/metadata-category-configs.d.ts +12 -0
  88. package/dist/src/commands/metadata-category-configs.js +112 -0
  89. package/dist/src/commands/metadata-category-configs.js.map +1 -0
  90. package/dist/src/commands/metadata.d.ts +2 -0
  91. package/dist/src/commands/metadata.js +281 -0
  92. package/dist/src/commands/metadata.js.map +1 -0
  93. package/dist/src/commands/prompts.d.ts +2 -0
  94. package/dist/src/commands/prompts.js +275 -621
  95. package/dist/src/commands/prompts.js.map +1 -1
  96. package/dist/src/commands/rule-sets.d.ts +3 -0
  97. package/dist/src/commands/rule-sets.js +137 -147
  98. package/dist/src/commands/rule-sets.js.map +1 -1
  99. package/dist/src/commands/scripts.d.ts +30 -0
  100. package/dist/src/commands/scripts.js +679 -0
  101. package/dist/src/commands/scripts.js.map +1 -0
  102. package/dist/src/commands/secrets.d.ts +2 -0
  103. package/dist/src/commands/secrets.js +108 -0
  104. package/dist/src/commands/secrets.js.map +1 -0
  105. package/dist/src/commands/sessions.d.ts +2 -0
  106. package/dist/src/commands/sessions.js +75 -0
  107. package/dist/src/commands/sessions.js.map +1 -0
  108. package/dist/src/commands/settings.d.ts +18 -0
  109. package/dist/src/commands/settings.js +54 -0
  110. package/dist/src/commands/settings.js.map +1 -0
  111. package/dist/src/commands/skill.d.ts +2 -0
  112. package/dist/src/commands/skill.js +29 -0
  113. package/dist/src/commands/skill.js.map +1 -0
  114. package/dist/src/commands/sync-app-settings.d.ts +158 -0
  115. package/dist/src/commands/sync-app-settings.js +328 -0
  116. package/dist/src/commands/sync-app-settings.js.map +1 -0
  117. package/dist/src/commands/sync.d.ts +2514 -0
  118. package/dist/src/commands/sync.js +16106 -848
  119. package/dist/src/commands/sync.js.map +1 -1
  120. package/dist/src/commands/tokens.d.ts +2 -0
  121. package/dist/src/commands/tokens.js +130 -21
  122. package/dist/src/commands/tokens.js.map +1 -1
  123. package/dist/src/commands/users.d.ts +2 -0
  124. package/dist/src/commands/users.js +532 -23
  125. package/dist/src/commands/users.js.map +1 -1
  126. package/dist/src/commands/vars.d.ts +8 -0
  127. package/dist/src/commands/vars.js +96 -0
  128. package/dist/src/commands/vars.js.map +1 -0
  129. package/dist/src/commands/waitlist.d.ts +2 -0
  130. package/dist/src/commands/waitlist.js +10 -10
  131. package/dist/src/commands/waitlist.js.map +1 -1
  132. package/dist/src/commands/webhooks.d.ts +2 -0
  133. package/dist/src/commands/webhooks.js +562 -0
  134. package/dist/src/commands/webhooks.js.map +1 -0
  135. package/dist/src/commands/workflows.d.ts +116 -0
  136. package/dist/src/commands/workflows.js +1638 -729
  137. package/dist/src/commands/workflows.js.map +1 -1
  138. package/dist/src/lib/access-rule-display.d.ts +21 -0
  139. package/dist/src/lib/access-rule-display.js +34 -0
  140. package/dist/src/lib/access-rule-display.js.map +1 -0
  141. package/dist/src/lib/api-client.d.ts +2147 -0
  142. package/dist/src/lib/api-client.js +2042 -160
  143. package/dist/src/lib/api-client.js.map +1 -1
  144. package/dist/src/lib/app-match-guard.d.ts +44 -0
  145. package/dist/src/lib/app-match-guard.js +72 -0
  146. package/dist/src/lib/app-match-guard.js.map +1 -0
  147. package/dist/src/lib/app-settings-descriptor.d.ts +263 -0
  148. package/dist/src/lib/app-settings-descriptor.js +584 -0
  149. package/dist/src/lib/app-settings-descriptor.js.map +1 -0
  150. package/dist/src/lib/auth-flow.d.ts +8 -0
  151. package/dist/src/lib/batch.d.ts +26 -0
  152. package/dist/src/lib/batch.js +32 -0
  153. package/dist/src/lib/batch.js.map +1 -0
  154. package/dist/src/lib/block-layout.d.ts +160 -0
  155. package/dist/src/lib/block-layout.js +451 -0
  156. package/dist/src/lib/block-layout.js.map +1 -0
  157. package/dist/src/lib/block-selector.d.ts +58 -0
  158. package/dist/src/lib/block-selector.js +92 -0
  159. package/dist/src/lib/block-selector.js.map +1 -0
  160. package/dist/src/lib/canonical-json.d.ts +12 -0
  161. package/dist/src/lib/canonical-json.js +35 -0
  162. package/dist/src/lib/canonical-json.js.map +1 -0
  163. package/dist/src/lib/channel.d.ts +30 -0
  164. package/dist/src/lib/channel.js +68 -0
  165. package/dist/src/lib/channel.js.map +1 -0
  166. package/dist/src/lib/cli-manifest.d.ts +68 -0
  167. package/dist/src/lib/cli-manifest.js +71 -0
  168. package/dist/src/lib/cli-manifest.js.map +1 -0
  169. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +101 -0
  170. package/dist/src/lib/codegen-shared/generatedFiles.js +191 -0
  171. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -0
  172. package/dist/src/lib/codegen-shared/prettierStable.d.ts +262 -0
  173. package/dist/src/lib/codegen-shared/prettierStable.js +610 -0
  174. package/dist/src/lib/codegen-shared/prettierStable.js.map +1 -0
  175. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +38 -0
  176. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +46 -0
  177. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -0
  178. package/dist/src/lib/config-json-field.d.ts +28 -0
  179. package/dist/src/lib/config-json-field.js +56 -0
  180. package/dist/src/lib/config-json-field.js.map +1 -0
  181. package/dist/src/lib/config-object-descriptor.d.ts +127 -0
  182. package/dist/src/lib/config-object-descriptor.js +696 -0
  183. package/dist/src/lib/config-object-descriptor.js.map +1 -0
  184. package/dist/src/lib/config-payload.d.ts +85 -0
  185. package/dist/src/lib/config-payload.js +116 -0
  186. package/dist/src/lib/config-payload.js.map +1 -0
  187. package/dist/src/lib/config-surface.d.ts +140 -0
  188. package/dist/src/lib/config-surface.js +317 -0
  189. package/dist/src/lib/config-surface.js.map +1 -0
  190. package/dist/src/lib/config-toml.d.ts +10 -0
  191. package/dist/src/lib/config-toml.js +42 -0
  192. package/dist/src/lib/config-toml.js.map +1 -0
  193. package/dist/src/lib/config.d.ts +71 -0
  194. package/dist/src/lib/config.js +71 -68
  195. package/dist/src/lib/config.js.map +1 -1
  196. package/dist/src/lib/confirm-prompt.d.ts +83 -0
  197. package/dist/src/lib/confirm-prompt.js +110 -0
  198. package/dist/src/lib/confirm-prompt.js.map +1 -0
  199. package/dist/src/lib/constants.d.ts +11 -0
  200. package/dist/src/lib/constants.js +12 -0
  201. package/dist/src/lib/constants.js.map +1 -0
  202. package/dist/src/lib/crash-handlers.d.ts +20 -0
  203. package/dist/src/lib/crash-handlers.js +49 -0
  204. package/dist/src/lib/crash-handlers.js.map +1 -0
  205. package/dist/src/lib/credentials-store.d.ts +104 -0
  206. package/dist/src/lib/credentials-store.js +336 -0
  207. package/dist/src/lib/credentials-store.js.map +1 -0
  208. package/dist/src/lib/csv.d.ts +47 -0
  209. package/dist/src/lib/csv.js +172 -0
  210. package/dist/src/lib/csv.js.map +1 -0
  211. package/dist/src/lib/data-input.d.ts +23 -0
  212. package/dist/src/lib/data-input.js +50 -0
  213. package/dist/src/lib/data-input.js.map +1 -0
  214. package/dist/src/lib/db-codegen/dbFingerprint.d.ts +10 -0
  215. package/dist/src/lib/db-codegen/dbFingerprint.js +17 -0
  216. package/dist/src/lib/db-codegen/dbFingerprint.js.map +1 -0
  217. package/dist/src/lib/db-codegen/dbGenerator.d.ts +67 -0
  218. package/dist/src/lib/db-codegen/dbGenerator.js +170 -0
  219. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -0
  220. package/dist/src/lib/db-codegen/dbNaming.d.ts +87 -0
  221. package/dist/src/lib/db-codegen/dbNaming.js +180 -0
  222. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -0
  223. package/dist/src/lib/db-codegen/dbTemplates.d.ts +272 -0
  224. package/dist/src/lib/db-codegen/dbTemplates.js +480 -0
  225. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -0
  226. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +73 -0
  227. package/dist/src/lib/db-codegen/dbTsTypes.js +139 -0
  228. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -0
  229. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +146 -0
  230. package/dist/src/lib/db-codegen/dbTypeIR.js +517 -0
  231. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -0
  232. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +112 -0
  233. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +211 -0
  234. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -0
  235. package/dist/src/lib/document-export-permissions.d.ts +30 -0
  236. package/dist/src/lib/document-export-permissions.js +54 -0
  237. package/dist/src/lib/document-export-permissions.js.map +1 -0
  238. package/dist/src/lib/env-resolver-core.d.ts +253 -0
  239. package/dist/src/lib/env-resolver-core.js +459 -0
  240. package/dist/src/lib/env-resolver-core.js.map +1 -0
  241. package/dist/src/lib/env-resolver.d.ts +99 -0
  242. package/dist/src/lib/env-resolver.js +153 -0
  243. package/dist/src/lib/env-resolver.js.map +1 -0
  244. package/dist/src/lib/fetch.d.ts +5 -0
  245. package/dist/src/lib/function-bundle.d.ts +141 -0
  246. package/dist/src/lib/function-bundle.js +333 -0
  247. package/dist/src/lib/function-bundle.js.map +1 -0
  248. package/dist/src/lib/function-collect.d.ts +123 -0
  249. package/dist/src/lib/function-collect.js +594 -0
  250. package/dist/src/lib/function-collect.js.map +1 -0
  251. package/dist/src/lib/function-db-types.d.ts +187 -0
  252. package/dist/src/lib/function-db-types.js +782 -0
  253. package/dist/src/lib/function-db-types.js.map +1 -0
  254. package/dist/src/lib/function-document-types.d.ts +50 -0
  255. package/dist/src/lib/function-document-types.js +163 -0
  256. package/dist/src/lib/function-document-types.js.map +1 -0
  257. package/dist/src/lib/function-grants-preflight.d.ts +64 -0
  258. package/dist/src/lib/function-grants-preflight.js +100 -0
  259. package/dist/src/lib/function-grants-preflight.js.map +1 -0
  260. package/dist/src/lib/function-log-tail.d.ts +83 -0
  261. package/dist/src/lib/function-log-tail.js +115 -0
  262. package/dist/src/lib/function-log-tail.js.map +1 -0
  263. package/dist/src/lib/function-schema-codegen.d.ts +118 -0
  264. package/dist/src/lib/function-schema-codegen.js +402 -0
  265. package/dist/src/lib/function-schema-codegen.js.map +1 -0
  266. package/dist/src/lib/function-sync.d.ts +263 -0
  267. package/dist/src/lib/function-sync.js +604 -0
  268. package/dist/src/lib/function-sync.js.map +1 -0
  269. package/dist/src/lib/function-triggers.d.ts +66 -0
  270. package/dist/src/lib/function-triggers.js +285 -0
  271. package/dist/src/lib/function-triggers.js.map +1 -0
  272. package/dist/src/lib/function-typecheck.d.ts +86 -0
  273. package/dist/src/lib/function-typecheck.js +370 -0
  274. package/dist/src/lib/function-typecheck.js.map +1 -0
  275. package/dist/src/lib/generated-allowlist.d.ts +28 -0
  276. package/dist/src/lib/generated-allowlist.js +281 -0
  277. package/dist/src/lib/generated-allowlist.js.map +1 -0
  278. package/dist/src/lib/generated-config-surfaces.d.ts +1445 -0
  279. package/dist/src/lib/generated-config-surfaces.js +5836 -0
  280. package/dist/src/lib/generated-config-surfaces.js.map +1 -0
  281. package/dist/src/lib/generated-sdk-types.d.ts +12 -0
  282. package/dist/src/lib/generated-sdk-types.js +13 -0
  283. package/dist/src/lib/generated-sdk-types.js.map +1 -0
  284. package/dist/src/lib/generated-template-lint.d.ts +212 -0
  285. package/dist/src/lib/generated-template-lint.js +624 -0
  286. package/dist/src/lib/generated-template-lint.js.map +1 -0
  287. package/dist/src/lib/generated-workflow-model-fields.d.ts +20 -0
  288. package/dist/src/lib/generated-workflow-model-fields.js +53 -0
  289. package/dist/src/lib/generated-workflow-model-fields.js.map +1 -0
  290. package/dist/src/lib/google-client-secret-status.d.ts +35 -0
  291. package/dist/src/lib/google-client-secret-status.js +56 -0
  292. package/dist/src/lib/google-client-secret-status.js.map +1 -0
  293. package/dist/src/lib/init-adopt.d.ts +16 -0
  294. package/dist/src/lib/init-adopt.js +34 -0
  295. package/dist/src/lib/init-adopt.js.map +1 -0
  296. package/dist/src/lib/init-assets.d.ts +39 -0
  297. package/dist/src/lib/init-assets.js +97 -0
  298. package/dist/src/lib/init-assets.js.map +1 -0
  299. package/dist/src/lib/init-client-platforms.d.ts +14 -0
  300. package/dist/src/lib/init-client-platforms.js +71 -0
  301. package/dist/src/lib/init-client-platforms.js.map +1 -0
  302. package/dist/src/lib/init-config.d.ts +98 -0
  303. package/dist/src/lib/init-config.js +186 -0
  304. package/dist/src/lib/init-config.js.map +1 -0
  305. package/dist/src/lib/init-email-redirect-uris.d.ts +37 -0
  306. package/dist/src/lib/init-email-redirect-uris.js +46 -0
  307. package/dist/src/lib/init-email-redirect-uris.js.map +1 -0
  308. package/dist/src/lib/init-ios-links.d.ts +91 -0
  309. package/dist/src/lib/init-ios-links.js +219 -0
  310. package/dist/src/lib/init-ios-links.js.map +1 -0
  311. package/dist/src/lib/init-plan.d.ts +80 -0
  312. package/dist/src/lib/init-plan.js +95 -0
  313. package/dist/src/lib/init-plan.js.map +1 -0
  314. package/dist/src/lib/init-production-env.d.ts +48 -0
  315. package/dist/src/lib/init-production-env.js +59 -0
  316. package/dist/src/lib/init-production-env.js.map +1 -0
  317. package/dist/src/lib/init-schema.d.ts +74 -0
  318. package/dist/src/lib/init-schema.js +358 -0
  319. package/dist/src/lib/init-schema.js.map +1 -0
  320. package/dist/src/lib/init-xcode.d.ts +34 -0
  321. package/dist/src/lib/init-xcode.js +138 -0
  322. package/dist/src/lib/init-xcode.js.map +1 -0
  323. package/dist/src/lib/integration-request-config.d.ts +30 -0
  324. package/dist/src/lib/integration-request-config.js +145 -0
  325. package/dist/src/lib/integration-request-config.js.map +1 -0
  326. package/dist/src/lib/integration-selector.d.ts +42 -0
  327. package/dist/src/lib/integration-selector.js +46 -0
  328. package/dist/src/lib/integration-selector.js.map +1 -0
  329. package/dist/src/lib/ios-app-id.d.ts +34 -0
  330. package/dist/src/lib/ios-app-id.js +69 -0
  331. package/dist/src/lib/ios-app-id.js.map +1 -0
  332. package/dist/src/lib/local-state.d.ts +55 -0
  333. package/dist/src/lib/local-state.js +167 -0
  334. package/dist/src/lib/local-state.js.map +1 -0
  335. package/dist/src/lib/local-test-cases.d.ts +63 -0
  336. package/dist/src/lib/local-test-cases.js +136 -0
  337. package/dist/src/lib/local-test-cases.js.map +1 -0
  338. package/dist/src/lib/log-inspection.d.ts +680 -0
  339. package/dist/src/lib/log-inspection.js +784 -0
  340. package/dist/src/lib/log-inspection.js.map +1 -0
  341. package/dist/src/lib/migration-nag.d.ts +49 -0
  342. package/dist/src/lib/migration-nag.js +164 -0
  343. package/dist/src/lib/migration-nag.js.map +1 -0
  344. package/dist/src/lib/object-status-filter.d.ts +22 -0
  345. package/dist/src/lib/object-status-filter.js +45 -0
  346. package/dist/src/lib/object-status-filter.js.map +1 -0
  347. package/dist/src/lib/output.d.ts +109 -0
  348. package/dist/src/lib/output.js +191 -8
  349. package/dist/src/lib/output.js.map +1 -1
  350. package/dist/src/lib/package-manager.d.ts +140 -0
  351. package/dist/src/lib/package-manager.js +305 -0
  352. package/dist/src/lib/package-manager.js.map +1 -0
  353. package/dist/src/lib/paginate.d.ts +83 -0
  354. package/dist/src/lib/paginate.js +95 -0
  355. package/dist/src/lib/paginate.js.map +1 -0
  356. package/dist/src/lib/platform-owned.d.ts +63 -0
  357. package/dist/src/lib/platform-owned.js +85 -0
  358. package/dist/src/lib/platform-owned.js.map +1 -0
  359. package/dist/src/lib/project-config.d.ts +122 -0
  360. package/dist/src/lib/project-config.js +244 -0
  361. package/dist/src/lib/project-config.js.map +1 -0
  362. package/dist/src/lib/query-operators.d.ts +43 -0
  363. package/dist/src/lib/query-operators.js +80 -0
  364. package/dist/src/lib/query-operators.js.map +1 -0
  365. package/dist/src/lib/record-filter.d.ts +18 -0
  366. package/dist/src/lib/record-filter.js +55 -0
  367. package/dist/src/lib/record-filter.js.map +1 -0
  368. package/dist/src/lib/refresh-admin-credentials.d.ts +65 -0
  369. package/dist/src/lib/refresh-admin-credentials.js +103 -0
  370. package/dist/src/lib/refresh-admin-credentials.js.map +1 -0
  371. package/dist/src/lib/resolve-init-dev-port.d.ts +56 -0
  372. package/dist/src/lib/resolve-init-dev-port.js +55 -0
  373. package/dist/src/lib/resolve-init-dev-port.js.map +1 -0
  374. package/dist/src/lib/resolve-init-server.d.ts +64 -0
  375. package/dist/src/lib/resolve-init-server.js +77 -0
  376. package/dist/src/lib/resolve-init-server.js.map +1 -0
  377. package/dist/src/lib/resolve-platform.d.ts +74 -0
  378. package/dist/src/lib/resolve-platform.js +105 -0
  379. package/dist/src/lib/resolve-platform.js.map +1 -0
  380. package/dist/src/lib/run-status.d.ts +19 -0
  381. package/dist/src/lib/run-status.generated.d.ts +39 -0
  382. package/dist/src/lib/run-status.generated.js +66 -0
  383. package/dist/src/lib/run-status.generated.js.map +1 -0
  384. package/dist/src/lib/run-status.js +19 -0
  385. package/dist/src/lib/run-status.js.map +1 -0
  386. package/dist/src/lib/server-text-normalization.d.ts +51 -0
  387. package/dist/src/lib/server-text-normalization.js +90 -0
  388. package/dist/src/lib/server-text-normalization.js.map +1 -0
  389. package/dist/src/lib/server-url.d.ts +22 -0
  390. package/dist/src/lib/server-url.js +33 -0
  391. package/dist/src/lib/server-url.js.map +1 -0
  392. package/dist/src/lib/signing-secret-status.d.ts +81 -0
  393. package/dist/src/lib/signing-secret-status.js +116 -0
  394. package/dist/src/lib/signing-secret-status.js.map +1 -0
  395. package/dist/src/lib/skill-installer.d.ts +25 -0
  396. package/dist/src/lib/skill-installer.js +266 -0
  397. package/dist/src/lib/skill-installer.js.map +1 -0
  398. package/dist/src/lib/snapshots.d.ts +98 -0
  399. package/dist/src/lib/snapshots.js +294 -0
  400. package/dist/src/lib/snapshots.js.map +1 -0
  401. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +113 -0
  402. package/dist/src/lib/swift-codegen/dbGenerator.js +914 -0
  403. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -0
  404. package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +42 -0
  405. package/dist/src/lib/swift-codegen/dbSwiftTypes.js +100 -0
  406. package/dist/src/lib/swift-codegen/dbSwiftTypes.js.map +1 -0
  407. package/dist/src/lib/swift-codegen/generator.d.ts +94 -0
  408. package/dist/src/lib/swift-codegen/generator.js +440 -0
  409. package/dist/src/lib/swift-codegen/generator.js.map +1 -0
  410. package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +72 -0
  411. package/dist/src/lib/swift-codegen/schemaToSwift.js +644 -0
  412. package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -0
  413. package/dist/src/lib/swift-codegen/siblingSymbols.d.ts +94 -0
  414. package/dist/src/lib/swift-codegen/siblingSymbols.js +155 -0
  415. package/dist/src/lib/swift-codegen/siblingSymbols.js.map +1 -0
  416. package/dist/src/lib/swift-codegen/swiftNaming.d.ts +85 -0
  417. package/dist/src/lib/swift-codegen/swiftNaming.js +198 -0
  418. package/dist/src/lib/swift-codegen/swiftNaming.js.map +1 -0
  419. package/dist/src/lib/sync-dir-selector.d.ts +21 -0
  420. package/dist/src/lib/sync-dir-selector.js +30 -0
  421. package/dist/src/lib/sync-dir-selector.js.map +1 -0
  422. package/dist/src/lib/sync-paths.d.ts +123 -0
  423. package/dist/src/lib/sync-paths.js +193 -0
  424. package/dist/src/lib/sync-paths.js.map +1 -0
  425. package/dist/src/lib/sync-resource-types.d.ts +563 -0
  426. package/dist/src/lib/sync-resource-types.js +1073 -0
  427. package/dist/src/lib/sync-resource-types.js.map +1 -0
  428. package/dist/src/lib/sync-selectors.d.ts +95 -0
  429. package/dist/src/lib/sync-selectors.js +228 -0
  430. package/dist/src/lib/sync-selectors.js.map +1 -0
  431. package/dist/src/lib/template.d.ts +170 -0
  432. package/dist/src/lib/template.js +484 -68
  433. package/dist/src/lib/template.js.map +1 -1
  434. package/dist/src/lib/test-case-file-names.d.ts +40 -0
  435. package/dist/src/lib/test-case-file-names.js +91 -0
  436. package/dist/src/lib/test-case-file-names.js.map +1 -0
  437. package/dist/src/lib/test-case-keys.d.ts +29 -0
  438. package/dist/src/lib/test-case-keys.js +55 -0
  439. package/dist/src/lib/test-case-keys.js.map +1 -0
  440. package/dist/src/lib/test-case-variables.d.ts +29 -0
  441. package/dist/src/lib/test-case-variables.js +71 -0
  442. package/dist/src/lib/test-case-variables.js.map +1 -0
  443. package/dist/src/lib/token-inject.d.ts +56 -0
  444. package/dist/src/lib/token-inject.js +204 -0
  445. package/dist/src/lib/token-inject.js.map +1 -0
  446. package/dist/src/lib/toml-database-config.d.ts +123 -0
  447. package/dist/src/lib/toml-database-config.js +544 -0
  448. package/dist/src/lib/toml-database-config.js.map +1 -0
  449. package/dist/src/lib/toml-metadata-config.d.ts +151 -0
  450. package/dist/src/lib/toml-metadata-config.js +476 -0
  451. package/dist/src/lib/toml-metadata-config.js.map +1 -0
  452. package/dist/src/lib/toml-native-form.d.ts +46 -0
  453. package/dist/src/lib/toml-native-form.js +78 -0
  454. package/dist/src/lib/toml-native-form.js.map +1 -0
  455. package/dist/src/lib/toml-params-validator.d.ts +129 -0
  456. package/dist/src/lib/toml-params-validator.js +298 -0
  457. package/dist/src/lib/toml-params-validator.js.map +1 -0
  458. package/dist/src/lib/toml-scalar-edit.d.ts +43 -0
  459. package/dist/src/lib/toml-scalar-edit.js +283 -0
  460. package/dist/src/lib/toml-scalar-edit.js.map +1 -0
  461. package/dist/src/lib/user-selector.d.ts +24 -0
  462. package/dist/src/lib/user-selector.js +33 -0
  463. package/dist/src/lib/user-selector.js.map +1 -0
  464. package/dist/src/lib/version-check.d.ts +35 -0
  465. package/dist/src/lib/version-check.js +241 -0
  466. package/dist/src/lib/version-check.js.map +1 -0
  467. package/dist/src/lib/watch.d.ts +121 -0
  468. package/dist/src/lib/watch.js +169 -0
  469. package/dist/src/lib/watch.js.map +1 -0
  470. package/dist/src/lib/web-url.d.ts +40 -0
  471. package/dist/src/lib/web-url.js +76 -0
  472. package/dist/src/lib/web-url.js.map +1 -0
  473. package/dist/src/lib/workflow-apply.d.ts +110 -0
  474. package/dist/src/lib/workflow-apply.js +164 -0
  475. package/dist/src/lib/workflow-apply.js.map +1 -0
  476. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.d.ts +129 -0
  477. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js +269 -0
  478. package/dist/src/lib/workflow-codegen/generated-schema-descriptor.js.map +1 -0
  479. package/dist/src/lib/workflow-codegen/generator.d.ts +96 -0
  480. package/dist/src/lib/workflow-codegen/generator.js +361 -0
  481. package/dist/src/lib/workflow-codegen/generator.js.map +1 -0
  482. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +94 -0
  483. package/dist/src/lib/workflow-codegen/invokerIR.js +76 -0
  484. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -0
  485. package/dist/src/lib/workflow-codegen/naming.d.ts +33 -0
  486. package/dist/src/lib/workflow-codegen/naming.js +81 -0
  487. package/dist/src/lib/workflow-codegen/naming.js.map +1 -0
  488. package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +80 -0
  489. package/dist/src/lib/workflow-codegen/schemaToTs.js +303 -0
  490. package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -0
  491. package/dist/src/lib/workflow-config-apply.d.ts +70 -0
  492. package/dist/src/lib/workflow-config-apply.js +137 -0
  493. package/dist/src/lib/workflow-config-apply.js.map +1 -0
  494. package/dist/src/lib/workflow-config-sidecar.d.ts +63 -0
  495. package/dist/src/lib/workflow-config-sidecar.js +96 -0
  496. package/dist/src/lib/workflow-config-sidecar.js.map +1 -0
  497. package/dist/src/lib/workflow-defaults.d.ts +29 -0
  498. package/dist/src/lib/workflow-defaults.js +41 -0
  499. package/dist/src/lib/workflow-defaults.js.map +1 -0
  500. package/dist/src/lib/workflow-field-descriptor.d.ts +91 -0
  501. package/dist/src/lib/workflow-field-descriptor.js +153 -0
  502. package/dist/src/lib/workflow-field-descriptor.js.map +1 -0
  503. package/dist/src/lib/workflow-fragments.d.ts +64 -0
  504. package/dist/src/lib/workflow-fragments.js +342 -0
  505. package/dist/src/lib/workflow-fragments.js.map +1 -0
  506. package/dist/src/lib/workflow-include-preserve.d.ts +76 -0
  507. package/dist/src/lib/workflow-include-preserve.js +286 -0
  508. package/dist/src/lib/workflow-include-preserve.js.map +1 -0
  509. package/dist/src/lib/workflow-payload.d.ts +98 -0
  510. package/dist/src/lib/workflow-payload.js +178 -0
  511. package/dist/src/lib/workflow-payload.js.map +1 -0
  512. package/dist/src/lib/workflow-toml-validator.d.ts +207 -0
  513. package/dist/src/lib/workflow-toml-validator.js +762 -0
  514. package/dist/src/lib/workflow-toml-validator.js.map +1 -0
  515. package/dist/src/lib/workflow-usage.d.ts +198 -0
  516. package/dist/src/lib/workflow-usage.js +312 -0
  517. package/dist/src/lib/workflow-usage.js.map +1 -0
  518. package/dist/src/types/index.d.ts +591 -0
  519. package/dist/src/validators.d.ts +65 -0
  520. package/dist/src/validators.js +64 -0
  521. package/dist/src/validators.js.map +1 -0
  522. package/package.json +34 -9
@@ -1,21 +1,304 @@
1
- import { readFileSync, writeFileSync, statSync } from "fs";
2
- import { basename } from "path";
3
- import * as TOML from "@iarna/toml";
4
- import { lookup as mimeLookup } from "mime-types";
1
+ import { readFileSync, writeFileSync, readdirSync, } from "fs";
2
+ import * as path from "path";
3
+ import { stringifyConfigToml } from "../lib/config-toml.js";
4
+ import { generateWorkflowTypes, } from "../lib/workflow-codegen/generator.js";
5
+ import { generateWorkflowSwiftTypes } from "../lib/swift-codegen/generator.js";
5
6
  import { ApiClient } from "../lib/api-client.js";
6
7
  import { resolveAppId } from "../lib/config.js";
8
+ import { resolveWorkflowSelector } from "../lib/block-selector.js";
9
+ import { formatAccessRuleForDisplay } from "../lib/access-rule-display.js";
10
+ import { resolveCodegenSourceDir } from "../lib/codegen-shared/resolveCodegenSourceDir.js";
11
+ import { expandWorkflow } from "../lib/workflow-fragments.js";
12
+ import { confirmPrompt } from "../lib/confirm-prompt.js";
13
+ import { parseStatusFilter } from "../lib/object-status-filter.js";
14
+ import { buildWorkflowRunEnvelope, buildWorkflowStepEnvelope, collectWorkflowFailures, describeEmptyRunPage, describeFailureSweep, formatFailureCodeCell, formatFailureErrorCell, formatFailureStepCell, selectFailedStepRow, toWorkflowRunInspectionRow, DEFAULT_FAILURE_SWEEP_MAX_SCAN, FAILURE_SWEEP_MAX_REQUESTS, SERVER_SCAN_ROWS_PER_REQUEST, } from "../lib/log-inspection.js";
7
15
  import chalk from "chalk";
8
- import { success, error, info, warn, keyValue, formatTable, formatId, formatDate, formatStatus, formatDuration, json, divider, } from "../lib/output.js";
16
+ import { success, error, info, warn, keyValue, result as printResult, formatTable, formatId, formatDate, formatStatus, formatDuration, json, jsonLine, divider, dim, progress, progressEnd, } from "../lib/output.js";
17
+ import { pollUntil, followLoop, watchLoop, parseIntervalSeconds, } from "../lib/watch.js";
18
+ import { APPLY_RUN_STATUSES, RUN_STATUS_LIST, SETTLED_RUN_STATUSES, } from "../lib/run-status.js";
19
+ import { requireTestCaseInputVariables } from "../lib/test-case-variables.js";
20
+ /**
21
+ * Render the `Step Results` section of `workflows runs status`. Pure
22
+ * formatting helper — returns lines as strings so it's unit-testable
23
+ * (the caller console.logs them). For each step, emits:
24
+ *
25
+ * ` <id>: <status-label> <duration>`
26
+ *
27
+ * and, when applicable, indented follow-up lines:
28
+ *
29
+ * - `error_captured` step with `error` → ` error: <truncated message>`
30
+ * - forEach output with `errors[]` non-empty →
31
+ * ` forEach errors: <N/M> - first: <truncated message>`
32
+ *
33
+ * The truncation length matches `runs steps` ERROR column (80 chars,
34
+ * trailing "..." when truncated). See #688.
35
+ */
36
+ /**
37
+ * Stop-polling predicate for the `workflows preview --wait` loop (issues #941,
38
+ * #2348). True when the run has settled and the wait should stop — as opposed
39
+ * to a still-in-flight status ("queued"/"running") that keeps the loop going
40
+ * until the cap.
41
+ *
42
+ * Since #2348 the server reconciles every status surface onto the canonical
43
+ * vocabulary before it goes on the wire, so this reads the one shared set in
44
+ * `lib/run-status.ts` instead of re-deriving terminality here:
45
+ * - "completed" / "failed" / "terminated" — true terminals. "terminated" is
46
+ * an aborted preview, a real end state (Codex P2 on #941: it used to be
47
+ * omitted, so an abort was misreported as a wait timeout).
48
+ * - "apply_pending" / "apply_claimed" — the run finished and is parked
49
+ * waiting for a client to apply its result. It never advances on its own,
50
+ * so the previous ladder spun here until the 60s cap.
51
+ * - "missing" — no live instance to observe; more polling cannot help.
52
+ *
53
+ * The raw Cloudflare spellings ("complete"/"errored"/"waiting") are gone: the
54
+ * server no longer puts them on the wire, and matching them here would have
55
+ * kept two vocabularies alive.
56
+ */
57
+ export function isPreviewWaitTerminalStatus(s) {
58
+ return typeof s === "string" && SETTLED_RUN_STATUSES.has(s);
59
+ }
60
+ /**
61
+ * Which arm the `workflows preview --wait` render ladder takes (#2636).
62
+ *
63
+ * Extracted from the ladder so the branch decision is testable: the point of
64
+ * the `skipped` arm is that an elided preview — one whose declarative lock was
65
+ * held under `onContention: "ignore"` — reports that it did not run, instead
66
+ * of falling through to the timeout branch and exiting 124 as though it were
67
+ * still going.
68
+ */
69
+ export function previewWaitOutcome(status) {
70
+ if (status === "completed")
71
+ return "completed";
72
+ if (status === "failed")
73
+ return "failed";
74
+ if (status === "terminated")
75
+ return "terminated";
76
+ if (status === "skipped")
77
+ return "skipped";
78
+ if (typeof status === "string" && APPLY_RUN_STATUSES.has(status))
79
+ return "apply";
80
+ if (status === "missing")
81
+ return "missing";
82
+ return "timeout";
83
+ }
84
+ export function renderRunStatusStepResults(stepResults) {
85
+ const lines = [];
86
+ for (const step of stepResults || []) {
87
+ const statusLabel = step.skipped
88
+ ? chalk.gray("skipped")
89
+ : step.status === "error_captured"
90
+ ? chalk.yellow("error_captured")
91
+ : step.status === "failed"
92
+ ? chalk.red("failed")
93
+ : step.status === "completed"
94
+ ? chalk.green("completed")
95
+ : step.status || "?";
96
+ const durationLabel = formatDuration(step.durationMs);
97
+ lines.push(` ${step.id}: ${statusLabel} ${durationLabel}`);
98
+ // Single-step error_captured: show the captured error message.
99
+ if (step.status === "error_captured" && step.error) {
100
+ const errMsg = typeof step.error === "string" ? step.error : String(step.error);
101
+ const truncated = errMsg.length > 80 ? errMsg.slice(0, 77) + "..." : errMsg;
102
+ lines.push(` ${chalk.dim("error:")} ${chalk.yellow(truncated)}`);
103
+ }
104
+ else if (step.status === "error_captured" && step.errorDetails) {
105
+ // No top-level error but errorDetails present — hint to drill in.
106
+ lines.push(` ${chalk.dim("error: <see runs step-detail for full errorDetails>")}`);
107
+ }
108
+ // forEach aggregated errors: a one-line summary surfaces the count
109
+ // of `error_captured` iterations and a sample message (#688).
110
+ const fo = step.output;
111
+ if (fo && typeof fo === "object" && Array.isArray(fo.errors) && fo.errors.length > 0) {
112
+ const total = (Array.isArray(fo.items) ? fo.items.length : 0)
113
+ || (fo.totalSucceeded ?? 0) + (fo.totalFailed ?? 0);
114
+ const firstErr = (Array.isArray(fo.errorMessages) && fo.errorMessages.length > 0
115
+ ? fo.errorMessages[0]
116
+ : (fo.errors[0]?.error ?? "?"));
117
+ const firstStr = typeof firstErr === "string" ? firstErr : String(firstErr);
118
+ const truncated = firstStr.length > 80 ? firstStr.slice(0, 77) + "..." : firstStr;
119
+ lines.push(` ${chalk.dim("forEach errors:")} ${chalk.yellow(`${fo.totalFailed ?? fo.errors.length}/${total}`)} ${chalk.dim("- first:")} ${chalk.yellow(truncated)}`);
120
+ }
121
+ }
122
+ return lines;
123
+ }
124
+ /**
125
+ * #2771 — how long a step row has been going.
126
+ *
127
+ * A step that is still executing is persisted with status `running` and a
128
+ * `durationMs` of 0 (the column is required; the real value is written when the
129
+ * step ends). Printing that 0 hides the one number that matters while a run is
130
+ * in flight — how long the current step has been stuck — so a running row is
131
+ * measured from its `startedAt` instead. Finished rows keep their stored
132
+ * duration. Exported for testability.
133
+ */
134
+ export function stepRunDisplayDurationMs(step, now = Date.now()) {
135
+ const stored = typeof step?.durationMs === "number" ? step.durationMs : null;
136
+ if (step?.status !== "running")
137
+ return stored;
138
+ const started = step?.startedAt ? new Date(step.startedAt).getTime() : NaN;
139
+ if (Number.isNaN(started))
140
+ return stored;
141
+ return Math.max(0, now - started);
142
+ }
143
+ /**
144
+ * #2771 — the step lines `workflows runs status` should render.
145
+ *
146
+ * Cloudflare's instance status carries `stepResults` only once the run reaches
147
+ * a terminal state, so a live run used to print `Status: running` and nothing
148
+ * else. The persisted step rows now exist DURING the run, so they are the
149
+ * fallback: mapped onto the shape `renderRunStatusStepResults` reads, with a
150
+ * running step's elapsed time in place of its 0 placeholder. The instance's own
151
+ * results always win when it has them. Exported for testability.
152
+ */
153
+ export function stepResultsForRunStatus(instanceStepResults, persistedStepRuns, now = Date.now()) {
154
+ if (instanceStepResults && instanceStepResults.length > 0)
155
+ return instanceStepResults;
156
+ return (persistedStepRuns || []).map((row) => ({
157
+ id: row?.stepId,
158
+ kind: row?.stepKind,
159
+ status: row?.status,
160
+ durationMs: stepRunDisplayDurationMs(row, now),
161
+ error: row?.error,
162
+ errorDetails: row?.errorDetails,
163
+ output: row?.output,
164
+ }));
165
+ }
166
+ /**
167
+ * #1367 — annotate step-run rows with the inter-step gap: the wall-clock time
168
+ * between the previous step's `endedAt` and this step's `startedAt`. Sorts by
169
+ * `startedAt` when every row has a parseable one (tie-break `stepIndex`),
170
+ * otherwise by `stepIndex` for the whole list — the ordering mode must be
171
+ * chosen once for all rows, not per pair: a per-pair timestamp-vs-index
172
+ * fallback is not transitive when some rows lack a timestamp (A<B by index,
173
+ * B<C by index, C<A by time), and a non-transitive comparator leaves
174
+ * Array.prototype.sort free to return an arbitrary order, computing gaps
175
+ * against the wrong predecessor. The first step has no predecessor, so its
176
+ * `gapMs` is `null`. A gap is only meaningful when both adjacent timestamps
177
+ * parse and the gap is positive. When the immediate predecessor lacks a valid
178
+ * `endedAt`, the gap is left `null` rather than measured from an older,
179
+ * non-adjacent step — otherwise a later step could raise a false stall
180
+ * warning against a step it does not actually follow. Exported for
181
+ * testability.
182
+ */
183
+ export function annotateStepGaps(steps) {
184
+ const rows = [...(steps || [])];
185
+ const allTimed = rows.every((r) => r?.startedAt && !Number.isNaN(new Date(r.startedAt).getTime()));
186
+ rows.sort((a, b) => {
187
+ if (allTimed) {
188
+ const sa = new Date(a.startedAt).getTime();
189
+ const sb = new Date(b.startedAt).getTime();
190
+ if (sa !== sb)
191
+ return sa - sb;
192
+ }
193
+ return (a?.stepIndex ?? 0) - (b?.stepIndex ?? 0);
194
+ });
195
+ let prevEnded = null;
196
+ for (const row of rows) {
197
+ const started = row?.startedAt ? new Date(row.startedAt).getTime() : NaN;
198
+ if (prevEnded != null && !Number.isNaN(started)) {
199
+ const gap = started - prevEnded;
200
+ row.gapMs = gap > 0 ? gap : 0;
201
+ }
202
+ else {
203
+ row.gapMs = null;
204
+ }
205
+ // Only genuinely adjacent steps yield a gap: if this step lacks a valid
206
+ // `endedAt`, clear the predecessor so the NEXT step's gap is left null
207
+ // instead of being measured from a non-adjacent earlier step.
208
+ const ended = row?.endedAt ? new Date(row.endedAt).getTime() : NaN;
209
+ prevEnded = Number.isNaN(ended) ? null : ended;
210
+ }
211
+ return rows;
212
+ }
213
+ /**
214
+ * #1367 — compute min / p50 / p95 / max over a set of latency samples (ms).
215
+ * Ignores non-finite values. Returns `null` when there are no valid samples.
216
+ * Percentiles use nearest-rank on the sorted samples. Exported for testability.
217
+ */
218
+ export function summarizeLatencyDistribution(values) {
219
+ const nums = (values || []).filter((v) => typeof v === "number" && Number.isFinite(v));
220
+ if (nums.length === 0)
221
+ return null;
222
+ nums.sort((a, b) => a - b);
223
+ const pct = (p) => {
224
+ const rank = Math.ceil((p / 100) * nums.length);
225
+ return nums[Math.min(nums.length - 1, Math.max(0, rank - 1))];
226
+ };
227
+ return {
228
+ count: nums.length,
229
+ min: nums[0],
230
+ p50: pct(50),
231
+ p95: pct(95),
232
+ max: nums[nums.length - 1],
233
+ };
234
+ }
235
+ /**
236
+ * Issue #687 (review feedback): render an unambiguous label for a workflow
237
+ * config slot ("active config", "draft config", etc.) when the slot may be
238
+ * unnamed. Falls back to a short ID prefix so users staging multiple unnamed
239
+ * configs can still tell them apart.
240
+ *
241
+ * formatConfigSlotLabel("active config", "v2", "01KRT...") → 'active config "v2"'
242
+ * formatConfigSlotLabel("active config", null, "01KRTABCDXYZ...") → 'active config (01KRTABCDXYZ…)'
243
+ * formatConfigSlotLabel("active config", null, null) → 'active config'
244
+ *
245
+ * Exported for testability.
246
+ */
247
+ export function formatConfigSlotLabel(slot, configName, configId, prefixLength = 12) {
248
+ if (configName) {
249
+ return `${slot} "${configName}"`;
250
+ }
251
+ if (configId) {
252
+ return `${slot} (${String(configId).slice(0, prefixLength)}…)`;
253
+ }
254
+ return slot;
255
+ }
256
+ /**
257
+ * Resolve a workflow reference — its server id, or the author-chosen key its
258
+ * TOML file is named for — for the `tests` verbs (#3249).
259
+ *
260
+ * The key is what an operator has: `workflows/<key>.toml` is the object and
261
+ * `workflows/<key>.tests/` its cases. The resolution lives in
262
+ * `block-selector.ts`, shared with `prompts`.
263
+ */
264
+ async function resolveWorkflowId(client, appId, ref) {
265
+ return (await resolveWorkflowSelector(client, appId, ref)).blockId;
266
+ }
9
267
  export function registerWorkflowsCommands(program) {
10
268
  const workflows = program
11
269
  .command("workflows")
12
- .description("Build multi-step workflows, publish revisions, and monitor runs")
270
+ .description("Inspect workflows, run and monitor them, and take one out of service")
13
271
  .addHelpText("after", `
14
272
  Examples:
15
273
  $ primitive workflows list
16
- $ primitive workflows create --from-file process-doc.toml
17
- $ primitive workflows publish 01HXY...
274
+ $ primitive config create workflow process-doc # scaffold workflows/process-doc.toml
275
+ $ primitive config push --only workflow/process-doc # apply it
18
276
  $ primitive workflows runs list 01HXY...
277
+ $ primitive workflows disable 01HXY... # take it out of service now
278
+ `);
279
+ // Server-side configuration is authored in TOML and applied with `config push`
280
+ // (issue #2645) — this group no longer carries config-setting flags.
281
+ workflows.addHelpText("after", `
282
+ Configuration lives in workflows/<key>.toml:
283
+ $ primitive config fields workflow # the file's keys, types and defaults
284
+ $ primitive config create workflow <key> # scaffold one locally
285
+ $ primitive config set workflow/<key> <path>=<value>
286
+ $ primitive config push --only workflow/<key>
287
+
288
+ Which body runs is configuration too: name it with activeConfigName in
289
+ workflows/<key>.toml (bodies live in workflows/<key>.configs/<name>.toml) and
290
+ apply it with 'config push'. There is no other write path: revisions are
291
+ read-only history from the retired legacy path (issue #2768).
292
+ `);
293
+ // Per-subject analytics have one home, the `analytics` noun (issue #2766) —
294
+ // this group's own analytics commands were removed, so say where they went.
295
+ workflows.addHelpText("after", `
296
+ Analytics live under the analytics noun:
297
+ $ primitive analytics workflows --window-days 7 --limit 10
298
+
299
+ That is the single home for per-subject analytics (workflows, prompts,
300
+ integrations). It ranks workflows by runs; there is no app-wide workflow
301
+ overview — the endpoint behind the retired overview command never existed.
19
302
  `);
20
303
  // List workflows
21
304
  workflows
@@ -23,14 +306,22 @@ Examples:
23
306
  .description("List workflows")
24
307
  .argument("[app-id]", "App ID (uses current app if not specified)")
25
308
  .option("--app <app-id>", "App ID")
26
- .option("--status <status>", "Filter by status: draft, active, archived")
309
+ .option("--status <status>", "Filter by status: active, inactive, archived")
27
310
  .option("--json", "Output as JSON")
28
311
  .action(async (appId, options) => {
29
312
  const resolvedAppId = resolveAppId(appId, options);
30
313
  const client = new ApiClient();
31
314
  try {
315
+ // #2803 — `draft` is gone; refuse it by name rather than returning the
316
+ // empty list a canonical filter would produce. #2887 added `archived`:
317
+ // the delete flow writes one now, so asking for the retired rows is a
318
+ // question with an answer rather than a 400.
32
319
  const { items } = await client.listWorkflows(resolvedAppId, {
33
- status: options.status,
320
+ status: parseStatusFilter("workflows", options.status, [
321
+ "active",
322
+ "inactive",
323
+ "archived",
324
+ ]),
34
325
  });
35
326
  if (options.json) {
36
327
  json(items);
@@ -53,83 +344,10 @@ Examples:
53
344
  process.exit(1);
54
345
  }
55
346
  });
56
- // Create workflow
57
- workflows
58
- .command("create")
59
- .description("Create a new workflow")
60
- .argument("[app-id]", "App ID (uses current app if not specified)")
61
- .option("--app <app-id>", "App ID")
62
- .option("--key <key>", "Workflow key (unique identifier)")
63
- .option("--name <name>", "Display name")
64
- .option("--description <desc>", "Description")
65
- .option("--from-file <path>", "Load workflow from TOML file")
66
- .option("--requires-client-apply <bool>", "Require client-side apply: true or false")
67
- .option("--json", "Output as JSON")
68
- .action(async (appId, options) => {
69
- const resolvedAppId = resolveAppId(appId, options);
70
- const client = new ApiClient();
71
- let payload;
72
- if (options.fromFile) {
73
- try {
74
- const content = readFileSync(options.fromFile, "utf-8");
75
- const tomlData = TOML.parse(content);
76
- const workflow = tomlData.workflow || tomlData;
77
- payload = {
78
- workflowKey: workflow.key || workflow.workflowKey,
79
- name: workflow.name,
80
- description: workflow.description,
81
- steps: tomlData.steps || [],
82
- inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : undefined,
83
- outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : undefined,
84
- perUserMaxRunning: workflow.perUserMaxRunning,
85
- perUserMaxQueued: workflow.perUserMaxQueued,
86
- perAppMaxRunning: workflow.perAppMaxRunning,
87
- perAppMaxQueued: workflow.perAppMaxQueued,
88
- queueTtlSeconds: workflow.queueTtlSeconds,
89
- dequeueOrder: workflow.dequeueOrder,
90
- requiresClientApply: workflow.requiresClientApply,
91
- };
92
- }
93
- catch (err) {
94
- error(`Failed to read TOML file: ${err.message}`);
95
- process.exit(1);
96
- }
97
- }
98
- else {
99
- if (!options.key || !options.name) {
100
- error("Required: --key, --name (or use --from-file)");
101
- process.exit(1);
102
- }
103
- payload = {
104
- workflowKey: options.key,
105
- name: options.name,
106
- description: options.description,
107
- steps: [],
108
- };
109
- }
110
- if (options.requiresClientApply !== undefined) {
111
- payload.requiresClientApply = options.requiresClientApply === "true";
112
- }
113
- try {
114
- const result = await client.createWorkflow(resolvedAppId, payload);
115
- if (options.json) {
116
- json(result);
117
- return;
118
- }
119
- success(`Workflow created: ${result.workflow?.name || options.name}`);
120
- keyValue("Workflow ID", result.workflow?.workflowId);
121
- keyValue("Key", result.workflow?.workflowKey);
122
- }
123
- catch (err) {
124
- error(err.message);
125
- process.exit(1);
126
- }
127
- });
128
- // Get workflow
129
347
  workflows
130
348
  .command("get")
131
349
  .description("Get workflow details")
132
- .argument("<workflow-id>", "Workflow ID")
350
+ .argument("<workflow-id>", "Workflow ID or key")
133
351
  .option("--app <app-id>", "App ID (uses current app if not specified)")
134
352
  .option("--json", "Output as JSON")
135
353
  .action(async (workflowId, options) => {
@@ -142,20 +360,36 @@ Examples:
142
360
  return;
143
361
  }
144
362
  const wf = result.workflow;
145
- keyValue("Workflow ID", wf.workflowId);
146
- keyValue("Key", wf.workflowKey);
147
- keyValue("Name", wf.name);
148
- keyValue("Description", wf.description);
149
- keyValue("Status", formatStatus(wf.status));
150
- keyValue("Active Config", wf.activeConfigId || "-");
151
- keyValue("Latest Revision", wf.latestRevision || "-");
152
- keyValue("Client Apply", wf.requiresClientApply !== false ? "yes" : "no");
363
+ printResult("Workflow ID", wf.workflowId);
364
+ printResult("Key", wf.workflowKey);
365
+ printResult("Name", wf.name);
366
+ printResult("Description", wf.description);
367
+ // #2803 — ONE availability row. The server returns the canonical
368
+ // value, so a workflow an operator disabled reads `inactive` here and
369
+ // there is no second row left to disagree with it.
370
+ printResult("Status", formatStatus(wf.status));
371
+ printResult("Active Config", wf.activeConfigId || "-");
372
+ printResult("Latest Revision", wf.latestRevision || "-");
373
+ printResult("Client Apply", wf.requiresClientApply !== false ? "yes" : "no");
374
+ printResult("Sync Callable", wf.syncCallable === true ? "yes" : "no");
375
+ // #1233 — runAs unset means the engine defaults to "caller"; show that
376
+ // default rather than a blank so the deployed principal mode is visible.
377
+ printResult("Run As", wf.runAs || "caller");
378
+ printResult("Capabilities", Array.isArray(wf.capabilities) && wf.capabilities.length > 0
379
+ ? wf.capabilities.join(", ")
380
+ : "-");
381
+ // #2652: a caller workflow with no rule denies every non-admin start,
382
+ // so the row is always printed — the sentinel names the consequence,
383
+ // and a `runAs: "system"` workflow gets its own exempt wording.
384
+ printResult("Access Rule", formatAccessRuleForDisplay(wf.accessRule, "workflow", {
385
+ runAs: wf.runAs,
386
+ }));
153
387
  divider();
154
388
  info("Queue Settings:");
155
- keyValue(" Per User Max Running", wf.perUserMaxRunning);
156
- keyValue(" Per User Max Queued", wf.perUserMaxQueued);
157
- keyValue(" Per App Max Running", wf.perAppMaxRunning);
158
- keyValue(" Dequeue Order", wf.dequeueOrder);
389
+ printResult(" Per User Max Running", wf.perUserMaxRunning);
390
+ printResult(" Per User Max Queued", wf.perUserMaxQueued);
391
+ printResult(" Per App Max Running", wf.perAppMaxRunning);
392
+ printResult(" Dequeue Order", wf.dequeueOrder);
159
393
  if (wf.inputSchema) {
160
394
  divider();
161
395
  info("Input Schema:");
@@ -196,163 +430,136 @@ Examples:
196
430
  process.exit(1);
197
431
  }
198
432
  });
199
- // Update workflow
200
433
  workflows
201
- .command("update")
202
- .description("Update workflow metadata")
203
- .argument("<workflow-id>", "Workflow ID")
204
- .option("--app <app-id>", "App ID (uses current app if not specified)")
205
- .option("--name <name>", "Display name")
206
- .option("--description <desc>", "Description")
207
- .option("--status <status>", "Status: draft, active, archived")
208
- .option("--per-user-max-running <n>", "Max running per user")
209
- .option("--per-user-max-queued <n>", "Max queued per user")
210
- .option("--dequeue-order <order>", "Dequeue order: fifo, lifo")
211
- .option("--requires-client-apply <bool>", "Require client-side apply: true or false")
212
- .option("--json", "Output as JSON")
213
- .action(async (workflowId, options) => {
214
- const resolvedAppId = resolveAppId(undefined, options);
215
- const payload = {};
216
- if (options.name)
217
- payload.name = options.name;
218
- if (options.description)
219
- payload.description = options.description;
220
- if (options.status)
221
- payload.status = options.status;
222
- if (options.perUserMaxRunning)
223
- payload.perUserMaxRunning = parseInt(options.perUserMaxRunning);
224
- if (options.perUserMaxQueued)
225
- payload.perUserMaxQueued = parseInt(options.perUserMaxQueued);
226
- if (options.dequeueOrder)
227
- payload.dequeueOrder = options.dequeueOrder;
228
- if (options.requiresClientApply !== undefined) {
229
- payload.requiresClientApply = options.requiresClientApply === "true";
230
- }
231
- if (Object.keys(payload).length === 0) {
232
- error("No update options specified.");
233
- process.exit(1);
234
- }
235
- const client = new ApiClient();
434
+ .command("expand")
435
+ .description("Expand a workflow TOML's include fragments and print the result")
436
+ .argument("<file>", "Path to the workflow TOML file")
437
+ .option("--format <format>", "Output format: json (default) or toml", "json")
438
+ .action((file, options) => {
236
439
  try {
237
- const result = await client.updateWorkflow(resolvedAppId, workflowId, payload);
238
- if (options.json) {
239
- json(result);
240
- return;
440
+ const expanded = expandWorkflow(file);
441
+ if (options.format === "toml") {
442
+ // Strip the `include` key (already removed by expander) and emit
443
+ // valid TOML so the result is round-trippable.
444
+ console.log(stringifyConfigToml(expanded));
445
+ }
446
+ else {
447
+ console.log(JSON.stringify(expanded, null, 2));
241
448
  }
242
- success("Workflow updated.");
243
449
  }
244
450
  catch (err) {
245
- error(err.message);
451
+ error(err?.message ?? String(err));
246
452
  process.exit(1);
247
453
  }
248
454
  });
249
- // Delete workflow
455
+ // #2645 criterion 9 / #2803 — the operational verbs.
456
+ //
457
+ // Configuration is authored in TOML and applied with `config push`, which would
458
+ // otherwise mean an operator needs a repo checkout to stop a misbehaving
459
+ // workflow. It does not: taking something out of service is an OPERATION, and
460
+ // these two verbs are the ONLY writers of `status`, which is server-owned and
461
+ // no longer a TOML key. The committed configuration says nothing about
462
+ // availability, so `config diff` reports the server's status as operator
463
+ // state rather than as drift.
250
464
  workflows
251
- .command("delete")
252
- .description("Delete or archive a workflow")
253
- .argument("<workflow-id>", "Workflow ID")
465
+ .command("disable")
466
+ .description("Take a workflow out of service now. Operational, not configuration: `status` is server-owned and 'config push' can neither set nor clear it.")
467
+ .argument("<workflow-id>", "Workflow ID or key")
254
468
  .option("--app <app-id>", "App ID (uses current app if not specified)")
255
- .option("--hard", "Permanently delete instead of archive")
256
- .option("-y, --yes", "Skip confirmation prompt")
469
+ .option("--json", "Output as JSON")
257
470
  .action(async (workflowId, options) => {
258
471
  const resolvedAppId = resolveAppId(undefined, options);
259
- if (!options.yes) {
260
- const action = options.hard ? "permanently delete" : "archive";
261
- const inquirer = await import("inquirer");
262
- const { confirm } = await inquirer.default.prompt([
263
- {
264
- type: "confirm",
265
- name: "confirm",
266
- message: `Are you sure you want to ${action} workflow ${workflowId}?`,
267
- default: false,
268
- },
269
- ]);
270
- if (!confirm) {
271
- info("Cancelled.");
272
- return;
273
- }
274
- }
275
472
  const client = new ApiClient();
276
473
  try {
277
- if (options.hard) {
278
- await client.deleteWorkflow(resolvedAppId, workflowId);
279
- success("Workflow deleted.");
280
- }
281
- else {
282
- await client.updateWorkflow(resolvedAppId, workflowId, { status: "archived" });
283
- success("Workflow archived.");
474
+ const result = await client.disableWorkflow(resolvedAppId, workflowId);
475
+ if (options.json) {
476
+ json(result);
477
+ return;
284
478
  }
479
+ success(`Workflow disabled: ${result.workflowKey || workflowId}`);
480
+ info("New runs are refused. Configuration is unchanged — `primitive workflows enable` puts it back.");
285
481
  }
286
482
  catch (err) {
287
483
  error(err.message);
288
484
  process.exit(1);
289
485
  }
290
486
  });
291
- // Draft subcommand
292
- const draft = workflows.command("draft").description("Manage workflow draft");
293
- // Update draft
294
- draft
295
- .command("update")
296
- .description("Update workflow draft steps")
297
- .argument("<workflow-id>", "Workflow ID")
487
+ workflows
488
+ .command("enable")
489
+ .description("Put a workflow back in service.")
490
+ .argument("<workflow-id>", "Workflow ID or key")
298
491
  .option("--app <app-id>", "App ID (uses current app if not specified)")
299
- .option("--from-file <path>", "Load steps from TOML file")
300
492
  .option("--json", "Output as JSON")
301
493
  .action(async (workflowId, options) => {
302
494
  const resolvedAppId = resolveAppId(undefined, options);
303
- if (!options.fromFile) {
304
- error("--from-file is required");
305
- process.exit(1);
306
- }
307
- let payload;
308
- try {
309
- const content = readFileSync(options.fromFile, "utf-8");
310
- const tomlData = TOML.parse(content);
311
- const workflow = tomlData.workflow || tomlData;
312
- payload = {
313
- steps: tomlData.steps || [],
314
- inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : undefined,
315
- outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : undefined,
316
- };
317
- }
318
- catch (err) {
319
- error(`Failed to read TOML file: ${err.message}`);
320
- process.exit(1);
321
- }
322
495
  const client = new ApiClient();
323
496
  try {
324
- const result = await client.updateWorkflowDraft(resolvedAppId, workflowId, payload);
497
+ const result = await client.enableWorkflow(resolvedAppId, workflowId);
325
498
  if (options.json) {
326
499
  json(result);
327
500
  return;
328
501
  }
329
- success("Draft updated.");
330
- keyValue("Steps", result.draft?.steps?.length || 0);
502
+ success(`Workflow enabled: ${result.workflowKey || workflowId}`);
331
503
  }
332
504
  catch (err) {
333
505
  error(err.message);
334
506
  process.exit(1);
335
507
  }
336
508
  });
337
- // Publish workflow
509
+ // #2907 — the delete flow's CLI spelling. Not a second writer of `status`:
510
+ // this is the plain `DELETE` #2887 made retire-with-history, which until now
511
+ // only the admin API and the console could reach. Named `archive`, not
512
+ // `delete`, because a configuration noun gets no `delete` verb
513
+ // (docs/cli-design.md) and prune-by-push stays the only CLI hard-delete path.
338
514
  workflows
339
- .command("publish")
340
- .description("Publish the current draft as a new revision")
341
- .argument("<workflow-id>", "Workflow ID")
515
+ .command("archive")
516
+ .description("Retire a workflow (soft delete). Delete-lifecycle, not `disable`: the definition is kept so its history still resolves, it goes on holding its key, and there is no un-archive.")
517
+ .argument("<workflow-id>", "Workflow ID or key")
342
518
  .option("--app <app-id>", "App ID (uses current app if not specified)")
519
+ .option("-y, --yes", "Skip confirmation prompt")
343
520
  .option("--json", "Output as JSON")
521
+ .addHelpText("after", `
522
+ What archiving a workflow costs (retire-with-history):
523
+ New runs are refused, and so is every \`workflow.call\` into it.
524
+ Its runs, revisions, configurations and test cases keep resolving what they
525
+ point at — that is what the tombstone exists for.
526
+ The archived row still holds its workflowKey.
527
+ \`primitive workflows enable\` refuses an archived workflow; there is no
528
+ un-archive.
529
+
530
+ Reclaiming the key means hard-deleting the row and its cascade:
531
+ $ rm workflows/<key>.toml
532
+ $ primitive config push --prune # confirmed; hard-deletes the row
533
+ # or hard-delete it in the console, then re-add the file and push.
534
+ `)
344
535
  .action(async (workflowId, options) => {
345
536
  const resolvedAppId = resolveAppId(undefined, options);
537
+ if (!options.yes) {
538
+ let confirmed;
539
+ try {
540
+ confirmed = await confirmPrompt(`Archive workflow ${workflowId}? New runs are refused, its history is ` +
541
+ `kept, it goes on holding its key, and there is no un-archive.`);
542
+ }
543
+ catch (err) {
544
+ error(err.message);
545
+ process.exit(1);
546
+ }
547
+ if (!confirmed) {
548
+ info("Cancelled.");
549
+ return;
550
+ }
551
+ }
346
552
  const client = new ApiClient();
347
553
  try {
348
- const result = await client.publishWorkflow(resolvedAppId, workflowId);
554
+ // The soft delete: no `hard`, so the #1238 cascade does not run and the
555
+ // definition row stays queryable.
556
+ const result = await client.deleteWorkflow(resolvedAppId, workflowId);
349
557
  if (options.json) {
350
558
  json(result);
351
559
  return;
352
560
  }
353
- success("Workflow published.");
354
- keyValue("Revision ID", result.revision?.revisionId);
355
- keyValue("Published At", formatDate(result.revision?.publishedAt));
561
+ success(`Workflow archived: ${workflowId}`);
562
+ info("Runs, revisions and test cases keep resolving. The row still holds its key — reclaim it by removing its file and running a confirmed `primitive config push --prune`.");
356
563
  }
357
564
  catch (err) {
358
565
  error(err.message);
@@ -363,14 +570,20 @@ Examples:
363
570
  workflows
364
571
  .command("preview")
365
572
  .description("Run a preview execution of the workflow")
366
- .argument("<workflow-id>", "Workflow ID")
573
+ .argument("<workflow-id>", "Workflow ID or key")
367
574
  .option("--app <app-id>", "App ID (uses current app if not specified)")
368
- .option("--config <config-id>", "Use specific configuration (uses draft if not specified)")
575
+ .option("--config <config-id>", "Use specific configuration")
576
+ .option("--draft", "Force preview of the draft version, even if active is newer")
577
+ .option("--active", "Force preview of the active config, even if a newer draft exists (issue #687)")
369
578
  .option("--input <json>", "Root input as JSON")
370
579
  .option("--wait", "Wait for completion and show result")
371
580
  .option("--json", "Output as JSON")
372
581
  .action(async (workflowId, options) => {
373
582
  const resolvedAppId = resolveAppId(undefined, options);
583
+ if (options.draft && options.active) {
584
+ error("--draft and --active are mutually exclusive");
585
+ process.exit(1);
586
+ }
374
587
  let rootInput;
375
588
  if (options.input) {
376
589
  try {
@@ -386,7 +599,15 @@ Examples:
386
599
  const result = await client.previewWorkflow(resolvedAppId, workflowId, {
387
600
  rootInput,
388
601
  configId: options.config,
602
+ useDraft: options.draft || false,
603
+ // Issue #687: --active forces the active config even when a newer
604
+ // draft exists (the inverse of --draft).
605
+ preferActive: options.active || false,
389
606
  });
607
+ // Display warning from the server (e.g. "previewing draft because newer than active")
608
+ if (result.warning) {
609
+ warn(result.warning);
610
+ }
390
611
  if (!options.wait) {
391
612
  if (options.json) {
392
613
  json(result);
@@ -394,26 +615,77 @@ Examples:
394
615
  }
395
616
  success("Preview started.");
396
617
  keyValue("Instance ID", result.instanceId);
618
+ // Issue #687: name the side we ran so the user always knows which
619
+ // version produced the output (the "print the source" pattern).
620
+ const source = result.source;
621
+ if (source === "draft") {
622
+ info("Previewing draft version.");
623
+ }
624
+ else if (source === "active") {
625
+ // Issue #687 (review feedback): when configName is missing,
626
+ // fall back to a short ID prefix so users staging multiple
627
+ // unnamed configs can disambiguate.
628
+ const label = formatConfigSlotLabel("active config", result.configName, result.configId);
629
+ info(`Previewing ${label}.`);
630
+ }
631
+ else if (source === "config") {
632
+ const label = formatConfigSlotLabel("the requested config", result.configName, result.configId);
633
+ info(`Previewing ${label}.`);
634
+ }
397
635
  info("Use 'workflows runs status' to check progress.");
398
636
  return;
399
637
  }
400
- // Poll for completion
638
+ // Poll for completion. Issue #941: track whether the loop broke on a
639
+ // terminal state vs. ran to exhaustion, so a timed-out wait is
640
+ // distinguishable from a workflow that legitimately reported "running".
401
641
  info("Waiting for completion...");
402
642
  let status;
403
- const maxAttempts = 60;
643
+ // Fixed ~60s cap (60 × 1s sleep + RTT). PRIMITIVE_PREVIEW_WAIT_ATTEMPTS
644
+ // overrides the iteration count for tests that exercise the
645
+ // poll-loop-exhaustion path without a full 60s wait; unset in normal use.
646
+ const maxAttempts = (() => {
647
+ const raw = process.env.PRIMITIVE_PREVIEW_WAIT_ATTEMPTS;
648
+ const parsed = raw ? parseInt(raw, 10) : NaN;
649
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 60;
650
+ })();
651
+ let reachedTerminal = false;
404
652
  for (let i = 0; i < maxAttempts; i++) {
405
653
  await new Promise((r) => setTimeout(r, 1000));
406
654
  const statusResult = await client.getPreviewStatus(resolvedAppId, workflowId, result.instanceId);
407
655
  status = statusResult.status;
408
- if (status?.status === "completed" || status?.status === "failed") {
656
+ const s = status?.status;
657
+ // Issue #941 (Codex P2): an aborted preview reports
658
+ // status:"terminated" from the admin preview-status endpoint, which
659
+ // is a real terminal state — the wait did NOT time out. The shared
660
+ // predicate includes it so the loop breaks and `waitTimedOut` stays
661
+ // false (vs. the prior check that omitted "terminated" and let the
662
+ // loop run to the cap, misreporting an abort as a timeout).
663
+ if (isPreviewWaitTerminalStatus(s)) {
664
+ reachedTerminal = true;
409
665
  break;
410
666
  }
411
667
  }
668
+ // Issue #941: on poll-loop exhaustion with a still-non-terminal run,
669
+ // emit an explicit timeout signal and exit non-zero (124, the GNU
670
+ // `timeout` convention) instead of silently returning the bare
671
+ // non-terminal status as if the wait succeeded.
672
+ const waitTimedOut = !reachedTerminal;
412
673
  if (options.json) {
413
- json({ instanceId: result.instanceId, status });
674
+ // Additive field only — `status` is preserved so existing parsers
675
+ // don't break; `waitTimedOut` lets automation tell "still running,
676
+ // wait gave up" from "finished".
677
+ json({ instanceId: result.instanceId, status, waitTimedOut });
678
+ if (waitTimedOut) {
679
+ process.exit(124);
680
+ }
414
681
  return;
415
682
  }
416
- if (status?.status === "completed") {
683
+ // #2348: this render ladder used to re-derive terminality (and matched
684
+ // the raw "complete"/"errored" spellings the server no longer emits).
685
+ // It now branches on the same canonical values the poll loop stops on,
686
+ // via the shared sets in lib/run-status.ts.
687
+ const waitOutcome = previewWaitOutcome(status?.status);
688
+ if (waitOutcome === "completed") {
417
689
  success("Preview completed.");
418
690
  if (status.output) {
419
691
  console.log("\nOutput:");
@@ -427,15 +699,45 @@ Examples:
427
699
  });
428
700
  }
429
701
  }
430
- else if (status?.status === "failed") {
702
+ else if (waitOutcome === "failed") {
431
703
  error("Preview failed.");
432
704
  if (status.error) {
433
705
  console.log("\nError:");
434
706
  console.log(JSON.stringify(status.error, null, 2));
435
707
  }
436
708
  }
709
+ else if (waitOutcome === "terminated") {
710
+ // Issue #941 (Codex P2): the preview was aborted (e.g. via
711
+ // `workflows preview ... abort`, or an external terminate). This is a
712
+ // real terminal state — NOT a timeout — so render it distinctly and
713
+ // exit 0 (the wait succeeded in observing a terminal result).
714
+ warn("Preview was terminated (aborted before completion).");
715
+ }
716
+ else if (waitOutcome === "skipped") {
717
+ // #2636 — the preview's declarative lock was held and the definition
718
+ // declared `onContention: "ignore"`, so it never ran. That is an
719
+ // outcome, not a timeout: report it and exit 0.
720
+ warn(`Preview did not run — lock held${status?.skipReason ? ` (${status.skipReason})` : ""}.`);
721
+ }
722
+ else if (waitOutcome === "apply") {
723
+ // #2348: the run finished and is parked waiting for a client to claim
724
+ // and apply its result. It will not advance on its own, so the wait
725
+ // stopping here is a success, not a timeout.
726
+ warn(`Preview finished and is waiting to be applied (status: ${status.status}).`);
727
+ if (status.output) {
728
+ console.log("\nOutput:");
729
+ console.log(JSON.stringify(status.output, null, 2));
730
+ }
731
+ }
732
+ else if (waitOutcome === "missing") {
733
+ // #2348: no live instance to observe — reporting a timeout here would
734
+ // suggest the preview is still running, which it is not.
735
+ error("Preview instance not found (status: missing).");
736
+ }
437
737
  else {
438
- info("Preview still running. Check status with 'workflows runs status'.");
738
+ // Timed out without ever reaching a terminal state (issue #941).
739
+ error(`Wait timed out after ${maxAttempts}s; preview still running. Check status with 'workflows runs status'.`);
740
+ process.exit(124);
439
741
  }
440
742
  }
441
743
  catch (err) {
@@ -443,51 +745,400 @@ Examples:
443
745
  process.exit(1);
444
746
  }
445
747
  });
748
+ // #1367 — hidden queue-latency measurement tool. Fires a batch of runs of a
749
+ // (trivial, no-op) workflow, polls until each has an executionStartedAt, and
750
+ // reports the observed queueDelayMs / createCallDurationMs distribution. This
751
+ // is the post-deploy verification vehicle: local miniflare does not exhibit
752
+ // real Cloudflare scheduling delay, so run it against an agents/alpha scratch
753
+ // app. Creates are paced (default 10/s) to stay clear of CF's ~100/s shared
754
+ // instance-creation limit; a create failure (e.g. 429) is recorded, not fatal.
755
+ workflows
756
+ .command("queue-latency-test", { hidden: true })
757
+ .description("(hidden) Fire N no-op runs and report the queue-delay distribution (post-deploy tool)")
758
+ .argument("<workflow-id>", "Workflow ID or key of a trivial no-op workflow")
759
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
760
+ .option("--count <n>", "Number of runs to fire", "50")
761
+ .option("--rate <r>", "Max creates per second (<=10 recommended)", "10")
762
+ .option("--input <json>", "Root input as JSON")
763
+ .option("--timeout <sec>", "Seconds to wait for all runs to start", "120")
764
+ .option("--verbose", "Print a per-run row")
765
+ .option("--json", "Output as JSON")
766
+ .action(async (workflowId, options) => {
767
+ const resolvedAppId = resolveAppId(undefined, options);
768
+ const client = new ApiClient();
769
+ const count = Math.max(1, parseInt(options.count, 10) || 50);
770
+ const rate = Math.max(1, parseInt(options.rate, 10) || 10);
771
+ const timeoutSec = Math.max(1, parseInt(options.timeout, 10) || 120);
772
+ const intervalMs = Math.ceil(1000 / rate);
773
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
774
+ let rootInput;
775
+ if (options.input) {
776
+ try {
777
+ rootInput = JSON.parse(options.input);
778
+ }
779
+ catch {
780
+ error("Invalid JSON in --input");
781
+ process.exit(1);
782
+ }
783
+ }
784
+ const fired = [];
785
+ if (!options.json) {
786
+ info(`Firing ${count} run(s) of ${workflowId} at up to ${rate}/s (interval ${intervalMs}ms)...`);
787
+ }
788
+ for (let i = 0; i < count; i++) {
789
+ try {
790
+ const res = await client.previewWorkflow(resolvedAppId, workflowId, {
791
+ rootInput,
792
+ });
793
+ fired.push({
794
+ index: i,
795
+ instanceId: res?.instanceId,
796
+ createFailed: false,
797
+ started: false,
798
+ });
799
+ }
800
+ catch (err) {
801
+ // A create failure (e.g. HTTP 500 from CF's shared-class rate limit) is
802
+ // a recorded datapoint, not a reason to abort the whole batch.
803
+ fired.push({
804
+ index: i,
805
+ createFailed: true,
806
+ createError: err?.message ? String(err.message) : String(err),
807
+ started: false,
808
+ });
809
+ }
810
+ if (i < count - 1)
811
+ await sleep(intervalMs);
812
+ }
813
+ // Poll the run list until every non-failed run has executionStartedAt set
814
+ // (queueDelayMs present) or we hit the timeout.
815
+ const byInstance = new Map();
816
+ for (const f of fired) {
817
+ if (f.instanceId)
818
+ byInstance.set(f.instanceId, f);
819
+ }
820
+ const deadline = Date.now() + timeoutSec * 1000;
821
+ const pending = () => fired.filter((f) => !f.createFailed && !f.started).length;
822
+ // The admin run-list endpoint caps each page at 200, so a single large
823
+ // request cannot cover a batch of more than 200 runs — the earliest ones
824
+ // would never be matched and would be falsely reported as not started.
825
+ // Page through with the cursor (runs come back newest-first, so our batch
826
+ // is at the front) until every pending run is matched or we run out of
827
+ // pages. Bound the scan so we never walk the whole run history.
828
+ const PAGE_LIMIT = 200;
829
+ const scanBudget = count + PAGE_LIMIT;
830
+ while (pending() > 0 && Date.now() < deadline) {
831
+ await sleep(1000);
832
+ try {
833
+ let cursor;
834
+ let scanned = 0;
835
+ do {
836
+ const { items, cursor: nextCursor } = await client.listWorkflowRuns(resolvedAppId, workflowId, { limit: PAGE_LIMIT, cursor: cursor ?? undefined });
837
+ for (const run of items || []) {
838
+ const f = run?.instanceId ? byInstance.get(run.instanceId) : undefined;
839
+ if (f && !f.started && run.executionStartedAt) {
840
+ f.started = true;
841
+ f.queueDelayMs =
842
+ typeof run.queueDelayMs === "number" ? run.queueDelayMs : null;
843
+ f.createCallDurationMs =
844
+ typeof run.createCallDurationMs === "number"
845
+ ? run.createCallDurationMs
846
+ : null;
847
+ }
848
+ }
849
+ scanned += items?.length || 0;
850
+ cursor = nextCursor;
851
+ } while (pending() > 0 && cursor && scanned < scanBudget);
852
+ }
853
+ catch {
854
+ // Transient list error — keep polling until the deadline.
855
+ }
856
+ }
857
+ const createFailures = fired.filter((f) => f.createFailed).length;
858
+ const startedRuns = fired.filter((f) => f.started);
859
+ const queueDelays = startedRuns.map((f) => f.queueDelayMs);
860
+ const createDurations = startedRuns.map((f) => f.createCallDurationMs);
861
+ const queueDist = summarizeLatencyDistribution(queueDelays);
862
+ const createDist = summarizeLatencyDistribution(createDurations);
863
+ const notStarted = fired.filter((f) => !f.createFailed && !f.started).length;
864
+ if (options.json) {
865
+ json({
866
+ workflowId,
867
+ requested: count,
868
+ created: count - createFailures,
869
+ createFailures,
870
+ started: startedRuns.length,
871
+ notStartedWithinTimeout: notStarted,
872
+ queueDelayMs: queueDist,
873
+ createCallDurationMs: createDist,
874
+ runs: options.verbose
875
+ ? fired.map((f) => ({
876
+ index: f.index,
877
+ instanceId: f.instanceId ?? null,
878
+ createFailed: f.createFailed,
879
+ createError: f.createError ?? null,
880
+ started: f.started,
881
+ queueDelayMs: f.queueDelayMs ?? null,
882
+ createCallDurationMs: f.createCallDurationMs ?? null,
883
+ }))
884
+ : undefined,
885
+ });
886
+ return;
887
+ }
888
+ divider();
889
+ keyValue("Requested", count);
890
+ keyValue("Created", count - createFailures);
891
+ keyValue("Create failures", createFailures);
892
+ keyValue("Started", startedRuns.length);
893
+ keyValue("Not started (timeout)", notStarted);
894
+ divider();
895
+ if (queueDist) {
896
+ info("Queue delay (executionStartedAt - requested):");
897
+ keyValue(" min", formatDuration(queueDist.min));
898
+ keyValue(" p50", formatDuration(queueDist.p50));
899
+ keyValue(" p95", formatDuration(queueDist.p95));
900
+ keyValue(" max", formatDuration(queueDist.max));
901
+ }
902
+ else {
903
+ warn("No runs started — no queue-delay samples collected.");
904
+ }
905
+ if (createDist) {
906
+ divider();
907
+ info("Create-call duration:");
908
+ keyValue(" min", formatDuration(createDist.min));
909
+ keyValue(" p50", formatDuration(createDist.p50));
910
+ keyValue(" p95", formatDuration(createDist.p95));
911
+ keyValue(" max", formatDuration(createDist.max));
912
+ }
913
+ if (options.verbose) {
914
+ divider();
915
+ console.log(formatTable(fired.map((f) => ({
916
+ run: f.index,
917
+ instanceId: f.instanceId ?? "-",
918
+ status: f.createFailed ? "create-failed" : f.started ? "started" : "pending",
919
+ queueDelayMs: f.queueDelayMs,
920
+ createCallDurationMs: f.createCallDurationMs,
921
+ })), [
922
+ { header: "RUN", key: "run" },
923
+ { header: "INSTANCE", key: "instanceId", format: formatId },
924
+ { header: "STATE", key: "status" },
925
+ { header: "DELAY", key: "queueDelayMs", format: formatDuration },
926
+ { header: "CREATE", key: "createCallDurationMs", format: formatDuration },
927
+ ]));
928
+ }
929
+ });
446
930
  // Runs subcommand
447
931
  const runs = workflows.command("runs").description("Manage workflow runs");
448
932
  // List runs
933
+ const runsTableColumns = [
934
+ { header: "RUN ID", key: "runId", format: formatId },
935
+ { header: "STATUS", key: "status", format: formatStatus },
936
+ { header: "STARTED", key: "startedAt", format: formatDate },
937
+ // #1367 — queue delay (requested → execution start).
938
+ { header: "DELAY", key: "queueDelayMs", format: formatDuration },
939
+ { header: "ENDED", key: "endedAt", format: formatDate },
940
+ { header: "PREVIEW", key: "isPreview", format: (v) => (v ? "yes" : "") },
941
+ ];
449
942
  runs
450
943
  .command("list")
451
944
  .description("List workflow runs")
452
- .argument("<workflow-id>", "Workflow ID")
945
+ // #1967 — the workflow id is optional when `--user-id` is given: that
946
+ // form lists one user's runs across every workflow.
947
+ .argument("[workflow-id]", "Workflow ID or key (optional with --user-id)")
453
948
  .option("--app <app-id>", "App ID (uses current app if not specified)")
454
- .option("--status <status>", "Filter by status: pending, running, completed, failed")
949
+ // #2348: the canonical run-status vocabulary. The old text advertised
950
+ // "pending", which no server path ever writes, and omitted "terminated".
951
+ // An unrecognised value is now a 400 from the server, not an empty page.
952
+ .option("--status <status>", `Filter by status: ${RUN_STATUS_LIST}`)
953
+ .option("--user-id <user-id>", "List runs started by this user, across every workflow")
455
954
  .option("--limit <n>", "Number of runs to show", "20")
955
+ .option("--cursor <cursor>", "Pagination cursor from a previous page")
956
+ // #1969 — shared tailing flags. `--watch` re-renders the current list each
957
+ // interval (snapshot); `--follow` appends new/changed runs (tail). Mutually
958
+ // exclusive; `--interval` floor is 1s (default 2s).
959
+ .option("--watch", "Re-render the current run list every interval (snapshot)")
960
+ .option("--follow", "Append runs as they start or change (tail)")
961
+ .option("--interval <seconds>", "Poll interval in seconds (min 1, default 2)")
456
962
  .option("--json", "Output as JSON")
457
963
  .action(async (workflowId, options) => {
458
964
  const resolvedAppId = resolveAppId(undefined, options);
459
965
  const client = new ApiClient();
460
- try {
461
- const { items } = await client.listWorkflowRuns(resolvedAppId, workflowId, {
462
- status: options.status,
463
- limit: parseInt(options.limit),
464
- });
465
- if (options.json) {
466
- json(items);
467
- return;
966
+ const limit = parseInt(options.limit);
967
+ if (options.watch && options.follow) {
968
+ error("--watch and --follow are mutually exclusive; pass only one.");
969
+ process.exit(1);
970
+ }
971
+ // #1967 — one of the two selectors is required; `list` never enumerates
972
+ // every run in the app.
973
+ if (!workflowId && !options.userId) {
974
+ error("Provide a workflow ID, or --user-id to list one user's runs across every workflow.");
975
+ process.exit(1);
976
+ }
977
+ // The user view reads the runs-by-user index, which has no resume
978
+ // contract — `--follow`'s checkpoint is keyed on the by-workflow index.
979
+ if (options.userId && options.follow) {
980
+ error("--follow is not supported with --user-id (it reads a different index). Use --watch instead.");
981
+ process.exit(1);
982
+ }
983
+ // Both selectors resolve through the same page shape, so the rest of the
984
+ // command is identical either way.
985
+ const fetchPage = (params) => options.userId
986
+ ? client.listUserWorkflowRuns(resolvedAppId, options.userId, {
987
+ ...params,
988
+ // With both a workflow id and --user-id, narrow to that workflow.
989
+ workflowId: workflowId || undefined,
990
+ })
991
+ : client.listWorkflowRuns(resolvedAppId, workflowId, params);
992
+ // One-shot list (no tailing).
993
+ if (!options.watch && !options.follow) {
994
+ try {
995
+ const page = await fetchPage({
996
+ status: options.status,
997
+ limit,
998
+ cursor: options.cursor,
999
+ });
1000
+ const { items } = page;
1001
+ if (options.json) {
1002
+ // #1316 envelope with the shared inspection item shape — never a
1003
+ // bare array — preserving nextCursor/hasMore + the deprecated
1004
+ // `cursor` dual-emit alias (behaviors 6, 7).
1005
+ json(buildWorkflowRunEnvelope(items, page));
1006
+ return;
1007
+ }
1008
+ // The cursor hint prints whether or not this page had rows: a
1009
+ // filtered read can come back empty with more of the index still to
1010
+ // walk, and a bare "No runs found." would read as "this user has no
1011
+ // matching runs" (#1967 review).
1012
+ const printCursorHint = () => {
1013
+ if (!page.nextCursor)
1014
+ return;
1015
+ console.log();
1016
+ dim(`More runs. Use --cursor ${page.nextCursor} for the next page.`);
1017
+ };
1018
+ if (!items || items.length === 0) {
1019
+ // #2237 — the honest empty state: a bounded filtered read can stop
1020
+ // before it reaches a match, so the message says how far it got and
1021
+ // how to continue instead of asserting there are none. `runs list`
1022
+ // deliberately does NOT auto-resume like `runs failures` — it is a
1023
+ // pager, and its `--limit` is a page size.
1024
+ info(describeEmptyRunPage(page, !!options.cursor));
1025
+ return;
1026
+ }
1027
+ console.log(formatTable(items, runsTableColumns));
1028
+ printCursorHint();
468
1029
  }
469
- if (!items || items.length === 0) {
470
- info("No runs found.");
471
- return;
1030
+ catch (err) {
1031
+ error(err.message);
1032
+ process.exit(1);
472
1033
  }
473
- console.log(formatTable(items, [
474
- { header: "RUN ID", key: "runId", format: formatId },
475
- { header: "STATUS", key: "status", format: formatStatus },
476
- { header: "STARTED", key: "startedAt", format: formatDate },
477
- { header: "ENDED", key: "endedAt", format: formatDate },
478
- { header: "PREVIEW", key: "isPreview", format: (v) => v ? "yes" : "" },
479
- ]));
1034
+ return;
1035
+ }
1036
+ // Tailing (`--watch` / `--follow`). SIGINT → AbortController installed
1037
+ // here at the command level; the handler is removed in `finally` so it
1038
+ // never leaks past this command (unlike the persistent process.on).
1039
+ let intervalSeconds;
1040
+ try {
1041
+ intervalSeconds = parseIntervalSeconds(options.interval);
480
1042
  }
481
1043
  catch (err) {
482
1044
  error(err.message);
483
1045
  process.exit(1);
484
1046
  }
1047
+ const intervalMs = intervalSeconds * 1000;
1048
+ const controller = new AbortController();
1049
+ const onSigint = () => controller.abort();
1050
+ process.on("SIGINT", onSigint);
1051
+ try {
1052
+ if (options.follow) {
1053
+ const source = {
1054
+ fetchAfter: async (resumeAfter) => {
1055
+ if (resumeAfter === undefined) {
1056
+ // Bootstrap: newest position, descending, one row. followLoop
1057
+ // discards the items and keeps only the baseline checkpoint.
1058
+ const page = await client.listWorkflowRuns(resolvedAppId, workflowId, {
1059
+ follow: true,
1060
+ forward: false,
1061
+ limit: 1,
1062
+ status: options.status,
1063
+ });
1064
+ return { items: page.items, hasMore: false, resumeAfter: page.resumeAfter };
1065
+ }
1066
+ const page = await client.listWorkflowRuns(resolvedAppId, workflowId, {
1067
+ follow: true,
1068
+ forward: true,
1069
+ after: resumeAfter,
1070
+ limit,
1071
+ status: options.status,
1072
+ });
1073
+ return {
1074
+ items: page.items,
1075
+ hasMore: !!page.hasMore,
1076
+ resumeAfter: page.resumeAfter,
1077
+ };
1078
+ },
1079
+ };
1080
+ if (!options.json) {
1081
+ info("Following runs (Ctrl-C to stop)…");
1082
+ }
1083
+ await followLoop(source, {
1084
+ intervalMs,
1085
+ signal: controller.signal,
1086
+ render: (items) => {
1087
+ if (options.json) {
1088
+ // One JSON object per line (a tail has no closing bracket to
1089
+ // wait for), each in the shared inspection item shape — the
1090
+ // same items the one-shot envelope carries (behavior 7).
1091
+ for (const run of items)
1092
+ jsonLine(toWorkflowRunInspectionRow(run));
1093
+ return;
1094
+ }
1095
+ for (const run of items) {
1096
+ console.log(`${formatDate(run.startedAt)} ${formatStatus(run.status)} ${formatId(run.runId)}`);
1097
+ }
1098
+ },
1099
+ });
1100
+ }
1101
+ else {
1102
+ // --watch: periodic snapshot redraw. The most recent page's
1103
+ // pagination fields are kept so the `--json` redraw emits the same
1104
+ // envelope the one-shot path does, not a bare array (behavior 7).
1105
+ let lastPage = {};
1106
+ await watchLoop(async () => {
1107
+ const page = await fetchPage({
1108
+ status: options.status,
1109
+ limit,
1110
+ cursor: options.cursor,
1111
+ });
1112
+ lastPage = page;
1113
+ return page.items;
1114
+ }, {
1115
+ intervalMs,
1116
+ signal: controller.signal,
1117
+ render: (items) => {
1118
+ if (options.json) {
1119
+ json(buildWorkflowRunEnvelope(items, lastPage));
1120
+ return;
1121
+ }
1122
+ // Renderer owns the redraw: clear the screen, then print.
1123
+ process.stdout.write("\x1b[2J\x1b[H");
1124
+ if (!items || items.length === 0) {
1125
+ console.log("No runs found.");
1126
+ return;
1127
+ }
1128
+ console.log(formatTable(items, runsTableColumns));
1129
+ },
1130
+ });
1131
+ }
1132
+ }
1133
+ finally {
1134
+ process.removeListener("SIGINT", onSigint);
1135
+ }
485
1136
  });
486
1137
  // Run status
487
1138
  runs
488
1139
  .command("status")
489
1140
  .description("Get status of a workflow run")
490
- .argument("<workflow-id>", "Workflow ID")
1141
+ .argument("<workflow-id>", "Workflow ID or key")
491
1142
  .argument("<run-id>", "Run ID")
492
1143
  .option("--app <app-id>", "App ID (uses current app if not specified)")
493
1144
  .option("--json", "Output as JSON")
@@ -496,89 +1147,137 @@ Examples:
496
1147
  const client = new ApiClient();
497
1148
  try {
498
1149
  const result = await client.getWorkflowRunStatus(resolvedAppId, workflowId, runId);
1150
+ // #2771 — a Cloudflare instance reports `stepResults` only once the run
1151
+ // is terminal, so a live run used to answer with `status: running` and
1152
+ // nothing else for its whole duration. Step rows are now written as the
1153
+ // run goes, so fall back to them: the steps finished so far plus the one
1154
+ // currently executing. Resolved BEFORE the output branches — a script
1155
+ // polling `--json` needs the in-flight trace as much as a human does.
1156
+ // Best-effort: a lookup failure still leaves the run details intact.
1157
+ const instanceStepResults = result.instanceStatus?.stepResults;
1158
+ let persistedStepRuns = [];
1159
+ if (!instanceStepResults || instanceStepResults.length === 0) {
1160
+ try {
1161
+ const stepRuns = await client.getWorkflowStepRuns(resolvedAppId, workflowId, runId);
1162
+ persistedStepRuns = stepRuns?.items || [];
1163
+ }
1164
+ catch {
1165
+ persistedStepRuns = [];
1166
+ }
1167
+ }
499
1168
  if (options.json) {
500
- json(result);
1169
+ // Purely additive: `stepRuns` appears only when the persisted rows are
1170
+ // what carries the trace, so a terminal run's JSON is unchanged.
1171
+ json(persistedStepRuns.length > 0 ? { ...result, stepRuns: persistedStepRuns } : result);
501
1172
  return;
502
1173
  }
503
1174
  const run = result.run;
504
1175
  keyValue("Run ID", run.runId);
505
1176
  keyValue("Status", formatStatus(run.status));
506
- keyValue("Started", formatDate(run.startedAt));
1177
+ // #2636 — the machine-readable outcome next to the status: why a
1178
+ // failed run failed, or why an elided run never ran.
1179
+ if (run.errorCode)
1180
+ keyValue("Error code", run.errorCode);
1181
+ if (run.skipReason)
1182
+ keyValue("Skip reason", run.skipReason);
1183
+ // #1367 — `startedAt` is the request time; `executionStartedAt` is when
1184
+ // Cloudflare actually started running the instance. Queue delay is the
1185
+ // gap. A still-queued run shows "-" for execution start / queue delay.
1186
+ keyValue("Requested", formatDate(run.startedAt));
1187
+ keyValue("Execution started", formatDate(run.executionStartedAt));
1188
+ keyValue("Queue delay", formatDuration(run.queueDelayMs));
1189
+ keyValue("Create call", formatDuration(run.createCallDurationMs));
507
1190
  keyValue("Ended", formatDate(run.endedAt));
508
1191
  keyValue("Preview", run.isPreview ? "yes" : "no");
509
- if (result.instanceStatus) {
510
- const status = result.instanceStatus;
511
- if (status.output) {
512
- divider();
513
- info("Output:");
514
- console.log(JSON.stringify(status.output, null, 2));
515
- }
516
- if (status.stepResults && status.stepResults.length > 0) {
517
- divider();
518
- info("Step Results:");
519
- status.stepResults.forEach((step) => {
520
- console.log(` ${step.id}: ${formatDuration(step.durationMs)} ${step.skipped ? "(skipped)" : ""}`);
521
- });
522
- }
523
- if (status.error) {
524
- divider();
525
- error("Error:");
526
- console.log(JSON.stringify(status.error, null, 2));
1192
+ const stepResultLines = renderRunStatusStepResults(stepResultsForRunStatus(instanceStepResults, persistedStepRuns));
1193
+ if (result.instanceStatus?.output) {
1194
+ divider();
1195
+ info("Output:");
1196
+ console.log(JSON.stringify(result.instanceStatus.output, null, 2));
1197
+ }
1198
+ if (stepResultLines.length > 0) {
1199
+ divider();
1200
+ info("Step Results:");
1201
+ for (const line of stepResultLines) {
1202
+ console.log(line);
527
1203
  }
528
1204
  }
1205
+ if (result.instanceStatus?.error) {
1206
+ divider();
1207
+ error("Error:");
1208
+ console.log(JSON.stringify(result.instanceStatus.error, null, 2));
1209
+ }
529
1210
  }
530
1211
  catch (err) {
531
1212
  error(err.message);
532
1213
  process.exit(1);
533
1214
  }
534
1215
  });
535
- // ============================================
536
- // CONFIGS SUBCOMMAND
537
- // ============================================
538
- const configs = workflows
539
- .command("configs")
540
- .description("Manage workflow configurations")
541
- .addHelpText("after", `
542
- Examples:
543
- $ primitive workflows configs list <workflow-id>
544
- $ primitive workflows configs get <workflow-id> <config-id>
545
- $ primitive workflows configs create <workflow-id> --name "v2" --from-file workflow.toml
546
- $ primitive workflows configs activate <workflow-id> <config-id>
547
- $ primitive workflows configs duplicate <workflow-id> <config-id> --name "v2-copy"
548
- `);
549
- // List configs
550
- configs
551
- .command("list")
552
- .description("List configurations for a workflow")
553
- .argument("<workflow-id>", "Workflow ID")
554
- .option("--app <app-id>", "App ID (uses current app if not specified)")
1216
+ // Steps for a run
1217
+ runs
1218
+ .command("steps")
1219
+ .description("Show step-level details for a workflow run")
1220
+ .argument("<workflow-id>", "Workflow ID or key")
1221
+ .argument("<run-id>", "Run ID")
1222
+ .option("--app <app-id>", "App ID")
555
1223
  .option("--json", "Output as JSON")
556
- .action(async (workflowId, options) => {
1224
+ .action(async (workflowId, runId, options) => {
557
1225
  const resolvedAppId = resolveAppId(undefined, options);
558
1226
  const client = new ApiClient();
559
1227
  try {
560
- const { items } = await client.listWorkflowConfigs(resolvedAppId, workflowId);
1228
+ const { items } = await client.getWorkflowStepRuns(resolvedAppId, workflowId, runId);
561
1229
  if (options.json) {
562
- json(items);
1230
+ // Steps are unpaginated (one run's full trace) — the shared
1231
+ // inspection item shape wrapped in `{items}`, no cursor (behavior 6).
1232
+ json(buildWorkflowStepEnvelope(items));
563
1233
  return;
564
1234
  }
565
1235
  if (!items || items.length === 0) {
566
- info("No configurations found.");
1236
+ info("No step runs found.");
567
1237
  return;
568
1238
  }
569
- // Get workflow to find active config
570
- const { workflow } = await client.getWorkflow(resolvedAppId, workflowId);
571
- const activeConfigId = workflow?.activeConfigId;
572
- console.log(formatTable(items, [
573
- { header: "ID", key: "configId", format: formatId },
574
- { header: "NAME", key: "configName" },
575
- { header: "STATUS", key: "status", format: formatStatus },
1239
+ // #1367 — inter-step gap: idle time between one step ending and the
1240
+ // next starting. Only surfaced above a 1s threshold so normal
1241
+ // back-to-back steps stay quiet; a large gap flags a mid-run stall.
1242
+ const STEP_GAP_THRESHOLD_MS = 1000;
1243
+ // #2771 — an in-flight run also returns the step executing right now
1244
+ // (status `running`, duration not written yet). Show its elapsed time
1245
+ // so a step stuck retrying is visible as such.
1246
+ const rows = annotateStepGaps(items).map((row) => ({
1247
+ ...row,
1248
+ displayDurationMs: stepRunDisplayDurationMs(row),
1249
+ }));
1250
+ console.log(formatTable(rows, [
1251
+ { header: "STEP", key: "stepId" },
1252
+ { header: "KIND", key: "stepKind" },
576
1253
  {
577
- header: "ACTIVE",
578
- key: "configId",
579
- format: (v) => v === activeConfigId ? "★" : "",
1254
+ header: "STATUS",
1255
+ key: "status",
1256
+ format: (v) => v === "completed" ? chalk.green(v) :
1257
+ v === "failed" ? chalk.red(v) :
1258
+ v === "skipped" ? chalk.gray(v) :
1259
+ v === "error_captured" ? chalk.yellow(v) :
1260
+ // #2771 — the step executing right now.
1261
+ v === "running" ? chalk.cyan(v) : v,
1262
+ },
1263
+ {
1264
+ header: "GAP",
1265
+ key: "gapMs",
1266
+ format: (v) => typeof v === "number" && v >= STEP_GAP_THRESHOLD_MS
1267
+ ? chalk.yellow(formatDuration(v))
1268
+ : chalk.dim("-"),
1269
+ },
1270
+ { header: "DURATION", key: "displayDurationMs", format: formatDuration },
1271
+ {
1272
+ header: "TOKENS",
1273
+ key: "totalTokens",
1274
+ format: (v) => v ? String(v).replace(/\B(?=(\d{3})+(?!\d))/g, ",") : "-",
1275
+ },
1276
+ {
1277
+ header: "ERROR",
1278
+ key: "error",
1279
+ format: (v) => v ? (v.length > 60 ? v.slice(0, 57) + "..." : v) : "",
580
1280
  },
581
- { header: "MODIFIED", key: "modifiedAt", format: formatDate },
582
1281
  ]));
583
1282
  }
584
1283
  catch (err) {
@@ -586,220 +1285,524 @@ Examples:
586
1285
  process.exit(1);
587
1286
  }
588
1287
  });
589
- // Get config
590
- configs
591
- .command("get")
592
- .description("Get configuration details")
593
- .argument("<workflow-id>", "Workflow ID")
594
- .argument("<config-id>", "Configuration ID")
595
- .option("--app <app-id>", "App ID (uses current app if not specified)")
1288
+ // Step detail
1289
+ runs
1290
+ .command("step-detail")
1291
+ .description("Show full details for a single step")
1292
+ .argument("<workflow-id>", "Workflow ID or key")
1293
+ .argument("<run-id>", "Run ID")
1294
+ .argument("<step-id>", "Step ID")
1295
+ .option("--app <app-id>", "App ID")
596
1296
  .option("--json", "Output as JSON")
597
- .action(async (workflowId, configId, options) => {
1297
+ .action(async (workflowId, runId, stepId, options) => {
598
1298
  const resolvedAppId = resolveAppId(undefined, options);
599
1299
  const client = new ApiClient();
600
1300
  try {
601
- const config = await client.getWorkflowConfig(resolvedAppId, workflowId, configId);
1301
+ const { items } = await client.getWorkflowStepRuns(resolvedAppId, workflowId, runId);
1302
+ const step = items.find((s) => s.stepId === stepId);
1303
+ if (!step) {
1304
+ error(`Step "${stepId}" not found in run ${runId}`);
1305
+ process.exit(1);
1306
+ }
602
1307
  if (options.json) {
603
- json(config);
1308
+ json(step);
604
1309
  return;
605
1310
  }
606
- keyValue("Config ID", config.configId);
607
- keyValue("Name", config.configName);
608
- keyValue("Description", config.description || "-");
609
- keyValue("Status", formatStatus(config.status));
610
- keyValue("Created", formatDate(config.createdAt));
611
- keyValue("Modified", formatDate(config.modifiedAt));
612
- if (config.steps && config.steps.length > 0) {
1311
+ keyValue("Step", step.stepId);
1312
+ keyValue("Kind", step.stepKind);
1313
+ keyValue("Status", formatStatus(step.status));
1314
+ keyValue("Duration", formatDuration(step.durationMs));
1315
+ keyValue("Started", formatDate(step.startedAt));
1316
+ keyValue("Ended", formatDate(step.endedAt));
1317
+ if (step.totalTokens) {
1318
+ keyValue("Tokens", `${step.inputTokens || 0} in / ${step.outputTokens || 0} out / ${step.totalTokens} total`);
1319
+ }
1320
+ if (step.retryCount) {
1321
+ keyValue("Retries", String(step.retryCount));
1322
+ }
1323
+ if (step.config) {
613
1324
  divider();
614
- info(`Steps (${config.steps.length}):`);
615
- config.steps.forEach((step, idx) => {
616
- console.log(` ${idx + 1}. ${step.id} (${step.kind})`);
1325
+ info("Config (rendered):");
1326
+ console.log(JSON.stringify(step.config, null, 2));
1327
+ }
1328
+ if (step.rawConfig) {
1329
+ divider();
1330
+ info("Config (raw):");
1331
+ console.log(JSON.stringify(step.rawConfig, null, 2));
1332
+ }
1333
+ if (step.input) {
1334
+ divider();
1335
+ info("Input:");
1336
+ console.log(JSON.stringify(step.input, null, 2));
1337
+ }
1338
+ if (step.output) {
1339
+ divider();
1340
+ info("Output:");
1341
+ console.log(JSON.stringify(step.output, null, 2));
1342
+ }
1343
+ if (step.error) {
1344
+ divider();
1345
+ error("Error: " + step.error);
1346
+ if (step.errorDetails) {
1347
+ console.log(step.errorDetails);
1348
+ }
1349
+ }
1350
+ if (step.templateWarnings && step.templateWarnings.length > 0) {
1351
+ divider();
1352
+ warn("Template Warnings:");
1353
+ step.templateWarnings.forEach((w) => {
1354
+ console.log(` ${w.expression}: ${w.message}`);
617
1355
  });
618
1356
  }
1357
+ if (step.context) {
1358
+ divider();
1359
+ info("Context Snapshot:");
1360
+ console.log(JSON.stringify(step.context, null, 2));
1361
+ }
619
1362
  }
620
1363
  catch (err) {
621
1364
  error(err.message);
622
1365
  process.exit(1);
623
1366
  }
624
1367
  });
625
- // Create config
626
- configs
627
- .command("create")
628
- .description("Create a new configuration")
629
- .argument("<workflow-id>", "Workflow ID")
1368
+ // Integration call logs for a run (cross-pivot from workflow-run to
1369
+ // integrations.logs — see issue #699).
1370
+ runs
1371
+ .command("logs")
1372
+ .description("List integration calls made by a specific workflow run (cross-pivot of `integrations logs`)")
1373
+ .argument("<run-id>", "Workflow run ID")
630
1374
  .option("--app <app-id>", "App ID (uses current app if not specified)")
631
- .option("--name <name>", "Configuration name (required)")
632
- .option("--description <desc>", "Description")
633
- .option("--from-file <path>", "Load steps from TOML file")
1375
+ .option("--limit <n>", "Number of logs to show", "100")
634
1376
  .option("--json", "Output as JSON")
635
- .action(async (workflowId, options) => {
1377
+ .action(async (runId, options) => {
636
1378
  const resolvedAppId = resolveAppId(undefined, options);
637
- if (!options.name) {
638
- error("--name is required");
1379
+ const client = new ApiClient();
1380
+ try {
1381
+ const logs = await client.listWorkflowRunIntegrationLogs(resolvedAppId, runId, { limit: parseInt(options.limit) });
1382
+ if (options.json) {
1383
+ json(logs);
1384
+ return;
1385
+ }
1386
+ if (!logs || logs.length === 0) {
1387
+ info("No integration calls found for this run.");
1388
+ return;
1389
+ }
1390
+ console.log(formatTable(logs, [
1391
+ { header: "TIME", key: "timestamp", format: formatDate },
1392
+ { header: "STEP", key: "stepId", format: (v) => v || "" },
1393
+ {
1394
+ header: "INTEGRATION",
1395
+ key: "integrationKey",
1396
+ format: (v) => v || "",
1397
+ },
1398
+ { header: "METHOD", key: "method" },
1399
+ { header: "PATH", key: "path" },
1400
+ { header: "STATUS", key: "status" },
1401
+ { header: "DURATION", key: "durationMs", format: formatDuration },
1402
+ { header: "TRACE", key: "traceId", format: formatId },
1403
+ ]));
1404
+ }
1405
+ catch (err) {
1406
+ error(err.message);
639
1407
  process.exit(1);
640
1408
  }
641
- let steps = [];
642
- if (options.fromFile) {
643
- try {
644
- const content = readFileSync(options.fromFile, "utf-8");
645
- const tomlData = TOML.parse(content);
646
- steps = tomlData.steps || [];
1409
+ });
1410
+ // Error summary
1411
+ runs
1412
+ .command("error")
1413
+ .description("Show error details for a failed workflow run")
1414
+ .argument("<workflow-id>", "Workflow ID or key")
1415
+ .argument("<run-id>", "Run ID")
1416
+ .option("--app <app-id>", "App ID")
1417
+ .option("--json", "Output as JSON")
1418
+ .action(async (workflowId, runId, options) => {
1419
+ const resolvedAppId = resolveAppId(undefined, options);
1420
+ const client = new ApiClient();
1421
+ try {
1422
+ const [runResult, stepsResult] = await Promise.all([
1423
+ client.getWorkflowRunStatus(resolvedAppId, workflowId, runId),
1424
+ client.getWorkflowStepRuns(resolvedAppId, workflowId, runId),
1425
+ ]);
1426
+ // #2074 — one selection rule shared with the STEP column of
1427
+ // `runs failures`: prefer the run's own denormalized attribution, and
1428
+ // fall back to the lowest-`stepIndex` failed row (never "whatever the
1429
+ // query returned first") so the two views can't disagree.
1430
+ const failedStep = selectFailedStepRow(stepsResult.items, runResult.run?.failedStepId);
1431
+ const errorInfo = {
1432
+ runId,
1433
+ status: runResult.run?.status,
1434
+ // #2636 — the platform's classification of the failure, when it owns
1435
+ // one (a lost declarative-lock race, an exhausted lock wait).
1436
+ errorCode: runResult.run?.errorCode ?? null,
1437
+ errorMessage: runResult.run?.errorMessage || runResult.instanceStatus?.error?.message,
1438
+ failedStep: failedStep ? {
1439
+ stepId: failedStep.stepId,
1440
+ stepKind: failedStep.stepKind,
1441
+ error: failedStep.error,
1442
+ errorDetails: failedStep.errorDetails,
1443
+ input: failedStep.input,
1444
+ config: failedStep.config,
1445
+ } : null,
1446
+ };
1447
+ if (options.json) {
1448
+ json(errorInfo);
1449
+ return;
647
1450
  }
648
- catch (err) {
649
- error(`Failed to read TOML file: ${err.message}`);
650
- process.exit(1);
1451
+ keyValue("Run ID", runId);
1452
+ keyValue("Status", formatStatus(errorInfo.status));
1453
+ if (errorInfo.errorCode) {
1454
+ keyValue("Code", errorInfo.errorCode);
651
1455
  }
1456
+ if (errorInfo.errorMessage) {
1457
+ divider();
1458
+ error("Error: " + errorInfo.errorMessage);
1459
+ }
1460
+ if (failedStep) {
1461
+ divider();
1462
+ info(`Failed Step: ${failedStep.stepId} (${failedStep.stepKind})`);
1463
+ if (failedStep.error) {
1464
+ error(" " + failedStep.error);
1465
+ }
1466
+ if (failedStep.errorDetails) {
1467
+ console.log(failedStep.errorDetails);
1468
+ }
1469
+ if (failedStep.input) {
1470
+ divider();
1471
+ info("Step Input:");
1472
+ console.log(JSON.stringify(failedStep.input, null, 2));
1473
+ }
1474
+ if (failedStep.config) {
1475
+ divider();
1476
+ info("Step Config:");
1477
+ console.log(JSON.stringify(failedStep.config, null, 2));
1478
+ }
1479
+ }
1480
+ else {
1481
+ info("No failed step found in step runs.");
1482
+ }
1483
+ }
1484
+ catch (err) {
1485
+ error(err.message);
1486
+ process.exit(1);
652
1487
  }
1488
+ });
1489
+ // Failures list
1490
+ runs
1491
+ .command("failures")
1492
+ .description("List recent workflow run failures")
1493
+ .argument("<workflow-id>", "Workflow ID or key")
1494
+ .option("--app <app-id>", "App ID")
1495
+ // #2237 — `--limit` is the number of FAILURES to collect, as the help text
1496
+ // always claimed. It used to be passed straight through as the server's
1497
+ // bounded page limit, so a workflow with a long success streak answered
1498
+ // "No failures found." while failures sat just past the scan budget.
1499
+ .option("--limit <n>", "Number of failures to collect", "10")
1500
+ .option("--max-scan <n>", `Stop after examining this many runs, to the nearest request (each examines up to ${SERVER_SCAN_ROWS_PER_REQUEST}; 0 = no cap; a sweep issues at most ${FAILURE_SWEEP_MAX_REQUESTS} requests either way)`, String(DEFAULT_FAILURE_SWEEP_MAX_SCAN))
1501
+ // `--cursor` seeds the sweep's starting position; the sweep then resumes
1502
+ // itself, so it is only needed to continue an earlier capped search.
1503
+ .option("--cursor <cursor>", "Resume the search from a previous cursor")
1504
+ .option("--json", "Output as JSON")
1505
+ .action(async (workflowId, options) => {
1506
+ const resolvedAppId = resolveAppId(undefined, options);
653
1507
  const client = new ApiClient();
1508
+ const limit = parseInt(options.limit, 10);
1509
+ const maxScan = parseInt(options.maxScan, 10);
1510
+ if (!Number.isFinite(limit) || limit < 1) {
1511
+ error("--limit must be a positive integer.");
1512
+ process.exit(1);
1513
+ }
1514
+ if (!Number.isFinite(maxScan) || maxScan < 0) {
1515
+ error("--max-scan must be a non-negative integer (0 = no cap).");
1516
+ process.exit(1);
1517
+ }
1518
+ // Ctrl-C during a multi-second sweep prints what was collected plus the
1519
+ // resume cursor rather than dropping the work on the floor. The handler
1520
+ // is removed in `finally` so it never leaks past this command.
1521
+ //
1522
+ // Registering a listener replaces Node's terminate-on-SIGINT default, and
1523
+ // `shouldStop` is only read between requests — so a first Ctrl-C during a
1524
+ // slow in-flight request would otherwise appear to do nothing and leave
1525
+ // the command unkillable. A second one exits immediately (#2237 review).
1526
+ let interrupted = false;
1527
+ let sigints = 0;
1528
+ const onSigint = () => {
1529
+ interrupted = true;
1530
+ if (++sigints > 1)
1531
+ process.exit(130);
1532
+ };
1533
+ process.on("SIGINT", onSigint);
1534
+ // Progress on stderr, TTY only, never in `--json` mode: a silent
1535
+ // multi-second command invites the Ctrl-C it is trying to survive.
1536
+ const showProgress = !options.json && !!process.stderr.isTTY;
654
1537
  try {
655
- const config = await client.createWorkflowConfig(resolvedAppId, workflowId, {
656
- configName: options.name,
657
- description: options.description,
658
- steps,
1538
+ const sweep = await collectWorkflowFailures(({ cursor, limit: pageLimit }) => client.listWorkflowRuns(resolvedAppId, workflowId, {
1539
+ status: "failed",
1540
+ limit: pageLimit,
1541
+ cursor,
1542
+ }), {
1543
+ limit,
1544
+ maxScan,
1545
+ startCursor: options.cursor,
1546
+ shouldStop: () => interrupted,
1547
+ onProgress: showProgress
1548
+ ? (scanned, collected) => progress(`Searching… ${scanned} runs examined, ${collected} failure${collected === 1 ? "" : "s"} found`)
1549
+ : undefined,
659
1550
  });
1551
+ if (showProgress && sweep.requests >= 1) {
1552
+ // Clear the progress line so it does not sit above the table. One
1553
+ // request is the common case — `onProgress` fires after EVERY request
1554
+ // including the first, so guarding on `> 1` left the line on screen
1555
+ // with the cursor parked at its end (#2237 review).
1556
+ process.stderr.write("\r\x1b[K");
1557
+ }
1558
+ // A SIGINT that arrived after the sweep returned (while the table is
1559
+ // rendering) did not truncate anything, so only a sweep that actually
1560
+ // stopped on the interrupt reports 130. `exitCode` rather than
1561
+ // `process.exit()` so a piped stdout is not truncated mid-write.
1562
+ const exitInterrupted = sweep.stoppedBecause === "interrupted";
1563
+ const items = sweep.items;
660
1564
  if (options.json) {
661
- json(config);
1565
+ // #2074 — the shared inspection item shape inside the endpoint's own
1566
+ // #1316 envelope, never a bare array. #2237 — `items` is the
1567
+ // concatenation across every resumed page, `scanned` their sum, and
1568
+ // `nextCursor` the last one observed.
1569
+ json(buildWorkflowRunEnvelope(items, {
1570
+ nextCursor: sweep.nextCursor,
1571
+ hasMore: !!sweep.nextCursor,
1572
+ ...(sweep.scannedKnown ? { scanned: sweep.scanned } : {}),
1573
+ }));
1574
+ if (exitInterrupted)
1575
+ process.exitCode = 130;
662
1576
  return;
663
1577
  }
664
- success(`Configuration created: ${config.configName}`);
665
- keyValue("Config ID", config.configId);
1578
+ if (items.length > 0) {
1579
+ console.log(formatTable(items, [
1580
+ { header: "RUN ID", key: "runId", format: formatId },
1581
+ // STEP sits before ERROR so the truncated message stays the last
1582
+ // wide column and the step id is never the thing that gets cut.
1583
+ // `-` is a routine outcome, not a bug: launch-time failures
1584
+ // (create/schema/R2 aborts, reclaimed runs) legitimately have no
1585
+ // failed step, and runs that predate #2074 carry no attribution.
1586
+ { header: "STEP", key: "failedStepId", format: formatFailureStepCell },
1587
+ // #2636 — the platform's classification, next to the message an
1588
+ // operator would otherwise have had to pattern-match.
1589
+ { header: "CODE", key: "errorCode", format: formatFailureCodeCell },
1590
+ { header: "ERROR", key: "errorMessage", format: formatFailureErrorCell },
1591
+ { header: "STARTED", key: "startedAt", format: formatDate },
1592
+ { header: "ENDED", key: "endedAt", format: formatDate },
1593
+ ]));
1594
+ }
1595
+ // #2237 — replaces the unconditional "No failures found.": every form
1596
+ // either states the search was complete or says how far it got and how
1597
+ // to continue. A triage command must never assert an absence it did
1598
+ // not establish.
1599
+ const summary = describeFailureSweep(sweep, limit);
1600
+ if (summary) {
1601
+ if (items.length > 0)
1602
+ console.log();
1603
+ info(summary);
1604
+ }
1605
+ if (exitInterrupted)
1606
+ process.exitCode = 130;
666
1607
  }
667
1608
  catch (err) {
668
1609
  error(err.message);
669
1610
  process.exit(1);
670
1611
  }
1612
+ finally {
1613
+ process.removeListener("SIGINT", onSigint);
1614
+ }
671
1615
  });
672
- // Update config
673
- configs
674
- .command("update")
675
- .description("Update a configuration")
676
- .argument("<workflow-id>", "Workflow ID")
677
- .argument("<config-id>", "Configuration ID")
1616
+ // ── Iterations: inspect / reset iterate-users singleton iterations (#1209) ──
1617
+ const iterations = workflows
1618
+ .command("iterations")
1619
+ .description("Inspect and reset iterate-users iterations");
1620
+ iterations
1621
+ .command("list")
1622
+ .description("List iterate-users iterations for the app")
678
1623
  .option("--app <app-id>", "App ID (uses current app if not specified)")
679
- .option("--name <name>", "Configuration name")
680
- .option("--description <desc>", "Description")
681
- .option("--from-file <path>", "Update steps from TOML file")
682
1624
  .option("--json", "Output as JSON")
683
- .action(async (workflowId, configId, options) => {
1625
+ .action(async (options) => {
684
1626
  const resolvedAppId = resolveAppId(undefined, options);
685
- const payload = {};
686
- if (options.name)
687
- payload.configName = options.name;
688
- if (options.description !== undefined)
689
- payload.description = options.description;
690
- if (options.fromFile) {
691
- try {
692
- const content = readFileSync(options.fromFile, "utf-8");
693
- const tomlData = TOML.parse(content);
694
- payload.steps = tomlData.steps || [];
1627
+ const client = new ApiClient();
1628
+ try {
1629
+ const { items } = await client.listIterations(resolvedAppId);
1630
+ if (options.json) {
1631
+ json(items);
1632
+ return;
695
1633
  }
696
- catch (err) {
697
- error(`Failed to read TOML file: ${err.message}`);
698
- process.exit(1);
1634
+ if (!items || items.length === 0) {
1635
+ info("No iterations found.");
1636
+ return;
699
1637
  }
1638
+ console.log(formatTable(items, [
1639
+ { header: "ITERATION", key: "iterationName" },
1640
+ { header: "STATUS", key: "status", format: formatStatus },
1641
+ { header: "ACQUIRED", key: "lastAcquireMode", format: (v) => v || "" },
1642
+ { header: "PROCESSED", key: "totalProcessed" },
1643
+ { header: "FAILED", key: "failedCount" },
1644
+ { header: "LAST RUN", key: "lastRunAt", format: formatDate },
1645
+ ]));
700
1646
  }
701
- if (Object.keys(payload).length === 0) {
702
- error("No update options specified. Use --name, --description, or --from-file.");
1647
+ catch (err) {
1648
+ error(err.message);
703
1649
  process.exit(1);
704
1650
  }
1651
+ });
1652
+ iterations
1653
+ .command("get")
1654
+ .description("Show details for a single iterate-users iteration")
1655
+ .argument("<name>", "Iteration name")
1656
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
1657
+ .option("--json", "Output as JSON")
1658
+ .action(async (name, options) => {
1659
+ const resolvedAppId = resolveAppId(undefined, options);
705
1660
  const client = new ApiClient();
706
1661
  try {
707
- const config = await client.updateWorkflowConfig(resolvedAppId, workflowId, configId, payload);
1662
+ const it = await client.getIteration(resolvedAppId, name);
708
1663
  if (options.json) {
709
- json(config);
1664
+ json(it);
710
1665
  return;
711
1666
  }
712
- success("Configuration updated.");
713
- keyValue("Config ID", config.configId);
714
- keyValue("Name", config.configName);
1667
+ keyValue("Iteration", it.iterationName);
1668
+ keyValue("Status", formatStatus(it.status));
1669
+ keyValue("Acquired as", it.lastAcquireMode || "(unknown)");
1670
+ keyValue("Run ID", it.runId);
1671
+ keyValue("Instance ID", it.instanceId);
1672
+ keyValue("Started", formatDate(it.startedAt));
1673
+ keyValue("Last run", formatDate(it.lastRunAt));
1674
+ keyValue("Lock expires", formatDate(it.lockExpiresAt));
1675
+ keyValue("Processed", String(it.totalProcessed ?? 0));
1676
+ keyValue("Succeeded", String(it.succeededCount ?? 0));
1677
+ keyValue("Failed (total)", String(it.failedCount ?? 0));
1678
+ keyValue("Skipped", String(it.skippedCount ?? 0));
1679
+ if (it.lastError) {
1680
+ keyValue("Last error", it.lastError);
1681
+ }
1682
+ const sample = Array.isArray(it.failedUserIdsSample)
1683
+ ? it.failedUserIdsSample
1684
+ : [];
1685
+ if (sample.length > 0) {
1686
+ const label = it.failedUserIdsTruncated
1687
+ ? `Failed user IDs (sample, truncated; ${it.failedCount} total)`
1688
+ : `Failed user IDs (sample)`;
1689
+ keyValue(label, sample.join(", "));
1690
+ }
715
1691
  }
716
1692
  catch (err) {
717
1693
  error(err.message);
718
1694
  process.exit(1);
719
1695
  }
720
1696
  });
721
- // Activate config
722
- configs
723
- .command("activate")
724
- .description("Set a configuration as the active (default) configuration")
725
- .argument("<workflow-id>", "Workflow ID")
726
- .argument("<config-id>", "Configuration ID")
1697
+ iterations
1698
+ .command("reset")
1699
+ .description("Reset a terminal (completed/failed) iteration so the next trigger runs fresh")
1700
+ .argument("<name>", "Iteration name")
727
1701
  .option("--app <app-id>", "App ID (uses current app if not specified)")
728
1702
  .option("--json", "Output as JSON")
729
- .action(async (workflowId, configId, options) => {
1703
+ .action(async (name, options) => {
730
1704
  const resolvedAppId = resolveAppId(undefined, options);
731
1705
  const client = new ApiClient();
732
1706
  try {
733
- const result = await client.activateWorkflowConfig(resolvedAppId, workflowId, configId);
1707
+ const res = await client.resetIteration(resolvedAppId, name);
734
1708
  if (options.json) {
735
- json(result);
1709
+ json(res);
736
1710
  return;
737
1711
  }
738
- success("Configuration activated.");
739
- keyValue("Active Config ID", result.activeConfigId || configId);
1712
+ success(`Reset iteration "${name}" (was ${res.previousStatus}). The next trigger will run fresh.`);
740
1713
  }
741
1714
  catch (err) {
1715
+ // A running iteration is refused with 409 + lock metadata — surface a
1716
+ // clear message rather than a raw error.
742
1717
  error(err.message);
743
1718
  process.exit(1);
744
1719
  }
745
1720
  });
746
- // Duplicate config
1721
+ // ============================================
1722
+ // CONFIGS SUBCOMMAND
1723
+ // ============================================
1724
+ const configs = workflows
1725
+ .command("configs")
1726
+ .description("Manage workflow configurations")
1727
+ .addHelpText("after", `
1728
+ Examples:
1729
+ $ primitive workflows configs list <workflow-id>
1730
+ $ primitive workflows configs get <workflow-id> <config-id>
1731
+
1732
+ Config bodies are authored as files, not verbs: write
1733
+ workflows/<key>.configs/<name>.toml and name the live one with
1734
+ activeConfigName in workflows/<key>.toml, then run 'primitive config push'.
1735
+ `);
1736
+ // List configs
747
1737
  configs
748
- .command("duplicate")
749
- .description("Duplicate a configuration")
750
- .argument("<workflow-id>", "Workflow ID")
751
- .argument("<config-id>", "Configuration ID to duplicate")
1738
+ .command("list")
1739
+ .description("List configurations for a workflow")
1740
+ .argument("<workflow-id>", "Workflow ID or key")
752
1741
  .option("--app <app-id>", "App ID (uses current app if not specified)")
753
- .option("--name <name>", "Name for the new configuration")
754
1742
  .option("--json", "Output as JSON")
755
- .action(async (workflowId, configId, options) => {
1743
+ .action(async (workflowId, options) => {
756
1744
  const resolvedAppId = resolveAppId(undefined, options);
757
1745
  const client = new ApiClient();
758
1746
  try {
759
- const config = await client.duplicateWorkflowConfig(resolvedAppId, workflowId, configId, {
760
- configName: options.name,
761
- });
1747
+ const { items } = await client.listWorkflowConfigs(resolvedAppId, workflowId);
762
1748
  if (options.json) {
763
- json(config);
1749
+ json(items);
764
1750
  return;
765
1751
  }
766
- success(`Configuration duplicated: ${config.configName}`);
767
- keyValue("New Config ID", config.configId);
1752
+ if (!items || items.length === 0) {
1753
+ info("No configurations found.");
1754
+ return;
1755
+ }
1756
+ // Get workflow to find active config
1757
+ const { workflow } = await client.getWorkflow(resolvedAppId, workflowId);
1758
+ const activeConfigId = workflow?.activeConfigId;
1759
+ console.log(formatTable(items, [
1760
+ { header: "ID", key: "configId", format: formatId },
1761
+ { header: "NAME", key: "configName" },
1762
+ { header: "STATUS", key: "status", format: formatStatus },
1763
+ {
1764
+ header: "ACTIVE",
1765
+ key: "configId",
1766
+ format: (v) => v === activeConfigId ? "★" : "",
1767
+ },
1768
+ { header: "MODIFIED", key: "modifiedAt", format: formatDate },
1769
+ ]));
768
1770
  }
769
1771
  catch (err) {
770
1772
  error(err.message);
771
1773
  process.exit(1);
772
1774
  }
773
1775
  });
774
- // Archive config
1776
+ // Get config
775
1777
  configs
776
- .command("archive")
777
- .description("Archive a configuration")
778
- .argument("<workflow-id>", "Workflow ID")
1778
+ .command("get")
1779
+ .description("Get configuration details")
1780
+ .argument("<workflow-id>", "Workflow ID or key")
779
1781
  .argument("<config-id>", "Configuration ID")
780
1782
  .option("--app <app-id>", "App ID (uses current app if not specified)")
781
- .option("-y, --yes", "Skip confirmation prompt")
1783
+ .option("--json", "Output as JSON")
782
1784
  .action(async (workflowId, configId, options) => {
783
1785
  const resolvedAppId = resolveAppId(undefined, options);
784
- if (!options.yes) {
785
- const inquirer = await import("inquirer");
786
- const { confirm } = await inquirer.default.prompt([
787
- {
788
- type: "confirm",
789
- name: "confirm",
790
- message: `Are you sure you want to archive configuration ${configId}?`,
791
- default: false,
792
- },
793
- ]);
794
- if (!confirm) {
795
- info("Cancelled.");
796
- return;
797
- }
798
- }
799
1786
  const client = new ApiClient();
800
1787
  try {
801
- await client.archiveWorkflowConfig(resolvedAppId, workflowId, configId);
802
- success("Configuration archived.");
1788
+ const config = await client.getWorkflowConfig(resolvedAppId, workflowId, configId);
1789
+ if (options.json) {
1790
+ json(config);
1791
+ return;
1792
+ }
1793
+ printResult("Config ID", config.configId);
1794
+ printResult("Name", config.configName);
1795
+ printResult("Description", config.description || "-");
1796
+ printResult("Status", formatStatus(config.status));
1797
+ printResult("Created", formatDate(config.createdAt));
1798
+ printResult("Modified", formatDate(config.modifiedAt));
1799
+ if (config.steps && config.steps.length > 0) {
1800
+ divider();
1801
+ info(`Steps (${config.steps.length}):`);
1802
+ config.steps.forEach((step, idx) => {
1803
+ console.log(` ${idx + 1}. ${step.id} (${step.kind})`);
1804
+ });
1805
+ }
803
1806
  }
804
1807
  catch (err) {
805
1808
  error(err.message);
@@ -811,26 +1814,36 @@ Examples:
811
1814
  // ============================================
812
1815
  const tests = workflows
813
1816
  .command("tests")
814
- .description("Manage and run workflow test cases")
1817
+ .description("Run and inspect workflow test cases")
815
1818
  .addHelpText("after", `
1819
+ Test cases are authored in TOML at workflows/<key>.tests/<name>.toml and applied
1820
+ with \`primitive config push\` (issue #2769); \`config fields workflow\` lists the
1821
+ fields. Deleting a case is removing its file and running \`config push --prune\`.
1822
+
1823
+ Every verb here takes the workflow's KEY or its ID (#3249). An identifier
1824
+ that names no workflow in the app fails; "No test cases found." only ever
1825
+ means the workflow exists and has no cases.
1826
+
816
1827
  Examples:
817
- $ primitive workflows tests list <workflow-id>
818
- $ primitive workflows tests create <workflow-id> --name "Basic test" --vars '{"input":"hello"}'
819
- $ primitive workflows tests run <workflow-id> <test-case-id>
820
- $ primitive workflows tests run-all <workflow-id>
821
- $ primitive workflows tests run-all <workflow-id> --test-cases 01ABC,01DEF,01GHI
1828
+ $ primitive workflows tests list <workflow>
1829
+ $ primitive workflows tests run <workflow> <test-case-id>
1830
+ $ primitive workflows tests run-all <workflow>
1831
+ $ primitive workflows tests run-all <workflow> --test-cases 01ABC,01DEF,01GHI
822
1832
  `);
823
- // List test cases
824
1833
  tests
825
1834
  .command("list")
826
1835
  .description("List test cases for a workflow")
827
- .argument("<workflow-id>", "Workflow ID")
1836
+ .argument("<workflow>", "Workflow key or ID")
828
1837
  .option("--app <app-id>", "App ID (uses current app if not specified)")
829
1838
  .option("--json", "Output as JSON")
830
- .action(async (workflowId, options) => {
1839
+ .action(async (workflow, options) => {
831
1840
  const resolvedAppId = resolveAppId(undefined, options);
832
1841
  const client = new ApiClient();
833
1842
  try {
1843
+ // #3249 — resolve BEFORE listing: the test-case endpoint answers an
1844
+ // unknown block with an empty page, so an unresolved identifier would
1845
+ // print "No test cases found." and exit 0.
1846
+ const workflowId = await resolveWorkflowId(client, resolvedAppId, workflow);
834
1847
  const { items } = await client.listTestCases(resolvedAppId, "workflow", workflowId);
835
1848
  if (options.json) {
836
1849
  json(items);
@@ -851,97 +1864,33 @@ Examples:
851
1864
  process.exit(1);
852
1865
  }
853
1866
  });
854
- // Create test case
855
- tests
856
- .command("create")
857
- .description("Create a test case for a workflow")
858
- .argument("<workflow-id>", "Workflow ID")
859
- .option("--app <app-id>", "App ID (uses current app if not specified)")
860
- .option("--name <name>", "Test case name (required)")
861
- .option("--vars <json>", "Input variables as JSON (required)")
862
- .option("--pattern <regex>", "Expected output pattern (regex)")
863
- .option("--contains <json>", "Expected strings to contain (JSON array)")
864
- .option("--json-subset <json>", "Expected JSON subset to match")
865
- .option("--json", "Output as JSON")
866
- .action(async (workflowId, options) => {
867
- const resolvedAppId = resolveAppId(undefined, options);
868
- if (!options.name || !options.vars) {
869
- error("Required: --name and --vars");
870
- process.exit(1);
871
- }
872
- let inputVariables;
873
- try {
874
- inputVariables = JSON.parse(options.vars);
875
- }
876
- catch {
877
- error("Invalid JSON in --vars");
878
- process.exit(1);
879
- }
880
- let expectedOutputContains;
881
- if (options.contains) {
882
- try {
883
- expectedOutputContains = JSON.parse(options.contains);
884
- }
885
- catch {
886
- error("Invalid JSON in --contains");
887
- process.exit(1);
888
- }
889
- }
890
- let expectedJsonSubset;
891
- if (options.jsonSubset) {
892
- try {
893
- expectedJsonSubset = JSON.parse(options.jsonSubset);
894
- }
895
- catch {
896
- error("Invalid JSON in --json-subset");
897
- process.exit(1);
898
- }
899
- }
900
- const client = new ApiClient();
901
- try {
902
- const result = await client.createTestCase(resolvedAppId, "workflow", workflowId, {
903
- name: options.name,
904
- inputVariables,
905
- expectedOutputPattern: options.pattern,
906
- expectedOutputContains,
907
- expectedJsonSubset,
908
- });
909
- if (options.json) {
910
- json(result);
911
- return;
912
- }
913
- success(`Test case created: ${result.name}`);
914
- keyValue("Test Case ID", result.testCaseId);
915
- }
916
- catch (err) {
917
- error(err.message);
918
- process.exit(1);
919
- }
920
- });
921
1867
  // Get test case
922
1868
  tests
923
1869
  .command("get")
924
1870
  .description("Get test case details")
925
- .argument("<workflow-id>", "Workflow ID")
1871
+ .argument("<workflow>", "Workflow key or ID")
926
1872
  .argument("<test-case-id>", "Test Case ID")
927
1873
  .option("--app <app-id>", "App ID (uses current app if not specified)")
928
1874
  .option("--json", "Output as JSON")
929
- .action(async (workflowId, testCaseId, options) => {
1875
+ .action(async (workflow, testCaseId, options) => {
930
1876
  const resolvedAppId = resolveAppId(undefined, options);
931
1877
  const client = new ApiClient();
932
1878
  try {
1879
+ const workflowId = await resolveWorkflowId(client, resolvedAppId, workflow);
933
1880
  const result = await client.getTestCase(resolvedAppId, "workflow", workflowId, testCaseId);
934
1881
  if (options.json) {
935
1882
  json(result);
936
1883
  return;
937
1884
  }
938
- keyValue("Test Case ID", result.testCaseId);
939
- keyValue("Name", result.name);
940
- keyValue("Created", formatDate(result.createdAt));
1885
+ printResult("Test Case ID", result.testCaseId);
1886
+ printResult("Name", result.name);
1887
+ printResult("Created", formatDate(result.createdAt));
941
1888
  divider();
942
1889
  info("Input Variables:");
943
1890
  try {
944
- const vars = JSON.parse(result.inputVariables || "{}");
1891
+ const vars = typeof result.inputVariables === 'string'
1892
+ ? JSON.parse(result.inputVariables || "{}")
1893
+ : (result.inputVariables || {});
945
1894
  console.log(JSON.stringify(vars, null, 2));
946
1895
  }
947
1896
  catch {
@@ -949,13 +1898,16 @@ Examples:
949
1898
  }
950
1899
  if (result.expectedOutputPattern) {
951
1900
  divider();
952
- keyValue("Expected Pattern", result.expectedOutputPattern);
1901
+ printResult("Expected Pattern", result.expectedOutputPattern);
953
1902
  }
954
1903
  if (result.expectedOutputContains) {
955
1904
  divider();
956
1905
  info("Expected Contains:");
957
1906
  try {
958
- console.log(JSON.stringify(JSON.parse(result.expectedOutputContains), null, 2));
1907
+ const contains = typeof result.expectedOutputContains === 'string'
1908
+ ? JSON.parse(result.expectedOutputContains)
1909
+ : result.expectedOutputContains;
1910
+ console.log(JSON.stringify(contains, null, 2));
959
1911
  }
960
1912
  catch {
961
1913
  console.log(result.expectedOutputContains);
@@ -965,7 +1917,10 @@ Examples:
965
1917
  divider();
966
1918
  info("Expected JSON Subset:");
967
1919
  try {
968
- console.log(JSON.stringify(JSON.parse(result.expectedJsonSubset), null, 2));
1920
+ const subset = typeof result.expectedJsonSubset === 'string'
1921
+ ? JSON.parse(result.expectedJsonSubset)
1922
+ : result.expectedJsonSubset;
1923
+ console.log(JSON.stringify(subset, null, 2));
969
1924
  }
970
1925
  catch {
971
1926
  console.log(result.expectedJsonSubset);
@@ -977,140 +1932,25 @@ Examples:
977
1932
  process.exit(1);
978
1933
  }
979
1934
  });
980
- // Update test case
981
- tests
982
- .command("update")
983
- .description("Update a test case")
984
- .argument("<workflow-id>", "Workflow ID")
985
- .argument("<test-case-id>", "Test Case ID")
986
- .option("--app <app-id>", "App ID (uses current app if not specified)")
987
- .option("--name <name>", "Test case name")
988
- .option("--vars <json>", "Input variables as JSON")
989
- .option("--pattern <regex>", "Expected output pattern (regex)")
990
- .option("--contains <json>", "Expected strings to contain (JSON array)")
991
- .option("--json-subset <json>", "Expected JSON subset to match")
992
- .option("--clear-pattern", "Clear expected pattern")
993
- .option("--clear-contains", "Clear expected contains")
994
- .option("--clear-json-subset", "Clear expected JSON subset")
995
- .option("--json", "Output as JSON")
996
- .action(async (workflowId, testCaseId, options) => {
997
- const resolvedAppId = resolveAppId(undefined, options);
998
- const payload = {};
999
- if (options.name)
1000
- payload.name = options.name;
1001
- if (options.vars) {
1002
- try {
1003
- payload.inputVariables = JSON.parse(options.vars);
1004
- }
1005
- catch {
1006
- error("Invalid JSON in --vars");
1007
- process.exit(1);
1008
- }
1009
- }
1010
- if (options.clearPattern) {
1011
- payload.expectedOutputPattern = null;
1012
- }
1013
- else if (options.pattern) {
1014
- payload.expectedOutputPattern = options.pattern;
1015
- }
1016
- if (options.clearContains) {
1017
- payload.expectedOutputContains = null;
1018
- }
1019
- else if (options.contains) {
1020
- try {
1021
- payload.expectedOutputContains = JSON.parse(options.contains);
1022
- }
1023
- catch {
1024
- error("Invalid JSON in --contains");
1025
- process.exit(1);
1026
- }
1027
- }
1028
- if (options.clearJsonSubset) {
1029
- payload.expectedJsonSubset = null;
1030
- }
1031
- else if (options.jsonSubset) {
1032
- try {
1033
- payload.expectedJsonSubset = JSON.parse(options.jsonSubset);
1034
- }
1035
- catch {
1036
- error("Invalid JSON in --json-subset");
1037
- process.exit(1);
1038
- }
1039
- }
1040
- if (Object.keys(payload).length === 0) {
1041
- error("No update options specified.");
1042
- process.exit(1);
1043
- }
1044
- const client = new ApiClient();
1045
- try {
1046
- const result = await client.updateTestCase(resolvedAppId, "workflow", workflowId, testCaseId, payload);
1047
- if (options.json) {
1048
- json(result);
1049
- return;
1050
- }
1051
- success("Test case updated.");
1052
- }
1053
- catch (err) {
1054
- error(err.message);
1055
- process.exit(1);
1056
- }
1057
- });
1058
- // Delete test case
1059
- tests
1060
- .command("delete")
1061
- .description("Delete a test case")
1062
- .argument("<workflow-id>", "Workflow ID")
1063
- .argument("<test-case-id>", "Test Case ID")
1064
- .option("--app <app-id>", "App ID (uses current app if not specified)")
1065
- .option("-y, --yes", "Skip confirmation prompt")
1066
- .action(async (workflowId, testCaseId, options) => {
1067
- const resolvedAppId = resolveAppId(undefined, options);
1068
- if (!options.yes) {
1069
- const inquirer = await import("inquirer");
1070
- const { confirm } = await inquirer.default.prompt([
1071
- {
1072
- type: "confirm",
1073
- name: "confirm",
1074
- message: `Are you sure you want to delete test case ${testCaseId}?`,
1075
- default: false,
1076
- },
1077
- ]);
1078
- if (!confirm) {
1079
- info("Cancelled.");
1080
- return;
1081
- }
1082
- }
1083
- const client = new ApiClient();
1084
- try {
1085
- await client.deleteTestCase(resolvedAppId, "workflow", workflowId, testCaseId);
1086
- success("Test case deleted.");
1087
- }
1088
- catch (err) {
1089
- error(err.message);
1090
- process.exit(1);
1091
- }
1092
- });
1093
1935
  // Run a single test case
1094
1936
  tests
1095
1937
  .command("run")
1096
1938
  .description("Run a single test case")
1097
- .argument("<workflow-id>", "Workflow ID")
1939
+ .argument("<workflow>", "Workflow key or ID")
1098
1940
  .argument("<test-case-id>", "Test Case ID")
1099
1941
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1100
1942
  .option("--json", "Output as JSON")
1101
- .action(async (workflowId, testCaseId, options) => {
1943
+ .action(async (workflow, testCaseId, options) => {
1102
1944
  const resolvedAppId = resolveAppId(undefined, options);
1103
1945
  const client = new ApiClient();
1104
1946
  try {
1947
+ const workflowId = await resolveWorkflowId(client, resolvedAppId, workflow);
1105
1948
  // Get test case to retrieve its variables
1106
1949
  const testCase = await client.getTestCase(resolvedAppId, "workflow", workflowId, testCaseId);
1107
- let variables = {};
1108
- try {
1109
- variables = JSON.parse(testCase.inputVariables || "{}");
1110
- }
1111
- catch {
1112
- // Use empty object if parsing fails
1113
- }
1950
+ // #3158 — the case's stored input IS the run's input, and the server
1951
+ // hands it back parsed; `JSON.parse` on that object threw and the run
1952
+ // went ahead with `{}`, disagreeing with `run-all` over the same case.
1953
+ const variables = requireTestCaseInputVariables(testCase.inputVariables, testCaseId);
1114
1954
  const result = await client.executeBlockTest(resolvedAppId, "workflow", workflowId, {
1115
1955
  variables,
1116
1956
  testCaseId,
@@ -1163,14 +2003,17 @@ Examples:
1163
2003
  tests
1164
2004
  .command("run-all")
1165
2005
  .description("Run all test cases for a workflow, or a specific subset")
1166
- .argument("<workflow-id>", "Workflow ID")
2006
+ .argument("<workflow>", "Workflow key or ID")
1167
2007
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1168
2008
  .option("--test-cases <ids>", "Comma-separated list of test case IDs to run (runs only these)")
1169
2009
  .option("--json", "Output as JSON")
1170
- .action(async (workflowId, options) => {
2010
+ .action(async (workflow, options) => {
1171
2011
  const resolvedAppId = resolveAppId(undefined, options);
1172
2012
  const client = new ApiClient();
1173
2013
  try {
2014
+ // #3249 — resolve first, so `Total Tests: 0` can only ever mean the
2015
+ // workflow exists and has no cases, never that the identifier was wrong.
2016
+ const workflowId = await resolveWorkflowId(client, resolvedAppId, workflow);
1174
2017
  // Parse test case IDs if provided
1175
2018
  let testCaseIds;
1176
2019
  if (options.testCases) {
@@ -1227,15 +2070,16 @@ Examples:
1227
2070
  tests
1228
2071
  .command("runs")
1229
2072
  .description("List recent test runs for a workflow")
1230
- .argument("<workflow-id>", "Workflow ID")
2073
+ .argument("<workflow>", "Workflow key or ID")
1231
2074
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1232
2075
  .option("--limit <n>", "Maximum number of runs to show", "20")
1233
2076
  .option("--group <group>", "Filter by comparison group")
1234
2077
  .option("--json", "Output as JSON")
1235
- .action(async (workflowId, options) => {
2078
+ .action(async (workflow, options) => {
1236
2079
  const resolvedAppId = resolveAppId(undefined, options);
1237
2080
  const client = new ApiClient();
1238
2081
  try {
2082
+ const workflowId = await resolveWorkflowId(client, resolvedAppId, workflow);
1239
2083
  const { items } = await client.listTestRuns(resolvedAppId, "workflow", workflowId, {
1240
2084
  limit: parseInt(options.limit),
1241
2085
  comparisonGroup: options.group,
@@ -1277,23 +2121,24 @@ Examples:
1277
2121
  .description("Run tests in parallel using workflow-based execution")
1278
2122
  .addHelpText("after", `
1279
2123
  Examples:
1280
- $ primitive workflows tests batch start <workflow-id>
1281
- $ primitive workflows tests batch start <workflow-id> --test-cases 01ABC,01DEF
1282
- $ primitive workflows tests batch status <workflow-id> <batch-id>
1283
- $ primitive workflows tests batch cancel <workflow-id> <batch-id>
2124
+ $ primitive workflows tests batch start <workflow>
2125
+ $ primitive workflows tests batch start <workflow> --test-cases 01ABC,01DEF
2126
+ $ primitive workflows tests batch status <workflow> <batch-id>
2127
+ $ primitive workflows tests batch cancel <workflow> <batch-id>
1284
2128
  `);
1285
2129
  // Start batch tests
1286
2130
  batch
1287
2131
  .command("start")
1288
2132
  .description("Start parallel batch test execution")
1289
- .argument("<workflow-id>", "Workflow ID")
2133
+ .argument("<workflow>", "Workflow key or ID")
1290
2134
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1291
2135
  .option("--test-cases <ids>", "Comma-separated list of test case IDs to run")
1292
2136
  .option("--json", "Output as JSON")
1293
- .action(async (workflowId, options) => {
2137
+ .action(async (workflow, options) => {
1294
2138
  const resolvedAppId = resolveAppId(undefined, options);
1295
2139
  const client = new ApiClient();
1296
2140
  try {
2141
+ const workflowId = await resolveWorkflowId(client, resolvedAppId, workflow);
1297
2142
  let testCaseIds;
1298
2143
  if (options.testCases) {
1299
2144
  testCaseIds = options.testCases.split(",").map((id) => id.trim()).filter(Boolean);
@@ -1329,27 +2174,37 @@ Examples:
1329
2174
  batch
1330
2175
  .command("status")
1331
2176
  .description("Get status of a batch test execution")
1332
- .argument("<workflow-id>", "Workflow ID")
2177
+ .argument("<workflow>", "Workflow key or ID")
1333
2178
  .argument("<batch-id>", "Batch ID")
1334
2179
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1335
2180
  .option("--wait", "Wait for completion (polls every 2 seconds)")
1336
2181
  .option("--json", "Output as JSON")
1337
- .action(async (workflowId, batchId, options) => {
2182
+ .action(async (workflow, batchId, options) => {
1338
2183
  const resolvedAppId = resolveAppId(undefined, options);
1339
2184
  const client = new ApiClient();
1340
2185
  try {
2186
+ const workflowId = await resolveWorkflowId(client, resolvedAppId, workflow);
1341
2187
  const fetchStatus = async () => {
1342
2188
  return client.getBatchTestStatus(resolvedAppId, "workflow", workflowId, batchId);
1343
2189
  };
1344
2190
  let result = await fetchStatus();
1345
2191
  if (options.wait && result.status === "running") {
1346
2192
  info("Waiting for batch completion...");
1347
- while (result.status === "running") {
1348
- await new Promise((r) => setTimeout(r, 2000));
1349
- result = await fetchStatus();
1350
- process.stdout.write(`\r Completed: ${result.completed}/${result.results?.length || 0} `);
1351
- }
1352
- console.log();
2193
+ // #1969 — shared poll primitive (delay-then-probe every 2s, honoring
2194
+ // an AbortSignal). `onError: "throw"` preserves the prior loop's
2195
+ // fail-fast behavior: a throwing fetchStatus (expired token, deleted
2196
+ // batch → 401/404) propagates to the command-level catch below and
2197
+ // exits non-zero, rather than spinning forever with a frozen progress
2198
+ // line. (The follow/watch tail loops legitimately keep "continue".)
2199
+ const controller = new AbortController();
2200
+ result = await pollUntil(fetchStatus, (r) => r.status !== "running", {
2201
+ intervalMs: 2000,
2202
+ signal: controller.signal,
2203
+ onError: "throw",
2204
+ // Progress goes to stderr so it can't corrupt JSON on stdout under --json.
2205
+ onValue: (r) => progress(` Completed: ${r.completed}/${r.results?.length || 0} `),
2206
+ });
2207
+ progressEnd();
1353
2208
  }
1354
2209
  if (options.json) {
1355
2210
  json(result);
@@ -1385,23 +2240,22 @@ Examples:
1385
2240
  batch
1386
2241
  .command("cancel")
1387
2242
  .description("Cancel a running batch test execution")
1388
- .argument("<workflow-id>", "Workflow ID")
2243
+ .argument("<workflow>", "Workflow key or ID")
1389
2244
  .argument("<batch-id>", "Batch ID")
1390
2245
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1391
2246
  .option("-y, --yes", "Skip confirmation prompt")
1392
2247
  .option("--json", "Output as JSON")
1393
- .action(async (workflowId, batchId, options) => {
2248
+ .action(async (workflow, batchId, options) => {
1394
2249
  const resolvedAppId = resolveAppId(undefined, options);
1395
2250
  if (!options.yes) {
1396
- const inquirer = await import("inquirer");
1397
- const { confirm } = await inquirer.default.prompt([
1398
- {
1399
- type: "confirm",
1400
- name: "confirm",
1401
- message: `Are you sure you want to cancel batch ${batchId}?`,
1402
- default: false,
1403
- },
1404
- ]);
2251
+ let confirm;
2252
+ try {
2253
+ confirm = await confirmPrompt(`Are you sure you want to cancel batch ${batchId}?`);
2254
+ }
2255
+ catch (err) {
2256
+ error(err.message);
2257
+ process.exit(1);
2258
+ }
1405
2259
  if (!confirm) {
1406
2260
  info("Cancelled.");
1407
2261
  return;
@@ -1409,6 +2263,7 @@ Examples:
1409
2263
  }
1410
2264
  const client = new ApiClient();
1411
2265
  try {
2266
+ const workflowId = await resolveWorkflowId(client, resolvedAppId, workflow);
1412
2267
  // First get current status to get instance IDs
1413
2268
  const status = await client.getBatchTestStatus(resolvedAppId, "workflow", workflowId, batchId);
1414
2269
  // Get instance IDs that are still running
@@ -1441,26 +2296,29 @@ Examples:
1441
2296
  // ============================================
1442
2297
  const attachments = tests
1443
2298
  .command("attachments")
1444
- .description("Manage test case file attachments")
2299
+ .description("Inspect test case file attachments")
1445
2300
  .addHelpText("after", `
2301
+ Attachments are authored as files in workflows/<key>.tests/<name>/ and applied
2302
+ with \`primitive config push\` (#2769); removing one and running
2303
+ \`config push --prune\` deletes it server-side. These verbs read server state.
2304
+
1446
2305
  Examples:
1447
- $ primitive workflows tests attachments list <workflow-id> <test-case-id>
1448
- $ primitive workflows tests attachments upload <workflow-id> <test-case-id> ./document.pdf
1449
- $ primitive workflows tests attachments download <workflow-id> <test-case-id> document.pdf
1450
- $ primitive workflows tests attachments delete <workflow-id> <test-case-id> document.pdf
2306
+ $ primitive workflows tests attachments list <workflow> <test-case-id>
2307
+ $ primitive workflows tests attachments download <workflow> <test-case-id> document.pdf
1451
2308
  `);
1452
2309
  // List attachments
1453
2310
  attachments
1454
2311
  .command("list")
1455
2312
  .description("List attachments for a test case")
1456
- .argument("<workflow-id>", "Workflow ID")
2313
+ .argument("<workflow>", "Workflow key or ID")
1457
2314
  .argument("<test-case-id>", "Test Case ID")
1458
2315
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1459
2316
  .option("--json", "Output as JSON")
1460
- .action(async (workflowId, testCaseId, options) => {
2317
+ .action(async (workflow, testCaseId, options) => {
1461
2318
  const resolvedAppId = resolveAppId(undefined, options);
1462
2319
  const client = new ApiClient();
1463
2320
  try {
2321
+ const workflowId = await resolveWorkflowId(client, resolvedAppId, workflow);
1464
2322
  const result = await client.listTestCaseAttachments(resolvedAppId, "workflow", workflowId, testCaseId);
1465
2323
  if (options.json) {
1466
2324
  json(result.attachments);
@@ -1485,62 +2343,20 @@ Examples:
1485
2343
  process.exit(1);
1486
2344
  }
1487
2345
  });
1488
- // Upload attachment
1489
- attachments
1490
- .command("upload")
1491
- .description("Upload a file attachment to a test case")
1492
- .argument("<workflow-id>", "Workflow ID")
1493
- .argument("<test-case-id>", "Test Case ID")
1494
- .argument("<file-path>", "Path to the file to upload")
1495
- .option("--app <app-id>", "App ID (uses current app if not specified)")
1496
- .option("--name <name>", "Override the filename")
1497
- .option("--json", "Output as JSON")
1498
- .action(async (workflowId, testCaseId, filePath, options) => {
1499
- const resolvedAppId = resolveAppId(undefined, options);
1500
- const client = new ApiClient();
1501
- try {
1502
- // Check file exists and get stats
1503
- const stats = statSync(filePath);
1504
- if (!stats.isFile()) {
1505
- error(`Not a file: ${filePath}`);
1506
- process.exit(1);
1507
- }
1508
- // Check file size (10 MB limit)
1509
- const TEN_MB = 10 * 1024 * 1024;
1510
- if (stats.size > TEN_MB) {
1511
- error(`File too large (max 10MB): ${formatFileSize(stats.size)}`);
1512
- process.exit(1);
1513
- }
1514
- const filename = options.name || basename(filePath);
1515
- const data = readFileSync(filePath);
1516
- const contentType = mimeLookup(filePath) || "application/octet-stream";
1517
- const result = await client.uploadTestCaseAttachment(resolvedAppId, "workflow", workflowId, testCaseId, filename, data, contentType);
1518
- if (options.json) {
1519
- json(result);
1520
- return;
1521
- }
1522
- success(`Uploaded: ${filename}`);
1523
- keyValue("Size", formatFileSize(result.attachment.size));
1524
- keyValue("Type", result.attachment.contentType);
1525
- }
1526
- catch (err) {
1527
- error(err.message);
1528
- process.exit(1);
1529
- }
1530
- });
1531
2346
  // Download attachment
1532
2347
  attachments
1533
2348
  .command("download")
1534
2349
  .description("Download a file attachment from a test case")
1535
- .argument("<workflow-id>", "Workflow ID")
2350
+ .argument("<workflow>", "Workflow key or ID")
1536
2351
  .argument("<test-case-id>", "Test Case ID")
1537
2352
  .argument("<filename>", "Name of the file to download")
1538
2353
  .argument("[output-path]", "Output path (defaults to current directory)")
1539
2354
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1540
- .action(async (workflowId, testCaseId, filename, outputPath, options) => {
2355
+ .action(async (workflow, testCaseId, filename, outputPath, options) => {
1541
2356
  const resolvedAppId = resolveAppId(undefined, options);
1542
2357
  const client = new ApiClient();
1543
2358
  try {
2359
+ const workflowId = await resolveWorkflowId(client, resolvedAppId, workflow);
1544
2360
  const result = await client.downloadTestCaseAttachment(resolvedAppId, "workflow", workflowId, testCaseId, filename);
1545
2361
  const finalPath = outputPath || filename;
1546
2362
  writeFileSync(finalPath, result.data);
@@ -1553,36 +2369,129 @@ Examples:
1553
2369
  process.exit(1);
1554
2370
  }
1555
2371
  });
1556
- // Delete attachment
1557
- attachments
1558
- .command("delete")
1559
- .description("Delete a file attachment from a test case")
1560
- .argument("<workflow-id>", "Workflow ID")
1561
- .argument("<test-case-id>", "Test Case ID")
1562
- .argument("<filename>", "Name of the file to delete")
1563
- .option("--app <app-id>", "App ID (uses current app if not specified)")
1564
- .option("-y, --yes", "Skip confirmation prompt")
1565
- .action(async (workflowId, testCaseId, filename, options) => {
1566
- const resolvedAppId = resolveAppId(undefined, options);
1567
- if (!options.yes) {
1568
- const inquirer = await import("inquirer");
1569
- const { confirm } = await inquirer.default.prompt([
1570
- {
1571
- type: "confirm",
1572
- name: "confirm",
1573
- message: `Are you sure you want to delete attachment "${filename}"?`,
1574
- default: false,
1575
- },
1576
- ]);
1577
- if (!confirm) {
1578
- info("Cancelled.");
2372
+ // ============================================
2373
+ // Codegen command (issue #1442)
2374
+ // ============================================
2375
+ workflows
2376
+ .command("codegen")
2377
+ .description("Generate typed client signatures from the local workflows/*.toml schemas. TypeScript (default): one <key>.generated.ts per workflow (<Key>Input/<Key>Output types + a <key>(client) invoker factory). Swift (--lang swift): one <key>.generated.swift per workflow (<Key>Input/<Key>Output Codable types).\n\nFormatting: the emitted TypeScript is already formatted the way Prettier's DEFAULT config formats it, so a `codegen` -> `prettier --write` -> `codegen --check` pipeline stays green. If your project uses a NON-default Prettier config (a different printWidth, trailingComma, or quote style), Prettier will reformat the generated files and `--check` will then fail: add the generated directory to `.prettierignore` and commit the raw emission instead.")
2378
+ .argument("[workflow-key]", "Generate for a single workflow (defaults to every workflows/*.toml found)")
2379
+ .option("-o, --output <dir>", "Output directory for the generated files (defaults to <config-dir>/workflows/generated/)")
2380
+ .option("--lang <lang>", "Target language for the generated types: 'ts' (default) or 'swift'.", "ts")
2381
+ .option("--check", "Exit non-zero if generated output is out of date (CI guard); does not write. Compares byte-for-byte, so the file on disk must match the emission exactly.")
2382
+ .option("--json", "Output the result summary as JSON")
2383
+ .action(async (workflowKey, options) => {
2384
+ try {
2385
+ // 0. Validate --lang up front so an unknown value fails clearly rather
2386
+ // than silently defaulting to the TS emitter.
2387
+ const lang = String(options.lang ?? "ts").toLowerCase();
2388
+ if (lang !== "ts" && lang !== "swift") {
2389
+ error(`Unknown --lang "${options.lang}". Use "ts" or "swift".`);
2390
+ process.exit(1);
2391
+ }
2392
+ // 1. The single source workflows/ directory: the environment's, and
2393
+ // only ever the environment's (#1510, #3154).
2394
+ const workflowsSourceDir = resolveCodegenSourceDir({
2395
+ subdir: "workflows",
2396
+ });
2397
+ // 2. Collect the source .toml files (one per workflow), filtered to a
2398
+ // single workflow when an argument is given. Match on the file stem;
2399
+ // the generator resolves the real key from `[workflow].key`.
2400
+ const inputs = [];
2401
+ for (const fileName of readdirSync(workflowsSourceDir)) {
2402
+ if (!fileName.endsWith(".toml"))
2403
+ continue;
2404
+ const stem = fileName.slice(0, -".toml".length);
2405
+ if (workflowKey && stem !== workflowKey)
2406
+ continue;
2407
+ const tomlPath = path.join(workflowsSourceDir, fileName);
2408
+ inputs.push({
2409
+ fileStem: stem,
2410
+ tomlPath,
2411
+ tomlContent: readFileSync(tomlPath, "utf-8"),
2412
+ });
2413
+ }
2414
+ if (inputs.length === 0) {
2415
+ error(workflowKey
2416
+ ? `No workflows/${workflowKey}.toml found under primitive/<env>/.`
2417
+ : "No workflows/*.toml files found to generate from.");
2418
+ process.exit(1);
2419
+ }
2420
+ // 3. Resolve the output directory. Default: a `generated/` subdir of
2421
+ // the first source dir (keeps generated output beside the schema).
2422
+ const outputDir = options.output
2423
+ ? path.resolve(options.output)
2424
+ : path.join(path.dirname(inputs[0].tomlPath), "generated");
2425
+ // 4. Run codegen (or --check) with the selected emitter. Both the TS
2426
+ // and Swift generators return the same WriteOrCheckResult shape, so
2427
+ // the reporting below is emitter-agnostic. Fork 1 = A: only the
2428
+ // emitter differs; both read the same synced TOMLs and the same
2429
+ // drift-guarded schema descriptor.
2430
+ const codegenResult = lang === "swift"
2431
+ ? await generateWorkflowSwiftTypes({
2432
+ inputs,
2433
+ outputDir,
2434
+ check: !!options.check,
2435
+ singleWorkflow: !!workflowKey,
2436
+ })
2437
+ : await generateWorkflowTypes({
2438
+ inputs,
2439
+ outputDir,
2440
+ check: !!options.check,
2441
+ singleWorkflow: !!workflowKey,
2442
+ });
2443
+ if (options.check) {
2444
+ if (codegenResult.mismatches.length > 0) {
2445
+ if (options.json) {
2446
+ json({ ok: false, mismatches: codegenResult.mismatches });
2447
+ }
2448
+ else {
2449
+ error(`Check failed: ${codegenResult.mismatches.length} file(s) out of date.`);
2450
+ for (const m of codegenResult.mismatches) {
2451
+ error(` ${m.reason}: ${path.relative(process.cwd(), m.filePath)}`);
2452
+ }
2453
+ // Echo back the options that decide WHERE and WHAT was
2454
+ // checked. Suggesting a bare `workflows codegen` after a
2455
+ // `--check -o <dir>` sent people to regenerate into the
2456
+ // default <config-dir>/workflows/generated/ instead of the
2457
+ // directory that is actually out of date, so the files stayed
2458
+ // stale and the next check failed the same way.
2459
+ const regenerate = ["primitive workflows codegen"];
2460
+ if (workflowKey)
2461
+ regenerate.push(workflowKey);
2462
+ if (lang === "swift")
2463
+ regenerate.push("--lang swift");
2464
+ if (options.output)
2465
+ regenerate.push(`-o ${options.output}`);
2466
+ info(`Run \`${regenerate.join(" ")}\` to regenerate.`);
2467
+ }
2468
+ process.exit(1);
2469
+ }
2470
+ if (options.json) {
2471
+ json({ ok: true, checked: codegenResult.writtenFiles.length });
2472
+ }
2473
+ else {
2474
+ success(`Check passed: ${codegenResult.writtenFiles.length} file(s) up to date.`);
2475
+ }
1579
2476
  return;
1580
2477
  }
1581
- }
1582
- const client = new ApiClient();
1583
- try {
1584
- await client.deleteTestCaseAttachment(resolvedAppId, "workflow", workflowId, testCaseId, filename);
1585
- success(`Deleted: ${filename}`);
2478
+ if (options.json) {
2479
+ json({
2480
+ written: codegenResult.writtenFiles,
2481
+ deleted: codegenResult.deletedFiles,
2482
+ });
2483
+ return;
2484
+ }
2485
+ success(`Generated ${codegenResult.writtenFiles.length} file(s)` +
2486
+ (codegenResult.deletedFiles.length > 0
2487
+ ? `, deleted ${codegenResult.deletedFiles.length} stale file(s).`
2488
+ : "."));
2489
+ for (const f of codegenResult.writtenFiles) {
2490
+ keyValue(" wrote", path.relative(process.cwd(), f));
2491
+ }
2492
+ for (const f of codegenResult.deletedFiles) {
2493
+ keyValue(" deleted", path.relative(process.cwd(), f));
2494
+ }
1586
2495
  }
1587
2496
  catch (err) {
1588
2497
  error(err.message);