primitive-admin 1.0.60 → 1.0.62

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 (310) hide show
  1. package/README.md +125 -69
  2. package/assets/skill/skills/primitive-platform/SKILL.md +336 -37
  3. package/dist/bin/primitive.js +29 -14
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/admins.js +22 -8
  6. package/dist/src/commands/admins.js.map +1 -1
  7. package/dist/src/commands/analytics.js +96 -16
  8. package/dist/src/commands/analytics.js.map +1 -1
  9. package/dist/src/commands/apps.js +49 -188
  10. package/dist/src/commands/apps.js.map +1 -1
  11. package/dist/src/commands/blob-buckets.js +50 -155
  12. package/dist/src/commands/blob-buckets.js.map +1 -1
  13. package/dist/src/commands/collection-type-configs.js +14 -100
  14. package/dist/src/commands/collection-type-configs.js.map +1 -1
  15. package/dist/src/commands/collections.js +47 -8
  16. package/dist/src/commands/collections.js.map +1 -1
  17. package/dist/src/commands/config.d.ts +46 -0
  18. package/dist/src/commands/config.js +479 -0
  19. package/dist/src/commands/config.js.map +1 -0
  20. package/dist/src/commands/connections.d.ts +2 -0
  21. package/dist/src/commands/connections.js +100 -0
  22. package/dist/src/commands/connections.js.map +1 -0
  23. package/dist/src/commands/cron-triggers.js +84 -176
  24. package/dist/src/commands/cron-triggers.js.map +1 -1
  25. package/dist/src/commands/database-type-configs.d.ts +2 -0
  26. package/dist/src/commands/database-type-configs.js +171 -0
  27. package/dist/src/commands/database-type-configs.js.map +1 -0
  28. package/dist/src/commands/database-types.js +4 -4
  29. package/dist/src/commands/database-types.js.map +1 -1
  30. package/dist/src/commands/databases.d.ts +63 -0
  31. package/dist/src/commands/databases.js +649 -87
  32. package/dist/src/commands/databases.js.map +1 -1
  33. package/dist/src/commands/documents.js +869 -12
  34. package/dist/src/commands/documents.js.map +1 -1
  35. package/dist/src/commands/email-templates.js +18 -125
  36. package/dist/src/commands/email-templates.js.map +1 -1
  37. package/dist/src/commands/env.d.ts +12 -1
  38. package/dist/src/commands/env.js +98 -25
  39. package/dist/src/commands/env.js.map +1 -1
  40. package/dist/src/commands/feature-flags.d.ts +14 -0
  41. package/dist/src/commands/feature-flags.js +116 -0
  42. package/dist/src/commands/feature-flags.js.map +1 -0
  43. package/dist/src/commands/group-type-configs.js +14 -109
  44. package/dist/src/commands/group-type-configs.js.map +1 -1
  45. package/dist/src/commands/init.d.ts +9 -1
  46. package/dist/src/commands/init.js +1058 -279
  47. package/dist/src/commands/init.js.map +1 -1
  48. package/dist/src/commands/integrations.js +136 -421
  49. package/dist/src/commands/integrations.js.map +1 -1
  50. package/dist/src/commands/metadata-category-configs.d.ts +12 -0
  51. package/dist/src/commands/metadata-category-configs.js +112 -0
  52. package/dist/src/commands/metadata-category-configs.js.map +1 -0
  53. package/dist/src/commands/metadata.js +33 -141
  54. package/dist/src/commands/metadata.js.map +1 -1
  55. package/dist/src/commands/prompts.js +206 -563
  56. package/dist/src/commands/prompts.js.map +1 -1
  57. package/dist/src/commands/rule-sets.d.ts +1 -1
  58. package/dist/src/commands/rule-sets.js +16 -132
  59. package/dist/src/commands/rule-sets.js.map +1 -1
  60. package/dist/src/commands/scripts.js +67 -234
  61. package/dist/src/commands/scripts.js.map +1 -1
  62. package/dist/src/commands/sessions.d.ts +2 -0
  63. package/dist/src/commands/sessions.js +75 -0
  64. package/dist/src/commands/sessions.js.map +1 -0
  65. package/dist/src/commands/skill.js +2 -2
  66. package/dist/src/commands/skill.js.map +1 -1
  67. package/dist/src/commands/sync-app-settings.d.ts +113 -70
  68. package/dist/src/commands/sync-app-settings.js +193 -242
  69. package/dist/src/commands/sync-app-settings.js.map +1 -1
  70. package/dist/src/commands/sync.d.ts +1932 -49
  71. package/dist/src/commands/sync.js +9584 -1725
  72. package/dist/src/commands/sync.js.map +1 -1
  73. package/dist/src/commands/tokens.js +8 -2
  74. package/dist/src/commands/tokens.js.map +1 -1
  75. package/dist/src/commands/users.js +92 -1
  76. package/dist/src/commands/users.js.map +1 -1
  77. package/dist/src/commands/vars.js +44 -58
  78. package/dist/src/commands/vars.js.map +1 -1
  79. package/dist/src/commands/webhooks.js +318 -156
  80. package/dist/src/commands/webhooks.js.map +1 -1
  81. package/dist/src/commands/workflows.d.ts +49 -12
  82. package/dist/src/commands/workflows.js +652 -1043
  83. package/dist/src/commands/workflows.js.map +1 -1
  84. package/dist/src/lib/access-rule-display.d.ts +21 -0
  85. package/dist/src/lib/access-rule-display.js +34 -0
  86. package/dist/src/lib/access-rule-display.js.map +1 -0
  87. package/dist/src/lib/api-client.d.ts +630 -61
  88. package/dist/src/lib/api-client.js +676 -116
  89. package/dist/src/lib/api-client.js.map +1 -1
  90. package/dist/src/lib/app-settings-descriptor.d.ts +180 -25
  91. package/dist/src/lib/app-settings-descriptor.js +403 -82
  92. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  93. package/dist/src/lib/canonical-json.d.ts +12 -0
  94. package/dist/src/lib/canonical-json.js +35 -0
  95. package/dist/src/lib/canonical-json.js.map +1 -0
  96. package/dist/src/lib/cli-manifest.d.ts +8 -0
  97. package/dist/src/lib/cli-manifest.js +1 -0
  98. package/dist/src/lib/cli-manifest.js.map +1 -1
  99. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +6 -2
  100. package/dist/src/lib/codegen-shared/generatedFiles.js +9 -2
  101. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  102. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +6 -6
  103. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +8 -8
  104. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
  105. package/dist/src/lib/config-object-descriptor.d.ts +127 -0
  106. package/dist/src/lib/config-object-descriptor.js +658 -0
  107. package/dist/src/lib/config-object-descriptor.js.map +1 -0
  108. package/dist/src/lib/config-payload.d.ts +85 -0
  109. package/dist/src/lib/config-payload.js +116 -0
  110. package/dist/src/lib/config-payload.js.map +1 -0
  111. package/dist/src/lib/config-surface.d.ts +130 -0
  112. package/dist/src/lib/config-surface.js +300 -0
  113. package/dist/src/lib/config-surface.js.map +1 -0
  114. package/dist/src/lib/credentials-store.d.ts +10 -0
  115. package/dist/src/lib/credentials-store.js +23 -0
  116. package/dist/src/lib/credentials-store.js.map +1 -1
  117. package/dist/src/lib/data-input.d.ts +23 -0
  118. package/dist/src/lib/data-input.js +50 -0
  119. package/dist/src/lib/data-input.js.map +1 -0
  120. package/dist/src/lib/db-codegen/dbTemplates.js +1 -1
  121. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  122. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +1 -1
  123. package/dist/src/lib/env-resolver-core.d.ts +147 -0
  124. package/dist/src/lib/env-resolver-core.js +265 -0
  125. package/dist/src/lib/env-resolver-core.js.map +1 -0
  126. package/dist/src/lib/env-resolver.d.ts +28 -6
  127. package/dist/src/lib/env-resolver.js +44 -32
  128. package/dist/src/lib/env-resolver.js.map +1 -1
  129. package/dist/src/lib/generated-allowlist.js +13 -0
  130. package/dist/src/lib/generated-allowlist.js.map +1 -1
  131. package/dist/src/lib/generated-config-surfaces.d.ts +682 -0
  132. package/dist/src/lib/generated-config-surfaces.js +4058 -0
  133. package/dist/src/lib/generated-config-surfaces.js.map +1 -0
  134. package/dist/src/lib/generated-template-lint.d.ts +212 -0
  135. package/dist/src/lib/generated-template-lint.js +624 -0
  136. package/dist/src/lib/generated-template-lint.js.map +1 -0
  137. package/dist/src/lib/init-adopt.d.ts +16 -0
  138. package/dist/src/lib/init-adopt.js +34 -0
  139. package/dist/src/lib/init-adopt.js.map +1 -0
  140. package/dist/src/lib/init-assets.d.ts +39 -0
  141. package/dist/src/lib/init-assets.js +97 -0
  142. package/dist/src/lib/init-assets.js.map +1 -0
  143. package/dist/src/lib/init-config.d.ts +44 -5
  144. package/dist/src/lib/init-config.js +80 -7
  145. package/dist/src/lib/init-config.js.map +1 -1
  146. package/dist/src/lib/init-ios-links.d.ts +50 -0
  147. package/dist/src/lib/init-ios-links.js +153 -0
  148. package/dist/src/lib/init-ios-links.js.map +1 -0
  149. package/dist/src/lib/init-plan.d.ts +80 -0
  150. package/dist/src/lib/init-plan.js +95 -0
  151. package/dist/src/lib/init-plan.js.map +1 -0
  152. package/dist/src/lib/init-production-env.d.ts +48 -0
  153. package/dist/src/lib/init-production-env.js +59 -0
  154. package/dist/src/lib/init-production-env.js.map +1 -0
  155. package/dist/src/lib/init-schema.d.ts +74 -0
  156. package/dist/src/lib/init-schema.js +358 -0
  157. package/dist/src/lib/init-schema.js.map +1 -0
  158. package/dist/src/lib/init-xcode.d.ts +33 -0
  159. package/dist/src/lib/init-xcode.js +114 -0
  160. package/dist/src/lib/init-xcode.js.map +1 -0
  161. package/dist/src/lib/integration-request-config.d.ts +30 -0
  162. package/dist/src/lib/integration-request-config.js +145 -0
  163. package/dist/src/lib/integration-request-config.js.map +1 -0
  164. package/dist/src/lib/local-state.d.ts +55 -0
  165. package/dist/src/lib/local-state.js +167 -0
  166. package/dist/src/lib/local-state.js.map +1 -0
  167. package/dist/src/lib/log-inspection.d.ts +568 -0
  168. package/dist/src/lib/log-inspection.js +639 -0
  169. package/dist/src/lib/log-inspection.js.map +1 -0
  170. package/dist/src/lib/migration-nag.d.ts +2 -2
  171. package/dist/src/lib/migration-nag.js +3 -3
  172. package/dist/src/lib/object-status-filter.d.ts +22 -0
  173. package/dist/src/lib/object-status-filter.js +45 -0
  174. package/dist/src/lib/object-status-filter.js.map +1 -0
  175. package/dist/src/lib/output.d.ts +24 -1
  176. package/dist/src/lib/output.js +42 -1
  177. package/dist/src/lib/output.js.map +1 -1
  178. package/dist/src/lib/package-manager.d.ts +140 -0
  179. package/dist/src/lib/package-manager.js +305 -0
  180. package/dist/src/lib/package-manager.js.map +1 -0
  181. package/dist/src/lib/paginate.d.ts +50 -0
  182. package/dist/src/lib/paginate.js +53 -0
  183. package/dist/src/lib/paginate.js.map +1 -1
  184. package/dist/src/lib/platform-owned.d.ts +29 -3
  185. package/dist/src/lib/platform-owned.js +42 -3
  186. package/dist/src/lib/platform-owned.js.map +1 -1
  187. package/dist/src/lib/project-config.js +13 -5
  188. package/dist/src/lib/project-config.js.map +1 -1
  189. package/dist/src/lib/query-operators.d.ts +1 -1
  190. package/dist/src/lib/query-operators.js +1 -1
  191. package/dist/src/lib/record-filter.d.ts +18 -0
  192. package/dist/src/lib/record-filter.js +55 -0
  193. package/dist/src/lib/record-filter.js.map +1 -0
  194. package/dist/src/lib/resolve-init-dev-port.d.ts +56 -0
  195. package/dist/src/lib/resolve-init-dev-port.js +55 -0
  196. package/dist/src/lib/resolve-init-dev-port.js.map +1 -0
  197. package/dist/src/lib/resolve-init-server.d.ts +64 -0
  198. package/dist/src/lib/resolve-init-server.js +77 -0
  199. package/dist/src/lib/resolve-init-server.js.map +1 -0
  200. package/dist/src/lib/resolve-platform.d.ts +43 -14
  201. package/dist/src/lib/resolve-platform.js +74 -12
  202. package/dist/src/lib/resolve-platform.js.map +1 -1
  203. package/dist/src/lib/run-status.d.ts +19 -0
  204. package/dist/src/lib/run-status.generated.d.ts +39 -0
  205. package/dist/src/lib/run-status.generated.js +66 -0
  206. package/dist/src/lib/run-status.generated.js.map +1 -0
  207. package/dist/src/lib/run-status.js +19 -0
  208. package/dist/src/lib/run-status.js.map +1 -0
  209. package/dist/src/lib/server-text-normalization.d.ts +51 -0
  210. package/dist/src/lib/server-text-normalization.js +90 -0
  211. package/dist/src/lib/server-text-normalization.js.map +1 -0
  212. package/dist/src/lib/server-url.d.ts +22 -0
  213. package/dist/src/lib/server-url.js +33 -0
  214. package/dist/src/lib/server-url.js.map +1 -0
  215. package/dist/src/lib/signing-secret-status.d.ts +81 -0
  216. package/dist/src/lib/signing-secret-status.js +116 -0
  217. package/dist/src/lib/signing-secret-status.js.map +1 -0
  218. package/dist/src/lib/skill-installer.d.ts +4 -2
  219. package/dist/src/lib/skill-installer.js +143 -12
  220. package/dist/src/lib/skill-installer.js.map +1 -1
  221. package/dist/src/lib/snapshots.d.ts +2 -2
  222. package/dist/src/lib/snapshots.js +2 -2
  223. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +15 -2
  224. package/dist/src/lib/swift-codegen/dbGenerator.js +250 -18
  225. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
  226. package/dist/src/lib/swift-codegen/generator.d.ts +11 -2
  227. package/dist/src/lib/swift-codegen/generator.js +181 -7
  228. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  229. package/dist/src/lib/swift-codegen/siblingSymbols.d.ts +7 -3
  230. package/dist/src/lib/swift-codegen/siblingSymbols.js +4 -1
  231. package/dist/src/lib/swift-codegen/siblingSymbols.js.map +1 -1
  232. package/dist/src/lib/sync-dir-selector.d.ts +21 -0
  233. package/dist/src/lib/sync-dir-selector.js +30 -0
  234. package/dist/src/lib/sync-dir-selector.js.map +1 -0
  235. package/dist/src/lib/sync-paths.d.ts +39 -0
  236. package/dist/src/lib/sync-paths.js +70 -2
  237. package/dist/src/lib/sync-paths.js.map +1 -1
  238. package/dist/src/lib/sync-resource-types.d.ts +138 -31
  239. package/dist/src/lib/sync-resource-types.js +241 -47
  240. package/dist/src/lib/sync-resource-types.js.map +1 -1
  241. package/dist/src/lib/sync-selectors.d.ts +95 -0
  242. package/dist/src/lib/sync-selectors.js +228 -0
  243. package/dist/src/lib/sync-selectors.js.map +1 -0
  244. package/dist/src/lib/template.d.ts +62 -4
  245. package/dist/src/lib/template.js +147 -39
  246. package/dist/src/lib/template.js.map +1 -1
  247. package/dist/src/lib/test-case-keys.d.ts +29 -0
  248. package/dist/src/lib/test-case-keys.js +55 -0
  249. package/dist/src/lib/test-case-keys.js.map +1 -0
  250. package/dist/src/lib/test-case-variables.d.ts +15 -0
  251. package/dist/src/lib/test-case-variables.js +29 -0
  252. package/dist/src/lib/test-case-variables.js.map +1 -0
  253. package/dist/src/lib/toml-database-config.d.ts +4 -4
  254. package/dist/src/lib/toml-database-config.js +27 -34
  255. package/dist/src/lib/toml-database-config.js.map +1 -1
  256. package/dist/src/lib/toml-metadata-config.d.ts +9 -5
  257. package/dist/src/lib/toml-metadata-config.js +40 -45
  258. package/dist/src/lib/toml-metadata-config.js.map +1 -1
  259. package/dist/src/lib/toml-native-form.d.ts +1 -1
  260. package/dist/src/lib/toml-native-form.js +1 -1
  261. package/dist/src/lib/toml-params-validator.d.ts +1 -1
  262. package/dist/src/lib/toml-params-validator.js +1 -1
  263. package/dist/src/lib/toml-scalar-edit.d.ts +43 -0
  264. package/dist/src/lib/toml-scalar-edit.js +283 -0
  265. package/dist/src/lib/toml-scalar-edit.js.map +1 -0
  266. package/dist/src/lib/user-selector.d.ts +24 -0
  267. package/dist/src/lib/user-selector.js +33 -0
  268. package/dist/src/lib/user-selector.js.map +1 -0
  269. package/dist/src/lib/version-check.d.ts +25 -0
  270. package/dist/src/lib/version-check.js +77 -8
  271. package/dist/src/lib/version-check.js.map +1 -1
  272. package/dist/src/lib/watch.d.ts +121 -0
  273. package/dist/src/lib/watch.js +169 -0
  274. package/dist/src/lib/watch.js.map +1 -0
  275. package/dist/src/lib/workflow-apply.d.ts +69 -45
  276. package/dist/src/lib/workflow-apply.js +99 -72
  277. package/dist/src/lib/workflow-apply.js.map +1 -1
  278. package/dist/src/lib/workflow-codegen/generator.js +4 -4
  279. package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
  280. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +11 -0
  281. package/dist/src/lib/workflow-codegen/invokerIR.js +5 -0
  282. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -1
  283. package/dist/src/lib/workflow-config-apply.d.ts +70 -0
  284. package/dist/src/lib/workflow-config-apply.js +137 -0
  285. package/dist/src/lib/workflow-config-apply.js.map +1 -0
  286. package/dist/src/lib/workflow-config-sidecar.d.ts +63 -0
  287. package/dist/src/lib/workflow-config-sidecar.js +96 -0
  288. package/dist/src/lib/workflow-config-sidecar.js.map +1 -0
  289. package/dist/src/lib/workflow-defaults.d.ts +29 -0
  290. package/dist/src/lib/workflow-defaults.js +41 -0
  291. package/dist/src/lib/workflow-defaults.js.map +1 -0
  292. package/dist/src/lib/workflow-fragments.d.ts +23 -0
  293. package/dist/src/lib/workflow-fragments.js +229 -8
  294. package/dist/src/lib/workflow-fragments.js.map +1 -1
  295. package/dist/src/lib/workflow-include-preserve.d.ts +76 -0
  296. package/dist/src/lib/workflow-include-preserve.js +286 -0
  297. package/dist/src/lib/workflow-include-preserve.js.map +1 -0
  298. package/dist/src/lib/workflow-payload.d.ts +31 -20
  299. package/dist/src/lib/workflow-payload.js +100 -93
  300. package/dist/src/lib/workflow-payload.js.map +1 -1
  301. package/dist/src/lib/workflow-toml-validator.d.ts +80 -3
  302. package/dist/src/lib/workflow-toml-validator.js +231 -3
  303. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  304. package/dist/src/types/index.d.ts +78 -22
  305. package/dist/src/validators.d.ts +7 -7
  306. package/dist/src/validators.js +6 -6
  307. package/package.json +11 -6
  308. package/dist/src/commands/settings.d.ts +0 -15
  309. package/dist/src/commands/settings.js +0 -102
  310. package/dist/src/commands/settings.js.map +0 -1
@@ -1,20 +1,22 @@
1
- import { readFileSync, writeFileSync, statSync, readdirSync, } from "fs";
2
- import { basename } from "path";
1
+ import { Option } from "commander";
2
+ import { readFileSync, writeFileSync, readdirSync, } from "fs";
3
3
  import * as path from "path";
4
- import { parseConfigToml, stringifyConfigToml } from "../lib/config-toml.js";
5
- import { lookup as mimeLookup } from "mime-types";
4
+ import { stringifyConfigToml } from "../lib/config-toml.js";
6
5
  import { generateWorkflowTypes, } from "../lib/workflow-codegen/generator.js";
7
6
  import { generateWorkflowSwiftTypes } from "../lib/swift-codegen/generator.js";
8
7
  import { ApiClient } from "../lib/api-client.js";
9
8
  import { resolveAppId } from "../lib/config.js";
9
+ import { formatAccessRuleForDisplay } from "../lib/access-rule-display.js";
10
10
  import { resolveCodegenSourceDir } from "../lib/codegen-shared/resolveCodegenSourceDir.js";
11
- import { validateWorkflowToml, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
11
+ import { resolveSyncDirOverride } from "../lib/sync-dir-selector.js";
12
12
  import { expandWorkflow } from "../lib/workflow-fragments.js";
13
- import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../lib/workflow-payload.js";
14
- import { applyWorkflowBody, workflowKeyMismatchError, parseCapabilitiesFlag, } from "../lib/workflow-apply.js";
15
13
  import { confirmPrompt } from "../lib/confirm-prompt.js";
14
+ import { parseStatusFilter } from "../lib/object-status-filter.js";
15
+ 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";
16
16
  import chalk from "chalk";
17
- import { success, error, printApiError, info, warn, keyValue, result as printResult, formatTable, formatId, formatDate, formatStatus, formatDuration, json, divider, progress, progressEnd, } from "../lib/output.js";
17
+ import { success, error, info, warn, keyValue, result as printResult, formatTable, formatId, formatDate, formatStatus, formatDuration, json, jsonLine, divider, dim, progress, progressEnd, } from "../lib/output.js";
18
+ import { pollUntil, followLoop, watchLoop, parseIntervalSeconds, } from "../lib/watch.js";
19
+ import { APPLY_RUN_STATUSES, RUN_STATUS_LIST, SETTLED_RUN_STATUSES, } from "../lib/run-status.js";
18
20
  /**
19
21
  * Render the `Step Results` section of `workflows runs status`. Pure
20
22
  * formatting helper — returns lines as strings so it's unit-testable
@@ -32,27 +34,52 @@ import { success, error, printApiError, info, warn, keyValue, result as printRes
32
34
  * trailing "..." when truncated). See #688.
33
35
  */
34
36
  /**
35
- * Terminal-status predicate for the `workflows preview --wait` poll loop
36
- * (issue #941). A status is terminal when the run has reached an end state and
37
- * the wait should stop polling — as opposed to a still-in-flight status
38
- * ("running"/"queued"/"waiting") that should keep the loop going until the cap.
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.
39
41
  *
40
- * The admin preview-status endpoint emits these terminal values (see
41
- * `mapWorkflowStatus` / the server-side `isEnded` set in admin-api.ts):
42
- * - "completed" (Cloudflare "complete" normalized)
43
- * - "failed" (Cloudflare "errored" normalized)
44
- * - "terminated" (an aborted preview Codex P2: previously NOT treated as
45
- * terminal, so an aborted preview was misreported as a wait timeout)
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.
46
52
  *
47
- * We accept the un-normalized Cloudflare spellings too ("complete"/"errored")
48
- * defensively, in case a status slips through un-normalized.
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.
49
56
  */
50
57
  export function isPreviewWaitTerminalStatus(s) {
51
- return (s === "completed" ||
52
- s === "complete" ||
53
- s === "failed" ||
54
- s === "errored" ||
55
- s === "terminated");
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";
56
83
  }
57
84
  export function renderRunStatusStepResults(stepResults) {
58
85
  const lines = [];
@@ -94,6 +121,48 @@ export function renderRunStatusStepResults(stepResults) {
94
121
  }
95
122
  return lines;
96
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
+ }
97
166
  /**
98
167
  * #1367 — annotate step-run rows with the inter-step gap: the wall-clock time
99
168
  * between the previous step's `endedAt` and this step's `startedAt`. Sorts by
@@ -187,13 +256,38 @@ export function formatConfigSlotLabel(slot, configName, configId, prefixLength =
187
256
  export function registerWorkflowsCommands(program) {
188
257
  const workflows = program
189
258
  .command("workflows")
190
- .description("Build multi-step workflows, publish revisions, and monitor runs")
259
+ .description("Inspect workflows, run and monitor them, and take one out of service")
191
260
  .addHelpText("after", `
192
261
  Examples:
193
262
  $ primitive workflows list
194
- $ primitive workflows create --from-file process-doc.toml
195
- $ primitive workflows publish 01HXY...
263
+ $ primitive config create workflow process-doc # scaffold workflows/process-doc.toml
264
+ $ primitive config push --only workflow/process-doc # apply it
196
265
  $ primitive workflows runs list 01HXY...
266
+ $ primitive workflows disable 01HXY... # take it out of service now
267
+ `);
268
+ // Server-side configuration is authored in TOML and applied with `config push`
269
+ // (issue #2645) — this group no longer carries config-setting flags.
270
+ workflows.addHelpText("after", `
271
+ Configuration lives in workflows/<key>.toml:
272
+ $ primitive config fields workflow # the file's keys, types and defaults
273
+ $ primitive config create workflow <key> # scaffold one locally
274
+ $ primitive config set workflow/<key> <path>=<value>
275
+ $ primitive config push --only workflow/<key>
276
+
277
+ Which body runs is configuration too: name it with activeConfigName in
278
+ workflows/<key>.toml (bodies live in workflows/<key>.configs/<name>.toml) and
279
+ apply it with 'config push'. There is no other write path: revisions are
280
+ read-only history from the retired legacy path (issue #2768).
281
+ `);
282
+ // Per-subject analytics have one home, the `analytics` noun (issue #2766) —
283
+ // this group's own analytics commands were removed, so say where they went.
284
+ workflows.addHelpText("after", `
285
+ Analytics live under the analytics noun:
286
+ $ primitive analytics workflows --window-days 7 --limit 10
287
+
288
+ That is the single home for per-subject analytics (workflows, prompts,
289
+ integrations). It ranks workflows by runs; there is no app-wide workflow
290
+ overview — the endpoint behind the retired overview command never existed.
197
291
  `);
198
292
  // List workflows
199
293
  workflows
@@ -201,14 +295,22 @@ Examples:
201
295
  .description("List workflows")
202
296
  .argument("[app-id]", "App ID (uses current app if not specified)")
203
297
  .option("--app <app-id>", "App ID")
204
- .option("--status <status>", "Filter by status: draft, active, archived")
298
+ .option("--status <status>", "Filter by status: active, inactive, archived")
205
299
  .option("--json", "Output as JSON")
206
300
  .action(async (appId, options) => {
207
301
  const resolvedAppId = resolveAppId(appId, options);
208
302
  const client = new ApiClient();
209
303
  try {
304
+ // #2803 — `draft` is gone; refuse it by name rather than returning the
305
+ // empty list a canonical filter would produce. #2887 added `archived`:
306
+ // the delete flow writes one now, so asking for the retired rows is a
307
+ // question with an answer rather than a 400.
210
308
  const { items } = await client.listWorkflows(resolvedAppId, {
211
- status: options.status,
309
+ status: parseStatusFilter("workflows", options.status, [
310
+ "active",
311
+ "inactive",
312
+ "archived",
313
+ ]),
212
314
  });
213
315
  if (options.json) {
214
316
  json(items);
@@ -231,108 +333,6 @@ Examples:
231
333
  process.exit(1);
232
334
  }
233
335
  });
234
- // Create workflow
235
- workflows
236
- .command("create")
237
- .description("Create a new workflow")
238
- .argument("[app-id]", "App ID (uses current app if not specified)")
239
- .option("--app <app-id>", "App ID")
240
- .option("--key <key>", "Workflow key (unique identifier)")
241
- .option("--name <name>", "Display name")
242
- .option("--description <desc>", "Description")
243
- .option("--from-file <path>", "Load workflow from TOML file")
244
- .option("--requires-client-apply <bool>", "Require client-side apply: true or false")
245
- .option("--sync-callable <bool>", "Allow client.workflows.runSync(): true or false")
246
- .option("--json", "Output as JSON")
247
- .action(async (appId, options) => {
248
- const resolvedAppId = resolveAppId(appId, options);
249
- const client = new ApiClient();
250
- let payload;
251
- if (options.fromFile) {
252
- let tomlData;
253
- try {
254
- const content = readFileSync(options.fromFile, "utf-8");
255
- tomlData = parseConfigToml(content);
256
- }
257
- catch (err) {
258
- error(`Failed to read TOML file: ${err.message}`);
259
- process.exit(1);
260
- }
261
- // Issue #685: reject misnested headers (e.g. [steps.<id>.request])
262
- // before we send anything to the server. These parse to TOML
263
- // sub-tables that the runtime silently ignores. We validate
264
- // outside the parse try-block so the diagnostic isn't masked by
265
- // the generic "Failed to read TOML" handler.
266
- const tomlErrors = validateWorkflowToml(tomlData);
267
- if (tomlErrors.length > 0) {
268
- error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
269
- process.exit(1);
270
- }
271
- const workflow = tomlData.workflow || tomlData;
272
- // #1614 — parse the top-level `[expr.cel]` named-guard definitions
273
- // (structural, like steps) and validate them client-side before push.
274
- const fromFileSteps = tomlData.steps || [];
275
- const fromFileExpressions = parseWorkflowExpressionsToml(tomlData);
276
- // #1614 — validate the RAW `[expr.cel]` table shape first, so a
277
- // non-string body (e.g. `ready = true`) fails fast instead of being
278
- // silently dropped by parseWorkflowExpressionsToml.
279
- const fromFileExprErrors = [
280
- ...validateWorkflowExpressionsRawToml(tomlData),
281
- ...validateWorkflowExpressionsToml(fromFileSteps, fromFileExpressions),
282
- ];
283
- if (fromFileExprErrors.length > 0) {
284
- error(`${options.fromFile}: workflow expressions are invalid:\n - ${fromFileExprErrors.join("\n - ")}`);
285
- process.exit(1);
286
- }
287
- // #1177: build the `[workflow]`-derived fields through the shared
288
- // builder so this create path can't drift from `sync push` again
289
- // (`status`, `capabilities`, `perAppMax*`, `queueTtlSeconds`, `runAs`,
290
- // `accessRule`, `syncCallable` all flow from one field list). Structural
291
- // fields (`workflowKey`, `steps`, `expressions`) are attached here.
292
- payload = {
293
- workflowKey: workflow.key || workflow.workflowKey,
294
- steps: fromFileSteps,
295
- ...buildWorkflowPayloadFromToml(workflow, { mode: "create" }),
296
- // #1614 — named `expr.*` definitions (absent → undefined).
297
- expressions: fromFileExpressions ?? undefined,
298
- };
299
- }
300
- else {
301
- if (!options.key || !options.name) {
302
- error("Required: --key, --name (or use --from-file)");
303
- process.exit(1);
304
- }
305
- payload = {
306
- workflowKey: options.key,
307
- name: options.name,
308
- description: options.description,
309
- steps: [],
310
- };
311
- }
312
- if (options.requiresClientApply !== undefined) {
313
- payload.requiresClientApply = options.requiresClientApply === "true";
314
- }
315
- if (options.syncCallable !== undefined) {
316
- payload.syncCallable = options.syncCallable === "true";
317
- }
318
- try {
319
- const result = await client.createWorkflow(resolvedAppId, payload);
320
- if (options.json) {
321
- json(result);
322
- return;
323
- }
324
- success(`Workflow created: ${result.workflow?.name || options.name}`);
325
- keyValue("Workflow ID", result.workflow?.workflowId);
326
- keyValue("Key", result.workflow?.workflowKey);
327
- }
328
- catch (err) {
329
- // #1173: render the server's structured `details[]` (e.g. the specific
330
- // identity-validation reason) instead of only the generic message.
331
- printApiError(err);
332
- process.exit(1);
333
- }
334
- });
335
- // Get workflow
336
336
  workflows
337
337
  .command("get")
338
338
  .description("Get workflow details")
@@ -353,6 +353,9 @@ Examples:
353
353
  printResult("Key", wf.workflowKey);
354
354
  printResult("Name", wf.name);
355
355
  printResult("Description", wf.description);
356
+ // #2803 — ONE availability row. The server returns the canonical
357
+ // value, so a workflow an operator disabled reads `inactive` here and
358
+ // there is no second row left to disagree with it.
356
359
  printResult("Status", formatStatus(wf.status));
357
360
  printResult("Active Config", wf.activeConfigId || "-");
358
361
  printResult("Latest Revision", wf.latestRevision || "-");
@@ -364,9 +367,12 @@ Examples:
364
367
  printResult("Capabilities", Array.isArray(wf.capabilities) && wf.capabilities.length > 0
365
368
  ? wf.capabilities.join(", ")
366
369
  : "-");
367
- if (wf.accessRule) {
368
- printResult("Access Rule", wf.accessRule);
369
- }
370
+ // #2652: a caller workflow with no rule denies every non-admin start,
371
+ // so the row is always printed — the sentinel names the consequence,
372
+ // and a `runAs: "system"` workflow gets its own exempt wording.
373
+ printResult("Access Rule", formatAccessRuleForDisplay(wf.accessRule, "workflow", {
374
+ runAs: wf.runAs,
375
+ }));
370
376
  divider();
371
377
  info("Queue Settings:");
372
378
  printResult(" Per User Max Running", wf.perUserMaxRunning);
@@ -413,228 +419,6 @@ Examples:
413
419
  process.exit(1);
414
420
  }
415
421
  });
416
- // Update workflow
417
- workflows
418
- .command("update")
419
- .description("Update workflow metadata, or push a new body with --from-file")
420
- .argument("<workflow-id>", "Workflow ID or key")
421
- .option("--app <app-id>", "App ID (uses current app if not specified)")
422
- .option("--name <name>", "Display name")
423
- .option("--description <desc>", "Description")
424
- .option("--status <status>", "Status: draft, active, archived")
425
- .option("--per-user-max-running <n>", "Max running per user")
426
- .option("--per-user-max-queued <n>", "Max queued per user")
427
- .option("--dequeue-order <order>", "Dequeue order: fifo, lifo")
428
- .option("--requires-client-apply <bool>", "Require client-side apply: true or false")
429
- .option("--sync-callable <bool>", "Allow client.workflows.runSync(): true or false")
430
- .option("--access-rule <expr>", "CEL expression for workflow access (empty string to clear)")
431
- .option("--capabilities <list>", "Comma-separated capability grants (e.g. membership,secrets); empty string revokes all")
432
- .option("--from-file <path>", "Push a revised body (metadata + steps) from a TOML file, keeping the same key/id. " +
433
- "Overwrites the active config in place — live immediately (no separate publish). " +
434
- "Explicit metadata flags above override values in the TOML.")
435
- .option("--json", "Output as JSON")
436
- .action(async (workflowId, options) => {
437
- const resolvedAppId = resolveAppId(undefined, options);
438
- // --from-file: push a full body (metadata + steps) to the existing
439
- // workflow, keeping its key/id. Symmetric with `create --from-file` and
440
- // mirrors the `sync push` update-existing path via the shared
441
- // `applyWorkflowBody` helper (#1249).
442
- if (options.fromFile) {
443
- let tomlData;
444
- try {
445
- // Expand top-level `include = [...]` workflow fragments before
446
- // deriving steps, matching what `sync push` does (`parseTomlFile`
447
- // runs `expandWorkflowTomlData`). `update --from-file` overwrites an
448
- // existing workflow's live active config, so parsing the raw TOML
449
- // and reading only `tomlData.steps` would drop fragment-supplied
450
- // steps — an include-only workflow would overwrite the live body
451
- // with an empty step list (silent data loss, #1286). `expandWorkflow`
452
- // reads + parses + splices fragments; a TOML with no `include` key is
453
- // returned unchanged, so the non-fragment case is unaffected.
454
- tomlData = expandWorkflow(options.fromFile);
455
- }
456
- catch (err) {
457
- error(`Failed to read TOML file: ${err.message}`);
458
- process.exit(1);
459
- }
460
- // Issue #685: reject misnested headers before sending anything to the
461
- // server — identical validation to `create --from-file`. Runs on the
462
- // expanded data (post fragment splice), matching `sync push`.
463
- const tomlErrors = validateWorkflowToml(tomlData);
464
- if (tomlErrors.length > 0) {
465
- error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
466
- process.exit(1);
467
- }
468
- const tomlWorkflow = tomlData.workflow || tomlData;
469
- const steps = tomlData.steps || [];
470
- const client = new ApiClient();
471
- // Fetch the existing workflow: we need its real workflowKey (Fork 2
472
- // key-mismatch guard) and its activeConfigId (live body overwrite).
473
- let existing;
474
- try {
475
- const got = await client.getWorkflow(resolvedAppId, workflowId);
476
- existing = got?.workflow;
477
- }
478
- catch (err) {
479
- printApiError(err);
480
- process.exit(1);
481
- }
482
- if (!existing) {
483
- error(`Workflow not found: ${workflowId}`);
484
- process.exit(1);
485
- }
486
- // Fork 2: a TOML key that contradicts the target's key is almost
487
- // certainly the wrong file — error rather than silently ignore it.
488
- const mismatch = workflowKeyMismatchError(tomlWorkflow, existing.workflowKey);
489
- if (mismatch) {
490
- error(mismatch);
491
- process.exit(1);
492
- }
493
- // Fork 1 (B): explicit `update` flags override TOML-derived metadata.
494
- const mergedWorkflow = { ...tomlWorkflow };
495
- if (options.name)
496
- mergedWorkflow.name = options.name;
497
- if (options.description)
498
- mergedWorkflow.description = options.description;
499
- if (options.status)
500
- mergedWorkflow.status = options.status;
501
- if (options.perUserMaxRunning) {
502
- mergedWorkflow.perUserMaxRunning = parseInt(options.perUserMaxRunning);
503
- }
504
- if (options.perUserMaxQueued) {
505
- mergedWorkflow.perUserMaxQueued = parseInt(options.perUserMaxQueued);
506
- }
507
- if (options.dequeueOrder)
508
- mergedWorkflow.dequeueOrder = options.dequeueOrder;
509
- if (options.requiresClientApply !== undefined) {
510
- mergedWorkflow.requiresClientApply = options.requiresClientApply === "true";
511
- }
512
- if (options.syncCallable !== undefined) {
513
- mergedWorkflow.syncCallable = options.syncCallable === "true";
514
- }
515
- if (options.accessRule !== undefined) {
516
- mergedWorkflow.accessRule = options.accessRule;
517
- }
518
- // #1232: an explicit --capabilities flag overrides whatever the TOML
519
- // declares (grant/revoke on top of a --from-file body push).
520
- if (options.capabilities !== undefined) {
521
- mergedWorkflow.capabilities = parseCapabilitiesFlag(options.capabilities);
522
- }
523
- try {
524
- const { updateSlotLabel, fullWorkflow } = await applyWorkflowBody(client, resolvedAppId, workflowId, mergedWorkflow, steps, { activeConfigId: existing.activeConfigId });
525
- if (options.json) {
526
- json(fullWorkflow);
527
- return;
528
- }
529
- success(`Workflow updated from ${options.fromFile} (${updateSlotLabel}).`);
530
- keyValue("Workflow ID", workflowId);
531
- keyValue("Key", existing.workflowKey);
532
- keyValue("Steps", steps.length);
533
- }
534
- catch (err) {
535
- // Surface the server's structured `details[]` (e.g. syncCallable /
536
- // identity validation reasons) like create/sync push do.
537
- printApiError(err);
538
- process.exit(1);
539
- }
540
- return;
541
- }
542
- const payload = {};
543
- if (options.name)
544
- payload.name = options.name;
545
- if (options.description)
546
- payload.description = options.description;
547
- if (options.status)
548
- payload.status = options.status;
549
- if (options.perUserMaxRunning)
550
- payload.perUserMaxRunning = parseInt(options.perUserMaxRunning);
551
- if (options.perUserMaxQueued)
552
- payload.perUserMaxQueued = parseInt(options.perUserMaxQueued);
553
- if (options.dequeueOrder)
554
- payload.dequeueOrder = options.dequeueOrder;
555
- if (options.requiresClientApply !== undefined) {
556
- payload.requiresClientApply = options.requiresClientApply === "true";
557
- }
558
- if (options.syncCallable !== undefined) {
559
- payload.syncCallable = options.syncCallable === "true";
560
- }
561
- // #571: presence guard, NOT truthiness — `--access-rule ""` must reach
562
- // the server (it clears the rule) while an omitted flag leaves
563
- // options.accessRule undefined so the server's hasOwnProperty guard
564
- // leaves the existing rule untouched.
565
- if (options.accessRule !== undefined) {
566
- payload.accessRule = options.accessRule;
567
- }
568
- // #1232: the CLI fallback the issue asked for — grant/revoke a workflow's
569
- // sensitive `capabilities` (e.g. `membership`) on an existing workflow.
570
- // Presence-guarded like --access-rule: an omitted flag stays undefined so
571
- // the server's hasOwnProperty guard leaves the existing grant untouched;
572
- // `--capabilities ""` sends [] to revoke. The server validates the names.
573
- if (options.capabilities !== undefined) {
574
- payload.capabilities = parseCapabilitiesFlag(options.capabilities);
575
- }
576
- if (Object.keys(payload).length === 0) {
577
- error("No update options specified.");
578
- process.exit(1);
579
- }
580
- const client = new ApiClient();
581
- try {
582
- const result = await client.updateWorkflow(resolvedAppId, workflowId, payload);
583
- if (options.json) {
584
- json(result);
585
- return;
586
- }
587
- success("Workflow updated.");
588
- }
589
- catch (err) {
590
- // #1173: surface structured `details[]` on update too.
591
- printApiError(err);
592
- process.exit(1);
593
- }
594
- });
595
- // Delete workflow
596
- workflows
597
- .command("delete")
598
- .description("Delete or archive a workflow")
599
- .argument("<workflow-id>", "Workflow ID or key")
600
- .option("--app <app-id>", "App ID (uses current app if not specified)")
601
- .option("--hard", "Permanently delete instead of archive")
602
- .option("-y, --yes", "Skip confirmation prompt")
603
- .action(async (workflowId, options) => {
604
- const resolvedAppId = resolveAppId(undefined, options);
605
- const client = new ApiClient();
606
- try {
607
- if (!options.yes) {
608
- const action = options.hard ? "permanently delete" : "archive";
609
- const confirmed = await confirmPrompt(`Are you sure you want to ${action} workflow ${workflowId}?`);
610
- if (!confirmed) {
611
- info("Cancelled.");
612
- return;
613
- }
614
- }
615
- if (options.hard) {
616
- await client.deleteWorkflow(resolvedAppId, workflowId);
617
- success("Workflow deleted.");
618
- }
619
- else {
620
- await client.updateWorkflow(resolvedAppId, workflowId, { status: "archived" });
621
- success("Workflow archived.");
622
- // #1238 — archiving keeps the workflowKey reserved, so recreating
623
- // under the same key fails with "workflowKey already exists". Point
624
- // at the remedy so the reserved-key behavior is discoverable.
625
- info(`The workflowKey stays reserved while archived. To free it for reuse, run: primitive workflows delete ${workflowId} --hard`);
626
- }
627
- }
628
- catch (err) {
629
- error(err.message);
630
- process.exit(1);
631
- }
632
- });
633
- // Expand a workflow TOML and print the result (no server contact).
634
- //
635
- // Authors use this to inspect what `include = [...]` fragments produce
636
- // before pushing. Surfaces include-collision and unique-id failures with
637
- // helpful messages without going through `sync push`.
638
422
  workflows
639
423
  .command("expand")
640
424
  .description("Expand a workflow TOML's include fragments and print the result")
@@ -657,117 +441,116 @@ Examples:
657
441
  process.exit(1);
658
442
  }
659
443
  });
660
- // Draft subcommand
661
- const draft = workflows.command("draft").description("Manage workflow draft");
662
- // Update draft
663
- draft
664
- .command("update")
665
- .description("Update workflow draft steps (deprecated; use 'workflows configs' for staged rollouts)")
444
+ // #2645 criterion 9 / #2803 — the operational verbs.
445
+ //
446
+ // Configuration is authored in TOML and applied with `config push`, which would
447
+ // otherwise mean an operator needs a repo checkout to stop a misbehaving
448
+ // workflow. It does not: taking something out of service is an OPERATION, and
449
+ // these two verbs are the ONLY writers of `status`, which is server-owned and
450
+ // no longer a TOML key. The committed configuration says nothing about
451
+ // availability, so `config diff` reports the server's status as operator
452
+ // state rather than as drift.
453
+ workflows
454
+ .command("disable")
455
+ .description("Take a workflow out of service now. Operational, not configuration: `status` is server-owned and 'config push' can neither set nor clear it.")
666
456
  .argument("<workflow-id>", "Workflow ID or key")
667
457
  .option("--app <app-id>", "App ID (uses current app if not specified)")
668
- .option("--from-file <path>", "Load steps from TOML file")
669
458
  .option("--json", "Output as JSON")
670
459
  .action(async (workflowId, options) => {
671
460
  const resolvedAppId = resolveAppId(undefined, options);
672
- if (!options.fromFile) {
673
- error("--from-file is required");
674
- process.exit(1);
675
- }
676
- let tomlData;
461
+ const client = new ApiClient();
677
462
  try {
678
- const content = readFileSync(options.fromFile, "utf-8");
679
- tomlData = parseConfigToml(content);
463
+ const result = await client.disableWorkflow(resolvedAppId, workflowId);
464
+ if (options.json) {
465
+ json(result);
466
+ return;
467
+ }
468
+ success(`Workflow disabled: ${result.workflowKey || workflowId}`);
469
+ info("New runs are refused. Configuration is unchanged — `primitive workflows enable` puts it back.");
680
470
  }
681
471
  catch (err) {
682
- error(`Failed to read TOML file: ${err.message}`);
683
- process.exit(1);
684
- }
685
- // Issue #685: reject misnested headers before pushing.
686
- const tomlErrors = validateWorkflowToml(tomlData);
687
- if (tomlErrors.length > 0) {
688
- error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
472
+ error(err.message);
689
473
  process.exit(1);
690
474
  }
691
- const workflow = tomlData.workflow || tomlData;
692
- const payload = {
693
- steps: tomlData.steps || [],
694
- inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : undefined,
695
- outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : undefined,
696
- };
475
+ });
476
+ workflows
477
+ .command("enable")
478
+ .description("Put a workflow back in service.")
479
+ .argument("<workflow-id>", "Workflow ID or key")
480
+ .option("--app <app-id>", "App ID (uses current app if not specified)")
481
+ .option("--json", "Output as JSON")
482
+ .action(async (workflowId, options) => {
483
+ const resolvedAppId = resolveAppId(undefined, options);
697
484
  const client = new ApiClient();
698
485
  try {
699
- const result = await client.updateWorkflowDraft(resolvedAppId, workflowId, payload);
486
+ const result = await client.enableWorkflow(resolvedAppId, workflowId);
700
487
  if (options.json) {
701
488
  json(result);
702
489
  return;
703
490
  }
704
- success("Draft updated.");
705
- keyValue("Steps", result.draft?.steps?.length || 0);
706
- // Issue #687: surface server-side deprecation hint when applicable.
707
- if (result.deprecation) {
708
- warn(`[deprecated] ${result.deprecation}`);
709
- }
491
+ success(`Workflow enabled: ${result.workflowKey || workflowId}`);
710
492
  }
711
493
  catch (err) {
712
- // Issue #987: config-model workflows reject the legacy draft slot with
713
- // a 409 (the draft never executes). Surface the actionable config path
714
- // cleanly instead of dumping a raw API error.
715
- if (err?.statusCode === 409 && err?.code === "WORKFLOW_CONFIG_MODEL") {
716
- error("This workflow has an active configuration, so the legacy draft slot is inert " +
717
- "(it won't change what runs). For dev-loop edits, use 'sync push' — it writes the\n" +
718
- "active config directly and activates the workflow end-to-end:\n" +
719
- " primitive sync push\n" +
720
- "For staged rollouts, create + activate a config, then flip the status to active\n" +
721
- "(configs activate only sets the active config; it does not change the workflow status):\n" +
722
- " primitive workflows configs create <workflow-id> --name <name> --from-file <path>\n" +
723
- " primitive workflows configs activate <workflow-id> <config-id>\n" +
724
- " primitive workflows update <workflow-id> --status active");
725
- process.exit(1);
726
- }
727
494
  error(err.message);
728
495
  process.exit(1);
729
496
  }
730
497
  });
731
- // Publish workflow
498
+ // #2907 — the delete flow's CLI spelling. Not a second writer of `status`:
499
+ // this is the plain `DELETE` #2887 made retire-with-history, which until now
500
+ // only the admin API and the console could reach. Named `archive`, not
501
+ // `delete`, because a configuration noun gets no `delete` verb
502
+ // (docs/cli-design.md) and prune-by-push stays the only CLI hard-delete path.
732
503
  workflows
733
- .command("publish")
734
- .description("Publish the current draft as a new revision (deprecated for new-model workflows; use 'workflows configs activate')")
504
+ .command("archive")
505
+ .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.")
735
506
  .argument("<workflow-id>", "Workflow ID or key")
736
507
  .option("--app <app-id>", "App ID (uses current app if not specified)")
508
+ .option("-y, --yes", "Skip confirmation prompt")
737
509
  .option("--json", "Output as JSON")
510
+ .addHelpText("after", `
511
+ What archiving a workflow costs (retire-with-history):
512
+ New runs are refused, and so is every \`workflow.call\` into it.
513
+ Its runs, revisions, configurations and test cases keep resolving what they
514
+ point at — that is what the tombstone exists for.
515
+ The archived row still holds its workflowKey.
516
+ \`primitive workflows enable\` refuses an archived workflow; there is no
517
+ un-archive.
518
+
519
+ Reclaiming the key means hard-deleting the row and its cascade:
520
+ $ rm workflows/<key>.toml
521
+ $ primitive config push --prune # confirmed; hard-deletes the row
522
+ # or hard-delete it in the console, then re-add the file and push.
523
+ `)
738
524
  .action(async (workflowId, options) => {
739
525
  const resolvedAppId = resolveAppId(undefined, options);
526
+ if (!options.yes) {
527
+ let confirmed;
528
+ try {
529
+ confirmed = await confirmPrompt(`Archive workflow ${workflowId}? New runs are refused, its history is ` +
530
+ `kept, it goes on holding its key, and there is no un-archive.`);
531
+ }
532
+ catch (err) {
533
+ error(err.message);
534
+ process.exit(1);
535
+ }
536
+ if (!confirmed) {
537
+ info("Cancelled.");
538
+ return;
539
+ }
540
+ }
740
541
  const client = new ApiClient();
741
542
  try {
742
- const result = await client.publishWorkflow(resolvedAppId, workflowId);
543
+ // The soft delete: no `hard`, so the #1238 cascade does not run and the
544
+ // definition row stays queryable.
545
+ const result = await client.deleteWorkflow(resolvedAppId, workflowId);
743
546
  if (options.json) {
744
547
  json(result);
745
548
  return;
746
549
  }
747
- success("Workflow published.");
748
- keyValue("Revision ID", result.revision?.revisionId);
749
- keyValue("Published At", formatDate(result.revision?.publishedAt));
750
- // Issue #687: surface server-side deprecation hint when applicable.
751
- if (result.deprecation) {
752
- warn(`[deprecated] ${result.deprecation}`);
753
- }
550
+ success(`Workflow archived: ${workflowId}`);
551
+ 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`.");
754
552
  }
755
553
  catch (err) {
756
- // Issue #987: 'publish' is inert on config-model workflows (production
757
- // runs read the active config), so the server rejects with a 409.
758
- // Point the user at the config primitives instead of a raw API error.
759
- if (err?.statusCode === 409 && err?.code === "WORKFLOW_CONFIG_MODEL") {
760
- error("This workflow has an active configuration, so 'publish' is inert " +
761
- "(it bumps a legacy pointer that doesn't change what runs). For dev-loop edits, use\n" +
762
- "'sync push' — it writes the active config directly and activates the workflow end-to-end:\n" +
763
- " primitive sync push\n" +
764
- "For staged rollouts, create + activate a config, then flip the status to active\n" +
765
- "(configs activate only sets the active config; it does not change the workflow status):\n" +
766
- " primitive workflows configs create <workflow-id> --name <name> --from-file <path>\n" +
767
- " primitive workflows configs activate <workflow-id> <config-id>\n" +
768
- " primitive workflows update <workflow-id> --status active");
769
- process.exit(1);
770
- }
771
554
  error(err.message);
772
555
  process.exit(1);
773
556
  }
@@ -886,7 +669,12 @@ Examples:
886
669
  }
887
670
  return;
888
671
  }
889
- if (status?.status === "completed" || status?.status === "complete") {
672
+ // #2348: this render ladder used to re-derive terminality (and matched
673
+ // the raw "complete"/"errored" spellings the server no longer emits).
674
+ // It now branches on the same canonical values the poll loop stops on,
675
+ // via the shared sets in lib/run-status.ts.
676
+ const waitOutcome = previewWaitOutcome(status?.status);
677
+ if (waitOutcome === "completed") {
890
678
  success("Preview completed.");
891
679
  if (status.output) {
892
680
  console.log("\nOutput:");
@@ -900,20 +688,41 @@ Examples:
900
688
  });
901
689
  }
902
690
  }
903
- else if (status?.status === "failed" || status?.status === "errored") {
691
+ else if (waitOutcome === "failed") {
904
692
  error("Preview failed.");
905
693
  if (status.error) {
906
694
  console.log("\nError:");
907
695
  console.log(JSON.stringify(status.error, null, 2));
908
696
  }
909
697
  }
910
- else if (status?.status === "terminated") {
698
+ else if (waitOutcome === "terminated") {
911
699
  // Issue #941 (Codex P2): the preview was aborted (e.g. via
912
700
  // `workflows preview ... abort`, or an external terminate). This is a
913
701
  // real terminal state — NOT a timeout — so render it distinctly and
914
702
  // exit 0 (the wait succeeded in observing a terminal result).
915
703
  warn("Preview was terminated (aborted before completion).");
916
704
  }
705
+ else if (waitOutcome === "skipped") {
706
+ // #2636 — the preview's declarative lock was held and the definition
707
+ // declared `onContention: "ignore"`, so it never ran. That is an
708
+ // outcome, not a timeout: report it and exit 0.
709
+ warn(`Preview did not run — lock held${status?.skipReason ? ` (${status.skipReason})` : ""}.`);
710
+ }
711
+ else if (waitOutcome === "apply") {
712
+ // #2348: the run finished and is parked waiting for a client to claim
713
+ // and apply its result. It will not advance on its own, so the wait
714
+ // stopping here is a success, not a timeout.
715
+ warn(`Preview finished and is waiting to be applied (status: ${status.status}).`);
716
+ if (status.output) {
717
+ console.log("\nOutput:");
718
+ console.log(JSON.stringify(status.output, null, 2));
719
+ }
720
+ }
721
+ else if (waitOutcome === "missing") {
722
+ // #2348: no live instance to observe — reporting a timeout here would
723
+ // suggest the preview is still running, which it is not.
724
+ error("Preview instance not found (status: missing).");
725
+ }
917
726
  else {
918
727
  // Timed out without ever reaching a terminal state (issue #941).
919
728
  error(`Wait timed out after ${maxAttempts}s; preview still running. Check status with 'workflows runs status'.`);
@@ -1110,44 +919,209 @@ Examples:
1110
919
  // Runs subcommand
1111
920
  const runs = workflows.command("runs").description("Manage workflow runs");
1112
921
  // List runs
922
+ const runsTableColumns = [
923
+ { header: "RUN ID", key: "runId", format: formatId },
924
+ { header: "STATUS", key: "status", format: formatStatus },
925
+ { header: "STARTED", key: "startedAt", format: formatDate },
926
+ // #1367 — queue delay (requested → execution start).
927
+ { header: "DELAY", key: "queueDelayMs", format: formatDuration },
928
+ { header: "ENDED", key: "endedAt", format: formatDate },
929
+ { header: "PREVIEW", key: "isPreview", format: (v) => (v ? "yes" : "") },
930
+ ];
1113
931
  runs
1114
932
  .command("list")
1115
933
  .description("List workflow runs")
1116
- .argument("<workflow-id>", "Workflow ID or key")
934
+ // #1967 — the workflow id is optional when `--user-id` is given: that
935
+ // form lists one user's runs across every workflow.
936
+ .argument("[workflow-id]", "Workflow ID or key (optional with --user-id)")
1117
937
  .option("--app <app-id>", "App ID (uses current app if not specified)")
1118
- .option("--status <status>", "Filter by status: pending, running, completed, failed")
938
+ // #2348: the canonical run-status vocabulary. The old text advertised
939
+ // "pending", which no server path ever writes, and omitted "terminated".
940
+ // An unrecognised value is now a 400 from the server, not an empty page.
941
+ .option("--status <status>", `Filter by status: ${RUN_STATUS_LIST}`)
942
+ .option("--user-id <user-id>", "List runs started by this user, across every workflow")
1119
943
  .option("--limit <n>", "Number of runs to show", "20")
944
+ .option("--cursor <cursor>", "Pagination cursor from a previous page")
945
+ // #1969 — shared tailing flags. `--watch` re-renders the current list each
946
+ // interval (snapshot); `--follow` appends new/changed runs (tail). Mutually
947
+ // exclusive; `--interval` floor is 1s (default 2s).
948
+ .option("--watch", "Re-render the current run list every interval (snapshot)")
949
+ .option("--follow", "Append runs as they start or change (tail)")
950
+ .option("--interval <seconds>", "Poll interval in seconds (min 1, default 2)")
1120
951
  .option("--json", "Output as JSON")
1121
952
  .action(async (workflowId, options) => {
1122
953
  const resolvedAppId = resolveAppId(undefined, options);
1123
954
  const client = new ApiClient();
1124
- try {
1125
- const { items } = await client.listWorkflowRuns(resolvedAppId, workflowId, {
1126
- status: options.status,
1127
- limit: parseInt(options.limit),
1128
- });
1129
- if (options.json) {
1130
- json(items);
1131
- return;
955
+ const limit = parseInt(options.limit);
956
+ if (options.watch && options.follow) {
957
+ error("--watch and --follow are mutually exclusive; pass only one.");
958
+ process.exit(1);
959
+ }
960
+ // #1967 — one of the two selectors is required; `list` never enumerates
961
+ // every run in the app.
962
+ if (!workflowId && !options.userId) {
963
+ error("Provide a workflow ID, or --user-id to list one user's runs across every workflow.");
964
+ process.exit(1);
965
+ }
966
+ // The user view reads the runs-by-user index, which has no resume
967
+ // contract — `--follow`'s checkpoint is keyed on the by-workflow index.
968
+ if (options.userId && options.follow) {
969
+ error("--follow is not supported with --user-id (it reads a different index). Use --watch instead.");
970
+ process.exit(1);
971
+ }
972
+ // Both selectors resolve through the same page shape, so the rest of the
973
+ // command is identical either way.
974
+ const fetchPage = (params) => options.userId
975
+ ? client.listUserWorkflowRuns(resolvedAppId, options.userId, {
976
+ ...params,
977
+ // With both a workflow id and --user-id, narrow to that workflow.
978
+ workflowId: workflowId || undefined,
979
+ })
980
+ : client.listWorkflowRuns(resolvedAppId, workflowId, params);
981
+ // One-shot list (no tailing).
982
+ if (!options.watch && !options.follow) {
983
+ try {
984
+ const page = await fetchPage({
985
+ status: options.status,
986
+ limit,
987
+ cursor: options.cursor,
988
+ });
989
+ const { items } = page;
990
+ if (options.json) {
991
+ // #1316 envelope with the shared inspection item shape — never a
992
+ // bare array — preserving nextCursor/hasMore + the deprecated
993
+ // `cursor` dual-emit alias (behaviors 6, 7).
994
+ json(buildWorkflowRunEnvelope(items, page));
995
+ return;
996
+ }
997
+ // The cursor hint prints whether or not this page had rows: a
998
+ // filtered read can come back empty with more of the index still to
999
+ // walk, and a bare "No runs found." would read as "this user has no
1000
+ // matching runs" (#1967 review).
1001
+ const printCursorHint = () => {
1002
+ if (!page.nextCursor)
1003
+ return;
1004
+ console.log();
1005
+ dim(`More runs. Use --cursor ${page.nextCursor} for the next page.`);
1006
+ };
1007
+ if (!items || items.length === 0) {
1008
+ // #2237 — the honest empty state: a bounded filtered read can stop
1009
+ // before it reaches a match, so the message says how far it got and
1010
+ // how to continue instead of asserting there are none. `runs list`
1011
+ // deliberately does NOT auto-resume like `runs failures` — it is a
1012
+ // pager, and its `--limit` is a page size.
1013
+ info(describeEmptyRunPage(page, !!options.cursor));
1014
+ return;
1015
+ }
1016
+ console.log(formatTable(items, runsTableColumns));
1017
+ printCursorHint();
1132
1018
  }
1133
- if (!items || items.length === 0) {
1134
- info("No runs found.");
1135
- return;
1019
+ catch (err) {
1020
+ error(err.message);
1021
+ process.exit(1);
1136
1022
  }
1137
- console.log(formatTable(items, [
1138
- { header: "RUN ID", key: "runId", format: formatId },
1139
- { header: "STATUS", key: "status", format: formatStatus },
1140
- { header: "STARTED", key: "startedAt", format: formatDate },
1141
- // #1367 queue delay (requested execution start).
1142
- { header: "DELAY", key: "queueDelayMs", format: formatDuration },
1143
- { header: "ENDED", key: "endedAt", format: formatDate },
1144
- { header: "PREVIEW", key: "isPreview", format: (v) => v ? "yes" : "" },
1145
- ]));
1023
+ return;
1024
+ }
1025
+ // Tailing (`--watch` / `--follow`). SIGINT AbortController installed
1026
+ // here at the command level; the handler is removed in `finally` so it
1027
+ // never leaks past this command (unlike the persistent process.on).
1028
+ let intervalSeconds;
1029
+ try {
1030
+ intervalSeconds = parseIntervalSeconds(options.interval);
1146
1031
  }
1147
1032
  catch (err) {
1148
1033
  error(err.message);
1149
1034
  process.exit(1);
1150
1035
  }
1036
+ const intervalMs = intervalSeconds * 1000;
1037
+ const controller = new AbortController();
1038
+ const onSigint = () => controller.abort();
1039
+ process.on("SIGINT", onSigint);
1040
+ try {
1041
+ if (options.follow) {
1042
+ const source = {
1043
+ fetchAfter: async (resumeAfter) => {
1044
+ if (resumeAfter === undefined) {
1045
+ // Bootstrap: newest position, descending, one row. followLoop
1046
+ // discards the items and keeps only the baseline checkpoint.
1047
+ const page = await client.listWorkflowRuns(resolvedAppId, workflowId, {
1048
+ follow: true,
1049
+ forward: false,
1050
+ limit: 1,
1051
+ status: options.status,
1052
+ });
1053
+ return { items: page.items, hasMore: false, resumeAfter: page.resumeAfter };
1054
+ }
1055
+ const page = await client.listWorkflowRuns(resolvedAppId, workflowId, {
1056
+ follow: true,
1057
+ forward: true,
1058
+ after: resumeAfter,
1059
+ limit,
1060
+ status: options.status,
1061
+ });
1062
+ return {
1063
+ items: page.items,
1064
+ hasMore: !!page.hasMore,
1065
+ resumeAfter: page.resumeAfter,
1066
+ };
1067
+ },
1068
+ };
1069
+ if (!options.json) {
1070
+ info("Following runs (Ctrl-C to stop)…");
1071
+ }
1072
+ await followLoop(source, {
1073
+ intervalMs,
1074
+ signal: controller.signal,
1075
+ render: (items) => {
1076
+ if (options.json) {
1077
+ // One JSON object per line (a tail has no closing bracket to
1078
+ // wait for), each in the shared inspection item shape — the
1079
+ // same items the one-shot envelope carries (behavior 7).
1080
+ for (const run of items)
1081
+ jsonLine(toWorkflowRunInspectionRow(run));
1082
+ return;
1083
+ }
1084
+ for (const run of items) {
1085
+ console.log(`${formatDate(run.startedAt)} ${formatStatus(run.status)} ${formatId(run.runId)}`);
1086
+ }
1087
+ },
1088
+ });
1089
+ }
1090
+ else {
1091
+ // --watch: periodic snapshot redraw. The most recent page's
1092
+ // pagination fields are kept so the `--json` redraw emits the same
1093
+ // envelope the one-shot path does, not a bare array (behavior 7).
1094
+ let lastPage = {};
1095
+ await watchLoop(async () => {
1096
+ const page = await fetchPage({
1097
+ status: options.status,
1098
+ limit,
1099
+ cursor: options.cursor,
1100
+ });
1101
+ lastPage = page;
1102
+ return page.items;
1103
+ }, {
1104
+ intervalMs,
1105
+ signal: controller.signal,
1106
+ render: (items) => {
1107
+ if (options.json) {
1108
+ json(buildWorkflowRunEnvelope(items, lastPage));
1109
+ return;
1110
+ }
1111
+ // Renderer owns the redraw: clear the screen, then print.
1112
+ process.stdout.write("\x1b[2J\x1b[H");
1113
+ if (!items || items.length === 0) {
1114
+ console.log("No runs found.");
1115
+ return;
1116
+ }
1117
+ console.log(formatTable(items, runsTableColumns));
1118
+ },
1119
+ });
1120
+ }
1121
+ }
1122
+ finally {
1123
+ process.removeListener("SIGINT", onSigint);
1124
+ }
1151
1125
  });
1152
1126
  // Run status
1153
1127
  runs
@@ -1162,13 +1136,39 @@ Examples:
1162
1136
  const client = new ApiClient();
1163
1137
  try {
1164
1138
  const result = await client.getWorkflowRunStatus(resolvedAppId, workflowId, runId);
1139
+ // #2771 — a Cloudflare instance reports `stepResults` only once the run
1140
+ // is terminal, so a live run used to answer with `status: running` and
1141
+ // nothing else for its whole duration. Step rows are now written as the
1142
+ // run goes, so fall back to them: the steps finished so far plus the one
1143
+ // currently executing. Resolved BEFORE the output branches — a script
1144
+ // polling `--json` needs the in-flight trace as much as a human does.
1145
+ // Best-effort: a lookup failure still leaves the run details intact.
1146
+ const instanceStepResults = result.instanceStatus?.stepResults;
1147
+ let persistedStepRuns = [];
1148
+ if (!instanceStepResults || instanceStepResults.length === 0) {
1149
+ try {
1150
+ const stepRuns = await client.getWorkflowStepRuns(resolvedAppId, workflowId, runId);
1151
+ persistedStepRuns = stepRuns?.items || [];
1152
+ }
1153
+ catch {
1154
+ persistedStepRuns = [];
1155
+ }
1156
+ }
1165
1157
  if (options.json) {
1166
- json(result);
1158
+ // Purely additive: `stepRuns` appears only when the persisted rows are
1159
+ // what carries the trace, so a terminal run's JSON is unchanged.
1160
+ json(persistedStepRuns.length > 0 ? { ...result, stepRuns: persistedStepRuns } : result);
1167
1161
  return;
1168
1162
  }
1169
1163
  const run = result.run;
1170
1164
  keyValue("Run ID", run.runId);
1171
1165
  keyValue("Status", formatStatus(run.status));
1166
+ // #2636 — the machine-readable outcome next to the status: why a
1167
+ // failed run failed, or why an elided run never ran.
1168
+ if (run.errorCode)
1169
+ keyValue("Error code", run.errorCode);
1170
+ if (run.skipReason)
1171
+ keyValue("Skip reason", run.skipReason);
1172
1172
  // #1367 — `startedAt` is the request time; `executionStartedAt` is when
1173
1173
  // Cloudflare actually started running the instance. Queue delay is the
1174
1174
  // gap. A still-queued run shows "-" for execution start / queue delay.
@@ -1178,26 +1178,24 @@ Examples:
1178
1178
  keyValue("Create call", formatDuration(run.createCallDurationMs));
1179
1179
  keyValue("Ended", formatDate(run.endedAt));
1180
1180
  keyValue("Preview", run.isPreview ? "yes" : "no");
1181
- if (result.instanceStatus) {
1182
- const status = result.instanceStatus;
1183
- if (status.output) {
1184
- divider();
1185
- info("Output:");
1186
- console.log(JSON.stringify(status.output, null, 2));
1187
- }
1188
- if (status.stepResults && status.stepResults.length > 0) {
1189
- divider();
1190
- info("Step Results:");
1191
- for (const line of renderRunStatusStepResults(status.stepResults)) {
1192
- console.log(line);
1193
- }
1194
- }
1195
- if (status.error) {
1196
- divider();
1197
- error("Error:");
1198
- console.log(JSON.stringify(status.error, null, 2));
1181
+ const stepResultLines = renderRunStatusStepResults(stepResultsForRunStatus(instanceStepResults, persistedStepRuns));
1182
+ if (result.instanceStatus?.output) {
1183
+ divider();
1184
+ info("Output:");
1185
+ console.log(JSON.stringify(result.instanceStatus.output, null, 2));
1186
+ }
1187
+ if (stepResultLines.length > 0) {
1188
+ divider();
1189
+ info("Step Results:");
1190
+ for (const line of stepResultLines) {
1191
+ console.log(line);
1199
1192
  }
1200
1193
  }
1194
+ if (result.instanceStatus?.error) {
1195
+ divider();
1196
+ error("Error:");
1197
+ console.log(JSON.stringify(result.instanceStatus.error, null, 2));
1198
+ }
1201
1199
  }
1202
1200
  catch (err) {
1203
1201
  error(err.message);
@@ -1218,7 +1216,9 @@ Examples:
1218
1216
  try {
1219
1217
  const { items } = await client.getWorkflowStepRuns(resolvedAppId, workflowId, runId);
1220
1218
  if (options.json) {
1221
- json(items);
1219
+ // Steps are unpaginated (one run's full trace) — the shared
1220
+ // inspection item shape wrapped in `{items}`, no cursor (behavior 6).
1221
+ json(buildWorkflowStepEnvelope(items));
1222
1222
  return;
1223
1223
  }
1224
1224
  if (!items || items.length === 0) {
@@ -1229,7 +1229,13 @@ Examples:
1229
1229
  // next starting. Only surfaced above a 1s threshold so normal
1230
1230
  // back-to-back steps stay quiet; a large gap flags a mid-run stall.
1231
1231
  const STEP_GAP_THRESHOLD_MS = 1000;
1232
- const rows = annotateStepGaps(items);
1232
+ // #2771 an in-flight run also returns the step executing right now
1233
+ // (status `running`, duration not written yet). Show its elapsed time
1234
+ // so a step stuck retrying is visible as such.
1235
+ const rows = annotateStepGaps(items).map((row) => ({
1236
+ ...row,
1237
+ displayDurationMs: stepRunDisplayDurationMs(row),
1238
+ }));
1233
1239
  console.log(formatTable(rows, [
1234
1240
  { header: "STEP", key: "stepId" },
1235
1241
  { header: "KIND", key: "stepKind" },
@@ -1239,7 +1245,9 @@ Examples:
1239
1245
  format: (v) => v === "completed" ? chalk.green(v) :
1240
1246
  v === "failed" ? chalk.red(v) :
1241
1247
  v === "skipped" ? chalk.gray(v) :
1242
- v === "error_captured" ? chalk.yellow(v) : v,
1248
+ v === "error_captured" ? chalk.yellow(v) :
1249
+ // #2771 — the step executing right now.
1250
+ v === "running" ? chalk.cyan(v) : v,
1243
1251
  },
1244
1252
  {
1245
1253
  header: "GAP",
@@ -1248,7 +1256,7 @@ Examples:
1248
1256
  ? chalk.yellow(formatDuration(v))
1249
1257
  : chalk.dim("-"),
1250
1258
  },
1251
- { header: "DURATION", key: "durationMs", format: formatDuration },
1259
+ { header: "DURATION", key: "displayDurationMs", format: formatDuration },
1252
1260
  {
1253
1261
  header: "TOKENS",
1254
1262
  key: "totalTokens",
@@ -1404,10 +1412,17 @@ Examples:
1404
1412
  client.getWorkflowRunStatus(resolvedAppId, workflowId, runId),
1405
1413
  client.getWorkflowStepRuns(resolvedAppId, workflowId, runId),
1406
1414
  ]);
1407
- const failedStep = stepsResult.items.find((s) => s.status === "failed");
1415
+ // #2074 one selection rule shared with the STEP column of
1416
+ // `runs failures`: prefer the run's own denormalized attribution, and
1417
+ // fall back to the lowest-`stepIndex` failed row (never "whatever the
1418
+ // query returned first") so the two views can't disagree.
1419
+ const failedStep = selectFailedStepRow(stepsResult.items, runResult.run?.failedStepId);
1408
1420
  const errorInfo = {
1409
1421
  runId,
1410
1422
  status: runResult.run?.status,
1423
+ // #2636 — the platform's classification of the failure, when it owns
1424
+ // one (a lost declarative-lock race, an exhausted lock wait).
1425
+ errorCode: runResult.run?.errorCode ?? null,
1411
1426
  errorMessage: runResult.run?.errorMessage || runResult.instanceStatus?.error?.message,
1412
1427
  failedStep: failedStep ? {
1413
1428
  stepId: failedStep.stepId,
@@ -1424,6 +1439,9 @@ Examples:
1424
1439
  }
1425
1440
  keyValue("Run ID", runId);
1426
1441
  keyValue("Status", formatStatus(errorInfo.status));
1442
+ if (errorInfo.errorCode) {
1443
+ keyValue("Code", errorInfo.errorCode);
1444
+ }
1427
1445
  if (errorInfo.errorMessage) {
1428
1446
  divider();
1429
1447
  error("Error: " + errorInfo.errorMessage);
@@ -1463,35 +1481,126 @@ Examples:
1463
1481
  .description("List recent workflow run failures")
1464
1482
  .argument("<workflow-id>", "Workflow ID or key")
1465
1483
  .option("--app <app-id>", "App ID")
1466
- .option("--limit <n>", "Number of failures to show", "10")
1484
+ // #2237 — `--limit` is the number of FAILURES to collect, as the help text
1485
+ // always claimed. It used to be passed straight through as the server's
1486
+ // bounded page limit, so a workflow with a long success streak answered
1487
+ // "No failures found." while failures sat just past the scan budget.
1488
+ .option("--limit <n>", "Number of failures to collect", "10")
1489
+ .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))
1490
+ // `--cursor` seeds the sweep's starting position; the sweep then resumes
1491
+ // itself, so it is only needed to continue an earlier capped search.
1492
+ .option("--cursor <cursor>", "Resume the search from a previous cursor")
1467
1493
  .option("--json", "Output as JSON")
1468
1494
  .action(async (workflowId, options) => {
1469
1495
  const resolvedAppId = resolveAppId(undefined, options);
1470
1496
  const client = new ApiClient();
1497
+ const limit = parseInt(options.limit, 10);
1498
+ const maxScan = parseInt(options.maxScan, 10);
1499
+ if (!Number.isFinite(limit) || limit < 1) {
1500
+ error("--limit must be a positive integer.");
1501
+ process.exit(1);
1502
+ }
1503
+ if (!Number.isFinite(maxScan) || maxScan < 0) {
1504
+ error("--max-scan must be a non-negative integer (0 = no cap).");
1505
+ process.exit(1);
1506
+ }
1507
+ // Ctrl-C during a multi-second sweep prints what was collected plus the
1508
+ // resume cursor rather than dropping the work on the floor. The handler
1509
+ // is removed in `finally` so it never leaks past this command.
1510
+ //
1511
+ // Registering a listener replaces Node's terminate-on-SIGINT default, and
1512
+ // `shouldStop` is only read between requests — so a first Ctrl-C during a
1513
+ // slow in-flight request would otherwise appear to do nothing and leave
1514
+ // the command unkillable. A second one exits immediately (#2237 review).
1515
+ let interrupted = false;
1516
+ let sigints = 0;
1517
+ const onSigint = () => {
1518
+ interrupted = true;
1519
+ if (++sigints > 1)
1520
+ process.exit(130);
1521
+ };
1522
+ process.on("SIGINT", onSigint);
1523
+ // Progress on stderr, TTY only, never in `--json` mode: a silent
1524
+ // multi-second command invites the Ctrl-C it is trying to survive.
1525
+ const showProgress = !options.json && !!process.stderr.isTTY;
1471
1526
  try {
1472
- const { items } = await client.listWorkflowRuns(resolvedAppId, workflowId, {
1527
+ const sweep = await collectWorkflowFailures(({ cursor, limit: pageLimit }) => client.listWorkflowRuns(resolvedAppId, workflowId, {
1473
1528
  status: "failed",
1474
- limit: parseInt(options.limit),
1529
+ limit: pageLimit,
1530
+ cursor,
1531
+ }), {
1532
+ limit,
1533
+ maxScan,
1534
+ startCursor: options.cursor,
1535
+ shouldStop: () => interrupted,
1536
+ onProgress: showProgress
1537
+ ? (scanned, collected) => progress(`Searching… ${scanned} runs examined, ${collected} failure${collected === 1 ? "" : "s"} found`)
1538
+ : undefined,
1475
1539
  });
1540
+ if (showProgress && sweep.requests >= 1) {
1541
+ // Clear the progress line so it does not sit above the table. One
1542
+ // request is the common case — `onProgress` fires after EVERY request
1543
+ // including the first, so guarding on `> 1` left the line on screen
1544
+ // with the cursor parked at its end (#2237 review).
1545
+ process.stderr.write("\r\x1b[K");
1546
+ }
1547
+ // A SIGINT that arrived after the sweep returned (while the table is
1548
+ // rendering) did not truncate anything, so only a sweep that actually
1549
+ // stopped on the interrupt reports 130. `exitCode` rather than
1550
+ // `process.exit()` so a piped stdout is not truncated mid-write.
1551
+ const exitInterrupted = sweep.stoppedBecause === "interrupted";
1552
+ const items = sweep.items;
1476
1553
  if (options.json) {
1477
- json(items);
1554
+ // #2074 — the shared inspection item shape inside the endpoint's own
1555
+ // #1316 envelope, never a bare array. #2237 — `items` is the
1556
+ // concatenation across every resumed page, `scanned` their sum, and
1557
+ // `nextCursor` the last one observed.
1558
+ json(buildWorkflowRunEnvelope(items, {
1559
+ nextCursor: sweep.nextCursor,
1560
+ hasMore: !!sweep.nextCursor,
1561
+ ...(sweep.scannedKnown ? { scanned: sweep.scanned } : {}),
1562
+ }));
1563
+ if (exitInterrupted)
1564
+ process.exitCode = 130;
1478
1565
  return;
1479
1566
  }
1480
- if (!items || items.length === 0) {
1481
- info("No failures found.");
1482
- return;
1567
+ if (items.length > 0) {
1568
+ console.log(formatTable(items, [
1569
+ { header: "RUN ID", key: "runId", format: formatId },
1570
+ // STEP sits before ERROR so the truncated message stays the last
1571
+ // wide column and the step id is never the thing that gets cut.
1572
+ // `-` is a routine outcome, not a bug: launch-time failures
1573
+ // (create/schema/R2 aborts, reclaimed runs) legitimately have no
1574
+ // failed step, and runs that predate #2074 carry no attribution.
1575
+ { header: "STEP", key: "failedStepId", format: formatFailureStepCell },
1576
+ // #2636 — the platform's classification, next to the message an
1577
+ // operator would otherwise have had to pattern-match.
1578
+ { header: "CODE", key: "errorCode", format: formatFailureCodeCell },
1579
+ { header: "ERROR", key: "errorMessage", format: formatFailureErrorCell },
1580
+ { header: "STARTED", key: "startedAt", format: formatDate },
1581
+ { header: "ENDED", key: "endedAt", format: formatDate },
1582
+ ]));
1483
1583
  }
1484
- console.log(formatTable(items, [
1485
- { header: "RUN ID", key: "runId", format: formatId },
1486
- { header: "ERROR", key: "errorMessage", format: (v) => v ? (v.length > 50 ? v.slice(0, 47) + "..." : v) : "-" },
1487
- { header: "STARTED", key: "startedAt", format: formatDate },
1488
- { header: "ENDED", key: "endedAt", format: formatDate },
1489
- ]));
1584
+ // #2237 — replaces the unconditional "No failures found.": every form
1585
+ // either states the search was complete or says how far it got and how
1586
+ // to continue. A triage command must never assert an absence it did
1587
+ // not establish.
1588
+ const summary = describeFailureSweep(sweep, limit);
1589
+ if (summary) {
1590
+ if (items.length > 0)
1591
+ console.log();
1592
+ info(summary);
1593
+ }
1594
+ if (exitInterrupted)
1595
+ process.exitCode = 130;
1490
1596
  }
1491
1597
  catch (err) {
1492
1598
  error(err.message);
1493
1599
  process.exit(1);
1494
1600
  }
1601
+ finally {
1602
+ process.removeListener("SIGINT", onSigint);
1603
+ }
1495
1604
  });
1496
1605
  // ── Iterations: inspect / reset iterate-users singleton iterations (#1209) ──
1497
1606
  const iterations = workflows
@@ -1598,80 +1707,6 @@ Examples:
1598
1707
  process.exit(1);
1599
1708
  }
1600
1709
  });
1601
- // Analytics overview
1602
- const analyticsCmd = workflows.command("analytics").description("View workflow performance analytics");
1603
- analyticsCmd
1604
- .command("overview")
1605
- .description("View workflow performance metrics")
1606
- .option("--app <app-id>", "App ID")
1607
- .option("--days <n>", "Time window in days", "7")
1608
- .option("--json", "Output as JSON")
1609
- .action(async (options) => {
1610
- const resolvedAppId = resolveAppId(undefined, options);
1611
- const client = new ApiClient();
1612
- try {
1613
- const result = await client.getWorkflowAnalytics(resolvedAppId, {
1614
- windowDays: parseInt(options.days),
1615
- });
1616
- if (options.json) {
1617
- json(result);
1618
- return;
1619
- }
1620
- info(`Workflow Analytics (${result.windowDays || options.days} days)`);
1621
- console.log();
1622
- keyValue("Total Runs", String(result.totals?.totalRuns ?? 0));
1623
- keyValue("Success Rate", `${((result.totals?.successRate ?? 0) * 100).toFixed(1)}%`);
1624
- keyValue("Avg Duration", formatDuration(result.totals?.avgDurationMs));
1625
- if (result.tokenUsage) {
1626
- console.log();
1627
- info("Token Usage:");
1628
- keyValue(" Input Tokens", String(result.tokenUsage.totalInputTokens ?? 0));
1629
- keyValue(" Output Tokens", String(result.tokenUsage.totalOutputTokens ?? 0));
1630
- keyValue(" Total Tokens", String(result.tokenUsage.totalTokens ?? 0));
1631
- }
1632
- }
1633
- catch (err) {
1634
- error(err.message);
1635
- process.exit(1);
1636
- }
1637
- });
1638
- analyticsCmd
1639
- .command("top")
1640
- .description("View top workflows by usage")
1641
- .option("--app <app-id>", "App ID")
1642
- .option("--days <n>", "Time window in days", "7")
1643
- .option("--limit <n>", "Number of workflows to show", "10")
1644
- .option("--json", "Output as JSON")
1645
- .action(async (options) => {
1646
- const resolvedAppId = resolveAppId(undefined, options);
1647
- const client = new ApiClient();
1648
- try {
1649
- const result = await client.getTopWorkflows(resolvedAppId, {
1650
- windowDays: parseInt(options.days),
1651
- limit: parseInt(options.limit),
1652
- });
1653
- if (options.json) {
1654
- json(result);
1655
- return;
1656
- }
1657
- if (!result.items || result.items.length === 0) {
1658
- info("No workflow activity in this period.");
1659
- return;
1660
- }
1661
- console.log(formatTable(result.items, [
1662
- { header: "WORKFLOW", key: "workflow" },
1663
- { header: "RUNS", key: "executions" },
1664
- { header: "SUCCESS", key: "success_pct", format: (v) => `${v}%` },
1665
- { header: "P50", key: "p50", format: (v) => formatDuration(v) },
1666
- { header: "P95", key: "p95", format: (v) => formatDuration(v) },
1667
- { header: "TOKENS", key: "total_tokens", format: (v) => v ? String(Math.round(v)).replace(/\B(?=(\d{3})+(?!\d))/g, ",") : "-" },
1668
- ]));
1669
- }
1670
- catch (err) {
1671
- error(err.message);
1672
- process.exit(1);
1673
- }
1674
- });
1675
1710
  // ============================================
1676
1711
  // CONFIGS SUBCOMMAND
1677
1712
  // ============================================
@@ -1682,9 +1717,10 @@ Examples:
1682
1717
  Examples:
1683
1718
  $ primitive workflows configs list <workflow-id>
1684
1719
  $ primitive workflows configs get <workflow-id> <config-id>
1685
- $ primitive workflows configs create <workflow-id> --name "v2" --from-file workflow.toml
1686
- $ primitive workflows configs activate <workflow-id> <config-id>
1687
- $ primitive workflows configs duplicate <workflow-id> <config-id> --name "v2-copy"
1720
+
1721
+ Config bodies are authored as files, not verbs: write
1722
+ workflows/<key>.configs/<name>.toml and name the live one with
1723
+ activeConfigName in workflows/<key>.toml, then run 'primitive config push'.
1688
1724
  `);
1689
1725
  // List configs
1690
1726
  configs
@@ -1762,218 +1798,23 @@ Examples:
1762
1798
  process.exit(1);
1763
1799
  }
1764
1800
  });
1765
- // Create config
1766
- configs
1767
- .command("create")
1768
- .description("Create a new configuration")
1769
- .argument("<workflow-id>", "Workflow ID or key")
1770
- .option("--app <app-id>", "App ID (uses current app if not specified)")
1771
- .option("--name <name>", "Configuration name (required)")
1772
- .option("--description <desc>", "Description")
1773
- .option("--from-file <path>", "Load steps from TOML file")
1774
- .option("--json", "Output as JSON")
1775
- .action(async (workflowId, options) => {
1776
- const resolvedAppId = resolveAppId(undefined, options);
1777
- if (!options.name) {
1778
- error("--name is required");
1779
- process.exit(1);
1780
- }
1781
- let steps = [];
1782
- if (options.fromFile) {
1783
- let tomlData;
1784
- try {
1785
- const content = readFileSync(options.fromFile, "utf-8");
1786
- tomlData = parseConfigToml(content);
1787
- }
1788
- catch (err) {
1789
- error(`Failed to read TOML file: ${err.message}`);
1790
- process.exit(1);
1791
- }
1792
- // Issue #685: reject misnested headers before pushing.
1793
- const tomlErrors = validateWorkflowToml(tomlData);
1794
- if (tomlErrors.length > 0) {
1795
- error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
1796
- process.exit(1);
1797
- }
1798
- steps = tomlData.steps || [];
1799
- }
1800
- const client = new ApiClient();
1801
- try {
1802
- const config = await client.createWorkflowConfig(resolvedAppId, workflowId, {
1803
- configName: options.name,
1804
- description: options.description,
1805
- steps,
1806
- });
1807
- if (options.json) {
1808
- json(config);
1809
- return;
1810
- }
1811
- success(`Configuration created: ${config.configName}`);
1812
- keyValue("Config ID", config.configId);
1813
- }
1814
- catch (err) {
1815
- error(err.message);
1816
- process.exit(1);
1817
- }
1818
- });
1819
- // Update config
1820
- configs
1821
- .command("update")
1822
- .description("Update a configuration")
1823
- .argument("<workflow-id>", "Workflow ID or key")
1824
- .argument("<config-id>", "Configuration ID")
1825
- .option("--app <app-id>", "App ID (uses current app if not specified)")
1826
- .option("--name <name>", "Configuration name")
1827
- .option("--description <desc>", "Description")
1828
- .option("--from-file <path>", "Update steps from TOML file")
1829
- .option("--json", "Output as JSON")
1830
- .action(async (workflowId, configId, options) => {
1831
- const resolvedAppId = resolveAppId(undefined, options);
1832
- const payload = {};
1833
- if (options.name)
1834
- payload.configName = options.name;
1835
- if (options.description !== undefined)
1836
- payload.description = options.description;
1837
- if (options.fromFile) {
1838
- let tomlData;
1839
- try {
1840
- const content = readFileSync(options.fromFile, "utf-8");
1841
- tomlData = parseConfigToml(content);
1842
- }
1843
- catch (err) {
1844
- error(`Failed to read TOML file: ${err.message}`);
1845
- process.exit(1);
1846
- }
1847
- // Issue #685: reject misnested headers before pushing.
1848
- const tomlErrors = validateWorkflowToml(tomlData);
1849
- if (tomlErrors.length > 0) {
1850
- error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
1851
- process.exit(1);
1852
- }
1853
- payload.steps = tomlData.steps || [];
1854
- }
1855
- if (Object.keys(payload).length === 0) {
1856
- error("No update options specified. Use --name, --description, or --from-file.");
1857
- process.exit(1);
1858
- }
1859
- const client = new ApiClient();
1860
- try {
1861
- const config = await client.updateWorkflowConfig(resolvedAppId, workflowId, configId, payload);
1862
- if (options.json) {
1863
- json(config);
1864
- return;
1865
- }
1866
- success("Configuration updated.");
1867
- keyValue("Config ID", config.configId);
1868
- keyValue("Name", config.configName);
1869
- }
1870
- catch (err) {
1871
- error(err.message);
1872
- process.exit(1);
1873
- }
1874
- });
1875
- // Activate config
1876
- configs
1877
- .command("activate")
1878
- .description("Set a configuration as the active (default) configuration")
1879
- .argument("<workflow-id>", "Workflow ID or key")
1880
- .argument("<config-id>", "Configuration ID")
1881
- .option("--app <app-id>", "App ID (uses current app if not specified)")
1882
- .option("--json", "Output as JSON")
1883
- .action(async (workflowId, configId, options) => {
1884
- const resolvedAppId = resolveAppId(undefined, options);
1885
- const client = new ApiClient();
1886
- try {
1887
- const result = await client.activateWorkflowConfig(resolvedAppId, workflowId, configId);
1888
- if (options.json) {
1889
- json(result);
1890
- return;
1891
- }
1892
- success("Configuration activated.");
1893
- keyValue("Active Config ID", result.activeConfigId || configId);
1894
- }
1895
- catch (err) {
1896
- error(err.message);
1897
- process.exit(1);
1898
- }
1899
- });
1900
- // Duplicate config
1901
- configs
1902
- .command("duplicate")
1903
- .description("Duplicate a configuration")
1904
- .argument("<workflow-id>", "Workflow ID or key")
1905
- .argument("<config-id>", "Configuration ID to duplicate")
1906
- .option("--app <app-id>", "App ID (uses current app if not specified)")
1907
- .option("--name <name>", "Name for the new configuration")
1908
- .option("--json", "Output as JSON")
1909
- .action(async (workflowId, configId, options) => {
1910
- const resolvedAppId = resolveAppId(undefined, options);
1911
- const client = new ApiClient();
1912
- try {
1913
- const config = await client.duplicateWorkflowConfig(resolvedAppId, workflowId, configId, {
1914
- configName: options.name,
1915
- });
1916
- if (options.json) {
1917
- json(config);
1918
- return;
1919
- }
1920
- success(`Configuration duplicated: ${config.configName}`);
1921
- keyValue("New Config ID", config.configId);
1922
- }
1923
- catch (err) {
1924
- error(err.message);
1925
- process.exit(1);
1926
- }
1927
- });
1928
- // Archive config
1929
- configs
1930
- .command("archive")
1931
- .description("Archive a configuration")
1932
- .argument("<workflow-id>", "Workflow ID or key")
1933
- .argument("<config-id>", "Configuration ID")
1934
- .option("--app <app-id>", "App ID (uses current app if not specified)")
1935
- .option("-y, --yes", "Skip confirmation prompt")
1936
- .action(async (workflowId, configId, options) => {
1937
- const resolvedAppId = resolveAppId(undefined, options);
1938
- if (!options.yes) {
1939
- let confirm;
1940
- try {
1941
- confirm = await confirmPrompt(`Are you sure you want to archive configuration ${configId}?`);
1942
- }
1943
- catch (err) {
1944
- error(err.message);
1945
- process.exit(1);
1946
- }
1947
- if (!confirm) {
1948
- info("Cancelled.");
1949
- return;
1950
- }
1951
- }
1952
- const client = new ApiClient();
1953
- try {
1954
- await client.archiveWorkflowConfig(resolvedAppId, workflowId, configId);
1955
- success("Configuration archived.");
1956
- }
1957
- catch (err) {
1958
- error(err.message);
1959
- process.exit(1);
1960
- }
1961
- });
1962
1801
  // ============================================
1963
1802
  // TESTS SUBCOMMAND
1964
1803
  // ============================================
1965
1804
  const tests = workflows
1966
1805
  .command("tests")
1967
- .description("Manage and run workflow test cases")
1806
+ .description("Run and inspect workflow test cases")
1968
1807
  .addHelpText("after", `
1808
+ Test cases are authored in TOML at workflows/<key>.tests/<name>.toml and applied
1809
+ with \`primitive config push\` (issue #2769); \`config fields workflow\` lists the
1810
+ fields. Deleting a case is removing its file and running \`config push --prune\`.
1811
+
1969
1812
  Examples:
1970
1813
  $ primitive workflows tests list <workflow-id>
1971
- $ primitive workflows tests create <workflow-id> --name "Basic test" --vars '{"input":"hello"}'
1972
1814
  $ primitive workflows tests run <workflow-id> <test-case-id>
1973
1815
  $ primitive workflows tests run-all <workflow-id>
1974
1816
  $ primitive workflows tests run-all <workflow-id> --test-cases 01ABC,01DEF,01GHI
1975
1817
  `);
1976
- // List test cases
1977
1818
  tests
1978
1819
  .command("list")
1979
1820
  .description("List test cases for a workflow")
@@ -2004,73 +1845,6 @@ Examples:
2004
1845
  process.exit(1);
2005
1846
  }
2006
1847
  });
2007
- // Create test case
2008
- tests
2009
- .command("create")
2010
- .description("Create a test case for a workflow")
2011
- .argument("<workflow-id>", "Workflow ID")
2012
- .option("--app <app-id>", "App ID (uses current app if not specified)")
2013
- .option("--name <name>", "Test case name (required)")
2014
- .option("--vars <json>", "Input variables as JSON (required)")
2015
- .option("--pattern <regex>", "Expected output pattern (regex)")
2016
- .option("--contains <json>", "Expected strings to contain (JSON array)")
2017
- .option("--json-subset <json>", "Expected JSON subset to match")
2018
- .option("--json", "Output as JSON")
2019
- .action(async (workflowId, options) => {
2020
- const resolvedAppId = resolveAppId(undefined, options);
2021
- if (!options.name || !options.vars) {
2022
- error("Required: --name and --vars");
2023
- process.exit(1);
2024
- }
2025
- let inputVariables;
2026
- try {
2027
- inputVariables = JSON.parse(options.vars);
2028
- }
2029
- catch {
2030
- error("Invalid JSON in --vars");
2031
- process.exit(1);
2032
- }
2033
- let expectedOutputContains;
2034
- if (options.contains) {
2035
- try {
2036
- expectedOutputContains = JSON.parse(options.contains);
2037
- }
2038
- catch {
2039
- error("Invalid JSON in --contains");
2040
- process.exit(1);
2041
- }
2042
- }
2043
- let expectedJsonSubset;
2044
- if (options.jsonSubset) {
2045
- try {
2046
- expectedJsonSubset = JSON.parse(options.jsonSubset);
2047
- }
2048
- catch {
2049
- error("Invalid JSON in --json-subset");
2050
- process.exit(1);
2051
- }
2052
- }
2053
- const client = new ApiClient();
2054
- try {
2055
- const result = await client.createTestCase(resolvedAppId, "workflow", workflowId, {
2056
- name: options.name,
2057
- inputVariables,
2058
- expectedOutputPattern: options.pattern,
2059
- expectedOutputContains,
2060
- expectedJsonSubset,
2061
- });
2062
- if (options.json) {
2063
- json(result);
2064
- return;
2065
- }
2066
- success(`Test case created: ${result.name}`);
2067
- keyValue("Test Case ID", result.testCaseId);
2068
- }
2069
- catch (err) {
2070
- error(err.message);
2071
- process.exit(1);
2072
- }
2073
- });
2074
1848
  // Get test case
2075
1849
  tests
2076
1850
  .command("get")
@@ -2138,118 +1912,6 @@ Examples:
2138
1912
  process.exit(1);
2139
1913
  }
2140
1914
  });
2141
- // Update test case
2142
- tests
2143
- .command("update")
2144
- .description("Update a test case")
2145
- .argument("<workflow-id>", "Workflow ID")
2146
- .argument("<test-case-id>", "Test Case ID")
2147
- .option("--app <app-id>", "App ID (uses current app if not specified)")
2148
- .option("--name <name>", "Test case name")
2149
- .option("--vars <json>", "Input variables as JSON")
2150
- .option("--pattern <regex>", "Expected output pattern (regex)")
2151
- .option("--contains <json>", "Expected strings to contain (JSON array)")
2152
- .option("--json-subset <json>", "Expected JSON subset to match")
2153
- .option("--clear-pattern", "Clear expected pattern")
2154
- .option("--clear-contains", "Clear expected contains")
2155
- .option("--clear-json-subset", "Clear expected JSON subset")
2156
- .option("--json", "Output as JSON")
2157
- .action(async (workflowId, testCaseId, options) => {
2158
- const resolvedAppId = resolveAppId(undefined, options);
2159
- const payload = {};
2160
- if (options.name)
2161
- payload.name = options.name;
2162
- if (options.vars) {
2163
- try {
2164
- payload.inputVariables = JSON.parse(options.vars);
2165
- }
2166
- catch {
2167
- error("Invalid JSON in --vars");
2168
- process.exit(1);
2169
- }
2170
- }
2171
- if (options.clearPattern) {
2172
- payload.expectedOutputPattern = null;
2173
- }
2174
- else if (options.pattern) {
2175
- payload.expectedOutputPattern = options.pattern;
2176
- }
2177
- if (options.clearContains) {
2178
- payload.expectedOutputContains = null;
2179
- }
2180
- else if (options.contains) {
2181
- try {
2182
- payload.expectedOutputContains = JSON.parse(options.contains);
2183
- }
2184
- catch {
2185
- error("Invalid JSON in --contains");
2186
- process.exit(1);
2187
- }
2188
- }
2189
- if (options.clearJsonSubset) {
2190
- payload.expectedJsonSubset = null;
2191
- }
2192
- else if (options.jsonSubset) {
2193
- try {
2194
- payload.expectedJsonSubset = JSON.parse(options.jsonSubset);
2195
- }
2196
- catch {
2197
- error("Invalid JSON in --json-subset");
2198
- process.exit(1);
2199
- }
2200
- }
2201
- if (Object.keys(payload).length === 0) {
2202
- error("No update options specified.");
2203
- process.exit(1);
2204
- }
2205
- const client = new ApiClient();
2206
- try {
2207
- const result = await client.updateTestCase(resolvedAppId, "workflow", workflowId, testCaseId, payload);
2208
- if (options.json) {
2209
- json(result);
2210
- return;
2211
- }
2212
- success("Test case updated.");
2213
- }
2214
- catch (err) {
2215
- error(err.message);
2216
- process.exit(1);
2217
- }
2218
- });
2219
- // Delete test case
2220
- tests
2221
- .command("delete")
2222
- .description("Delete a test case")
2223
- .argument("<workflow-id>", "Workflow ID")
2224
- .argument("<test-case-id>", "Test Case ID")
2225
- .option("--app <app-id>", "App ID (uses current app if not specified)")
2226
- .option("-y, --yes", "Skip confirmation prompt")
2227
- .action(async (workflowId, testCaseId, options) => {
2228
- const resolvedAppId = resolveAppId(undefined, options);
2229
- if (!options.yes) {
2230
- let confirm;
2231
- try {
2232
- confirm = await confirmPrompt(`Are you sure you want to delete test case ${testCaseId}?`);
2233
- }
2234
- catch (err) {
2235
- error(err.message);
2236
- process.exit(1);
2237
- }
2238
- if (!confirm) {
2239
- info("Cancelled.");
2240
- return;
2241
- }
2242
- }
2243
- const client = new ApiClient();
2244
- try {
2245
- await client.deleteTestCase(resolvedAppId, "workflow", workflowId, testCaseId);
2246
- success("Test case deleted.");
2247
- }
2248
- catch (err) {
2249
- error(err.message);
2250
- process.exit(1);
2251
- }
2252
- });
2253
1915
  // Run a single test case
2254
1916
  tests
2255
1917
  .command("run")
@@ -2504,12 +2166,20 @@ Examples:
2504
2166
  let result = await fetchStatus();
2505
2167
  if (options.wait && result.status === "running") {
2506
2168
  info("Waiting for batch completion...");
2507
- while (result.status === "running") {
2508
- await new Promise((r) => setTimeout(r, 2000));
2509
- result = await fetchStatus();
2169
+ // #1969 — shared poll primitive (delay-then-probe every 2s, honoring
2170
+ // an AbortSignal). `onError: "throw"` preserves the prior loop's
2171
+ // fail-fast behavior: a throwing fetchStatus (expired token, deleted
2172
+ // batch → 401/404) propagates to the command-level catch below and
2173
+ // exits non-zero, rather than spinning forever with a frozen progress
2174
+ // line. (The follow/watch tail loops legitimately keep "continue".)
2175
+ const controller = new AbortController();
2176
+ result = await pollUntil(fetchStatus, (r) => r.status !== "running", {
2177
+ intervalMs: 2000,
2178
+ signal: controller.signal,
2179
+ onError: "throw",
2510
2180
  // Progress goes to stderr so it can't corrupt JSON on stdout under --json.
2511
- progress(` Completed: ${result.completed}/${result.results?.length || 0} `);
2512
- }
2181
+ onValue: (r) => progress(` Completed: ${r.completed}/${r.results?.length || 0} `),
2182
+ });
2513
2183
  progressEnd();
2514
2184
  }
2515
2185
  if (options.json) {
@@ -2601,13 +2271,15 @@ Examples:
2601
2271
  // ============================================
2602
2272
  const attachments = tests
2603
2273
  .command("attachments")
2604
- .description("Manage test case file attachments")
2274
+ .description("Inspect test case file attachments")
2605
2275
  .addHelpText("after", `
2276
+ Attachments are authored as files in workflows/<key>.tests/<name>/ and applied
2277
+ with \`primitive config push\` (#2769); removing one and running
2278
+ \`config push --prune\` deletes it server-side. These verbs read server state.
2279
+
2606
2280
  Examples:
2607
2281
  $ primitive workflows tests attachments list <workflow-id> <test-case-id>
2608
- $ primitive workflows tests attachments upload <workflow-id> <test-case-id> ./document.pdf
2609
2282
  $ primitive workflows tests attachments download <workflow-id> <test-case-id> document.pdf
2610
- $ primitive workflows tests attachments delete <workflow-id> <test-case-id> document.pdf
2611
2283
  `);
2612
2284
  // List attachments
2613
2285
  attachments
@@ -2645,49 +2317,6 @@ Examples:
2645
2317
  process.exit(1);
2646
2318
  }
2647
2319
  });
2648
- // Upload attachment
2649
- attachments
2650
- .command("upload")
2651
- .description("Upload a file attachment to a test case")
2652
- .argument("<workflow-id>", "Workflow ID")
2653
- .argument("<test-case-id>", "Test Case ID")
2654
- .argument("<file-path>", "Path to the file to upload")
2655
- .option("--app <app-id>", "App ID (uses current app if not specified)")
2656
- .option("--name <name>", "Override the filename")
2657
- .option("--json", "Output as JSON")
2658
- .action(async (workflowId, testCaseId, filePath, options) => {
2659
- const resolvedAppId = resolveAppId(undefined, options);
2660
- const client = new ApiClient();
2661
- try {
2662
- // Check file exists and get stats
2663
- const stats = statSync(filePath);
2664
- if (!stats.isFile()) {
2665
- error(`Not a file: ${filePath}`);
2666
- process.exit(1);
2667
- }
2668
- // Check file size (10 MB limit)
2669
- const TEN_MB = 10 * 1024 * 1024;
2670
- if (stats.size > TEN_MB) {
2671
- error(`File too large (max 10MB): ${formatFileSize(stats.size)}`);
2672
- process.exit(1);
2673
- }
2674
- const filename = options.name || basename(filePath);
2675
- const data = readFileSync(filePath);
2676
- const contentType = mimeLookup(filePath) || "application/octet-stream";
2677
- const result = await client.uploadTestCaseAttachment(resolvedAppId, "workflow", workflowId, testCaseId, filename, data, contentType);
2678
- if (options.json) {
2679
- json(result);
2680
- return;
2681
- }
2682
- success(`Uploaded: ${filename}`);
2683
- keyValue("Size", formatFileSize(result.attachment.size));
2684
- keyValue("Type", result.attachment.contentType);
2685
- }
2686
- catch (err) {
2687
- error(err.message);
2688
- process.exit(1);
2689
- }
2690
- });
2691
2320
  // Download attachment
2692
2321
  attachments
2693
2322
  .command("download")
@@ -2713,41 +2342,6 @@ Examples:
2713
2342
  process.exit(1);
2714
2343
  }
2715
2344
  });
2716
- // Delete attachment
2717
- attachments
2718
- .command("delete")
2719
- .description("Delete a file attachment from a test case")
2720
- .argument("<workflow-id>", "Workflow ID")
2721
- .argument("<test-case-id>", "Test Case ID")
2722
- .argument("<filename>", "Name of the file to delete")
2723
- .option("--app <app-id>", "App ID (uses current app if not specified)")
2724
- .option("-y, --yes", "Skip confirmation prompt")
2725
- .action(async (workflowId, testCaseId, filename, options) => {
2726
- const resolvedAppId = resolveAppId(undefined, options);
2727
- if (!options.yes) {
2728
- let confirm;
2729
- try {
2730
- confirm = await confirmPrompt(`Are you sure you want to delete attachment "${filename}"?`);
2731
- }
2732
- catch (err) {
2733
- error(err.message);
2734
- process.exit(1);
2735
- }
2736
- if (!confirm) {
2737
- info("Cancelled.");
2738
- return;
2739
- }
2740
- }
2741
- const client = new ApiClient();
2742
- try {
2743
- await client.deleteTestCaseAttachment(resolvedAppId, "workflow", workflowId, testCaseId, filename);
2744
- success(`Deleted: ${filename}`);
2745
- }
2746
- catch (err) {
2747
- error(err.message);
2748
- process.exit(1);
2749
- }
2750
- });
2751
2345
  // ============================================
2752
2346
  // Codegen command (issue #1442)
2753
2347
  // ============================================
@@ -2756,8 +2350,9 @@ Examples:
2756
2350
  .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.")
2757
2351
  .argument("[workflow-key]", "Generate for a single workflow (defaults to every workflows/*.toml found)")
2758
2352
  .option("--app <app-id>", "App ID")
2759
- .option("--sync-dir <path>", "Override path to the sync directory (defaults to ./.primitive/sync/<env>/<appId>/)")
2760
- .option("-o, --output <dir>", "Output directory for the generated files (defaults to <sync-dir>/workflows/generated/)")
2353
+ .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
2354
+ .addOption(new Option("--sync-dir <path>", "Deprecated alias of --dir").hideHelp())
2355
+ .option("-o, --output <dir>", "Output directory for the generated files (defaults to <config-dir>/workflows/generated/)")
2761
2356
  .option("--lang <lang>", "Target language for the generated types: 'ts' (default) or 'swift'.", "ts")
2762
2357
  .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.")
2763
2358
  .option("--json", "Output the result summary as JSON")
@@ -2771,13 +2366,16 @@ Examples:
2771
2366
  process.exit(1);
2772
2367
  }
2773
2368
  // 1. Resolve the single source workflows/ directory via the shared
2774
- // active-environment resolver (issue #1510). Honors --sync-dir /
2369
+ // active-environment resolver (issue #1510). Honors --dir /
2775
2370
  // --app overrides, resolves the active env
2776
2371
  // (--env → PRIMITIVE_ENV → defaultEnvironment → single-env), and
2777
2372
  // preserves the legacy per-app scan fallback in bare-dir mode.
2778
2373
  const workflowsSourceDir = resolveCodegenSourceDir({
2779
2374
  subdir: "workflows",
2780
- options: { app: options.app, syncDir: options.syncDir },
2375
+ options: {
2376
+ app: options.app,
2377
+ syncDir: resolveSyncDirOverride(options, "workflows codegen"),
2378
+ },
2781
2379
  });
2782
2380
  // 2. Collect the source .toml files (one per workflow), filtered to a
2783
2381
  // single workflow when an argument is given. Match on the file stem;
@@ -2835,9 +2433,20 @@ Examples:
2835
2433
  for (const m of codegenResult.mismatches) {
2836
2434
  error(` ${m.reason}: ${path.relative(process.cwd(), m.filePath)}`);
2837
2435
  }
2838
- info(lang === "swift"
2839
- ? "Run `primitive workflows codegen --lang swift` to regenerate."
2840
- : "Run `primitive workflows codegen` to regenerate.");
2436
+ // Echo back the options that decide WHERE and WHAT was
2437
+ // checked. Suggesting a bare `workflows codegen` after a
2438
+ // `--check -o <dir>` sent people to regenerate into the
2439
+ // default <config-dir>/workflows/generated/ instead of the
2440
+ // directory that is actually out of date, so the files stayed
2441
+ // stale and the next check failed the same way.
2442
+ const regenerate = ["primitive workflows codegen"];
2443
+ if (workflowKey)
2444
+ regenerate.push(workflowKey);
2445
+ if (lang === "swift")
2446
+ regenerate.push("--lang swift");
2447
+ if (options.output)
2448
+ regenerate.push(`-o ${options.output}`);
2449
+ info(`Run \`${regenerate.join(" ")}\` to regenerate.`);
2841
2450
  }
2842
2451
  process.exit(1);
2843
2452
  }