primitive-admin 1.0.61 → 1.0.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -69
- package/assets/skill/skills/primitive-platform/SKILL.md +336 -37
- package/dist/bin/primitive.js +25 -14
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/analytics.js +36 -16
- package/dist/src/commands/analytics.js.map +1 -1
- package/dist/src/commands/apps.js +52 -188
- package/dist/src/commands/apps.js.map +1 -1
- package/dist/src/commands/blob-buckets.js +13 -153
- package/dist/src/commands/blob-buckets.js.map +1 -1
- package/dist/src/commands/collection-type-configs.js +14 -100
- package/dist/src/commands/collection-type-configs.js.map +1 -1
- package/dist/src/commands/collections.js +47 -8
- package/dist/src/commands/collections.js.map +1 -1
- package/dist/src/commands/config.d.ts +46 -0
- package/dist/src/commands/config.js +479 -0
- package/dist/src/commands/config.js.map +1 -0
- package/dist/src/commands/connections.js +12 -7
- package/dist/src/commands/connections.js.map +1 -1
- package/dist/src/commands/cron-triggers.js +84 -176
- package/dist/src/commands/cron-triggers.js.map +1 -1
- package/dist/src/commands/database-type-configs.d.ts +2 -0
- package/dist/src/commands/database-type-configs.js +171 -0
- package/dist/src/commands/database-type-configs.js.map +1 -0
- package/dist/src/commands/database-types.js +4 -4
- package/dist/src/commands/database-types.js.map +1 -1
- package/dist/src/commands/databases.d.ts +63 -0
- package/dist/src/commands/databases.js +649 -87
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.js +869 -12
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/email-templates.js +21 -128
- package/dist/src/commands/email-templates.js.map +1 -1
- package/dist/src/commands/env.d.ts +12 -1
- package/dist/src/commands/env.js +115 -26
- package/dist/src/commands/env.js.map +1 -1
- package/dist/src/commands/feature-flags.d.ts +14 -0
- package/dist/src/commands/feature-flags.js +116 -0
- package/dist/src/commands/feature-flags.js.map +1 -0
- package/dist/src/commands/group-type-configs.js +14 -109
- package/dist/src/commands/group-type-configs.js.map +1 -1
- package/dist/src/commands/init.d.ts +24 -1
- package/dist/src/commands/init.js +1194 -279
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/integrations.js +166 -435
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/metadata-category-configs.d.ts +12 -0
- package/dist/src/commands/metadata-category-configs.js +112 -0
- package/dist/src/commands/metadata-category-configs.js.map +1 -0
- package/dist/src/commands/metadata.js +33 -141
- package/dist/src/commands/metadata.js.map +1 -1
- package/dist/src/commands/prompts.js +206 -563
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.d.ts +1 -1
- package/dist/src/commands/rule-sets.js +16 -132
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/scripts.d.ts +10 -0
- package/dist/src/commands/scripts.js +203 -241
- package/dist/src/commands/scripts.js.map +1 -1
- package/dist/src/commands/sessions.js +17 -5
- package/dist/src/commands/sessions.js.map +1 -1
- package/dist/src/commands/sync-app-settings.d.ts +113 -70
- package/dist/src/commands/sync-app-settings.js +193 -242
- package/dist/src/commands/sync-app-settings.js.map +1 -1
- package/dist/src/commands/sync.d.ts +1904 -51
- package/dist/src/commands/sync.js +9468 -1790
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/tokens.js +8 -2
- package/dist/src/commands/tokens.js.map +1 -1
- package/dist/src/commands/users.js +92 -1
- package/dist/src/commands/users.js.map +1 -1
- package/dist/src/commands/vars.js +44 -58
- package/dist/src/commands/vars.js.map +1 -1
- package/dist/src/commands/webhooks.js +318 -156
- package/dist/src/commands/webhooks.js.map +1 -1
- package/dist/src/commands/workflows.d.ts +49 -12
- package/dist/src/commands/workflows.js +652 -1086
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/access-rule-display.d.ts +21 -0
- package/dist/src/lib/access-rule-display.js +34 -0
- package/dist/src/lib/access-rule-display.js.map +1 -0
- package/dist/src/lib/api-client.d.ts +541 -61
- package/dist/src/lib/api-client.js +559 -102
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-settings-descriptor.d.ts +180 -25
- package/dist/src/lib/app-settings-descriptor.js +403 -82
- package/dist/src/lib/app-settings-descriptor.js.map +1 -1
- package/dist/src/lib/canonical-json.d.ts +12 -0
- package/dist/src/lib/canonical-json.js +35 -0
- package/dist/src/lib/canonical-json.js.map +1 -0
- package/dist/src/lib/cli-manifest.d.ts +8 -0
- package/dist/src/lib/cli-manifest.js +1 -0
- package/dist/src/lib/cli-manifest.js.map +1 -1
- package/dist/src/lib/codegen-shared/generatedFiles.d.ts +6 -2
- package/dist/src/lib/codegen-shared/generatedFiles.js +9 -2
- package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +6 -6
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +8 -8
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
- package/dist/src/lib/config-object-descriptor.d.ts +127 -0
- package/dist/src/lib/config-object-descriptor.js +658 -0
- package/dist/src/lib/config-object-descriptor.js.map +1 -0
- package/dist/src/lib/config-payload.d.ts +85 -0
- package/dist/src/lib/config-payload.js +116 -0
- package/dist/src/lib/config-payload.js.map +1 -0
- package/dist/src/lib/config-surface.d.ts +130 -0
- package/dist/src/lib/config-surface.js +300 -0
- package/dist/src/lib/config-surface.js.map +1 -0
- package/dist/src/lib/credentials-store.d.ts +10 -0
- package/dist/src/lib/credentials-store.js +23 -0
- package/dist/src/lib/credentials-store.js.map +1 -1
- package/dist/src/lib/data-input.d.ts +23 -0
- package/dist/src/lib/data-input.js +50 -0
- package/dist/src/lib/data-input.js.map +1 -0
- package/dist/src/lib/db-codegen/dbTemplates.js +1 -1
- package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTypeIR.d.ts +1 -1
- package/dist/src/lib/env-resolver-core.d.ts +187 -0
- package/dist/src/lib/env-resolver-core.js +325 -0
- package/dist/src/lib/env-resolver-core.js.map +1 -0
- package/dist/src/lib/env-resolver.d.ts +28 -6
- package/dist/src/lib/env-resolver.js +44 -32
- package/dist/src/lib/env-resolver.js.map +1 -1
- package/dist/src/lib/generated-allowlist.js +12 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/lib/generated-config-surfaces.d.ts +682 -0
- package/dist/src/lib/generated-config-surfaces.js +4058 -0
- package/dist/src/lib/generated-config-surfaces.js.map +1 -0
- package/dist/src/lib/generated-template-lint.d.ts +212 -0
- package/dist/src/lib/generated-template-lint.js +624 -0
- package/dist/src/lib/generated-template-lint.js.map +1 -0
- package/dist/src/lib/init-adopt.d.ts +16 -0
- package/dist/src/lib/init-adopt.js +34 -0
- package/dist/src/lib/init-adopt.js.map +1 -0
- package/dist/src/lib/init-assets.d.ts +39 -0
- package/dist/src/lib/init-assets.js +97 -0
- package/dist/src/lib/init-assets.js.map +1 -0
- package/dist/src/lib/init-client-platforms.d.ts +14 -0
- package/dist/src/lib/init-client-platforms.js +71 -0
- package/dist/src/lib/init-client-platforms.js.map +1 -0
- package/dist/src/lib/init-config.d.ts +44 -5
- package/dist/src/lib/init-config.js +80 -7
- package/dist/src/lib/init-config.js.map +1 -1
- package/dist/src/lib/init-email-redirect-uris.d.ts +37 -0
- package/dist/src/lib/init-email-redirect-uris.js +46 -0
- package/dist/src/lib/init-email-redirect-uris.js.map +1 -0
- package/dist/src/lib/init-ios-links.d.ts +90 -0
- package/dist/src/lib/init-ios-links.js +218 -0
- package/dist/src/lib/init-ios-links.js.map +1 -0
- package/dist/src/lib/init-plan.d.ts +80 -0
- package/dist/src/lib/init-plan.js +95 -0
- package/dist/src/lib/init-plan.js.map +1 -0
- package/dist/src/lib/init-production-env.d.ts +48 -0
- package/dist/src/lib/init-production-env.js +59 -0
- package/dist/src/lib/init-production-env.js.map +1 -0
- package/dist/src/lib/init-schema.d.ts +74 -0
- package/dist/src/lib/init-schema.js +358 -0
- package/dist/src/lib/init-schema.js.map +1 -0
- package/dist/src/lib/init-xcode.d.ts +33 -0
- package/dist/src/lib/init-xcode.js +114 -0
- package/dist/src/lib/init-xcode.js.map +1 -0
- package/dist/src/lib/integration-request-config.d.ts +30 -0
- package/dist/src/lib/integration-request-config.js +145 -0
- package/dist/src/lib/integration-request-config.js.map +1 -0
- package/dist/src/lib/integration-selector.d.ts +42 -0
- package/dist/src/lib/integration-selector.js +46 -0
- package/dist/src/lib/integration-selector.js.map +1 -0
- package/dist/src/lib/local-state.d.ts +55 -0
- package/dist/src/lib/local-state.js +167 -0
- package/dist/src/lib/local-state.js.map +1 -0
- package/dist/src/lib/local-test-cases.d.ts +63 -0
- package/dist/src/lib/local-test-cases.js +135 -0
- package/dist/src/lib/local-test-cases.js.map +1 -0
- package/dist/src/lib/log-inspection.d.ts +568 -0
- package/dist/src/lib/log-inspection.js +639 -0
- package/dist/src/lib/log-inspection.js.map +1 -0
- package/dist/src/lib/migration-nag.d.ts +2 -2
- package/dist/src/lib/migration-nag.js +3 -3
- package/dist/src/lib/object-status-filter.d.ts +22 -0
- package/dist/src/lib/object-status-filter.js +45 -0
- package/dist/src/lib/object-status-filter.js.map +1 -0
- package/dist/src/lib/output.d.ts +24 -1
- package/dist/src/lib/output.js +42 -1
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/package-manager.d.ts +140 -0
- package/dist/src/lib/package-manager.js +305 -0
- package/dist/src/lib/package-manager.js.map +1 -0
- package/dist/src/lib/paginate.d.ts +50 -0
- package/dist/src/lib/paginate.js +53 -0
- package/dist/src/lib/paginate.js.map +1 -1
- package/dist/src/lib/platform-owned.d.ts +29 -3
- package/dist/src/lib/platform-owned.js +42 -3
- package/dist/src/lib/platform-owned.js.map +1 -1
- package/dist/src/lib/project-config.d.ts +8 -0
- package/dist/src/lib/project-config.js +30 -5
- package/dist/src/lib/project-config.js.map +1 -1
- package/dist/src/lib/query-operators.d.ts +1 -1
- package/dist/src/lib/query-operators.js +1 -1
- package/dist/src/lib/record-filter.d.ts +18 -0
- package/dist/src/lib/record-filter.js +55 -0
- package/dist/src/lib/record-filter.js.map +1 -0
- package/dist/src/lib/resolve-init-dev-port.d.ts +56 -0
- package/dist/src/lib/resolve-init-dev-port.js +55 -0
- package/dist/src/lib/resolve-init-dev-port.js.map +1 -0
- package/dist/src/lib/resolve-init-server.d.ts +64 -0
- package/dist/src/lib/resolve-init-server.js +77 -0
- package/dist/src/lib/resolve-init-server.js.map +1 -0
- package/dist/src/lib/resolve-platform.d.ts +43 -14
- package/dist/src/lib/resolve-platform.js +74 -12
- package/dist/src/lib/resolve-platform.js.map +1 -1
- package/dist/src/lib/run-status.d.ts +19 -0
- package/dist/src/lib/run-status.generated.d.ts +39 -0
- package/dist/src/lib/run-status.generated.js +66 -0
- package/dist/src/lib/run-status.generated.js.map +1 -0
- package/dist/src/lib/run-status.js +19 -0
- package/dist/src/lib/run-status.js.map +1 -0
- package/dist/src/lib/server-text-normalization.d.ts +51 -0
- package/dist/src/lib/server-text-normalization.js +90 -0
- package/dist/src/lib/server-text-normalization.js.map +1 -0
- package/dist/src/lib/server-url.d.ts +22 -0
- package/dist/src/lib/server-url.js +33 -0
- package/dist/src/lib/server-url.js.map +1 -0
- package/dist/src/lib/signing-secret-status.d.ts +81 -0
- package/dist/src/lib/signing-secret-status.js +116 -0
- package/dist/src/lib/signing-secret-status.js.map +1 -0
- package/dist/src/lib/skill-installer.js +6 -3
- package/dist/src/lib/skill-installer.js.map +1 -1
- package/dist/src/lib/snapshots.d.ts +2 -2
- package/dist/src/lib/snapshots.js +2 -2
- package/dist/src/lib/swift-codegen/dbGenerator.d.ts +15 -2
- package/dist/src/lib/swift-codegen/dbGenerator.js +250 -18
- package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
- package/dist/src/lib/swift-codegen/generator.d.ts +11 -2
- package/dist/src/lib/swift-codegen/generator.js +181 -7
- package/dist/src/lib/swift-codegen/generator.js.map +1 -1
- package/dist/src/lib/swift-codegen/siblingSymbols.d.ts +7 -3
- package/dist/src/lib/swift-codegen/siblingSymbols.js +4 -1
- package/dist/src/lib/swift-codegen/siblingSymbols.js.map +1 -1
- package/dist/src/lib/sync-dir-selector.d.ts +21 -0
- package/dist/src/lib/sync-dir-selector.js +30 -0
- package/dist/src/lib/sync-dir-selector.js.map +1 -0
- package/dist/src/lib/sync-paths.d.ts +39 -0
- package/dist/src/lib/sync-paths.js +70 -2
- package/dist/src/lib/sync-paths.js.map +1 -1
- package/dist/src/lib/sync-resource-types.d.ts +138 -31
- package/dist/src/lib/sync-resource-types.js +241 -47
- package/dist/src/lib/sync-resource-types.js.map +1 -1
- package/dist/src/lib/sync-selectors.d.ts +95 -0
- package/dist/src/lib/sync-selectors.js +228 -0
- package/dist/src/lib/sync-selectors.js.map +1 -0
- package/dist/src/lib/template.d.ts +62 -4
- package/dist/src/lib/template.js +147 -39
- package/dist/src/lib/template.js.map +1 -1
- package/dist/src/lib/test-case-file-names.d.ts +40 -0
- package/dist/src/lib/test-case-file-names.js +91 -0
- package/dist/src/lib/test-case-file-names.js.map +1 -0
- package/dist/src/lib/test-case-keys.d.ts +29 -0
- package/dist/src/lib/test-case-keys.js +55 -0
- package/dist/src/lib/test-case-keys.js.map +1 -0
- package/dist/src/lib/test-case-variables.d.ts +15 -0
- package/dist/src/lib/test-case-variables.js +29 -0
- package/dist/src/lib/test-case-variables.js.map +1 -0
- package/dist/src/lib/toml-database-config.d.ts +4 -4
- package/dist/src/lib/toml-database-config.js +27 -34
- package/dist/src/lib/toml-database-config.js.map +1 -1
- package/dist/src/lib/toml-metadata-config.d.ts +9 -5
- package/dist/src/lib/toml-metadata-config.js +40 -45
- package/dist/src/lib/toml-metadata-config.js.map +1 -1
- package/dist/src/lib/toml-native-form.d.ts +1 -1
- package/dist/src/lib/toml-native-form.js +1 -1
- package/dist/src/lib/toml-params-validator.d.ts +1 -1
- package/dist/src/lib/toml-params-validator.js +1 -1
- package/dist/src/lib/toml-scalar-edit.d.ts +43 -0
- package/dist/src/lib/toml-scalar-edit.js +283 -0
- package/dist/src/lib/toml-scalar-edit.js.map +1 -0
- package/dist/src/lib/user-selector.d.ts +24 -0
- package/dist/src/lib/user-selector.js +33 -0
- package/dist/src/lib/user-selector.js.map +1 -0
- package/dist/src/lib/version-check.d.ts +25 -0
- package/dist/src/lib/version-check.js +77 -8
- package/dist/src/lib/version-check.js.map +1 -1
- package/dist/src/lib/watch.d.ts +121 -0
- package/dist/src/lib/watch.js +169 -0
- package/dist/src/lib/watch.js.map +1 -0
- package/dist/src/lib/web-url.d.ts +40 -0
- package/dist/src/lib/web-url.js +76 -0
- package/dist/src/lib/web-url.js.map +1 -0
- package/dist/src/lib/workflow-apply.d.ts +69 -45
- package/dist/src/lib/workflow-apply.js +99 -72
- package/dist/src/lib/workflow-apply.js.map +1 -1
- package/dist/src/lib/workflow-codegen/generator.js +4 -4
- package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
- package/dist/src/lib/workflow-codegen/invokerIR.d.ts +11 -0
- package/dist/src/lib/workflow-codegen/invokerIR.js +5 -0
- package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -1
- package/dist/src/lib/workflow-config-apply.d.ts +70 -0
- package/dist/src/lib/workflow-config-apply.js +137 -0
- package/dist/src/lib/workflow-config-apply.js.map +1 -0
- package/dist/src/lib/workflow-config-sidecar.d.ts +63 -0
- package/dist/src/lib/workflow-config-sidecar.js +96 -0
- package/dist/src/lib/workflow-config-sidecar.js.map +1 -0
- package/dist/src/lib/workflow-defaults.d.ts +29 -0
- package/dist/src/lib/workflow-defaults.js +41 -0
- package/dist/src/lib/workflow-defaults.js.map +1 -0
- package/dist/src/lib/workflow-include-preserve.d.ts +76 -0
- package/dist/src/lib/workflow-include-preserve.js +286 -0
- package/dist/src/lib/workflow-include-preserve.js.map +1 -0
- package/dist/src/lib/workflow-payload.d.ts +31 -20
- package/dist/src/lib/workflow-payload.js +100 -110
- package/dist/src/lib/workflow-payload.js.map +1 -1
- package/dist/src/lib/workflow-toml-validator.d.ts +37 -16
- package/dist/src/lib/workflow-toml-validator.js +141 -16
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/dist/src/types/index.d.ts +78 -22
- package/dist/src/validators.d.ts +7 -7
- package/dist/src/validators.js +6 -6
- package/package.json +11 -6
- package/dist/src/commands/settings.d.ts +0 -15
- package/dist/src/commands/settings.js +0 -102
- package/dist/src/commands/settings.js.map +0 -1
|
@@ -1,20 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Option } from "commander";
|
|
2
|
+
import { readFileSync, writeFileSync, readdirSync, } from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
|
-
import {
|
|
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 {
|
|
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,
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* - "failed"
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
48
|
-
*
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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("
|
|
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
|
|
195
|
-
$ primitive
|
|
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:
|
|
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,109 +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
|
-
// Reject misnested headers (#685, e.g. [steps.<id>.request]) and the
|
|
262
|
-
// #1447 runAs="system" + accessRule dead-config combo before we send
|
|
263
|
-
// anything to the server. These parse to TOML
|
|
264
|
-
// sub-tables that the runtime silently ignores. We validate
|
|
265
|
-
// outside the parse try-block so the diagnostic isn't masked by
|
|
266
|
-
// the generic "Failed to read TOML" handler.
|
|
267
|
-
const tomlErrors = validateWorkflowToml(tomlData);
|
|
268
|
-
if (tomlErrors.length > 0) {
|
|
269
|
-
error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
|
|
270
|
-
process.exit(1);
|
|
271
|
-
}
|
|
272
|
-
const workflow = tomlData.workflow || tomlData;
|
|
273
|
-
// #1614 — parse the top-level `[expr.cel]` named-guard definitions
|
|
274
|
-
// (structural, like steps) and validate them client-side before push.
|
|
275
|
-
const fromFileSteps = tomlData.steps || [];
|
|
276
|
-
const fromFileExpressions = parseWorkflowExpressionsToml(tomlData);
|
|
277
|
-
// #1614 — validate the RAW `[expr.cel]` table shape first, so a
|
|
278
|
-
// non-string body (e.g. `ready = true`) fails fast instead of being
|
|
279
|
-
// silently dropped by parseWorkflowExpressionsToml.
|
|
280
|
-
const fromFileExprErrors = [
|
|
281
|
-
...validateWorkflowExpressionsRawToml(tomlData),
|
|
282
|
-
...validateWorkflowExpressionsToml(fromFileSteps, fromFileExpressions),
|
|
283
|
-
];
|
|
284
|
-
if (fromFileExprErrors.length > 0) {
|
|
285
|
-
error(`${options.fromFile}: workflow expressions are invalid:\n - ${fromFileExprErrors.join("\n - ")}`);
|
|
286
|
-
process.exit(1);
|
|
287
|
-
}
|
|
288
|
-
// #1177: build the `[workflow]`-derived fields through the shared
|
|
289
|
-
// builder so this create path can't drift from `sync push` again
|
|
290
|
-
// (`status`, `capabilities`, `perAppMax*`, `queueTtlSeconds`, `runAs`,
|
|
291
|
-
// `accessRule`, `syncCallable` all flow from one field list). Structural
|
|
292
|
-
// fields (`workflowKey`, `steps`, `expressions`) are attached here.
|
|
293
|
-
payload = {
|
|
294
|
-
workflowKey: workflow.key || workflow.workflowKey,
|
|
295
|
-
steps: fromFileSteps,
|
|
296
|
-
...buildWorkflowPayloadFromToml(workflow, { mode: "create" }),
|
|
297
|
-
// #1614 — named `expr.*` definitions (absent → undefined).
|
|
298
|
-
expressions: fromFileExpressions ?? undefined,
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
else {
|
|
302
|
-
if (!options.key || !options.name) {
|
|
303
|
-
error("Required: --key, --name (or use --from-file)");
|
|
304
|
-
process.exit(1);
|
|
305
|
-
}
|
|
306
|
-
payload = {
|
|
307
|
-
workflowKey: options.key,
|
|
308
|
-
name: options.name,
|
|
309
|
-
description: options.description,
|
|
310
|
-
steps: [],
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
if (options.requiresClientApply !== undefined) {
|
|
314
|
-
payload.requiresClientApply = options.requiresClientApply === "true";
|
|
315
|
-
}
|
|
316
|
-
if (options.syncCallable !== undefined) {
|
|
317
|
-
payload.syncCallable = options.syncCallable === "true";
|
|
318
|
-
}
|
|
319
|
-
try {
|
|
320
|
-
const result = await client.createWorkflow(resolvedAppId, payload);
|
|
321
|
-
if (options.json) {
|
|
322
|
-
json(result);
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
success(`Workflow created: ${result.workflow?.name || options.name}`);
|
|
326
|
-
keyValue("Workflow ID", result.workflow?.workflowId);
|
|
327
|
-
keyValue("Key", result.workflow?.workflowKey);
|
|
328
|
-
}
|
|
329
|
-
catch (err) {
|
|
330
|
-
// #1173: render the server's structured `details[]` (e.g. the specific
|
|
331
|
-
// identity-validation reason) instead of only the generic message.
|
|
332
|
-
printApiError(err);
|
|
333
|
-
process.exit(1);
|
|
334
|
-
}
|
|
335
|
-
});
|
|
336
|
-
// Get workflow
|
|
337
336
|
workflows
|
|
338
337
|
.command("get")
|
|
339
338
|
.description("Get workflow details")
|
|
@@ -354,6 +353,9 @@ Examples:
|
|
|
354
353
|
printResult("Key", wf.workflowKey);
|
|
355
354
|
printResult("Name", wf.name);
|
|
356
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.
|
|
357
359
|
printResult("Status", formatStatus(wf.status));
|
|
358
360
|
printResult("Active Config", wf.activeConfigId || "-");
|
|
359
361
|
printResult("Latest Revision", wf.latestRevision || "-");
|
|
@@ -365,9 +367,12 @@ Examples:
|
|
|
365
367
|
printResult("Capabilities", Array.isArray(wf.capabilities) && wf.capabilities.length > 0
|
|
366
368
|
? wf.capabilities.join(", ")
|
|
367
369
|
: "-");
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
+
}));
|
|
371
376
|
divider();
|
|
372
377
|
info("Queue Settings:");
|
|
373
378
|
printResult(" Per User Max Running", wf.perUserMaxRunning);
|
|
@@ -414,255 +419,6 @@ Examples:
|
|
|
414
419
|
process.exit(1);
|
|
415
420
|
}
|
|
416
421
|
});
|
|
417
|
-
// Update workflow
|
|
418
|
-
workflows
|
|
419
|
-
.command("update")
|
|
420
|
-
.description("Update workflow metadata, or push a new body with --from-file")
|
|
421
|
-
.argument("<workflow-id>", "Workflow ID or key")
|
|
422
|
-
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
423
|
-
.option("--name <name>", "Display name")
|
|
424
|
-
.option("--description <desc>", "Description")
|
|
425
|
-
.option("--status <status>", "Status: draft, active, archived")
|
|
426
|
-
.option("--per-user-max-running <n>", "Max running per user")
|
|
427
|
-
.option("--per-user-max-queued <n>", "Max queued per user")
|
|
428
|
-
.option("--dequeue-order <order>", "Dequeue order: fifo, lifo")
|
|
429
|
-
.option("--requires-client-apply <bool>", "Require client-side apply: true or false")
|
|
430
|
-
.option("--sync-callable <bool>", "Allow client.workflows.runSync(): true or false")
|
|
431
|
-
.option("--access-rule <expr>", "CEL expression for workflow access (empty string to clear)")
|
|
432
|
-
.option("--capabilities <list>", "Comma-separated capability grants (e.g. membership,secrets); empty string revokes all")
|
|
433
|
-
.option("--from-file <path>", "Push a revised body (metadata + steps) from a TOML file, keeping the same key/id. " +
|
|
434
|
-
"Overwrites the active config in place — live immediately (no separate publish). " +
|
|
435
|
-
"Explicit metadata flags above override values in the TOML.")
|
|
436
|
-
.option("--json", "Output as JSON")
|
|
437
|
-
.action(async (workflowId, options) => {
|
|
438
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
439
|
-
// --from-file: push a full body (metadata + steps) to the existing
|
|
440
|
-
// workflow, keeping its key/id. Symmetric with `create --from-file` and
|
|
441
|
-
// mirrors the `sync push` update-existing path via the shared
|
|
442
|
-
// `applyWorkflowBody` helper (#1249).
|
|
443
|
-
if (options.fromFile) {
|
|
444
|
-
let tomlData;
|
|
445
|
-
try {
|
|
446
|
-
// Expand top-level `include = [...]` workflow fragments before
|
|
447
|
-
// deriving steps, matching what `sync push` does (`parseTomlFile`
|
|
448
|
-
// runs `expandWorkflowTomlData`). `update --from-file` overwrites an
|
|
449
|
-
// existing workflow's live active config, so parsing the raw TOML
|
|
450
|
-
// and reading only `tomlData.steps` would drop fragment-supplied
|
|
451
|
-
// steps — an include-only workflow would overwrite the live body
|
|
452
|
-
// with an empty step list (silent data loss, #1286). `expandWorkflow`
|
|
453
|
-
// reads + parses + splices fragments; a TOML with no `include` key is
|
|
454
|
-
// returned unchanged, so the non-fragment case is unaffected.
|
|
455
|
-
tomlData = expandWorkflow(options.fromFile);
|
|
456
|
-
}
|
|
457
|
-
catch (err) {
|
|
458
|
-
error(`Failed to read TOML file: ${err.message}`);
|
|
459
|
-
process.exit(1);
|
|
460
|
-
}
|
|
461
|
-
// Reject misnested headers (#685) before sending anything to the
|
|
462
|
-
// server. Runs on the expanded data (post fragment splice), matching
|
|
463
|
-
// `sync push`.
|
|
464
|
-
//
|
|
465
|
-
// The #1447 system + accessRule identity check is SKIPPED here and
|
|
466
|
-
// re-run below against the merged metadata (codex review, 2026-07-15):
|
|
467
|
-
// `update --from-file` applies command-line overrides (`--access-rule`)
|
|
468
|
-
// after parsing, so `--access-rule ""` must be able to repair an
|
|
469
|
-
// offending file, and an override-introduced accessRule on a system
|
|
470
|
-
// workflow must still be caught. Checking the raw file here would do
|
|
471
|
-
// neither.
|
|
472
|
-
const tomlErrors = validateWorkflowToml(tomlData, {
|
|
473
|
-
skipWorkflowIdentity: true,
|
|
474
|
-
});
|
|
475
|
-
if (tomlErrors.length > 0) {
|
|
476
|
-
error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
|
|
477
|
-
process.exit(1);
|
|
478
|
-
}
|
|
479
|
-
const tomlWorkflow = tomlData.workflow || tomlData;
|
|
480
|
-
const steps = tomlData.steps || [];
|
|
481
|
-
const client = new ApiClient();
|
|
482
|
-
// Fetch the existing workflow: we need its real workflowKey (Fork 2
|
|
483
|
-
// key-mismatch guard) and its activeConfigId (live body overwrite).
|
|
484
|
-
let existing;
|
|
485
|
-
try {
|
|
486
|
-
const got = await client.getWorkflow(resolvedAppId, workflowId);
|
|
487
|
-
existing = got?.workflow;
|
|
488
|
-
}
|
|
489
|
-
catch (err) {
|
|
490
|
-
printApiError(err);
|
|
491
|
-
process.exit(1);
|
|
492
|
-
}
|
|
493
|
-
if (!existing) {
|
|
494
|
-
error(`Workflow not found: ${workflowId}`);
|
|
495
|
-
process.exit(1);
|
|
496
|
-
}
|
|
497
|
-
// Fork 2: a TOML key that contradicts the target's key is almost
|
|
498
|
-
// certainly the wrong file — error rather than silently ignore it.
|
|
499
|
-
const mismatch = workflowKeyMismatchError(tomlWorkflow, existing.workflowKey);
|
|
500
|
-
if (mismatch) {
|
|
501
|
-
error(mismatch);
|
|
502
|
-
process.exit(1);
|
|
503
|
-
}
|
|
504
|
-
// Fork 1 (B): explicit `update` flags override TOML-derived metadata.
|
|
505
|
-
const mergedWorkflow = { ...tomlWorkflow };
|
|
506
|
-
if (options.name)
|
|
507
|
-
mergedWorkflow.name = options.name;
|
|
508
|
-
if (options.description)
|
|
509
|
-
mergedWorkflow.description = options.description;
|
|
510
|
-
if (options.status)
|
|
511
|
-
mergedWorkflow.status = options.status;
|
|
512
|
-
if (options.perUserMaxRunning) {
|
|
513
|
-
mergedWorkflow.perUserMaxRunning = parseInt(options.perUserMaxRunning);
|
|
514
|
-
}
|
|
515
|
-
if (options.perUserMaxQueued) {
|
|
516
|
-
mergedWorkflow.perUserMaxQueued = parseInt(options.perUserMaxQueued);
|
|
517
|
-
}
|
|
518
|
-
if (options.dequeueOrder)
|
|
519
|
-
mergedWorkflow.dequeueOrder = options.dequeueOrder;
|
|
520
|
-
if (options.requiresClientApply !== undefined) {
|
|
521
|
-
mergedWorkflow.requiresClientApply = options.requiresClientApply === "true";
|
|
522
|
-
}
|
|
523
|
-
if (options.syncCallable !== undefined) {
|
|
524
|
-
mergedWorkflow.syncCallable = options.syncCallable === "true";
|
|
525
|
-
}
|
|
526
|
-
if (options.accessRule !== undefined) {
|
|
527
|
-
mergedWorkflow.accessRule = options.accessRule;
|
|
528
|
-
}
|
|
529
|
-
// #1232: an explicit --capabilities flag overrides whatever the TOML
|
|
530
|
-
// declares (grant/revoke on top of a --from-file body push).
|
|
531
|
-
if (options.capabilities !== undefined) {
|
|
532
|
-
mergedWorkflow.capabilities = parseCapabilitiesFlag(options.capabilities);
|
|
533
|
-
}
|
|
534
|
-
// #1447 (maintainer decision, 2026-07-21): run the identity lint
|
|
535
|
-
// against the EFFECTIVE identity that will be PERSISTED — the merge of
|
|
536
|
-
// the stored record (`existing.runAs` / `existing.accessRule`) with the
|
|
537
|
-
// incoming file/flags — not the file alone. `applyWorkflowBody` omits
|
|
538
|
-
// undefined fields, so the server retains the other side of the pair:
|
|
539
|
-
// an existing `runAs="system"` workflow plus a file adding only an
|
|
540
|
-
// `accessRule` (or a file flipping a workflow with a stored rule to
|
|
541
|
-
// `runAs="system"`) passes a file-only lint and is then rejected by the
|
|
542
|
-
// server's PATCH. `existing` is already fetched above, so merging costs
|
|
543
|
-
// zero extra round-trips. A field present in `mergedWorkflow` (including
|
|
544
|
-
// an empty-string clear, e.g. `--access-rule ""`) overrides the stored
|
|
545
|
-
// value; an absent field falls back to what the server keeps.
|
|
546
|
-
const mergedIdentityError = validateWorkflowIdentity(effectiveWorkflowIdentity(existing, mergedWorkflow));
|
|
547
|
-
if (mergedIdentityError) {
|
|
548
|
-
error(formatWorkflowTomlErrors(options.fromFile, [mergedIdentityError]));
|
|
549
|
-
process.exit(1);
|
|
550
|
-
}
|
|
551
|
-
try {
|
|
552
|
-
const { updateSlotLabel, fullWorkflow } = await applyWorkflowBody(client, resolvedAppId, workflowId, mergedWorkflow, steps, { activeConfigId: existing.activeConfigId });
|
|
553
|
-
if (options.json) {
|
|
554
|
-
json(fullWorkflow);
|
|
555
|
-
return;
|
|
556
|
-
}
|
|
557
|
-
success(`Workflow updated from ${options.fromFile} (${updateSlotLabel}).`);
|
|
558
|
-
keyValue("Workflow ID", workflowId);
|
|
559
|
-
keyValue("Key", existing.workflowKey);
|
|
560
|
-
keyValue("Steps", steps.length);
|
|
561
|
-
}
|
|
562
|
-
catch (err) {
|
|
563
|
-
// Surface the server's structured `details[]` (e.g. syncCallable /
|
|
564
|
-
// identity validation reasons) like create/sync push do.
|
|
565
|
-
printApiError(err);
|
|
566
|
-
process.exit(1);
|
|
567
|
-
}
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
const payload = {};
|
|
571
|
-
if (options.name)
|
|
572
|
-
payload.name = options.name;
|
|
573
|
-
if (options.description)
|
|
574
|
-
payload.description = options.description;
|
|
575
|
-
if (options.status)
|
|
576
|
-
payload.status = options.status;
|
|
577
|
-
if (options.perUserMaxRunning)
|
|
578
|
-
payload.perUserMaxRunning = parseInt(options.perUserMaxRunning);
|
|
579
|
-
if (options.perUserMaxQueued)
|
|
580
|
-
payload.perUserMaxQueued = parseInt(options.perUserMaxQueued);
|
|
581
|
-
if (options.dequeueOrder)
|
|
582
|
-
payload.dequeueOrder = options.dequeueOrder;
|
|
583
|
-
if (options.requiresClientApply !== undefined) {
|
|
584
|
-
payload.requiresClientApply = options.requiresClientApply === "true";
|
|
585
|
-
}
|
|
586
|
-
if (options.syncCallable !== undefined) {
|
|
587
|
-
payload.syncCallable = options.syncCallable === "true";
|
|
588
|
-
}
|
|
589
|
-
// #571: presence guard, NOT truthiness — `--access-rule ""` must reach
|
|
590
|
-
// the server (it clears the rule) while an omitted flag leaves
|
|
591
|
-
// options.accessRule undefined so the server's hasOwnProperty guard
|
|
592
|
-
// leaves the existing rule untouched.
|
|
593
|
-
if (options.accessRule !== undefined) {
|
|
594
|
-
payload.accessRule = options.accessRule;
|
|
595
|
-
}
|
|
596
|
-
// #1232: the CLI fallback the issue asked for — grant/revoke a workflow's
|
|
597
|
-
// sensitive `capabilities` (e.g. `membership`) on an existing workflow.
|
|
598
|
-
// Presence-guarded like --access-rule: an omitted flag stays undefined so
|
|
599
|
-
// the server's hasOwnProperty guard leaves the existing grant untouched;
|
|
600
|
-
// `--capabilities ""` sends [] to revoke. The server validates the names.
|
|
601
|
-
if (options.capabilities !== undefined) {
|
|
602
|
-
payload.capabilities = parseCapabilitiesFlag(options.capabilities);
|
|
603
|
-
}
|
|
604
|
-
if (Object.keys(payload).length === 0) {
|
|
605
|
-
error("No update options specified.");
|
|
606
|
-
process.exit(1);
|
|
607
|
-
}
|
|
608
|
-
const client = new ApiClient();
|
|
609
|
-
try {
|
|
610
|
-
const result = await client.updateWorkflow(resolvedAppId, workflowId, payload);
|
|
611
|
-
if (options.json) {
|
|
612
|
-
json(result);
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
success("Workflow updated.");
|
|
616
|
-
}
|
|
617
|
-
catch (err) {
|
|
618
|
-
// #1173: surface structured `details[]` on update too.
|
|
619
|
-
printApiError(err);
|
|
620
|
-
process.exit(1);
|
|
621
|
-
}
|
|
622
|
-
});
|
|
623
|
-
// Delete workflow
|
|
624
|
-
workflows
|
|
625
|
-
.command("delete")
|
|
626
|
-
.description("Delete or archive a workflow")
|
|
627
|
-
.argument("<workflow-id>", "Workflow ID or key")
|
|
628
|
-
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
629
|
-
.option("--hard", "Permanently delete instead of archive")
|
|
630
|
-
.option("-y, --yes", "Skip confirmation prompt")
|
|
631
|
-
.action(async (workflowId, options) => {
|
|
632
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
633
|
-
const client = new ApiClient();
|
|
634
|
-
try {
|
|
635
|
-
if (!options.yes) {
|
|
636
|
-
const action = options.hard ? "permanently delete" : "archive";
|
|
637
|
-
const confirmed = await confirmPrompt(`Are you sure you want to ${action} workflow ${workflowId}?`);
|
|
638
|
-
if (!confirmed) {
|
|
639
|
-
info("Cancelled.");
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
if (options.hard) {
|
|
644
|
-
await client.deleteWorkflow(resolvedAppId, workflowId);
|
|
645
|
-
success("Workflow deleted.");
|
|
646
|
-
}
|
|
647
|
-
else {
|
|
648
|
-
await client.updateWorkflow(resolvedAppId, workflowId, { status: "archived" });
|
|
649
|
-
success("Workflow archived.");
|
|
650
|
-
// #1238 — archiving keeps the workflowKey reserved, so recreating
|
|
651
|
-
// under the same key fails with "workflowKey already exists". Point
|
|
652
|
-
// at the remedy so the reserved-key behavior is discoverable.
|
|
653
|
-
info(`The workflowKey stays reserved while archived. To free it for reuse, run: primitive workflows delete ${workflowId} --hard`);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
catch (err) {
|
|
657
|
-
error(err.message);
|
|
658
|
-
process.exit(1);
|
|
659
|
-
}
|
|
660
|
-
});
|
|
661
|
-
// Expand a workflow TOML and print the result (no server contact).
|
|
662
|
-
//
|
|
663
|
-
// Authors use this to inspect what `include = [...]` fragments produce
|
|
664
|
-
// before pushing. Surfaces include-collision and unique-id failures with
|
|
665
|
-
// helpful messages without going through `sync push`.
|
|
666
422
|
workflows
|
|
667
423
|
.command("expand")
|
|
668
424
|
.description("Expand a workflow TOML's include fragments and print the result")
|
|
@@ -685,122 +441,116 @@ Examples:
|
|
|
685
441
|
process.exit(1);
|
|
686
442
|
}
|
|
687
443
|
});
|
|
688
|
-
//
|
|
689
|
-
|
|
690
|
-
//
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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.")
|
|
694
456
|
.argument("<workflow-id>", "Workflow ID or key")
|
|
695
457
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
696
|
-
.option("--from-file <path>", "Load steps from TOML file")
|
|
697
458
|
.option("--json", "Output as JSON")
|
|
698
459
|
.action(async (workflowId, options) => {
|
|
699
460
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
700
|
-
|
|
701
|
-
error("--from-file is required");
|
|
702
|
-
process.exit(1);
|
|
703
|
-
}
|
|
704
|
-
let tomlData;
|
|
461
|
+
const client = new ApiClient();
|
|
705
462
|
try {
|
|
706
|
-
const
|
|
707
|
-
|
|
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.");
|
|
708
470
|
}
|
|
709
471
|
catch (err) {
|
|
710
|
-
error(
|
|
711
|
-
process.exit(1);
|
|
712
|
-
}
|
|
713
|
-
// `draft update` is step-only — it sends `steps` (and draft schemas),
|
|
714
|
-
// never the `[workflow]` identity fields — so skip the #1447 identity
|
|
715
|
-
// lint (a false positive here; codex round-2 finding 2) but keep the
|
|
716
|
-
// structural #685 checks.
|
|
717
|
-
const tomlErrors = validateWorkflowToml(tomlData, {
|
|
718
|
-
skipWorkflowIdentity: true,
|
|
719
|
-
});
|
|
720
|
-
if (tomlErrors.length > 0) {
|
|
721
|
-
error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
|
|
472
|
+
error(err.message);
|
|
722
473
|
process.exit(1);
|
|
723
474
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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);
|
|
730
484
|
const client = new ApiClient();
|
|
731
485
|
try {
|
|
732
|
-
const result = await client.
|
|
486
|
+
const result = await client.enableWorkflow(resolvedAppId, workflowId);
|
|
733
487
|
if (options.json) {
|
|
734
488
|
json(result);
|
|
735
489
|
return;
|
|
736
490
|
}
|
|
737
|
-
success(
|
|
738
|
-
keyValue("Steps", result.draft?.steps?.length || 0);
|
|
739
|
-
// Issue #687: surface server-side deprecation hint when applicable.
|
|
740
|
-
if (result.deprecation) {
|
|
741
|
-
warn(`[deprecated] ${result.deprecation}`);
|
|
742
|
-
}
|
|
491
|
+
success(`Workflow enabled: ${result.workflowKey || workflowId}`);
|
|
743
492
|
}
|
|
744
493
|
catch (err) {
|
|
745
|
-
// Issue #987: config-model workflows reject the legacy draft slot with
|
|
746
|
-
// a 409 (the draft never executes). Surface the actionable config path
|
|
747
|
-
// cleanly instead of dumping a raw API error.
|
|
748
|
-
if (err?.statusCode === 409 && err?.code === "WORKFLOW_CONFIG_MODEL") {
|
|
749
|
-
error("This workflow has an active configuration, so the legacy draft slot is inert " +
|
|
750
|
-
"(it won't change what runs). For dev-loop edits, use 'sync push' — it writes the\n" +
|
|
751
|
-
"active config directly and activates the workflow end-to-end:\n" +
|
|
752
|
-
" primitive sync push\n" +
|
|
753
|
-
"For staged rollouts, create + activate a config, then flip the status to active\n" +
|
|
754
|
-
"(configs activate only sets the active config; it does not change the workflow status):\n" +
|
|
755
|
-
" primitive workflows configs create <workflow-id> --name <name> --from-file <path>\n" +
|
|
756
|
-
" primitive workflows configs activate <workflow-id> <config-id>\n" +
|
|
757
|
-
" primitive workflows update <workflow-id> --status active");
|
|
758
|
-
process.exit(1);
|
|
759
|
-
}
|
|
760
494
|
error(err.message);
|
|
761
495
|
process.exit(1);
|
|
762
496
|
}
|
|
763
497
|
});
|
|
764
|
-
//
|
|
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.
|
|
765
503
|
workflows
|
|
766
|
-
.command("
|
|
767
|
-
.description("
|
|
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.")
|
|
768
506
|
.argument("<workflow-id>", "Workflow ID or key")
|
|
769
507
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
508
|
+
.option("-y, --yes", "Skip confirmation prompt")
|
|
770
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
|
+
`)
|
|
771
524
|
.action(async (workflowId, options) => {
|
|
772
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
|
+
}
|
|
773
541
|
const client = new ApiClient();
|
|
774
542
|
try {
|
|
775
|
-
|
|
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);
|
|
776
546
|
if (options.json) {
|
|
777
547
|
json(result);
|
|
778
548
|
return;
|
|
779
549
|
}
|
|
780
|
-
success(
|
|
781
|
-
|
|
782
|
-
keyValue("Published At", formatDate(result.revision?.publishedAt));
|
|
783
|
-
// Issue #687: surface server-side deprecation hint when applicable.
|
|
784
|
-
if (result.deprecation) {
|
|
785
|
-
warn(`[deprecated] ${result.deprecation}`);
|
|
786
|
-
}
|
|
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`.");
|
|
787
552
|
}
|
|
788
553
|
catch (err) {
|
|
789
|
-
// Issue #987: 'publish' is inert on config-model workflows (production
|
|
790
|
-
// runs read the active config), so the server rejects with a 409.
|
|
791
|
-
// Point the user at the config primitives instead of a raw API error.
|
|
792
|
-
if (err?.statusCode === 409 && err?.code === "WORKFLOW_CONFIG_MODEL") {
|
|
793
|
-
error("This workflow has an active configuration, so 'publish' is inert " +
|
|
794
|
-
"(it bumps a legacy pointer that doesn't change what runs). For dev-loop edits, use\n" +
|
|
795
|
-
"'sync push' — it writes the active config directly and activates the workflow end-to-end:\n" +
|
|
796
|
-
" primitive sync push\n" +
|
|
797
|
-
"For staged rollouts, create + activate a config, then flip the status to active\n" +
|
|
798
|
-
"(configs activate only sets the active config; it does not change the workflow status):\n" +
|
|
799
|
-
" primitive workflows configs create <workflow-id> --name <name> --from-file <path>\n" +
|
|
800
|
-
" primitive workflows configs activate <workflow-id> <config-id>\n" +
|
|
801
|
-
" primitive workflows update <workflow-id> --status active");
|
|
802
|
-
process.exit(1);
|
|
803
|
-
}
|
|
804
554
|
error(err.message);
|
|
805
555
|
process.exit(1);
|
|
806
556
|
}
|
|
@@ -919,7 +669,12 @@ Examples:
|
|
|
919
669
|
}
|
|
920
670
|
return;
|
|
921
671
|
}
|
|
922
|
-
|
|
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") {
|
|
923
678
|
success("Preview completed.");
|
|
924
679
|
if (status.output) {
|
|
925
680
|
console.log("\nOutput:");
|
|
@@ -933,20 +688,41 @@ Examples:
|
|
|
933
688
|
});
|
|
934
689
|
}
|
|
935
690
|
}
|
|
936
|
-
else if (
|
|
691
|
+
else if (waitOutcome === "failed") {
|
|
937
692
|
error("Preview failed.");
|
|
938
693
|
if (status.error) {
|
|
939
694
|
console.log("\nError:");
|
|
940
695
|
console.log(JSON.stringify(status.error, null, 2));
|
|
941
696
|
}
|
|
942
697
|
}
|
|
943
|
-
else if (
|
|
698
|
+
else if (waitOutcome === "terminated") {
|
|
944
699
|
// Issue #941 (Codex P2): the preview was aborted (e.g. via
|
|
945
700
|
// `workflows preview ... abort`, or an external terminate). This is a
|
|
946
701
|
// real terminal state — NOT a timeout — so render it distinctly and
|
|
947
702
|
// exit 0 (the wait succeeded in observing a terminal result).
|
|
948
703
|
warn("Preview was terminated (aborted before completion).");
|
|
949
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
|
+
}
|
|
950
726
|
else {
|
|
951
727
|
// Timed out without ever reaching a terminal state (issue #941).
|
|
952
728
|
error(`Wait timed out after ${maxAttempts}s; preview still running. Check status with 'workflows runs status'.`);
|
|
@@ -1143,44 +919,209 @@ Examples:
|
|
|
1143
919
|
// Runs subcommand
|
|
1144
920
|
const runs = workflows.command("runs").description("Manage workflow runs");
|
|
1145
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
|
+
];
|
|
1146
931
|
runs
|
|
1147
932
|
.command("list")
|
|
1148
933
|
.description("List workflow runs")
|
|
1149
|
-
|
|
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)")
|
|
1150
937
|
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
1151
|
-
|
|
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")
|
|
1152
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)")
|
|
1153
951
|
.option("--json", "Output as JSON")
|
|
1154
952
|
.action(async (workflowId, options) => {
|
|
1155
953
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
1156
954
|
const client = new ApiClient();
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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();
|
|
1165
1018
|
}
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1019
|
+
catch (err) {
|
|
1020
|
+
error(err.message);
|
|
1021
|
+
process.exit(1);
|
|
1169
1022
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
]));
|
|
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);
|
|
1179
1031
|
}
|
|
1180
1032
|
catch (err) {
|
|
1181
1033
|
error(err.message);
|
|
1182
1034
|
process.exit(1);
|
|
1183
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
|
+
}
|
|
1184
1125
|
});
|
|
1185
1126
|
// Run status
|
|
1186
1127
|
runs
|
|
@@ -1195,13 +1136,39 @@ Examples:
|
|
|
1195
1136
|
const client = new ApiClient();
|
|
1196
1137
|
try {
|
|
1197
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
|
+
}
|
|
1198
1157
|
if (options.json) {
|
|
1199
|
-
|
|
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);
|
|
1200
1161
|
return;
|
|
1201
1162
|
}
|
|
1202
1163
|
const run = result.run;
|
|
1203
1164
|
keyValue("Run ID", run.runId);
|
|
1204
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);
|
|
1205
1172
|
// #1367 — `startedAt` is the request time; `executionStartedAt` is when
|
|
1206
1173
|
// Cloudflare actually started running the instance. Queue delay is the
|
|
1207
1174
|
// gap. A still-queued run shows "-" for execution start / queue delay.
|
|
@@ -1211,26 +1178,24 @@ Examples:
|
|
|
1211
1178
|
keyValue("Create call", formatDuration(run.createCallDurationMs));
|
|
1212
1179
|
keyValue("Ended", formatDate(run.endedAt));
|
|
1213
1180
|
keyValue("Preview", run.isPreview ? "yes" : "no");
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
console.log(line);
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
if (status.error) {
|
|
1229
|
-
divider();
|
|
1230
|
-
error("Error:");
|
|
1231
|
-
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);
|
|
1232
1192
|
}
|
|
1233
1193
|
}
|
|
1194
|
+
if (result.instanceStatus?.error) {
|
|
1195
|
+
divider();
|
|
1196
|
+
error("Error:");
|
|
1197
|
+
console.log(JSON.stringify(result.instanceStatus.error, null, 2));
|
|
1198
|
+
}
|
|
1234
1199
|
}
|
|
1235
1200
|
catch (err) {
|
|
1236
1201
|
error(err.message);
|
|
@@ -1251,7 +1216,9 @@ Examples:
|
|
|
1251
1216
|
try {
|
|
1252
1217
|
const { items } = await client.getWorkflowStepRuns(resolvedAppId, workflowId, runId);
|
|
1253
1218
|
if (options.json) {
|
|
1254
|
-
|
|
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));
|
|
1255
1222
|
return;
|
|
1256
1223
|
}
|
|
1257
1224
|
if (!items || items.length === 0) {
|
|
@@ -1262,7 +1229,13 @@ Examples:
|
|
|
1262
1229
|
// next starting. Only surfaced above a 1s threshold so normal
|
|
1263
1230
|
// back-to-back steps stay quiet; a large gap flags a mid-run stall.
|
|
1264
1231
|
const STEP_GAP_THRESHOLD_MS = 1000;
|
|
1265
|
-
|
|
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
|
+
}));
|
|
1266
1239
|
console.log(formatTable(rows, [
|
|
1267
1240
|
{ header: "STEP", key: "stepId" },
|
|
1268
1241
|
{ header: "KIND", key: "stepKind" },
|
|
@@ -1272,7 +1245,9 @@ Examples:
|
|
|
1272
1245
|
format: (v) => v === "completed" ? chalk.green(v) :
|
|
1273
1246
|
v === "failed" ? chalk.red(v) :
|
|
1274
1247
|
v === "skipped" ? chalk.gray(v) :
|
|
1275
|
-
v === "error_captured" ? chalk.yellow(v) :
|
|
1248
|
+
v === "error_captured" ? chalk.yellow(v) :
|
|
1249
|
+
// #2771 — the step executing right now.
|
|
1250
|
+
v === "running" ? chalk.cyan(v) : v,
|
|
1276
1251
|
},
|
|
1277
1252
|
{
|
|
1278
1253
|
header: "GAP",
|
|
@@ -1281,7 +1256,7 @@ Examples:
|
|
|
1281
1256
|
? chalk.yellow(formatDuration(v))
|
|
1282
1257
|
: chalk.dim("-"),
|
|
1283
1258
|
},
|
|
1284
|
-
{ header: "DURATION", key: "
|
|
1259
|
+
{ header: "DURATION", key: "displayDurationMs", format: formatDuration },
|
|
1285
1260
|
{
|
|
1286
1261
|
header: "TOKENS",
|
|
1287
1262
|
key: "totalTokens",
|
|
@@ -1437,10 +1412,17 @@ Examples:
|
|
|
1437
1412
|
client.getWorkflowRunStatus(resolvedAppId, workflowId, runId),
|
|
1438
1413
|
client.getWorkflowStepRuns(resolvedAppId, workflowId, runId),
|
|
1439
1414
|
]);
|
|
1440
|
-
|
|
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);
|
|
1441
1420
|
const errorInfo = {
|
|
1442
1421
|
runId,
|
|
1443
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,
|
|
1444
1426
|
errorMessage: runResult.run?.errorMessage || runResult.instanceStatus?.error?.message,
|
|
1445
1427
|
failedStep: failedStep ? {
|
|
1446
1428
|
stepId: failedStep.stepId,
|
|
@@ -1457,6 +1439,9 @@ Examples:
|
|
|
1457
1439
|
}
|
|
1458
1440
|
keyValue("Run ID", runId);
|
|
1459
1441
|
keyValue("Status", formatStatus(errorInfo.status));
|
|
1442
|
+
if (errorInfo.errorCode) {
|
|
1443
|
+
keyValue("Code", errorInfo.errorCode);
|
|
1444
|
+
}
|
|
1460
1445
|
if (errorInfo.errorMessage) {
|
|
1461
1446
|
divider();
|
|
1462
1447
|
error("Error: " + errorInfo.errorMessage);
|
|
@@ -1496,35 +1481,126 @@ Examples:
|
|
|
1496
1481
|
.description("List recent workflow run failures")
|
|
1497
1482
|
.argument("<workflow-id>", "Workflow ID or key")
|
|
1498
1483
|
.option("--app <app-id>", "App ID")
|
|
1499
|
-
|
|
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")
|
|
1500
1493
|
.option("--json", "Output as JSON")
|
|
1501
1494
|
.action(async (workflowId, options) => {
|
|
1502
1495
|
const resolvedAppId = resolveAppId(undefined, options);
|
|
1503
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;
|
|
1504
1526
|
try {
|
|
1505
|
-
const {
|
|
1527
|
+
const sweep = await collectWorkflowFailures(({ cursor, limit: pageLimit }) => client.listWorkflowRuns(resolvedAppId, workflowId, {
|
|
1506
1528
|
status: "failed",
|
|
1507
|
-
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,
|
|
1508
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;
|
|
1509
1553
|
if (options.json) {
|
|
1510
|
-
|
|
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;
|
|
1511
1565
|
return;
|
|
1512
1566
|
}
|
|
1513
|
-
if (
|
|
1514
|
-
|
|
1515
|
-
|
|
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
|
+
]));
|
|
1516
1583
|
}
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
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;
|
|
1523
1596
|
}
|
|
1524
1597
|
catch (err) {
|
|
1525
1598
|
error(err.message);
|
|
1526
1599
|
process.exit(1);
|
|
1527
1600
|
}
|
|
1601
|
+
finally {
|
|
1602
|
+
process.removeListener("SIGINT", onSigint);
|
|
1603
|
+
}
|
|
1528
1604
|
});
|
|
1529
1605
|
// ── Iterations: inspect / reset iterate-users singleton iterations (#1209) ──
|
|
1530
1606
|
const iterations = workflows
|
|
@@ -1631,80 +1707,6 @@ Examples:
|
|
|
1631
1707
|
process.exit(1);
|
|
1632
1708
|
}
|
|
1633
1709
|
});
|
|
1634
|
-
// Analytics overview
|
|
1635
|
-
const analyticsCmd = workflows.command("analytics").description("View workflow performance analytics");
|
|
1636
|
-
analyticsCmd
|
|
1637
|
-
.command("overview")
|
|
1638
|
-
.description("View workflow performance metrics")
|
|
1639
|
-
.option("--app <app-id>", "App ID")
|
|
1640
|
-
.option("--days <n>", "Time window in days", "7")
|
|
1641
|
-
.option("--json", "Output as JSON")
|
|
1642
|
-
.action(async (options) => {
|
|
1643
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
1644
|
-
const client = new ApiClient();
|
|
1645
|
-
try {
|
|
1646
|
-
const result = await client.getWorkflowAnalytics(resolvedAppId, {
|
|
1647
|
-
windowDays: parseInt(options.days),
|
|
1648
|
-
});
|
|
1649
|
-
if (options.json) {
|
|
1650
|
-
json(result);
|
|
1651
|
-
return;
|
|
1652
|
-
}
|
|
1653
|
-
info(`Workflow Analytics (${result.windowDays || options.days} days)`);
|
|
1654
|
-
console.log();
|
|
1655
|
-
keyValue("Total Runs", String(result.totals?.totalRuns ?? 0));
|
|
1656
|
-
keyValue("Success Rate", `${((result.totals?.successRate ?? 0) * 100).toFixed(1)}%`);
|
|
1657
|
-
keyValue("Avg Duration", formatDuration(result.totals?.avgDurationMs));
|
|
1658
|
-
if (result.tokenUsage) {
|
|
1659
|
-
console.log();
|
|
1660
|
-
info("Token Usage:");
|
|
1661
|
-
keyValue(" Input Tokens", String(result.tokenUsage.totalInputTokens ?? 0));
|
|
1662
|
-
keyValue(" Output Tokens", String(result.tokenUsage.totalOutputTokens ?? 0));
|
|
1663
|
-
keyValue(" Total Tokens", String(result.tokenUsage.totalTokens ?? 0));
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
catch (err) {
|
|
1667
|
-
error(err.message);
|
|
1668
|
-
process.exit(1);
|
|
1669
|
-
}
|
|
1670
|
-
});
|
|
1671
|
-
analyticsCmd
|
|
1672
|
-
.command("top")
|
|
1673
|
-
.description("View top workflows by usage")
|
|
1674
|
-
.option("--app <app-id>", "App ID")
|
|
1675
|
-
.option("--days <n>", "Time window in days", "7")
|
|
1676
|
-
.option("--limit <n>", "Number of workflows to show", "10")
|
|
1677
|
-
.option("--json", "Output as JSON")
|
|
1678
|
-
.action(async (options) => {
|
|
1679
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
1680
|
-
const client = new ApiClient();
|
|
1681
|
-
try {
|
|
1682
|
-
const result = await client.getTopWorkflows(resolvedAppId, {
|
|
1683
|
-
windowDays: parseInt(options.days),
|
|
1684
|
-
limit: parseInt(options.limit),
|
|
1685
|
-
});
|
|
1686
|
-
if (options.json) {
|
|
1687
|
-
json(result);
|
|
1688
|
-
return;
|
|
1689
|
-
}
|
|
1690
|
-
if (!result.items || result.items.length === 0) {
|
|
1691
|
-
info("No workflow activity in this period.");
|
|
1692
|
-
return;
|
|
1693
|
-
}
|
|
1694
|
-
console.log(formatTable(result.items, [
|
|
1695
|
-
{ header: "WORKFLOW", key: "workflow" },
|
|
1696
|
-
{ header: "RUNS", key: "executions" },
|
|
1697
|
-
{ header: "SUCCESS", key: "success_pct", format: (v) => `${v}%` },
|
|
1698
|
-
{ header: "P50", key: "p50", format: (v) => formatDuration(v) },
|
|
1699
|
-
{ header: "P95", key: "p95", format: (v) => formatDuration(v) },
|
|
1700
|
-
{ header: "TOKENS", key: "total_tokens", format: (v) => v ? String(Math.round(v)).replace(/\B(?=(\d{3})+(?!\d))/g, ",") : "-" },
|
|
1701
|
-
]));
|
|
1702
|
-
}
|
|
1703
|
-
catch (err) {
|
|
1704
|
-
error(err.message);
|
|
1705
|
-
process.exit(1);
|
|
1706
|
-
}
|
|
1707
|
-
});
|
|
1708
1710
|
// ============================================
|
|
1709
1711
|
// CONFIGS SUBCOMMAND
|
|
1710
1712
|
// ============================================
|
|
@@ -1715,9 +1717,10 @@ Examples:
|
|
|
1715
1717
|
Examples:
|
|
1716
1718
|
$ primitive workflows configs list <workflow-id>
|
|
1717
1719
|
$ primitive workflows configs get <workflow-id> <config-id>
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
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'.
|
|
1721
1724
|
`);
|
|
1722
1725
|
// List configs
|
|
1723
1726
|
configs
|
|
@@ -1795,228 +1798,23 @@ Examples:
|
|
|
1795
1798
|
process.exit(1);
|
|
1796
1799
|
}
|
|
1797
1800
|
});
|
|
1798
|
-
// Create config
|
|
1799
|
-
configs
|
|
1800
|
-
.command("create")
|
|
1801
|
-
.description("Create a new configuration")
|
|
1802
|
-
.argument("<workflow-id>", "Workflow ID or key")
|
|
1803
|
-
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
1804
|
-
.option("--name <name>", "Configuration name (required)")
|
|
1805
|
-
.option("--description <desc>", "Description")
|
|
1806
|
-
.option("--from-file <path>", "Load steps from TOML file")
|
|
1807
|
-
.option("--json", "Output as JSON")
|
|
1808
|
-
.action(async (workflowId, options) => {
|
|
1809
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
1810
|
-
if (!options.name) {
|
|
1811
|
-
error("--name is required");
|
|
1812
|
-
process.exit(1);
|
|
1813
|
-
}
|
|
1814
|
-
let steps = [];
|
|
1815
|
-
if (options.fromFile) {
|
|
1816
|
-
let tomlData;
|
|
1817
|
-
try {
|
|
1818
|
-
const content = readFileSync(options.fromFile, "utf-8");
|
|
1819
|
-
tomlData = parseConfigToml(content);
|
|
1820
|
-
}
|
|
1821
|
-
catch (err) {
|
|
1822
|
-
error(`Failed to read TOML file: ${err.message}`);
|
|
1823
|
-
process.exit(1);
|
|
1824
|
-
}
|
|
1825
|
-
// `configs create` is step-only — it consumes only `steps`, never the
|
|
1826
|
-
// `[workflow]` identity fields — so skip the #1447 identity lint (a
|
|
1827
|
-
// false positive here; codex round-2 finding 2) but keep the structural
|
|
1828
|
-
// #685 checks.
|
|
1829
|
-
const tomlErrors = validateWorkflowToml(tomlData, {
|
|
1830
|
-
skipWorkflowIdentity: true,
|
|
1831
|
-
});
|
|
1832
|
-
if (tomlErrors.length > 0) {
|
|
1833
|
-
error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
|
|
1834
|
-
process.exit(1);
|
|
1835
|
-
}
|
|
1836
|
-
steps = tomlData.steps || [];
|
|
1837
|
-
}
|
|
1838
|
-
const client = new ApiClient();
|
|
1839
|
-
try {
|
|
1840
|
-
const config = await client.createWorkflowConfig(resolvedAppId, workflowId, {
|
|
1841
|
-
configName: options.name,
|
|
1842
|
-
description: options.description,
|
|
1843
|
-
steps,
|
|
1844
|
-
});
|
|
1845
|
-
if (options.json) {
|
|
1846
|
-
json(config);
|
|
1847
|
-
return;
|
|
1848
|
-
}
|
|
1849
|
-
success(`Configuration created: ${config.configName}`);
|
|
1850
|
-
keyValue("Config ID", config.configId);
|
|
1851
|
-
}
|
|
1852
|
-
catch (err) {
|
|
1853
|
-
error(err.message);
|
|
1854
|
-
process.exit(1);
|
|
1855
|
-
}
|
|
1856
|
-
});
|
|
1857
|
-
// Update config
|
|
1858
|
-
configs
|
|
1859
|
-
.command("update")
|
|
1860
|
-
.description("Update a configuration")
|
|
1861
|
-
.argument("<workflow-id>", "Workflow ID or key")
|
|
1862
|
-
.argument("<config-id>", "Configuration ID")
|
|
1863
|
-
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
1864
|
-
.option("--name <name>", "Configuration name")
|
|
1865
|
-
.option("--description <desc>", "Description")
|
|
1866
|
-
.option("--from-file <path>", "Update steps from TOML file")
|
|
1867
|
-
.option("--json", "Output as JSON")
|
|
1868
|
-
.action(async (workflowId, configId, options) => {
|
|
1869
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
1870
|
-
const payload = {};
|
|
1871
|
-
if (options.name)
|
|
1872
|
-
payload.configName = options.name;
|
|
1873
|
-
if (options.description !== undefined)
|
|
1874
|
-
payload.description = options.description;
|
|
1875
|
-
if (options.fromFile) {
|
|
1876
|
-
let tomlData;
|
|
1877
|
-
try {
|
|
1878
|
-
const content = readFileSync(options.fromFile, "utf-8");
|
|
1879
|
-
tomlData = parseConfigToml(content);
|
|
1880
|
-
}
|
|
1881
|
-
catch (err) {
|
|
1882
|
-
error(`Failed to read TOML file: ${err.message}`);
|
|
1883
|
-
process.exit(1);
|
|
1884
|
-
}
|
|
1885
|
-
// `configs update` is step-only — it consumes only `steps`, never the
|
|
1886
|
-
// `[workflow]` identity fields — so skip the #1447 identity lint (a
|
|
1887
|
-
// false positive here; codex round-2 finding 2) but keep the structural
|
|
1888
|
-
// #685 checks.
|
|
1889
|
-
const tomlErrors = validateWorkflowToml(tomlData, {
|
|
1890
|
-
skipWorkflowIdentity: true,
|
|
1891
|
-
});
|
|
1892
|
-
if (tomlErrors.length > 0) {
|
|
1893
|
-
error(formatWorkflowTomlErrors(options.fromFile, tomlErrors));
|
|
1894
|
-
process.exit(1);
|
|
1895
|
-
}
|
|
1896
|
-
payload.steps = tomlData.steps || [];
|
|
1897
|
-
}
|
|
1898
|
-
if (Object.keys(payload).length === 0) {
|
|
1899
|
-
error("No update options specified. Use --name, --description, or --from-file.");
|
|
1900
|
-
process.exit(1);
|
|
1901
|
-
}
|
|
1902
|
-
const client = new ApiClient();
|
|
1903
|
-
try {
|
|
1904
|
-
const config = await client.updateWorkflowConfig(resolvedAppId, workflowId, configId, payload);
|
|
1905
|
-
if (options.json) {
|
|
1906
|
-
json(config);
|
|
1907
|
-
return;
|
|
1908
|
-
}
|
|
1909
|
-
success("Configuration updated.");
|
|
1910
|
-
keyValue("Config ID", config.configId);
|
|
1911
|
-
keyValue("Name", config.configName);
|
|
1912
|
-
}
|
|
1913
|
-
catch (err) {
|
|
1914
|
-
error(err.message);
|
|
1915
|
-
process.exit(1);
|
|
1916
|
-
}
|
|
1917
|
-
});
|
|
1918
|
-
// Activate config
|
|
1919
|
-
configs
|
|
1920
|
-
.command("activate")
|
|
1921
|
-
.description("Set a configuration as the active (default) configuration")
|
|
1922
|
-
.argument("<workflow-id>", "Workflow ID or key")
|
|
1923
|
-
.argument("<config-id>", "Configuration ID")
|
|
1924
|
-
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
1925
|
-
.option("--json", "Output as JSON")
|
|
1926
|
-
.action(async (workflowId, configId, options) => {
|
|
1927
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
1928
|
-
const client = new ApiClient();
|
|
1929
|
-
try {
|
|
1930
|
-
const result = await client.activateWorkflowConfig(resolvedAppId, workflowId, configId);
|
|
1931
|
-
if (options.json) {
|
|
1932
|
-
json(result);
|
|
1933
|
-
return;
|
|
1934
|
-
}
|
|
1935
|
-
success("Configuration activated.");
|
|
1936
|
-
keyValue("Active Config ID", result.activeConfigId || configId);
|
|
1937
|
-
}
|
|
1938
|
-
catch (err) {
|
|
1939
|
-
error(err.message);
|
|
1940
|
-
process.exit(1);
|
|
1941
|
-
}
|
|
1942
|
-
});
|
|
1943
|
-
// Duplicate config
|
|
1944
|
-
configs
|
|
1945
|
-
.command("duplicate")
|
|
1946
|
-
.description("Duplicate a configuration")
|
|
1947
|
-
.argument("<workflow-id>", "Workflow ID or key")
|
|
1948
|
-
.argument("<config-id>", "Configuration ID to duplicate")
|
|
1949
|
-
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
1950
|
-
.option("--name <name>", "Name for the new configuration")
|
|
1951
|
-
.option("--json", "Output as JSON")
|
|
1952
|
-
.action(async (workflowId, configId, options) => {
|
|
1953
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
1954
|
-
const client = new ApiClient();
|
|
1955
|
-
try {
|
|
1956
|
-
const config = await client.duplicateWorkflowConfig(resolvedAppId, workflowId, configId, {
|
|
1957
|
-
configName: options.name,
|
|
1958
|
-
});
|
|
1959
|
-
if (options.json) {
|
|
1960
|
-
json(config);
|
|
1961
|
-
return;
|
|
1962
|
-
}
|
|
1963
|
-
success(`Configuration duplicated: ${config.configName}`);
|
|
1964
|
-
keyValue("New Config ID", config.configId);
|
|
1965
|
-
}
|
|
1966
|
-
catch (err) {
|
|
1967
|
-
error(err.message);
|
|
1968
|
-
process.exit(1);
|
|
1969
|
-
}
|
|
1970
|
-
});
|
|
1971
|
-
// Archive config
|
|
1972
|
-
configs
|
|
1973
|
-
.command("archive")
|
|
1974
|
-
.description("Archive a configuration")
|
|
1975
|
-
.argument("<workflow-id>", "Workflow ID or key")
|
|
1976
|
-
.argument("<config-id>", "Configuration ID")
|
|
1977
|
-
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
1978
|
-
.option("-y, --yes", "Skip confirmation prompt")
|
|
1979
|
-
.action(async (workflowId, configId, options) => {
|
|
1980
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
1981
|
-
if (!options.yes) {
|
|
1982
|
-
let confirm;
|
|
1983
|
-
try {
|
|
1984
|
-
confirm = await confirmPrompt(`Are you sure you want to archive configuration ${configId}?`);
|
|
1985
|
-
}
|
|
1986
|
-
catch (err) {
|
|
1987
|
-
error(err.message);
|
|
1988
|
-
process.exit(1);
|
|
1989
|
-
}
|
|
1990
|
-
if (!confirm) {
|
|
1991
|
-
info("Cancelled.");
|
|
1992
|
-
return;
|
|
1993
|
-
}
|
|
1994
|
-
}
|
|
1995
|
-
const client = new ApiClient();
|
|
1996
|
-
try {
|
|
1997
|
-
await client.archiveWorkflowConfig(resolvedAppId, workflowId, configId);
|
|
1998
|
-
success("Configuration archived.");
|
|
1999
|
-
}
|
|
2000
|
-
catch (err) {
|
|
2001
|
-
error(err.message);
|
|
2002
|
-
process.exit(1);
|
|
2003
|
-
}
|
|
2004
|
-
});
|
|
2005
1801
|
// ============================================
|
|
2006
1802
|
// TESTS SUBCOMMAND
|
|
2007
1803
|
// ============================================
|
|
2008
1804
|
const tests = workflows
|
|
2009
1805
|
.command("tests")
|
|
2010
|
-
.description("
|
|
1806
|
+
.description("Run and inspect workflow test cases")
|
|
2011
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
|
+
|
|
2012
1812
|
Examples:
|
|
2013
1813
|
$ primitive workflows tests list <workflow-id>
|
|
2014
|
-
$ primitive workflows tests create <workflow-id> --name "Basic test" --vars '{"input":"hello"}'
|
|
2015
1814
|
$ primitive workflows tests run <workflow-id> <test-case-id>
|
|
2016
1815
|
$ primitive workflows tests run-all <workflow-id>
|
|
2017
1816
|
$ primitive workflows tests run-all <workflow-id> --test-cases 01ABC,01DEF,01GHI
|
|
2018
1817
|
`);
|
|
2019
|
-
// List test cases
|
|
2020
1818
|
tests
|
|
2021
1819
|
.command("list")
|
|
2022
1820
|
.description("List test cases for a workflow")
|
|
@@ -2047,73 +1845,6 @@ Examples:
|
|
|
2047
1845
|
process.exit(1);
|
|
2048
1846
|
}
|
|
2049
1847
|
});
|
|
2050
|
-
// Create test case
|
|
2051
|
-
tests
|
|
2052
|
-
.command("create")
|
|
2053
|
-
.description("Create a test case for a workflow")
|
|
2054
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
2055
|
-
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
2056
|
-
.option("--name <name>", "Test case name (required)")
|
|
2057
|
-
.option("--vars <json>", "Input variables as JSON (required)")
|
|
2058
|
-
.option("--pattern <regex>", "Expected output pattern (regex)")
|
|
2059
|
-
.option("--contains <json>", "Expected strings to contain (JSON array)")
|
|
2060
|
-
.option("--json-subset <json>", "Expected JSON subset to match")
|
|
2061
|
-
.option("--json", "Output as JSON")
|
|
2062
|
-
.action(async (workflowId, options) => {
|
|
2063
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
2064
|
-
if (!options.name || !options.vars) {
|
|
2065
|
-
error("Required: --name and --vars");
|
|
2066
|
-
process.exit(1);
|
|
2067
|
-
}
|
|
2068
|
-
let inputVariables;
|
|
2069
|
-
try {
|
|
2070
|
-
inputVariables = JSON.parse(options.vars);
|
|
2071
|
-
}
|
|
2072
|
-
catch {
|
|
2073
|
-
error("Invalid JSON in --vars");
|
|
2074
|
-
process.exit(1);
|
|
2075
|
-
}
|
|
2076
|
-
let expectedOutputContains;
|
|
2077
|
-
if (options.contains) {
|
|
2078
|
-
try {
|
|
2079
|
-
expectedOutputContains = JSON.parse(options.contains);
|
|
2080
|
-
}
|
|
2081
|
-
catch {
|
|
2082
|
-
error("Invalid JSON in --contains");
|
|
2083
|
-
process.exit(1);
|
|
2084
|
-
}
|
|
2085
|
-
}
|
|
2086
|
-
let expectedJsonSubset;
|
|
2087
|
-
if (options.jsonSubset) {
|
|
2088
|
-
try {
|
|
2089
|
-
expectedJsonSubset = JSON.parse(options.jsonSubset);
|
|
2090
|
-
}
|
|
2091
|
-
catch {
|
|
2092
|
-
error("Invalid JSON in --json-subset");
|
|
2093
|
-
process.exit(1);
|
|
2094
|
-
}
|
|
2095
|
-
}
|
|
2096
|
-
const client = new ApiClient();
|
|
2097
|
-
try {
|
|
2098
|
-
const result = await client.createTestCase(resolvedAppId, "workflow", workflowId, {
|
|
2099
|
-
name: options.name,
|
|
2100
|
-
inputVariables,
|
|
2101
|
-
expectedOutputPattern: options.pattern,
|
|
2102
|
-
expectedOutputContains,
|
|
2103
|
-
expectedJsonSubset,
|
|
2104
|
-
});
|
|
2105
|
-
if (options.json) {
|
|
2106
|
-
json(result);
|
|
2107
|
-
return;
|
|
2108
|
-
}
|
|
2109
|
-
success(`Test case created: ${result.name}`);
|
|
2110
|
-
keyValue("Test Case ID", result.testCaseId);
|
|
2111
|
-
}
|
|
2112
|
-
catch (err) {
|
|
2113
|
-
error(err.message);
|
|
2114
|
-
process.exit(1);
|
|
2115
|
-
}
|
|
2116
|
-
});
|
|
2117
1848
|
// Get test case
|
|
2118
1849
|
tests
|
|
2119
1850
|
.command("get")
|
|
@@ -2181,118 +1912,6 @@ Examples:
|
|
|
2181
1912
|
process.exit(1);
|
|
2182
1913
|
}
|
|
2183
1914
|
});
|
|
2184
|
-
// Update test case
|
|
2185
|
-
tests
|
|
2186
|
-
.command("update")
|
|
2187
|
-
.description("Update a test case")
|
|
2188
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
2189
|
-
.argument("<test-case-id>", "Test Case ID")
|
|
2190
|
-
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
2191
|
-
.option("--name <name>", "Test case name")
|
|
2192
|
-
.option("--vars <json>", "Input variables as JSON")
|
|
2193
|
-
.option("--pattern <regex>", "Expected output pattern (regex)")
|
|
2194
|
-
.option("--contains <json>", "Expected strings to contain (JSON array)")
|
|
2195
|
-
.option("--json-subset <json>", "Expected JSON subset to match")
|
|
2196
|
-
.option("--clear-pattern", "Clear expected pattern")
|
|
2197
|
-
.option("--clear-contains", "Clear expected contains")
|
|
2198
|
-
.option("--clear-json-subset", "Clear expected JSON subset")
|
|
2199
|
-
.option("--json", "Output as JSON")
|
|
2200
|
-
.action(async (workflowId, testCaseId, options) => {
|
|
2201
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
2202
|
-
const payload = {};
|
|
2203
|
-
if (options.name)
|
|
2204
|
-
payload.name = options.name;
|
|
2205
|
-
if (options.vars) {
|
|
2206
|
-
try {
|
|
2207
|
-
payload.inputVariables = JSON.parse(options.vars);
|
|
2208
|
-
}
|
|
2209
|
-
catch {
|
|
2210
|
-
error("Invalid JSON in --vars");
|
|
2211
|
-
process.exit(1);
|
|
2212
|
-
}
|
|
2213
|
-
}
|
|
2214
|
-
if (options.clearPattern) {
|
|
2215
|
-
payload.expectedOutputPattern = null;
|
|
2216
|
-
}
|
|
2217
|
-
else if (options.pattern) {
|
|
2218
|
-
payload.expectedOutputPattern = options.pattern;
|
|
2219
|
-
}
|
|
2220
|
-
if (options.clearContains) {
|
|
2221
|
-
payload.expectedOutputContains = null;
|
|
2222
|
-
}
|
|
2223
|
-
else if (options.contains) {
|
|
2224
|
-
try {
|
|
2225
|
-
payload.expectedOutputContains = JSON.parse(options.contains);
|
|
2226
|
-
}
|
|
2227
|
-
catch {
|
|
2228
|
-
error("Invalid JSON in --contains");
|
|
2229
|
-
process.exit(1);
|
|
2230
|
-
}
|
|
2231
|
-
}
|
|
2232
|
-
if (options.clearJsonSubset) {
|
|
2233
|
-
payload.expectedJsonSubset = null;
|
|
2234
|
-
}
|
|
2235
|
-
else if (options.jsonSubset) {
|
|
2236
|
-
try {
|
|
2237
|
-
payload.expectedJsonSubset = JSON.parse(options.jsonSubset);
|
|
2238
|
-
}
|
|
2239
|
-
catch {
|
|
2240
|
-
error("Invalid JSON in --json-subset");
|
|
2241
|
-
process.exit(1);
|
|
2242
|
-
}
|
|
2243
|
-
}
|
|
2244
|
-
if (Object.keys(payload).length === 0) {
|
|
2245
|
-
error("No update options specified.");
|
|
2246
|
-
process.exit(1);
|
|
2247
|
-
}
|
|
2248
|
-
const client = new ApiClient();
|
|
2249
|
-
try {
|
|
2250
|
-
const result = await client.updateTestCase(resolvedAppId, "workflow", workflowId, testCaseId, payload);
|
|
2251
|
-
if (options.json) {
|
|
2252
|
-
json(result);
|
|
2253
|
-
return;
|
|
2254
|
-
}
|
|
2255
|
-
success("Test case updated.");
|
|
2256
|
-
}
|
|
2257
|
-
catch (err) {
|
|
2258
|
-
error(err.message);
|
|
2259
|
-
process.exit(1);
|
|
2260
|
-
}
|
|
2261
|
-
});
|
|
2262
|
-
// Delete test case
|
|
2263
|
-
tests
|
|
2264
|
-
.command("delete")
|
|
2265
|
-
.description("Delete a test case")
|
|
2266
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
2267
|
-
.argument("<test-case-id>", "Test Case ID")
|
|
2268
|
-
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
2269
|
-
.option("-y, --yes", "Skip confirmation prompt")
|
|
2270
|
-
.action(async (workflowId, testCaseId, options) => {
|
|
2271
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
2272
|
-
if (!options.yes) {
|
|
2273
|
-
let confirm;
|
|
2274
|
-
try {
|
|
2275
|
-
confirm = await confirmPrompt(`Are you sure you want to delete test case ${testCaseId}?`);
|
|
2276
|
-
}
|
|
2277
|
-
catch (err) {
|
|
2278
|
-
error(err.message);
|
|
2279
|
-
process.exit(1);
|
|
2280
|
-
}
|
|
2281
|
-
if (!confirm) {
|
|
2282
|
-
info("Cancelled.");
|
|
2283
|
-
return;
|
|
2284
|
-
}
|
|
2285
|
-
}
|
|
2286
|
-
const client = new ApiClient();
|
|
2287
|
-
try {
|
|
2288
|
-
await client.deleteTestCase(resolvedAppId, "workflow", workflowId, testCaseId);
|
|
2289
|
-
success("Test case deleted.");
|
|
2290
|
-
}
|
|
2291
|
-
catch (err) {
|
|
2292
|
-
error(err.message);
|
|
2293
|
-
process.exit(1);
|
|
2294
|
-
}
|
|
2295
|
-
});
|
|
2296
1915
|
// Run a single test case
|
|
2297
1916
|
tests
|
|
2298
1917
|
.command("run")
|
|
@@ -2547,12 +2166,20 @@ Examples:
|
|
|
2547
2166
|
let result = await fetchStatus();
|
|
2548
2167
|
if (options.wait && result.status === "running") {
|
|
2549
2168
|
info("Waiting for batch completion...");
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
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",
|
|
2553
2180
|
// Progress goes to stderr so it can't corrupt JSON on stdout under --json.
|
|
2554
|
-
progress(` Completed: ${
|
|
2555
|
-
}
|
|
2181
|
+
onValue: (r) => progress(` Completed: ${r.completed}/${r.results?.length || 0} `),
|
|
2182
|
+
});
|
|
2556
2183
|
progressEnd();
|
|
2557
2184
|
}
|
|
2558
2185
|
if (options.json) {
|
|
@@ -2644,13 +2271,15 @@ Examples:
|
|
|
2644
2271
|
// ============================================
|
|
2645
2272
|
const attachments = tests
|
|
2646
2273
|
.command("attachments")
|
|
2647
|
-
.description("
|
|
2274
|
+
.description("Inspect test case file attachments")
|
|
2648
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
|
+
|
|
2649
2280
|
Examples:
|
|
2650
2281
|
$ primitive workflows tests attachments list <workflow-id> <test-case-id>
|
|
2651
|
-
$ primitive workflows tests attachments upload <workflow-id> <test-case-id> ./document.pdf
|
|
2652
2282
|
$ primitive workflows tests attachments download <workflow-id> <test-case-id> document.pdf
|
|
2653
|
-
$ primitive workflows tests attachments delete <workflow-id> <test-case-id> document.pdf
|
|
2654
2283
|
`);
|
|
2655
2284
|
// List attachments
|
|
2656
2285
|
attachments
|
|
@@ -2688,49 +2317,6 @@ Examples:
|
|
|
2688
2317
|
process.exit(1);
|
|
2689
2318
|
}
|
|
2690
2319
|
});
|
|
2691
|
-
// Upload attachment
|
|
2692
|
-
attachments
|
|
2693
|
-
.command("upload")
|
|
2694
|
-
.description("Upload a file attachment to a test case")
|
|
2695
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
2696
|
-
.argument("<test-case-id>", "Test Case ID")
|
|
2697
|
-
.argument("<file-path>", "Path to the file to upload")
|
|
2698
|
-
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
2699
|
-
.option("--name <name>", "Override the filename")
|
|
2700
|
-
.option("--json", "Output as JSON")
|
|
2701
|
-
.action(async (workflowId, testCaseId, filePath, options) => {
|
|
2702
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
2703
|
-
const client = new ApiClient();
|
|
2704
|
-
try {
|
|
2705
|
-
// Check file exists and get stats
|
|
2706
|
-
const stats = statSync(filePath);
|
|
2707
|
-
if (!stats.isFile()) {
|
|
2708
|
-
error(`Not a file: ${filePath}`);
|
|
2709
|
-
process.exit(1);
|
|
2710
|
-
}
|
|
2711
|
-
// Check file size (10 MB limit)
|
|
2712
|
-
const TEN_MB = 10 * 1024 * 1024;
|
|
2713
|
-
if (stats.size > TEN_MB) {
|
|
2714
|
-
error(`File too large (max 10MB): ${formatFileSize(stats.size)}`);
|
|
2715
|
-
process.exit(1);
|
|
2716
|
-
}
|
|
2717
|
-
const filename = options.name || basename(filePath);
|
|
2718
|
-
const data = readFileSync(filePath);
|
|
2719
|
-
const contentType = mimeLookup(filePath) || "application/octet-stream";
|
|
2720
|
-
const result = await client.uploadTestCaseAttachment(resolvedAppId, "workflow", workflowId, testCaseId, filename, data, contentType);
|
|
2721
|
-
if (options.json) {
|
|
2722
|
-
json(result);
|
|
2723
|
-
return;
|
|
2724
|
-
}
|
|
2725
|
-
success(`Uploaded: ${filename}`);
|
|
2726
|
-
keyValue("Size", formatFileSize(result.attachment.size));
|
|
2727
|
-
keyValue("Type", result.attachment.contentType);
|
|
2728
|
-
}
|
|
2729
|
-
catch (err) {
|
|
2730
|
-
error(err.message);
|
|
2731
|
-
process.exit(1);
|
|
2732
|
-
}
|
|
2733
|
-
});
|
|
2734
2320
|
// Download attachment
|
|
2735
2321
|
attachments
|
|
2736
2322
|
.command("download")
|
|
@@ -2756,41 +2342,6 @@ Examples:
|
|
|
2756
2342
|
process.exit(1);
|
|
2757
2343
|
}
|
|
2758
2344
|
});
|
|
2759
|
-
// Delete attachment
|
|
2760
|
-
attachments
|
|
2761
|
-
.command("delete")
|
|
2762
|
-
.description("Delete a file attachment from a test case")
|
|
2763
|
-
.argument("<workflow-id>", "Workflow ID")
|
|
2764
|
-
.argument("<test-case-id>", "Test Case ID")
|
|
2765
|
-
.argument("<filename>", "Name of the file to delete")
|
|
2766
|
-
.option("--app <app-id>", "App ID (uses current app if not specified)")
|
|
2767
|
-
.option("-y, --yes", "Skip confirmation prompt")
|
|
2768
|
-
.action(async (workflowId, testCaseId, filename, options) => {
|
|
2769
|
-
const resolvedAppId = resolveAppId(undefined, options);
|
|
2770
|
-
if (!options.yes) {
|
|
2771
|
-
let confirm;
|
|
2772
|
-
try {
|
|
2773
|
-
confirm = await confirmPrompt(`Are you sure you want to delete attachment "${filename}"?`);
|
|
2774
|
-
}
|
|
2775
|
-
catch (err) {
|
|
2776
|
-
error(err.message);
|
|
2777
|
-
process.exit(1);
|
|
2778
|
-
}
|
|
2779
|
-
if (!confirm) {
|
|
2780
|
-
info("Cancelled.");
|
|
2781
|
-
return;
|
|
2782
|
-
}
|
|
2783
|
-
}
|
|
2784
|
-
const client = new ApiClient();
|
|
2785
|
-
try {
|
|
2786
|
-
await client.deleteTestCaseAttachment(resolvedAppId, "workflow", workflowId, testCaseId, filename);
|
|
2787
|
-
success(`Deleted: ${filename}`);
|
|
2788
|
-
}
|
|
2789
|
-
catch (err) {
|
|
2790
|
-
error(err.message);
|
|
2791
|
-
process.exit(1);
|
|
2792
|
-
}
|
|
2793
|
-
});
|
|
2794
2345
|
// ============================================
|
|
2795
2346
|
// Codegen command (issue #1442)
|
|
2796
2347
|
// ============================================
|
|
@@ -2799,8 +2350,9 @@ Examples:
|
|
|
2799
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.")
|
|
2800
2351
|
.argument("[workflow-key]", "Generate for a single workflow (defaults to every workflows/*.toml found)")
|
|
2801
2352
|
.option("--app <app-id>", "App ID")
|
|
2802
|
-
.option("--
|
|
2803
|
-
.
|
|
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/)")
|
|
2804
2356
|
.option("--lang <lang>", "Target language for the generated types: 'ts' (default) or 'swift'.", "ts")
|
|
2805
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.")
|
|
2806
2358
|
.option("--json", "Output the result summary as JSON")
|
|
@@ -2814,13 +2366,16 @@ Examples:
|
|
|
2814
2366
|
process.exit(1);
|
|
2815
2367
|
}
|
|
2816
2368
|
// 1. Resolve the single source workflows/ directory via the shared
|
|
2817
|
-
// active-environment resolver (issue #1510). Honors --
|
|
2369
|
+
// active-environment resolver (issue #1510). Honors --dir /
|
|
2818
2370
|
// --app overrides, resolves the active env
|
|
2819
2371
|
// (--env → PRIMITIVE_ENV → defaultEnvironment → single-env), and
|
|
2820
2372
|
// preserves the legacy per-app scan fallback in bare-dir mode.
|
|
2821
2373
|
const workflowsSourceDir = resolveCodegenSourceDir({
|
|
2822
2374
|
subdir: "workflows",
|
|
2823
|
-
options: {
|
|
2375
|
+
options: {
|
|
2376
|
+
app: options.app,
|
|
2377
|
+
syncDir: resolveSyncDirOverride(options, "workflows codegen"),
|
|
2378
|
+
},
|
|
2824
2379
|
});
|
|
2825
2380
|
// 2. Collect the source .toml files (one per workflow), filtered to a
|
|
2826
2381
|
// single workflow when an argument is given. Match on the file stem;
|
|
@@ -2878,9 +2433,20 @@ Examples:
|
|
|
2878
2433
|
for (const m of codegenResult.mismatches) {
|
|
2879
2434
|
error(` ${m.reason}: ${path.relative(process.cwd(), m.filePath)}`);
|
|
2880
2435
|
}
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
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.`);
|
|
2884
2450
|
}
|
|
2885
2451
|
process.exit(1);
|
|
2886
2452
|
}
|