primitive-admin 1.0.62 → 1.0.64
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 +132 -42
- package/assets/skill/skills/primitive-platform/SKILL.md +142 -661
- package/dist/bin/primitive.js +121 -41
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/admins.js +37 -50
- package/dist/src/commands/admins.js.map +1 -1
- package/dist/src/commands/analytics.js +178 -9
- package/dist/src/commands/analytics.js.map +1 -1
- package/dist/src/commands/apps.js +17 -7
- package/dist/src/commands/apps.js.map +1 -1
- package/dist/src/commands/auth-sessions.d.ts +7 -0
- package/dist/src/commands/auth-sessions.js +144 -0
- package/dist/src/commands/auth-sessions.js.map +1 -0
- package/dist/src/commands/auth.js +76 -116
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/blob-buckets.js +2 -1
- package/dist/src/commands/blob-buckets.js.map +1 -1
- package/dist/src/commands/catalog.js +26 -10
- package/dist/src/commands/catalog.js.map +1 -1
- package/dist/src/commands/collection-type-configs.js +3 -10
- package/dist/src/commands/collection-type-configs.js.map +1 -1
- package/dist/src/commands/collections.js +736 -19
- package/dist/src/commands/collections.js.map +1 -1
- package/dist/src/commands/config.js +13 -27
- package/dist/src/commands/config.js.map +1 -1
- package/dist/src/commands/connections.js +8 -9
- package/dist/src/commands/connections.js.map +1 -1
- package/dist/src/commands/cron-triggers.js +2 -1
- package/dist/src/commands/cron-triggers.js.map +1 -1
- package/dist/src/commands/database-type-configs.js +5 -12
- package/dist/src/commands/database-type-configs.js.map +1 -1
- package/dist/src/commands/databases.js +59 -72
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.d.ts +58 -0
- package/dist/src/commands/documents.js +1623 -95
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/email-templates.js +5 -4
- package/dist/src/commands/email-templates.js.map +1 -1
- package/dist/src/commands/env.d.ts +1 -1
- package/dist/src/commands/env.js +106 -44
- package/dist/src/commands/env.js.map +1 -1
- package/dist/src/commands/feature-flags.js +2 -1
- package/dist/src/commands/feature-flags.js.map +1 -1
- package/dist/src/commands/functions.d.ts +20 -0
- package/dist/src/commands/functions.js +1630 -0
- package/dist/src/commands/functions.js.map +1 -0
- package/dist/src/commands/group-type-configs.js +2 -1
- package/dist/src/commands/group-type-configs.js.map +1 -1
- package/dist/src/commands/groups.js +30 -17
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/guides.js +17 -11
- package/dist/src/commands/guides.js.map +1 -1
- package/dist/src/commands/init.d.ts +24 -12
- package/dist/src/commands/init.js +198 -110
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/integrations.js +65 -26
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/locks.js +17 -2
- package/dist/src/commands/locks.js.map +1 -1
- package/dist/src/commands/metadata-category-configs.js +2 -1
- package/dist/src/commands/metadata-category-configs.js.map +1 -1
- package/dist/src/commands/metadata.js +8 -1
- package/dist/src/commands/metadata.js.map +1 -1
- package/dist/src/commands/prompts.js +106 -59
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.js +3 -10
- 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 +159 -25
- package/dist/src/commands/scripts.js.map +1 -1
- package/dist/src/commands/secrets.js +2 -1
- package/dist/src/commands/secrets.js.map +1 -1
- package/dist/src/commands/sessions.js +10 -9
- package/dist/src/commands/sessions.js.map +1 -1
- package/dist/src/commands/sync-app-settings.js +3 -5
- package/dist/src/commands/sync-app-settings.js.map +1 -1
- package/dist/src/commands/sync.d.ts +391 -44
- package/dist/src/commands/sync.js +2622 -534
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/tokens.js +2 -1
- package/dist/src/commands/tokens.js.map +1 -1
- package/dist/src/commands/users.js +19 -10
- package/dist/src/commands/users.js.map +1 -1
- package/dist/src/commands/vars.js +2 -1
- package/dist/src/commands/vars.js.map +1 -1
- package/dist/src/commands/waitlist.js +2 -1
- package/dist/src/commands/waitlist.js.map +1 -1
- package/dist/src/commands/webhooks.d.ts +29 -0
- package/dist/src/commands/webhooks.js +135 -64
- package/dist/src/commands/webhooks.js.map +1 -1
- package/dist/src/commands/workflows.d.ts +1 -29
- package/dist/src/commands/workflows.js +113 -179
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +628 -14
- package/dist/src/lib/api-client.js +776 -70
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-settings-descriptor.d.ts +1 -1
- package/dist/src/lib/app-settings-descriptor.js +9 -1
- package/dist/src/lib/app-settings-descriptor.js.map +1 -1
- package/dist/src/lib/block-selector.d.ts +58 -0
- package/dist/src/lib/block-selector.js +92 -0
- package/dist/src/lib/block-selector.js.map +1 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +24 -54
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +31 -153
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
- package/dist/src/lib/collection-export.d.ts +184 -0
- package/dist/src/lib/collection-export.js +252 -0
- package/dist/src/lib/collection-export.js.map +1 -0
- package/dist/src/lib/config-json-field.d.ts +28 -0
- package/dist/src/lib/config-json-field.js +56 -0
- package/dist/src/lib/config-json-field.js.map +1 -0
- package/dist/src/lib/config-object-descriptor.js +88 -6
- package/dist/src/lib/config-object-descriptor.js.map +1 -1
- package/dist/src/lib/config-payload.d.ts +8 -1
- package/dist/src/lib/config-payload.js +49 -4
- package/dist/src/lib/config-payload.js.map +1 -1
- package/dist/src/lib/config-surface.d.ts +12 -1
- package/dist/src/lib/config-surface.js +71 -3
- package/dist/src/lib/config-surface.js.map +1 -1
- package/dist/src/lib/config.d.ts +31 -13
- package/dist/src/lib/config.js +34 -70
- package/dist/src/lib/config.js.map +1 -1
- package/dist/src/lib/credentials-store.d.ts +37 -22
- package/dist/src/lib/credentials-store.js +103 -97
- package/dist/src/lib/credentials-store.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTypeIR.js +8 -0
- package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -1
- package/dist/src/lib/deprecation.d.ts +22 -0
- package/dist/src/lib/deprecation.js +43 -0
- package/dist/src/lib/deprecation.js.map +1 -0
- package/dist/src/lib/document-export-permissions.d.ts +30 -0
- package/dist/src/lib/document-export-permissions.js +54 -0
- package/dist/src/lib/document-export-permissions.js.map +1 -0
- package/dist/src/lib/document-ingest-artifact.d.ts +120 -0
- package/dist/src/lib/document-ingest-artifact.js +505 -0
- package/dist/src/lib/document-ingest-artifact.js.map +1 -0
- package/dist/src/lib/document-ingest-input.d.ts +51 -0
- package/dist/src/lib/document-ingest-input.js +132 -0
- package/dist/src/lib/document-ingest-input.js.map +1 -0
- package/dist/src/lib/document-ingest-rows.d.ts +137 -0
- package/dist/src/lib/document-ingest-rows.js +181 -0
- package/dist/src/lib/document-ingest-rows.js.map +1 -0
- package/dist/src/lib/document-ingest.d.ts +101 -0
- package/dist/src/lib/document-ingest.js +384 -0
- package/dist/src/lib/document-ingest.js.map +1 -0
- package/dist/src/lib/env-resolver-core.d.ts +128 -17
- package/dist/src/lib/env-resolver-core.js +204 -22
- package/dist/src/lib/env-resolver-core.js.map +1 -1
- package/dist/src/lib/env-resolver.d.ts +18 -3
- package/dist/src/lib/env-resolver.js +23 -3
- package/dist/src/lib/env-resolver.js.map +1 -1
- package/dist/src/lib/function-bundle.d.ts +147 -0
- package/dist/src/lib/function-bundle.js +341 -0
- package/dist/src/lib/function-bundle.js.map +1 -0
- package/dist/src/lib/function-collect.d.ts +123 -0
- package/dist/src/lib/function-collect.js +610 -0
- package/dist/src/lib/function-collect.js.map +1 -0
- package/dist/src/lib/function-db-types.d.ts +202 -0
- package/dist/src/lib/function-db-types.js +870 -0
- package/dist/src/lib/function-db-types.js.map +1 -0
- package/dist/src/lib/function-document-types.d.ts +144 -0
- package/dist/src/lib/function-document-types.js +370 -0
- package/dist/src/lib/function-document-types.js.map +1 -0
- package/dist/src/lib/function-grants-preflight.d.ts +64 -0
- package/dist/src/lib/function-grants-preflight.js +105 -0
- package/dist/src/lib/function-grants-preflight.js.map +1 -0
- package/dist/src/lib/function-log-lines.d.ts +76 -0
- package/dist/src/lib/function-log-lines.js +160 -0
- package/dist/src/lib/function-log-lines.js.map +1 -0
- package/dist/src/lib/function-log-row.d.ts +29 -0
- package/dist/src/lib/function-log-row.js +73 -0
- package/dist/src/lib/function-log-row.js.map +1 -0
- package/dist/src/lib/function-log-tail.d.ts +132 -0
- package/dist/src/lib/function-log-tail.js +262 -0
- package/dist/src/lib/function-log-tail.js.map +1 -0
- package/dist/src/lib/function-run.d.ts +289 -0
- package/dist/src/lib/function-run.js +389 -0
- package/dist/src/lib/function-run.js.map +1 -0
- package/dist/src/lib/function-schema-codegen.d.ts +143 -0
- package/dist/src/lib/function-schema-codegen.js +420 -0
- package/dist/src/lib/function-schema-codegen.js.map +1 -0
- package/dist/src/lib/function-sync.d.ts +459 -0
- package/dist/src/lib/function-sync.js +1258 -0
- package/dist/src/lib/function-sync.js.map +1 -0
- package/dist/src/lib/function-trigger-listing.d.ts +27 -0
- package/dist/src/lib/function-trigger-listing.js +70 -0
- package/dist/src/lib/function-trigger-listing.js.map +1 -0
- package/dist/src/lib/function-typecheck.d.ts +86 -0
- package/dist/src/lib/function-typecheck.js +370 -0
- package/dist/src/lib/function-typecheck.js.map +1 -0
- package/dist/src/lib/function-versions.d.ts +122 -0
- package/dist/src/lib/function-versions.js +182 -0
- package/dist/src/lib/function-versions.js.map +1 -0
- package/dist/src/lib/generated-allowlist.js +4 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/lib/generated-config-surfaces.d.ts +2332 -78
- package/dist/src/lib/generated-config-surfaces.js +6979 -468
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- package/dist/src/lib/generated-sdk-types.d.ts +12 -0
- package/dist/src/lib/generated-sdk-types.js +13 -0
- package/dist/src/lib/generated-sdk-types.js.map +1 -0
- package/dist/src/lib/init-adopt.js +2 -2
- package/dist/src/lib/init-adopt.js.map +1 -1
- package/dist/src/lib/init-assets.js +3 -3
- 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 +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 +41 -0
- package/dist/src/lib/init-ios-links.js +67 -1
- package/dist/src/lib/init-ios-links.js.map +1 -1
- package/dist/src/lib/init-plan.d.ts +1 -1
- package/dist/src/lib/init-plan.js +1 -1
- package/dist/src/lib/init-xcode.d.ts +6 -5
- package/dist/src/lib/init-xcode.js +34 -10
- package/dist/src/lib/init-xcode.js.map +1 -1
- 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/ios-app-id.d.ts +34 -0
- package/dist/src/lib/ios-app-id.js +69 -0
- package/dist/src/lib/ios-app-id.js.map +1 -0
- package/dist/src/lib/list-options.d.ts +68 -0
- package/dist/src/lib/list-options.js +89 -0
- package/dist/src/lib/list-options.js.map +1 -0
- package/dist/src/lib/local-state.d.ts +1 -1
- package/dist/src/lib/local-state.js +1 -1
- package/dist/src/lib/local-test-cases.d.ts +63 -0
- package/dist/src/lib/local-test-cases.js +136 -0
- package/dist/src/lib/local-test-cases.js.map +1 -0
- package/dist/src/lib/log-inspection.d.ts +152 -5
- package/dist/src/lib/log-inspection.js +179 -2
- package/dist/src/lib/log-inspection.js.map +1 -1
- package/dist/src/lib/logout-admin-session.d.ts +33 -0
- package/dist/src/lib/logout-admin-session.js +70 -0
- package/dist/src/lib/logout-admin-session.js.map +1 -0
- package/dist/src/lib/output.d.ts +15 -0
- package/dist/src/lib/output.js +28 -0
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/paginate.d.ts +15 -0
- package/dist/src/lib/paginate.js +17 -0
- package/dist/src/lib/paginate.js.map +1 -1
- package/dist/src/lib/project-config.d.ts +46 -21
- package/dist/src/lib/project-config.js +74 -47
- package/dist/src/lib/project-config.js.map +1 -1
- package/dist/src/lib/prompt-cost-format.d.ts +11 -0
- package/dist/src/lib/prompt-cost-format.js +41 -0
- package/dist/src/lib/prompt-cost-format.js.map +1 -0
- package/dist/src/lib/prompt-schema-codegen.d.ts +147 -0
- package/dist/src/lib/prompt-schema-codegen.js +462 -0
- package/dist/src/lib/prompt-schema-codegen.js.map +1 -0
- package/dist/src/lib/refresh-admin-credentials.d.ts +9 -1
- package/dist/src/lib/refresh-admin-credentials.js +22 -2
- package/dist/src/lib/refresh-admin-credentials.js.map +1 -1
- package/dist/src/lib/resolve-owner.d.ts +19 -0
- package/dist/src/lib/resolve-owner.js +20 -0
- package/dist/src/lib/resolve-owner.js.map +1 -0
- package/dist/src/lib/skill-installer.d.ts +49 -3
- package/dist/src/lib/skill-installer.js +275 -100
- package/dist/src/lib/skill-installer.js.map +1 -1
- package/dist/src/lib/snapshot-audit-source.d.ts +45 -0
- package/dist/src/lib/snapshot-audit-source.js +58 -0
- package/dist/src/lib/snapshot-audit-source.js.map +1 -0
- package/dist/src/lib/snapshot-audit-store.d.ts +52 -0
- package/dist/src/lib/snapshot-audit-store.js +196 -0
- package/dist/src/lib/snapshot-audit-store.js.map +1 -0
- package/dist/src/lib/snapshot-audit.d.ts +207 -0
- package/dist/src/lib/snapshot-audit.js +431 -0
- package/dist/src/lib/snapshot-audit.js.map +1 -0
- package/dist/src/lib/snapshot-build-rows.d.ts +60 -0
- package/dist/src/lib/snapshot-build-rows.js +87 -0
- package/dist/src/lib/snapshot-build-rows.js.map +1 -0
- package/dist/src/lib/snapshot-build.d.ts +50 -0
- package/dist/src/lib/snapshot-build.js +111 -0
- package/dist/src/lib/snapshot-build.js.map +1 -0
- package/dist/src/lib/snapshot-manifest-layout.d.ts +61 -0
- package/dist/src/lib/snapshot-manifest-layout.js +70 -0
- package/dist/src/lib/snapshot-manifest-layout.js.map +1 -0
- package/dist/src/lib/snapshots.d.ts +8 -9
- package/dist/src/lib/snapshots.js +20 -83
- package/dist/src/lib/snapshots.js.map +1 -1
- package/dist/src/lib/step-run-table.d.ts +43 -0
- package/dist/src/lib/step-run-table.js +129 -0
- package/dist/src/lib/step-run-table.js.map +1 -0
- package/dist/src/lib/storage-pending-retry.d.ts +26 -0
- package/dist/src/lib/storage-pending-retry.js +42 -0
- package/dist/src/lib/storage-pending-retry.js.map +1 -0
- package/dist/src/lib/swift-codegen/agentGenerator.d.ts +42 -0
- package/dist/src/lib/swift-codegen/agentGenerator.js +118 -0
- package/dist/src/lib/swift-codegen/agentGenerator.js.map +1 -0
- package/dist/src/lib/swift-codegen/banners.d.ts +24 -0
- package/dist/src/lib/swift-codegen/banners.js +25 -0
- package/dist/src/lib/swift-codegen/banners.js.map +1 -0
- package/dist/src/lib/swift-codegen/dbGenerator.js +19 -7
- package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
- package/dist/src/lib/swift-codegen/functionGenerator.d.ts +139 -0
- package/dist/src/lib/swift-codegen/functionGenerator.js +462 -0
- package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -0
- package/dist/src/lib/swift-codegen/generator.d.ts +7 -1
- package/dist/src/lib/swift-codegen/generator.js +28 -11
- package/dist/src/lib/swift-codegen/generator.js.map +1 -1
- package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +15 -0
- package/dist/src/lib/swift-codegen/schemaToSwift.js +21 -4
- package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -1
- package/dist/src/lib/sync-paths.d.ts +72 -55
- package/dist/src/lib/sync-paths.js +97 -100
- package/dist/src/lib/sync-paths.js.map +1 -1
- package/dist/src/lib/sync-resource-types.d.ts +23 -4
- package/dist/src/lib/sync-resource-types.js +106 -8
- package/dist/src/lib/sync-resource-types.js.map +1 -1
- package/dist/src/lib/sync-selectors.d.ts +45 -2
- package/dist/src/lib/sync-selectors.js +66 -5
- package/dist/src/lib/sync-selectors.js.map +1 -1
- package/dist/src/lib/template.d.ts +2 -2
- package/dist/src/lib/template.js +2 -2
- 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 +1 -1
- package/dist/src/lib/test-case-keys.js +1 -1
- package/dist/src/lib/test-case-variables.d.ts +14 -0
- package/dist/src/lib/test-case-variables.js +42 -0
- package/dist/src/lib/test-case-variables.js.map +1 -1
- package/dist/src/lib/toml-database-config.js +19 -2
- package/dist/src/lib/toml-database-config.js.map +1 -1
- 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/webhook-deliver.d.ts +209 -0
- package/dist/src/lib/webhook-deliver.js +519 -0
- package/dist/src/lib/webhook-deliver.js.map +1 -0
- package/dist/src/lib/workflow-codegen/generator.d.ts +1 -1
- package/dist/src/lib/workflow-codegen/generator.js +1 -1
- package/dist/src/lib/workflow-toml-validator.d.ts +17 -8
- package/dist/src/lib/workflow-toml-validator.js +25 -12
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/dist/src/lib/workflow-usage.d.ts +196 -0
- package/dist/src/lib/workflow-usage.js +310 -0
- package/dist/src/lib/workflow-usage.js.map +1 -0
- package/dist/src/types/index.d.ts +12 -2
- package/package.json +5 -4
|
@@ -3,13 +3,17 @@ import { join, basename } from "path";
|
|
|
3
3
|
import { createHash } from "crypto";
|
|
4
4
|
import { lookup as mimeLookup } from "mime-types";
|
|
5
5
|
import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefError, SchemaBreaksOpsError, SchemaHasUncheckableOpsError, TomlParseError, OpsExistError, } from "../lib/api-client.js";
|
|
6
|
+
import { retryOnStoragePending } from "../lib/storage-pending-retry.js";
|
|
6
7
|
import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, normalizeSubscriptionFromToml, SubscriptionAccessKeyConflictError, } from "../lib/toml-database-config.js";
|
|
7
8
|
import { canEmitNative } from "../lib/toml-native-form.js";
|
|
9
|
+
// #1446's `type: "json"` emission rule, shared with `serializeFunction` since
|
|
10
|
+
// #3317 — see `config-json-field.ts`.
|
|
11
|
+
import { serializeJsonField } from "../lib/config-json-field.js";
|
|
8
12
|
import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMetadataCategoryConfig, parseDeclaredAccessManifestToml, validateDeclaredAccessManifest, serializeDeclaredAccessManifest, } from "../lib/toml-metadata-config.js";
|
|
9
13
|
import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
|
|
10
14
|
import { isPlatformOwnedWorkflow, isPlatformOwnedWorkflowKey, } from "../lib/platform-owned.js";
|
|
11
|
-
import {
|
|
12
|
-
import { resolveSyncDir, resolveSnapshotsRoot,
|
|
15
|
+
import { getCurrentAppId, getServerUrl } from "../lib/config.js";
|
|
16
|
+
import { resolveSyncDir, resolveSnapshotsRoot, syncStatePath, syncStateReadPath, SYNC_STATE_FILENAME, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
|
|
13
17
|
import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
|
|
14
18
|
import { validateWorkflowToml, collectWorkflowTomlNotes, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
|
|
15
19
|
import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
|
|
@@ -29,14 +33,50 @@ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../
|
|
|
29
33
|
// payload builder so the diff normalizer and the wire cannot disagree.
|
|
30
34
|
import { WORKFLOW_MODEL_DEFAULTS } from "../lib/workflow-defaults.js";
|
|
31
35
|
// #2644 — the one definition per configuration object, vendored from the server.
|
|
32
|
-
import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, findMisshapenTables, findUnknownDocumentKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
|
|
36
|
+
import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownGroupedTomlKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
|
|
33
37
|
import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
|
|
34
|
-
import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfaces.js";
|
|
38
|
+
import { CONFIG_SURFACES, isEmptyForEmit, tomlGroupsOf, } from "../lib/generated-config-surfaces.js";
|
|
39
|
+
// #3626 — the prompt KIND rule, vendored with the definitions so the CLI's
|
|
40
|
+
// preflight refuses exactly what the admin routes refuse.
|
|
41
|
+
import { hoistDeprecatedChatKeys, promptKindOf, resolvePromptKind, } from "../lib/generated-config-surfaces.js";
|
|
42
|
+
// #3798 — the `[prompt.agent]` declaration rule, vendored the same way.
|
|
43
|
+
import { agentDeclarationFunctionKeys, agentTestCaseRefusal, resolveAgentDeclaration, } from "../lib/generated-config-surfaces.js";
|
|
44
|
+
// #3799 — the meaning-preserving schema translation, vendored the same way, so
|
|
45
|
+
// the preflight refuses exactly the schemas the admin routes refuse.
|
|
46
|
+
import { agentSchemaTranslationRefusals } from "../lib/generated-config-surfaces.js";
|
|
47
|
+
// #3801 — the strict-output rule, vendored the same way, so the preflight
|
|
48
|
+
// refuses exactly the strict configs the admin routes refuse.
|
|
49
|
+
import { resolvePromptStrictOutput } from "../lib/generated-config-surfaces.js";
|
|
50
|
+
// #3179 — the function's two halves: the header payloads and the envelope
|
|
51
|
+
// `config push` sends, and the bytes `config pull` writes back.
|
|
52
|
+
import { applyFunctionPull, buildFunctionConfigPush, createDocumentSchemaDropWarner, documentSchemaRetentionFailure, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, functionTomlIgnoredKeyWarnings, functionTomlPreflightErrors, planDocumentSchemaPull, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
|
|
53
|
+
import { newerVersionLine, pullNewerVersionLine, rollbackDiffHint, } from "../lib/function-versions.js";
|
|
54
|
+
import { FunctionBuildError } from "../lib/function-bundle.js";
|
|
55
|
+
// #3182 — the local half of capability validation. The grammar is the vendored
|
|
56
|
+
// module the server also imports; this is where an author's own file is read.
|
|
57
|
+
import { configObjectKeysFromTree, functionGrantErrors, mergeKnownGrantTargets, } from "../lib/function-grants-preflight.js";
|
|
58
|
+
// #3182 — the typed handle's generated declarations and the compiler wiring
|
|
59
|
+
// that makes an editor load them.
|
|
60
|
+
import { functionTypeWiringProblems, staleFunctionTypeArtifacts, writeFunctionTypeArtifacts, } from "../lib/function-db-types.js";
|
|
61
|
+
// #3281 — the functions declaration and the per-key invokers ride the same
|
|
62
|
+
// plan; a malformed schema is their one refusal, named by file.
|
|
63
|
+
import { FunctionSchemaCodegenError } from "../lib/function-schema-codegen.js";
|
|
64
|
+
// #3455 — the one resolution rule push, pull and diff share for the project's
|
|
65
|
+
// document schema.
|
|
66
|
+
import { isFunctionDeclarationFile, resolveDocumentSchema, } from "../lib/function-document-types.js";
|
|
67
|
+
// #3331 — and the compiler is actually asked: a push holds the code it ships
|
|
68
|
+
// to the declarations it just generated.
|
|
69
|
+
import { createFunctionTypechecker, } from "../lib/function-typecheck.js";
|
|
35
70
|
import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
|
|
36
71
|
import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, VALIDATION_ERROR, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, isSafeSyncFilename, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
|
|
37
72
|
import { normalizeTestCaseKey, testCaseKeyError, } from "../lib/test-case-keys.js";
|
|
73
|
+
import { slugifyTestCaseName, testCaseFileBasenames, } from "../lib/test-case-file-names.js";
|
|
74
|
+
// The sidecar naming rules moved to `lib/` so `scripts tests` can read them
|
|
75
|
+
// without loading the sync tree (#3061); re-exported here because pull, push,
|
|
76
|
+
// diff and their tests have always imported them from this module.
|
|
77
|
+
export { resolveSlugCollisions, slugifyTestCaseName, testCaseFileBasenames, } from "../lib/test-case-file-names.js";
|
|
38
78
|
import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
39
|
-
import { filterFilesBySelection, parseSyncSelectors, SelectorError, selectionIncludesKey, selectionIncludesType, selectionIncludesApp, selectionIncludesAnyVar, unmatchedSelectors, } from "../lib/sync-selectors.js";
|
|
79
|
+
import { filterFilesBySelection, parseSyncSelectors, SelectorError, selectionIncludesKey, selectionIncludesType, selectionIncludesApp, selectionIncludesAnyVar, trackedSelectorKeys, unmatchedSelectors, } from "../lib/sync-selectors.js";
|
|
40
80
|
import { confirmPrompt, ConfirmPromptError } from "../lib/confirm-prompt.js";
|
|
41
81
|
import { signingSecretStatusWarning, signingSecretHealthWarning, previousSigningSecretWarning, } from "../lib/signing-secret-status.js";
|
|
42
82
|
import chalk from "chalk";
|
|
@@ -59,7 +99,7 @@ function printStaleInstanceWarning(updated) {
|
|
|
59
99
|
}
|
|
60
100
|
}
|
|
61
101
|
function loadSyncState(configDir) {
|
|
62
|
-
const syncFile =
|
|
102
|
+
const syncFile = syncStateReadPath(configDir);
|
|
63
103
|
if (!existsSync(syncFile)) {
|
|
64
104
|
return null;
|
|
65
105
|
}
|
|
@@ -71,7 +111,7 @@ function loadSyncState(configDir) {
|
|
|
71
111
|
}
|
|
72
112
|
}
|
|
73
113
|
function saveSyncState(configDir, state) {
|
|
74
|
-
const syncFile =
|
|
114
|
+
const syncFile = syncStatePath(configDir);
|
|
75
115
|
writeFileSync(syncFile, JSON.stringify(state, null, 2));
|
|
76
116
|
}
|
|
77
117
|
/**
|
|
@@ -685,6 +725,20 @@ export function validateVarsFile(parsedVars, options = {}) {
|
|
|
685
725
|
* edit — reported as a conflict instead. `force` skips the conflict guard,
|
|
686
726
|
* matching `config push --force` for every other entity.
|
|
687
727
|
*
|
|
728
|
+
* A var only the SERVER moved is the other half of the same comparison, and it
|
|
729
|
+
* is reported rather than skipped (issue #3714). Its local value still equals
|
|
730
|
+
* the baseline, so there is nothing to push and no conflict — but the skip that
|
|
731
|
+
* follows from "local is unchanged" used to be taken before the snapshot was
|
|
732
|
+
* consulted at all, so the push said nothing about a var edited in the Admin
|
|
733
|
+
* Console and counted it among the skipped-unchanged. The `drifted[]` keys are
|
|
734
|
+
* what every other synced type reaches through `classifyPushChange` →
|
|
735
|
+
* `server-drifted`: the caller prints `DRIFT var: <key>` under "Server drift —
|
|
736
|
+
* not pushed", writes nothing, and leaves the exit code alone. A baseline key
|
|
737
|
+
* the snapshot no longer holds at all is the same one-sided move (the server
|
|
738
|
+
* deleted the row; the local file stands still) and is reported the same way.
|
|
739
|
+
* `--force` is the resolution that keeps the local value, `config pull` the one
|
|
740
|
+
* that takes the server's.
|
|
741
|
+
*
|
|
688
742
|
* Pure and side-effect-free (the caller owns the API calls and sync-state
|
|
689
743
|
* updates) so the guard is unit-testable without a live server. Assumes
|
|
690
744
|
* `parsedVars` has already passed `validateVarsFile` (values are strings). The
|
|
@@ -698,6 +752,7 @@ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
|
|
|
698
752
|
const remoteByKey = new Map(remoteVars.map((r) => [r.key, r]));
|
|
699
753
|
const upserts = [];
|
|
700
754
|
const conflicts = [];
|
|
755
|
+
const drifted = [];
|
|
701
756
|
const deletions = [];
|
|
702
757
|
let skippedCount = 0;
|
|
703
758
|
const localKeys = new Set();
|
|
@@ -705,9 +760,32 @@ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
|
|
|
705
760
|
localKeys.add(key);
|
|
706
761
|
const valueHash = hashVarValue(value);
|
|
707
762
|
const existing = baseline?.[key];
|
|
708
|
-
// Unchanged since last sync —
|
|
709
|
-
// content-hash skip the other file-backed entities use.
|
|
763
|
+
// Unchanged since last sync — nothing to push (unless forced), mirroring
|
|
764
|
+
// the content-hash skip the other file-backed entities use. But "the local
|
|
765
|
+
// file did not move" is only half the comparison: ask the snapshot whether
|
|
766
|
+
// the SERVER moved before calling the pair unchanged (issue #3714). One
|
|
767
|
+
// side having moved is drift, which is reported and not written; only a
|
|
768
|
+
// pair that still agrees is a skipped-unchanged file.
|
|
710
769
|
if (!options.force && existing && existing.contentHash === valueHash) {
|
|
770
|
+
const remote = remoteByKey.get(key);
|
|
771
|
+
if (!remote) {
|
|
772
|
+
// The row is gone from an authoritative snapshot (the caller fails the
|
|
773
|
+
// push closed when the fetch fails, and the `--force` fallback that
|
|
774
|
+
// leaves it empty never reaches here): the server deleted what we last
|
|
775
|
+
// synced while the file stood still. `--force` recreates it from the
|
|
776
|
+
// local value; `config pull` drops the line.
|
|
777
|
+
drifted.push({ key, serverModifiedAt: "deleted" });
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (typeof remote.value === "string" &&
|
|
781
|
+
hashVarValue(remote.value) !== existing.contentHash) {
|
|
782
|
+
drifted.push({ key, serverModifiedAt: remote.updatedAt || "unknown" });
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
// Either both sides still hold the last-synced value, or the snapshot
|
|
786
|
+
// carries no readable value for the key and nothing establishes a move.
|
|
787
|
+
// Nothing is written on this path either way, so the only question is
|
|
788
|
+
// what to report, and an unprovable drift is not one to claim.
|
|
711
789
|
skippedCount++;
|
|
712
790
|
continue;
|
|
713
791
|
}
|
|
@@ -795,7 +873,7 @@ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
|
|
|
795
873
|
}
|
|
796
874
|
deletions.push(key);
|
|
797
875
|
}
|
|
798
|
-
return { upserts, deletions, conflicts, skippedCount };
|
|
876
|
+
return { upserts, deletions, conflicts, drifted, skippedCount };
|
|
799
877
|
}
|
|
800
878
|
/**
|
|
801
879
|
* Compute the number of config vars the server would hold after a push plan
|
|
@@ -1589,6 +1667,30 @@ export function buildBlobBucketUpdatePayload(bucket) {
|
|
|
1589
1667
|
}
|
|
1590
1668
|
return updatePayload;
|
|
1591
1669
|
}
|
|
1670
|
+
/**
|
|
1671
|
+
* The `[[configs]] status` the server assigns when a file names none (#3201).
|
|
1672
|
+
*
|
|
1673
|
+
* Taken from the definition, not spelled again here: pull omits the key at
|
|
1674
|
+
* this value and push sends this value for the omitted key, and those two
|
|
1675
|
+
* statements have to be the same one or a pulled file would not describe the
|
|
1676
|
+
* state it was pulled from.
|
|
1677
|
+
*/
|
|
1678
|
+
const PROMPT_CONFIG_DEFAULT_STATUS = fieldServerDefault(PROMPT_CONFIG_TABLE, "status");
|
|
1679
|
+
/**
|
|
1680
|
+
* #3201's post-create status patch is RETIRED — #3799 (DSO-3799-005).
|
|
1681
|
+
*
|
|
1682
|
+
* `status` was update-only, so `POST /prompts` and `POST /prompts/:id/configs`
|
|
1683
|
+
* both assigned `active` whatever the body said, and an authored
|
|
1684
|
+
* `status = "archived"` needed a follow-up PATCH ordered after the activation of
|
|
1685
|
+
* the entry the file marks live. Both create paths honor the key now
|
|
1686
|
+
* (`writableOn: BOTH`, validated by one rule on all three paths), so the entry
|
|
1687
|
+
* lands retired on its own create: the same end state, two fewer calls per
|
|
1688
|
+
* retired config, and no window in which the server holds a status the file
|
|
1689
|
+
* never asked for. That window is what made a pulled tree unpushable into a
|
|
1690
|
+
* fresh environment once an agent's schemas are checked against each
|
|
1691
|
+
* non-archived config's provider — the check fired on the transient active
|
|
1692
|
+
* state.
|
|
1693
|
+
*/
|
|
1592
1694
|
/**
|
|
1593
1695
|
* Which `[[configs]]` entry a prompt file marks as live (issue #2645).
|
|
1594
1696
|
*
|
|
@@ -1696,17 +1798,43 @@ export function serializePrompt(prompt, logger) {
|
|
|
1696
1798
|
...projectRecordToToml(PROMPT_TABLE, prompt, {
|
|
1697
1799
|
inputSchema: jsonField(promptSubject),
|
|
1698
1800
|
outputSchema: jsonField(promptSubject),
|
|
1801
|
+
// #3798 — the agent declaration goes out as `[prompt.agent]` tables
|
|
1802
|
+
// (JSON-string fallback for a `null` TOML cannot spell), exactly as
|
|
1803
|
+
// stored: the server keeps it as authored, so what pull writes is
|
|
1804
|
+
// what the author pushed.
|
|
1805
|
+
agent: jsonField(promptSubject),
|
|
1699
1806
|
}),
|
|
1700
1807
|
},
|
|
1701
1808
|
configs: (prompt.configs || []).map((config) => ({
|
|
1702
1809
|
...projectRecordToToml(PROMPT_CONFIG_TABLE, config, {
|
|
1810
|
+
// #3201 — the per-version key, written only when the config is
|
|
1811
|
+
// RETIRED. `active` is what the create handler assigns and what an
|
|
1812
|
+
// absent key means (the definition's `serverDefault`), so emitting it
|
|
1813
|
+
// put a `status = "active"` line in every pulled prompt file: the one
|
|
1814
|
+
// spelling the availability contract says a config file never carries
|
|
1815
|
+
// (#2803), on the line `config create` scaffolds the `active = true`
|
|
1816
|
+
// marker onto. A retired config still says so — that is what #2645 put
|
|
1817
|
+
// the key in the file for.
|
|
1818
|
+
status: (record) => record?.status === PROMPT_CONFIG_DEFAULT_STATUS
|
|
1819
|
+
? undefined
|
|
1820
|
+
: record?.status,
|
|
1703
1821
|
// Stored as a string on the model and returned parsed; coerce
|
|
1704
1822
|
// defensively so an older server's string still writes as a number.
|
|
1705
1823
|
temperature: (record) => record.temperature != null ? Number(record.temperature) : undefined,
|
|
1706
1824
|
topP: (record) => (record.topP != null ? Number(record.topP) : undefined),
|
|
1707
1825
|
outputSchema: jsonField(`Prompt '${prompt?.promptKey}' config '${config?.configName}'`),
|
|
1708
1826
|
providerConfig: jsonField(`Prompt '${prompt?.promptKey}' config '${config?.configName}'`),
|
|
1709
|
-
|
|
1827
|
+
// #3626, CR3626-002 — the decisions declaration is a `json` field and
|
|
1828
|
+
// goes out the same way its siblings do. Written straight through, a
|
|
1829
|
+
// stored question carrying a `null` anywhere lost that member on pull
|
|
1830
|
+
// (smol-toml has no null), and a `null` inside a LIST threw, taking
|
|
1831
|
+
// `config pull` and `config diff` down with it. The JSON-string
|
|
1832
|
+
// fallback keeps the declaration whole either way.
|
|
1833
|
+
questions: jsonField(`Prompt '${prompt?.promptKey}' config '${config?.configName}'`),
|
|
1834
|
+
},
|
|
1835
|
+
// #3798 — the model settings chat and agent share are written under the
|
|
1836
|
+
// block the prompt's kind names: `[configs.agent]` for an agent.
|
|
1837
|
+
{ tomlGroup: promptKindOf(prompt) }),
|
|
1710
1838
|
// #2645 — the live config has to be identifiable in the repo. Emitted
|
|
1711
1839
|
// only on the active entry (`undefined` elsewhere so the key is omitted),
|
|
1712
1840
|
// which keeps the marker unambiguous by construction.
|
|
@@ -1778,11 +1906,43 @@ export function buildPromptPushPayload(key, tomlData, { mode = "update" } = {})
|
|
|
1778
1906
|
// dropped and leaving a stale rule live. On create it is omitted so
|
|
1779
1907
|
// the push-adopt path (#1006) can still reach the server's 409.
|
|
1780
1908
|
accessRule: (value) => (create ? value : (value ?? null)),
|
|
1909
|
+
// #3798 — the agent declaration, as authored: the server stores it
|
|
1910
|
+
// unchanged, so the value sent is the value pull writes back. On
|
|
1911
|
+
// update a deleted block CLEARS it — for an agent only: a chat or
|
|
1912
|
+
// decisions file carries no block to delete, and omitting the key
|
|
1913
|
+
// keeps its body what it was before this field existed.
|
|
1914
|
+
agent: (value) => create || value !== undefined
|
|
1915
|
+
? value
|
|
1916
|
+
: promptKindOf(prompt) === "agent"
|
|
1917
|
+
? null
|
|
1918
|
+
: undefined,
|
|
1919
|
+
// #3626, CR3626-004 — the kind NORMALIZED, exactly as the resolver
|
|
1920
|
+
// that validates it reads it. `kind = " chat "` and `kind = ""` both
|
|
1921
|
+
// mean `chat` to `resolvePromptKind`, so the server stores `chat` and
|
|
1922
|
+
// the comparison — which projects `[prompt]` in create mode precisely
|
|
1923
|
+
// so a changed kind is visible (SO3626-008) — would otherwise read the
|
|
1924
|
+
// authored spelling against the stored one and call a freshly created
|
|
1925
|
+
// prompt an attempted kind change, refused as immutable on every later
|
|
1926
|
+
// push including `--force`. An UNKNOWN kind is passed through as
|
|
1927
|
+
// written: it is a refusal (preflight, then the create handler), and it
|
|
1928
|
+
// has to name what the file says.
|
|
1929
|
+
kind: (value) => {
|
|
1930
|
+
if (value === undefined || value === null)
|
|
1931
|
+
return "chat";
|
|
1932
|
+
if (typeof value !== "string")
|
|
1933
|
+
return value;
|
|
1934
|
+
const text = value.trim();
|
|
1935
|
+
return text === "" ? "chat" : text;
|
|
1936
|
+
},
|
|
1781
1937
|
},
|
|
1782
1938
|
}),
|
|
1783
1939
|
};
|
|
1784
1940
|
if (create)
|
|
1785
1941
|
payload.promptKey = key;
|
|
1942
|
+
// #3798 — a prompt that declares no agent block sends no `agent` key at all,
|
|
1943
|
+
// so a chat or decisions body is byte for byte what it was.
|
|
1944
|
+
if (payload.agent === undefined)
|
|
1945
|
+
delete payload.agent;
|
|
1786
1946
|
return payload;
|
|
1787
1947
|
}
|
|
1788
1948
|
/**
|
|
@@ -1812,11 +1972,25 @@ export function buildPromptCreatePayload(key, tomlData) {
|
|
|
1812
1972
|
for (const owned of ["configName", ...SEEDED_CONFIG_DEFERRED]) {
|
|
1813
1973
|
delete seed[owned];
|
|
1814
1974
|
}
|
|
1975
|
+
const prompt = buildPromptPushPayload(key, tomlData, { mode: "create" });
|
|
1976
|
+
// #3626, SO3626-001 — the placeholder is a CHAT fallback. A decisions prompt
|
|
1977
|
+
// has no template at all (its input is a `state` value plus its questions),
|
|
1978
|
+
// and injecting one would store a chat key the block rule then refuses on
|
|
1979
|
+
// every later update. #3798 — nor has an agent: the model sees each message
|
|
1980
|
+
// as sent.
|
|
1981
|
+
if (promptKindOf(prompt) !== "chat") {
|
|
1982
|
+
const body = { ...prompt, ...seed };
|
|
1983
|
+
for (const field of Object.keys(body)) {
|
|
1984
|
+
if (body[field] === undefined)
|
|
1985
|
+
delete body[field];
|
|
1986
|
+
}
|
|
1987
|
+
return body;
|
|
1988
|
+
}
|
|
1815
1989
|
return {
|
|
1816
|
-
...
|
|
1990
|
+
...prompt,
|
|
1817
1991
|
...seed,
|
|
1818
|
-
// The server requires a template; keep the historical
|
|
1819
|
-
// prompt authored without one still creates.
|
|
1992
|
+
// The server requires a template for a chat config; keep the historical
|
|
1993
|
+
// placeholder so a prompt authored without one still creates.
|
|
1820
1994
|
userPromptTemplate: seed.userPromptTemplate || "{{ input }}",
|
|
1821
1995
|
};
|
|
1822
1996
|
}
|
|
@@ -1847,15 +2021,26 @@ const SEEDED_CONFIG_DEFERRED = ["outputSchema", "description"];
|
|
|
1847
2021
|
* `seededConfigName` is what the server called it — read from the create
|
|
1848
2022
|
* response rather than assumed, so a handler that one day honors the name
|
|
1849
2023
|
* produces no needless PATCH.
|
|
2024
|
+
*
|
|
2025
|
+
* #3201 — the entry's `status` was deferred the OTHER way, to a patch after the
|
|
2026
|
+
* activation, because the create pointed the prompt's `activeConfigId` at a
|
|
2027
|
+
* config it had forced live. #3799 (DSO-3799-005) — the create carries the
|
|
2028
|
+
* entry's `status` itself now, so there is no deferral either way and this patch
|
|
2029
|
+
* is the two fields `POST /prompts` cannot express, nothing more.
|
|
1850
2030
|
*/
|
|
1851
2031
|
export function seededOnlyPromptConfigFields(firstConfig, seededConfigName = "default") {
|
|
1852
2032
|
const full = buildPromptConfigPayload(firstConfig || {}, { mode: "update" });
|
|
1853
2033
|
const patch = {};
|
|
1854
2034
|
for (const field of SEEDED_CONFIG_DEFERRED) {
|
|
1855
2035
|
const value = full[field];
|
|
1856
|
-
//
|
|
1857
|
-
//
|
|
1858
|
-
|
|
2036
|
+
// #3626 — an omitted value CLEARS. The create handler does not merely skip
|
|
2037
|
+
// `description`, it writes the hard-coded "Default configuration", so
|
|
2038
|
+
// leaving it alone left every newly-pushed prompt reporting `Modified`
|
|
2039
|
+
// forever against a description its file never asked for: push's own
|
|
2040
|
+
// "the file is the whole truth" rule (#1567), broken by the one field the
|
|
2041
|
+
// create body cannot carry. `outputSchema` is unaffected — create never
|
|
2042
|
+
// sets the seeded config's, so the null is a no-op there.
|
|
2043
|
+
if (value === undefined)
|
|
1859
2044
|
continue;
|
|
1860
2045
|
patch[field] = value;
|
|
1861
2046
|
}
|
|
@@ -1874,62 +2059,62 @@ export function seededOnlyPromptConfigFields(firstConfig, seededConfigName = "de
|
|
|
1874
2059
|
*/
|
|
1875
2060
|
export function buildPromptConfigPayload(tomlConfig, { mode = "update" } = {}) {
|
|
1876
2061
|
const create = mode === "create";
|
|
1877
|
-
|
|
2062
|
+
/**
|
|
2063
|
+
* Issue #1567, generalized at #3358 (DSO-3358-005): the field is TOML-OWNED
|
|
2064
|
+
* on update — send `?? null` so removing the line from a `[[configs]]` block
|
|
2065
|
+
* CLEARS it on the server (the PATCH nulls it on an explicit `null` and
|
|
2066
|
+
* preserves it on omit). Without this an absent key is `undefined`, which
|
|
2067
|
+
* `JSON.stringify` drops, so a deleted line left the stored value live and
|
|
2068
|
+
* the file stopped describing what runs.
|
|
2069
|
+
*
|
|
2070
|
+
* `create` still omits: a create body that nulled every unset key would
|
|
2071
|
+
* differ from the one the server has always received.
|
|
2072
|
+
*/
|
|
2073
|
+
const tomlOwned = (value) => (create ? value : (value ?? null));
|
|
2074
|
+
// #3626 §4 — the nine chat keys moved under `[configs.chat]`, and the flat
|
|
2075
|
+
// spelling is accepted as DEPRECATED. Hoisting here rather than at each call
|
|
2076
|
+
// site means push, `config diff` and the create-seed builder all read a file
|
|
2077
|
+
// the same way, so an unmigrated file and its pulled rewrite compare Synced.
|
|
2078
|
+
// A key set BOTH ways is refused in preflight
|
|
2079
|
+
// (`promptDeprecatedChatKeyErrors`); the hoist leaves the grouped value
|
|
2080
|
+
// standing so nothing here has to guess.
|
|
2081
|
+
const entry = hoistDeprecatedChatKeys(tomlConfig).entry;
|
|
2082
|
+
return buildPayloadFromToml(PROMPT_CONFIG_TABLE, entry, {
|
|
1878
2083
|
mode,
|
|
1879
2084
|
values: {
|
|
1880
2085
|
provider: (value) => (create ? value || "openrouter" : value),
|
|
2086
|
+
// #3799 (DSO-3799-005) — carried on CREATE when the entry names one, and
|
|
2087
|
+
// omitted otherwise, so the create body for a file that says nothing
|
|
2088
|
+
// about status is what the server has always received (principle 5). On
|
|
2089
|
+
// update it still sends the declared default for an absent key, which is
|
|
2090
|
+
// what lets a deleted line converge (#2880 DSO-002).
|
|
2091
|
+
status: (value) => create ? value : (value ?? PROMPT_CONFIG_DEFAULT_STATUS),
|
|
1881
2092
|
model: (value) => create ? value || "google/gemini-2.0-flash-001" : value,
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
2093
|
+
description: tomlOwned,
|
|
2094
|
+
systemPrompt: tomlOwned,
|
|
2095
|
+
userPromptTemplate: tomlOwned,
|
|
2096
|
+
temperature: tomlOwned,
|
|
2097
|
+
topP: tomlOwned,
|
|
2098
|
+
maxTokens: tomlOwned,
|
|
2099
|
+
outputFormat: tomlOwned,
|
|
2100
|
+
outputSchema: tomlOwned,
|
|
2101
|
+
providerConfig: tomlOwned,
|
|
2102
|
+
// #3358 — the two reasoning keys are what made the gap load-bearing:
|
|
2103
|
+
// they are mutually exclusive, so switching between them is a deleted
|
|
2104
|
+
// line plus an added one, and a deleted line that does not clear leaves
|
|
2105
|
+
// the merged config refused as "both set".
|
|
2106
|
+
reasoningEffort: tomlOwned,
|
|
2107
|
+
reasoningBudget: tomlOwned,
|
|
2108
|
+
// #3801 — deleting `strictOutput = true` has to turn strict output off
|
|
2109
|
+
// server-side, or the file stops describing the request that is sent.
|
|
2110
|
+
strictOutput: tomlOwned,
|
|
2111
|
+
// #3626, SO3626-002 — the decisions declaration is TOML-owned on the
|
|
2112
|
+
// same terms: deleting `[configs.decisions]` has to clear `questions`
|
|
2113
|
+
// server-side, or the file stops describing what the model is asked.
|
|
2114
|
+
questions: tomlOwned,
|
|
1886
2115
|
},
|
|
1887
2116
|
});
|
|
1888
2117
|
}
|
|
1889
|
-
/**
|
|
1890
|
-
* Emit a `type: "json"` field (a JSON Schema, a provider-options blob) as TOML.
|
|
1891
|
-
*
|
|
1892
|
-
* Issue #1446: pull used to `JSON.stringify` schema fields into a single
|
|
1893
|
-
* escaped-JSON line — unreadable and undiffable. We now emit the parsed value
|
|
1894
|
-
* as a native table whenever it round-trips faithfully
|
|
1895
|
-
* (`canEmitNative === null`), falling back to the JSON string only for the
|
|
1896
|
-
* values TOML silently drops (`null` / `undefined` anywhere inside). The empty
|
|
1897
|
-
* object `{}` is a real, distinct value (for a schema: "accepts anything") and
|
|
1898
|
-
* emits as an empty table, so it survives a pull → push cycle instead of being
|
|
1899
|
-
* cleared server-side.
|
|
1900
|
-
*
|
|
1901
|
-
* Returns `undefined` for an absent value (the key is then omitted). The
|
|
1902
|
-
* sponsor-approved decision is to always move to native aggressively — no
|
|
1903
|
-
* form preservation — so this does not consult the existing file's form.
|
|
1904
|
-
*
|
|
1905
|
-
* `subject` names the object in the fallback warning, e.g. `Workflow 'greet'`.
|
|
1906
|
-
*/
|
|
1907
|
-
function serializeJsonField(value, fieldName, subject, log) {
|
|
1908
|
-
if (value === undefined || value === null)
|
|
1909
|
-
return undefined;
|
|
1910
|
-
// JSON fields arrive from the server GET parsed; defensively accept a JSON
|
|
1911
|
-
// string too (e.g. an inline-authored file re-serialized here).
|
|
1912
|
-
let obj = value;
|
|
1913
|
-
if (typeof value === "string") {
|
|
1914
|
-
try {
|
|
1915
|
-
obj = JSON.parse(value);
|
|
1916
|
-
}
|
|
1917
|
-
catch {
|
|
1918
|
-
// Not valid JSON — keep the original string as the emitted value.
|
|
1919
|
-
return value;
|
|
1920
|
-
}
|
|
1921
|
-
}
|
|
1922
|
-
if (obj && typeof obj === "object") {
|
|
1923
|
-
const reason = canEmitNative(obj);
|
|
1924
|
-
if (reason) {
|
|
1925
|
-
log(`${subject}: cannot emit \`${fieldName}\` as native TOML (${reason}); using JSON string.`);
|
|
1926
|
-
return JSON.stringify(obj);
|
|
1927
|
-
}
|
|
1928
|
-
return obj;
|
|
1929
|
-
}
|
|
1930
|
-
// A non-object value (unexpected) — preserve it as a JSON string.
|
|
1931
|
-
return JSON.stringify(obj);
|
|
1932
|
-
}
|
|
1933
2118
|
/** The `[workflow]` case of `serializeJsonField` (#1446, message unchanged). */
|
|
1934
2119
|
function serializeWorkflowSchemaField(value, fieldName, workflowKey, log) {
|
|
1935
2120
|
return serializeJsonField(value, fieldName, `Workflow '${workflowKey}'`, log);
|
|
@@ -2002,11 +2187,19 @@ export function configUnknownKeyError(filePath, tomlData, table) {
|
|
|
2002
2187
|
: []
|
|
2003
2188
|
: [at];
|
|
2004
2189
|
const unknown = new Set();
|
|
2190
|
+
// #3626 — a key inside a declared `[configs.<group>]` block that no field
|
|
2191
|
+
// declares under it, and a group value that is not a table at all. Reported
|
|
2192
|
+
// with the group in the path (`configs.chat.nope`) so the author is told
|
|
2193
|
+
// where the CLI looked.
|
|
2194
|
+
const groupedUnknown = new Set();
|
|
2005
2195
|
for (const entry of entries) {
|
|
2006
2196
|
for (const key of findUnknownTomlKeys(entry, table))
|
|
2007
2197
|
unknown.add(key);
|
|
2198
|
+
for (const key of findUnknownGroupedTomlKeys(entry, table)) {
|
|
2199
|
+
groupedUnknown.add(key);
|
|
2200
|
+
}
|
|
2008
2201
|
}
|
|
2009
|
-
if (unknown.size === 0)
|
|
2202
|
+
if (unknown.size === 0 && groupedUnknown.size === 0)
|
|
2010
2203
|
return null;
|
|
2011
2204
|
// #2645/#2803 — a key this CLI removed on purpose gets the guidance stated
|
|
2012
2205
|
// once beside the definitions (`src/config-surface/retired-keys.ts`), not the
|
|
@@ -2023,8 +2216,33 @@ export function configUnknownKeyError(filePath, tomlData, table) {
|
|
|
2023
2216
|
for (const key of retiredHere) {
|
|
2024
2217
|
messages.push(`${filePath}: ${prefix}.${key} — ${retiredConfigKey(prefix, key).toml}`);
|
|
2025
2218
|
}
|
|
2026
|
-
|
|
2027
|
-
|
|
2219
|
+
// #3626, SO3626-013 — a key the definition DOES declare, written at the
|
|
2220
|
+
// table root when it is authored under a group. Before grouping,
|
|
2221
|
+
// `acceptedTomlKeys` took every field key at the root, so a flat `questions`
|
|
2222
|
+
// passed this check and was then dropped in silence by the payload builder.
|
|
2223
|
+
// It is an unknown key now, and the message says where it belongs rather
|
|
2224
|
+
// than sending the author to the generic "check the spelling" hint.
|
|
2225
|
+
const misplaced = trulyUnknown
|
|
2226
|
+
.map((key) => ({
|
|
2227
|
+
key,
|
|
2228
|
+
groups: tomlGroupsOf(table.fields.find((f) => f.tomlKey === key && f.tomlGroup) ??
|
|
2229
|
+
{}),
|
|
2230
|
+
}))
|
|
2231
|
+
.filter((entry) => entry.groups.length > 0);
|
|
2232
|
+
for (const { key, groups } of misplaced) {
|
|
2233
|
+
// #3798 — a key two blocks share names both; the prompt's kind picks one.
|
|
2234
|
+
messages.push(`${filePath}: ${prefix}[0].${key} is not a [[${prefix}]] key; ` +
|
|
2235
|
+
`write it under ${groups.map((g) => `[${prefix}.${g}]`).join(" or ")}`);
|
|
2236
|
+
}
|
|
2237
|
+
const misplacedKeys = new Set(misplaced.map((m) => m.key));
|
|
2238
|
+
const stillUnknown = trulyUnknown.filter((k) => !misplacedKeys.has(k));
|
|
2239
|
+
if (stillUnknown.length > 0) {
|
|
2240
|
+
messages.push(unrecognizedPushKeysMessage(filePath, table.tomlPath, stillUnknown));
|
|
2241
|
+
}
|
|
2242
|
+
if (groupedUnknown.size > 0) {
|
|
2243
|
+
messages.push(unrecognizedPushKeysMessage(filePath, table.tomlPath, [
|
|
2244
|
+
...groupedUnknown,
|
|
2245
|
+
].sort()));
|
|
2028
2246
|
}
|
|
2029
2247
|
return messages.join("\n");
|
|
2030
2248
|
}
|
|
@@ -2108,6 +2326,11 @@ function localConfigRowKey(label, tomlData, file) {
|
|
|
2108
2326
|
return stated("prompt", "key");
|
|
2109
2327
|
case "workflow":
|
|
2110
2328
|
return stated("workflow", "key");
|
|
2329
|
+
// #3320 — the declared key, like the function preflight collector, the
|
|
2330
|
+
// apply loop and `--only`. A renamed file is one object, so both
|
|
2331
|
+
// collectors' messages land on the one comparison row.
|
|
2332
|
+
case "function":
|
|
2333
|
+
return stated("function", "key");
|
|
2111
2334
|
case "email-template":
|
|
2112
2335
|
return stated("template", "emailType");
|
|
2113
2336
|
case "database-type-config":
|
|
@@ -2133,6 +2356,410 @@ function localConfigRowKey(label, tomlData, file) {
|
|
|
2133
2356
|
return fallback;
|
|
2134
2357
|
}
|
|
2135
2358
|
}
|
|
2359
|
+
/**
|
|
2360
|
+
* `config push`'s preflight — every check the authored files decide on their
|
|
2361
|
+
* own, run before the FIRST mutating call (#976 fix A, #3320, #3375).
|
|
2362
|
+
*
|
|
2363
|
+
* The block below was inline inside the push command's `.action()` callback
|
|
2364
|
+
* until #3375 lifted it here unchanged. It had to become an exported symbol
|
|
2365
|
+
* for the registry's promise to be checkable at all: a rule declared
|
|
2366
|
+
* `preflight` says it runs before `config push` mutates anything, and
|
|
2367
|
+
* `src/config-surface/preflight-reachability.ts` resolves that claim
|
|
2368
|
+
* statically from the declared `<module>#<export>` against exactly this entry
|
|
2369
|
+
* point. With the preflight anonymous, "reached from the preflight" had
|
|
2370
|
+
* nothing to resolve against, and the stage string was satisfied by typing it.
|
|
2371
|
+
*
|
|
2372
|
+
* Its boundary is therefore precise and load-bearing: this function is what
|
|
2373
|
+
* contributes to `preflightValidationErrors` and aborts. The advisory reads
|
|
2374
|
+
* that follow the abort in the action — the remote vars snapshot and both caps
|
|
2375
|
+
* — stay there and are declared `apply`, because the server is what enforces
|
|
2376
|
+
* them (`intent` §Non-goals, "Emptying the apply stage").
|
|
2377
|
+
*
|
|
2378
|
+
* A network call inside a preflight is not disqualifying: `listIntegrations`
|
|
2379
|
+
* was already here, answering "will this `integration:` grant have a target?"
|
|
2380
|
+
* against the live listing merged with the tree, and falling back to the tree
|
|
2381
|
+
* alone when the listing cannot be fetched.
|
|
2382
|
+
*
|
|
2383
|
+
* `input` names what the block used to close over. `client` is narrowed to the
|
|
2384
|
+
* one method it calls, so an apply call cannot drift in here later without
|
|
2385
|
+
* that being a visible change to this signature.
|
|
2386
|
+
*/
|
|
2387
|
+
export async function runConfigPushPreflight(input) {
|
|
2388
|
+
const { configDir, resolvedAppId, pushSelection, syncState, force, isCrossAppPush, client, } = input;
|
|
2389
|
+
/**
|
|
2390
|
+
* Narrow a type's file list to the `--only` selection — the same one-line
|
|
2391
|
+
* closure the action holds, rebuilt from the selection it passed in rather
|
|
2392
|
+
* than handed across the boundary as a function.
|
|
2393
|
+
*/
|
|
2394
|
+
const selectFiles = (label, dir, files) => filterFilesBySelection(pushSelection, label, dir, files);
|
|
2395
|
+
// ── Issue #976 (fix A): front-load ALL client-side TOML validation
|
|
2396
|
+
// before the first mutating call. Push is apply-as-you-go and processes
|
|
2397
|
+
// entities in a fixed sequence (cron triggers are created *before*
|
|
2398
|
+
// workflows are validated). Previously each entity's validation lived
|
|
2399
|
+
// inside its own apply loop and aborted via `process.exit(1)` — so a
|
|
2400
|
+
// known-bad sibling file (e.g. an invalid workflow TOML) could let an
|
|
2401
|
+
// earlier create (a cron trigger) be applied, then abort. The
|
|
2402
|
+
// `process.exit` also bypassed the save-on-failure catch below, so the
|
|
2403
|
+
// orphaned create never reached the sync-state baseline and the retry
|
|
2404
|
+
// re-issued a CREATE that the server rejected with 409 forever.
|
|
2405
|
+
//
|
|
2406
|
+
// Running every validation up-front means a known-bad file aborts
|
|
2407
|
+
// BEFORE anything is created. We collect all errors (not just the first)
|
|
2408
|
+
// and THROW — so the single save-on-failure catch is the only exit path
|
|
2409
|
+
// (no more `process.exit` bypass class). The per-loop validators below
|
|
2410
|
+
// remain as defense-in-depth but are converted to throws too.
|
|
2411
|
+
const preflightValidationErrors = [];
|
|
2412
|
+
// Validate all workflow TOMLs (#685 misnested-header check and the
|
|
2413
|
+
// #1447 system + accessRule lint).
|
|
2414
|
+
const preflightWorkflowsDir = join(configDir, "workflows");
|
|
2415
|
+
if (existsSync(preflightWorkflowsDir)) {
|
|
2416
|
+
// Under `--only` the preflight validates just the selected files:
|
|
2417
|
+
// an unrelated broken workflow elsewhere in the slot must not block
|
|
2418
|
+
// applying the one object the operator asked for.
|
|
2419
|
+
for (const file of selectFiles("workflow", preflightWorkflowsDir, readdirSync(preflightWorkflowsDir).filter((f) => f.endsWith(".toml")))) {
|
|
2420
|
+
const filePath = join(preflightWorkflowsDir, file);
|
|
2421
|
+
try {
|
|
2422
|
+
const tomlData = parseTomlFile(filePath);
|
|
2423
|
+
// #1660 — skip platform-owned workflows (the reserved
|
|
2424
|
+
// `__internal.*` namespace) BEFORE validating, mirroring the
|
|
2425
|
+
// apply loop below. `config push` validates every workflow TOML
|
|
2426
|
+
// here, up-front, before reaching that skip — so without this
|
|
2427
|
+
// second skip a stale/hollow `__internal.*.toml` with an invalid
|
|
2428
|
+
// `steps` shape would abort the whole push in preflight. Resolve
|
|
2429
|
+
// the key the same way the apply loop does (`[workflow].key`,
|
|
2430
|
+
// falling back to the filename) and match on the resolved key,
|
|
2431
|
+
// never the filename alone, so a renamed file is still caught.
|
|
2432
|
+
const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
|
|
2433
|
+
if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
|
|
2434
|
+
continue;
|
|
2435
|
+
}
|
|
2436
|
+
const tomlErrors = validateWorkflowToml(tomlData);
|
|
2437
|
+
if (tomlErrors.length > 0) {
|
|
2438
|
+
preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
|
|
2439
|
+
}
|
|
2440
|
+
// #2643 — non-blocking notes (e.g. `strict` / `strictParams` are
|
|
2441
|
+
// now no-ops). Printed once per file at pre-flight so the author
|
|
2442
|
+
// sees them whether or not the push goes on to apply.
|
|
2443
|
+
for (const note of collectWorkflowTomlNotes(tomlData)) {
|
|
2444
|
+
console.log(` ${filePath}: ${note}`);
|
|
2445
|
+
}
|
|
2446
|
+
const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
|
|
2447
|
+
if (unknownKeyError) {
|
|
2448
|
+
preflightValidationErrors.push(` ${unknownKeyError}`);
|
|
2449
|
+
}
|
|
2450
|
+
// #2743 — the always-send update payload cannot carry a missing
|
|
2451
|
+
// `[workflow].name`. That is a local TOML error like every other
|
|
2452
|
+
// one here, so it belongs in the front-loaded pass (#976): caught
|
|
2453
|
+
// in the apply loop it would abort AFTER earlier resources (app
|
|
2454
|
+
// settings, cron triggers, …) had already been written, and
|
|
2455
|
+
// `--dry-run` — which never reaches the apply — would plan an
|
|
2456
|
+
// update the real push then refuses.
|
|
2457
|
+
//
|
|
2458
|
+
// Scoped to the workflows this push will actually UPDATE. A
|
|
2459
|
+
// create falls back to the manifest key, so an absent name is
|
|
2460
|
+
// legitimate authoring there — and once that create lands, the
|
|
2461
|
+
// same unchanged file must keep pushing clean (the apply loop
|
|
2462
|
+
// skips it on its content hash) instead of failing a push that
|
|
2463
|
+
// was never going to send it.
|
|
2464
|
+
if (workflowPushSendsUpdate(configDir, tomlData.workflow?.key || basename(file, ".toml"), tomlData, syncState, force)) {
|
|
2465
|
+
const nameError = workflowUpdateNameError(filePath, tomlData.workflow || {});
|
|
2466
|
+
if (nameError) {
|
|
2467
|
+
preflightValidationErrors.push(` ${nameError}`);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
catch (err) {
|
|
2472
|
+
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
// Validate all database-type TOMLs (issue #803 subscription
|
|
2477
|
+
// access/accessRule conflict + issue #752 $params references).
|
|
2478
|
+
const preflightDbTypesDir = join(configDir, "database-type-configs");
|
|
2479
|
+
if (existsSync(preflightDbTypesDir)) {
|
|
2480
|
+
for (const file of selectFiles("database-type-config", preflightDbTypesDir, readdirSync(preflightDbTypesDir).filter((f) => f.endsWith(".toml")))) {
|
|
2481
|
+
const filePath = join(preflightDbTypesDir, file);
|
|
2482
|
+
let rawToml;
|
|
2483
|
+
let tomlData;
|
|
2484
|
+
try {
|
|
2485
|
+
rawToml = readFileSync(filePath, "utf-8");
|
|
2486
|
+
tomlData = parseTomlFile(filePath);
|
|
2487
|
+
}
|
|
2488
|
+
catch (err) {
|
|
2489
|
+
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
2490
|
+
continue;
|
|
2491
|
+
}
|
|
2492
|
+
let operations = [];
|
|
2493
|
+
try {
|
|
2494
|
+
({ operations } = parseDatabaseTypeToml(tomlData));
|
|
2495
|
+
}
|
|
2496
|
+
catch (err) {
|
|
2497
|
+
if (err instanceof SubscriptionAccessKeyConflictError) {
|
|
2498
|
+
preflightValidationErrors.push(` ${file}: ${err.message}`);
|
|
2499
|
+
continue;
|
|
2500
|
+
}
|
|
2501
|
+
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
2502
|
+
continue;
|
|
2503
|
+
}
|
|
2504
|
+
const validation = validateOperations({
|
|
2505
|
+
filePath,
|
|
2506
|
+
rawToml,
|
|
2507
|
+
operations,
|
|
2508
|
+
});
|
|
2509
|
+
for (const e of validation.errors) {
|
|
2510
|
+
preflightValidationErrors.push(` ${formatIssue(e)}`);
|
|
2511
|
+
}
|
|
2512
|
+
// Issue #1422: non-fatal deprecation notice. A `celContextAccess`
|
|
2513
|
+
// / `metadataAccess` gate on a database type config is a single CEL
|
|
2514
|
+
// expression that gates BOTH read and update of the database CEL
|
|
2515
|
+
// context — granting read implicitly grants update. Steer operators
|
|
2516
|
+
// to resource metadata categories (separate readRule/writeRule).
|
|
2517
|
+
// This is advisory only: the push continues and the runtime gate is
|
|
2518
|
+
// unchanged. It fires only when the deprecated key is actually
|
|
2519
|
+
// present, so metadata-category-only configs never trip it.
|
|
2520
|
+
const typeSection = tomlData.type || {};
|
|
2521
|
+
if (typeSection.celContextAccess || typeSection.metadataAccess) {
|
|
2522
|
+
const usedKey = typeSection.celContextAccess
|
|
2523
|
+
? "celContextAccess"
|
|
2524
|
+
: "metadataAccess";
|
|
2525
|
+
warn(` ${file}: \`${usedKey}\` is deprecated — it gates database CEL context READ and WRITE with one expression (read implies update). Prefer a resource metadata category with separate readRule/writeRule (issue #1420). Sync will continue; the runtime gate is unchanged.`);
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
// Validate app.toml settings up-front (issue #1033). A mistyped value
|
|
2530
|
+
// is a hard error that must abort BEFORE any mutation — same
|
|
2531
|
+
// fail-before-mutate contract as the vars preflight below. The apply
|
|
2532
|
+
// block re-parses for the payload.
|
|
2533
|
+
// #2645 — bounded by `--only`, like the workflow and database-type
|
|
2534
|
+
// preflights above. `config push --only workflow/x` is the replacement for
|
|
2535
|
+
// `workflows update --from-file`, so it must work on a tree where
|
|
2536
|
+
// app.toml happens to be mid-edit.
|
|
2537
|
+
const preflightAppTomlPath = join(configDir, "app.toml");
|
|
2538
|
+
if (selectionIncludesApp(pushSelection) && existsSync(preflightAppTomlPath)) {
|
|
2539
|
+
try {
|
|
2540
|
+
const parsedApp = parseTomlFile(preflightAppTomlPath);
|
|
2541
|
+
const appErrors = collectAppSettingsPushErrors(parsedApp, {
|
|
2542
|
+
// #2880 DSO-001 — the same exclusion the apply block computes, so
|
|
2543
|
+
// the preflight validates the body that will actually be sent.
|
|
2544
|
+
excludeName: appSettingsNameExcluded({
|
|
2545
|
+
isCrossAppPush: !!isCrossAppPush,
|
|
2546
|
+
recordedExcludedName: syncState?.entities?.app?.excludedName,
|
|
2547
|
+
fileName: parsedApp?.app?.name,
|
|
2548
|
+
}),
|
|
2549
|
+
});
|
|
2550
|
+
for (const e of appErrors) {
|
|
2551
|
+
preflightValidationErrors.push(` app.toml: ${e}`);
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
catch (err) {
|
|
2555
|
+
preflightValidationErrors.push(` app.toml: ${err?.message || String(err)}`);
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
// Validate config vars up-front (issue #1423 review). Parse vars.toml
|
|
2559
|
+
// and check the same key/value constraints the server enforces here, in
|
|
2560
|
+
// the preflight pass, so an invalid entry aborts BEFORE any mutation
|
|
2561
|
+
// (app settings or a var upsert) is applied. Previously validation ran
|
|
2562
|
+
// mid-push, after app settings could already have been updated.
|
|
2563
|
+
// #2645 — bounded by `--only`, for the same reason as app.toml above.
|
|
2564
|
+
const preflightVarsTomlPath = join(configDir, "vars.toml");
|
|
2565
|
+
const varsSelected = selectionIncludesAnyVar(pushSelection);
|
|
2566
|
+
let preflightParsedVars = null;
|
|
2567
|
+
if (varsSelected && existsSync(preflightVarsTomlPath)) {
|
|
2568
|
+
try {
|
|
2569
|
+
const parsed = parseTomlFile(preflightVarsTomlPath);
|
|
2570
|
+
// #2645 — under `--only var/<key>` only the selected entries are
|
|
2571
|
+
// checked. The rest of the file is not being written, and aborting
|
|
2572
|
+
// over an entry this push will not touch is the same over-reach as
|
|
2573
|
+
// running the leg at all for an unselected singleton.
|
|
2574
|
+
for (const e of validateVarsFile(parsed, {
|
|
2575
|
+
onlyKeys: pushSelection ? pushSelection.varKeys : undefined,
|
|
2576
|
+
})) {
|
|
2577
|
+
preflightValidationErrors.push(` ${e}`);
|
|
2578
|
+
}
|
|
2579
|
+
preflightParsedVars = parsed;
|
|
2580
|
+
}
|
|
2581
|
+
catch (err) {
|
|
2582
|
+
preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
// #3182 — a function's capabilities, checked against the author's own
|
|
2586
|
+
// file before anything is applied. The grammar is purely local (and
|
|
2587
|
+
// since #3279 it keeps three kinds: `integration:<key>`,
|
|
2588
|
+
// `secret:<NAME>` and the high-blast opt-ins — a retired grant string
|
|
2589
|
+
// is refused here with the retirement); whether an `integration:`
|
|
2590
|
+
// names an integration that will exist is answered against the
|
|
2591
|
+
// tree's own `integrations/` (applied BEFORE functions) merged with
|
|
2592
|
+
// the integrations the server already has. If the live listing cannot
|
|
2593
|
+
// be fetched and the tree declares none, the target check is skipped
|
|
2594
|
+
// and the server — which is authoritative — has the last word.
|
|
2595
|
+
const preflightFunctionsDir = join(configDir, "functions");
|
|
2596
|
+
if (existsSync(preflightFunctionsDir)) {
|
|
2597
|
+
const functionFiles = selectFiles("function", preflightFunctionsDir, readdirSync(preflightFunctionsDir).filter(isFunctionDeclarationFile));
|
|
2598
|
+
if (functionFiles.length > 0) {
|
|
2599
|
+
const treeTargets = configObjectKeysFromTree(configDir);
|
|
2600
|
+
let knownTargets = null;
|
|
2601
|
+
try {
|
|
2602
|
+
const integrations = await client.listIntegrations(resolvedAppId, {
|
|
2603
|
+
status: "active",
|
|
2604
|
+
});
|
|
2605
|
+
knownTargets = mergeKnownGrantTargets({
|
|
2606
|
+
integrations: new Set((integrations?.items ?? [])
|
|
2607
|
+
.map((row) => String(row?.integrationKey ?? ""))
|
|
2608
|
+
.filter(Boolean)),
|
|
2609
|
+
}, treeTargets);
|
|
2610
|
+
}
|
|
2611
|
+
catch {
|
|
2612
|
+
knownTargets =
|
|
2613
|
+
treeTargets.integrations.size > 0 ? treeTargets : null;
|
|
2614
|
+
}
|
|
2615
|
+
// #3719 — the project's document schema rides every function version,
|
|
2616
|
+
// so a unique constraint on a stringset field in it refuses the push
|
|
2617
|
+
// here, once, rather than deploying with seeding silently off. Bounded
|
|
2618
|
+
// by the selection like the rest of this block: only a push that
|
|
2619
|
+
// carries a function carries the schema.
|
|
2620
|
+
const documentSchemaError = documentSchemaPreflightError(resolveDocumentSchema(configDir));
|
|
2621
|
+
if (documentSchemaError) {
|
|
2622
|
+
preflightValidationErrors.push(` ${documentSchemaError}`);
|
|
2623
|
+
}
|
|
2624
|
+
for (const file of functionFiles) {
|
|
2625
|
+
const filePath = join(preflightFunctionsDir, file);
|
|
2626
|
+
let tomlData;
|
|
2627
|
+
try {
|
|
2628
|
+
tomlData = parseTomlFile(filePath);
|
|
2629
|
+
}
|
|
2630
|
+
catch (err) {
|
|
2631
|
+
preflightValidationErrors.push(` ${filePath}: ${err?.message || String(err)}`);
|
|
2632
|
+
continue;
|
|
2633
|
+
}
|
|
2634
|
+
for (const message of functionGrantErrors({
|
|
2635
|
+
filePath,
|
|
2636
|
+
tomlData,
|
|
2637
|
+
knownTargets,
|
|
2638
|
+
})) {
|
|
2639
|
+
preflightValidationErrors.push(` ${message}`);
|
|
2640
|
+
}
|
|
2641
|
+
// #3320 — and everything else the file decides on its own: the
|
|
2642
|
+
// `entry`, the mode the two spellings resolve to, the trigger
|
|
2643
|
+
// rules, and a webhook trigger's signing-secret requirement and
|
|
2644
|
+
// reference shape. These used to be raised inside the apply loop,
|
|
2645
|
+
// after webhooks, integrations, vars and database types had
|
|
2646
|
+
// already been written — the partial tree this issue is about.
|
|
2647
|
+
// Bounded by the same `selectFiles` above, so `--only` keeps its
|
|
2648
|
+
// posture (#2645).
|
|
2649
|
+
const functionKey = tomlData?.function?.key || basename(file, ".toml");
|
|
2650
|
+
for (const message of functionTomlPreflightErrors({
|
|
2651
|
+
filePath,
|
|
2652
|
+
functionKey,
|
|
2653
|
+
tomlData,
|
|
2654
|
+
})) {
|
|
2655
|
+
preflightValidationErrors.push(` ${filePath}: ${message}`);
|
|
2656
|
+
}
|
|
2657
|
+
// #3482 — the retired runtime keys. NOT refusals through phase 4:
|
|
2658
|
+
// the file pushes with every other key applied, and the author
|
|
2659
|
+
// hears which line to delete before phase 5 refuses it. On
|
|
2660
|
+
// stderr, like every other advisory, so a scripted push reading
|
|
2661
|
+
// stdout is unaffected. Inside this loop rather than over the
|
|
2662
|
+
// whole tree, so `--only` bounds the advice as it bounds the
|
|
2663
|
+
// refusals (#2645).
|
|
2664
|
+
for (const message of functionTomlIgnoredKeyWarnings({
|
|
2665
|
+
functionKey,
|
|
2666
|
+
tomlData,
|
|
2667
|
+
})) {
|
|
2668
|
+
warn(` ${message}`);
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
// #2644 — reject any key the vendored definition does not recognize,
|
|
2674
|
+
// for every migrated configuration type. Same posture the `[workflow]`
|
|
2675
|
+
// check above has, generalized: a mistyped key fails here, in the
|
|
2676
|
+
// preflight pass, so nothing is applied and no request is issued —
|
|
2677
|
+
// rather than being dropped on the way to the server and reading as a
|
|
2678
|
+
// successful push that changed nothing.
|
|
2679
|
+
// #2880 — the same collector `config diff` runs, so the two commands
|
|
2680
|
+
// cannot disagree about which files are pushable.
|
|
2681
|
+
for (const rejected of collectConfigFileValidationErrors(configDir)) {
|
|
2682
|
+
for (const message of rejected.messages) {
|
|
2683
|
+
preflightValidationErrors.push(` ${message}`);
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
// #3626 §4 — one advisory line per prompt file still written in the flat
|
|
2687
|
+
// chat spelling, beside the `celContextAccess` notice above. The push
|
|
2688
|
+
// continues and the keys are applied; `config pull` rewrites the file, so a
|
|
2689
|
+
// single pull-then-push clears the notice. Printed even under `--dry-run`,
|
|
2690
|
+
// which never reaches the apply — the notice is about the FILE.
|
|
2691
|
+
const preflightPromptsDir = join(configDir, "prompts");
|
|
2692
|
+
// #3798 — the functions an agent's references are checked against, read
|
|
2693
|
+
// once and only when a selected prompt is an agent that names one.
|
|
2694
|
+
let agentFunctions = null;
|
|
2695
|
+
if (existsSync(preflightPromptsDir)) {
|
|
2696
|
+
for (const file of selectFiles("prompt", preflightPromptsDir, readdirSync(preflightPromptsDir).filter((f) => f.endsWith(".toml")))) {
|
|
2697
|
+
const filePath = join(preflightPromptsDir, file);
|
|
2698
|
+
let tomlData;
|
|
2699
|
+
try {
|
|
2700
|
+
tomlData = parseTomlFile(filePath);
|
|
2701
|
+
}
|
|
2702
|
+
catch {
|
|
2703
|
+
// A file that does not parse is reported by the collector above.
|
|
2704
|
+
continue;
|
|
2705
|
+
}
|
|
2706
|
+
const notice = promptFlatChatKeyWarning(filePath, tomlData);
|
|
2707
|
+
if (notice)
|
|
2708
|
+
warn(` ${notice}`);
|
|
2709
|
+
// #3798 — an agent's tool, turn-context and history functions must
|
|
2710
|
+
// exist, and a tool's must declare both schemas, before anything is
|
|
2711
|
+
// applied. Bounded by `--only` like the rest of this loop.
|
|
2712
|
+
if (promptKindOf(tomlData?.prompt) === "agent" &&
|
|
2713
|
+
agentDeclarationFunctionKeys(tomlData?.prompt?.agent).length > 0) {
|
|
2714
|
+
if (!agentFunctions) {
|
|
2715
|
+
let live = null;
|
|
2716
|
+
try {
|
|
2717
|
+
live = (await client.listFunctions(resolvedAppId)).items;
|
|
2718
|
+
}
|
|
2719
|
+
catch {
|
|
2720
|
+
live = null;
|
|
2721
|
+
}
|
|
2722
|
+
agentFunctions = agentFunctionFactsForPush({
|
|
2723
|
+
configDir,
|
|
2724
|
+
pushSelection,
|
|
2725
|
+
live,
|
|
2726
|
+
});
|
|
2727
|
+
}
|
|
2728
|
+
for (const message of promptAgentFunctionErrors(filePath, tomlData, agentFunctions)) {
|
|
2729
|
+
preflightValidationErrors.push(` ${message}`);
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
// #3798 — a test case on an agent is refused by the admin routes; say so
|
|
2735
|
+
// here, before any other file is applied.
|
|
2736
|
+
for (const message of promptAgentSidecarErrors(configDir)) {
|
|
2737
|
+
preflightValidationErrors.push(` ${message}`);
|
|
2738
|
+
}
|
|
2739
|
+
// The `<key>.tests/*.toml` sidecars, which sit beside the block they
|
|
2740
|
+
// test rather than in a type directory of their own. Same posture,
|
|
2741
|
+
// reached through the registry's `test-case` surface — and through the
|
|
2742
|
+
// same collector `config diff` runs per sidecar (#2880), so neither
|
|
2743
|
+
// command can refuse a file the other calls Synced.
|
|
2744
|
+
for (const filePath of testCaseTomlFiles(configDir)) {
|
|
2745
|
+
let tomlData;
|
|
2746
|
+
try {
|
|
2747
|
+
tomlData = parseTomlFile(filePath);
|
|
2748
|
+
}
|
|
2749
|
+
catch {
|
|
2750
|
+
// Reported by the per-block test-case loop with its own message.
|
|
2751
|
+
continue;
|
|
2752
|
+
}
|
|
2753
|
+
for (const message of testCaseSidecarPreflightErrors(filePath, tomlData)) {
|
|
2754
|
+
preflightValidationErrors.push(` ${message}`);
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
if (preflightValidationErrors.length > 0) {
|
|
2758
|
+
throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
|
|
2759
|
+
preflightValidationErrors.join("\n"));
|
|
2760
|
+
}
|
|
2761
|
+
return { parsedVars: preflightParsedVars };
|
|
2762
|
+
}
|
|
2136
2763
|
/**
|
|
2137
2764
|
* Every per-entity config file whose TOML `config push` would refuse (#2880
|
|
2138
2765
|
* criterion 1, symmetry).
|
|
@@ -2143,18 +2770,445 @@ function localConfigRowKey(label, tomlData, file) {
|
|
|
2143
2770
|
* issue's reporter hit after #2803 retired `[prompt].status`, where a local key
|
|
2144
2771
|
* with no server counterpart is invisible to a comparison and fatal to a push.
|
|
2145
2772
|
*
|
|
2146
|
-
* Three checks, all definition-driven: the document's table shape, keys no
|
|
2147
|
-
* definition declares (retired keys among them), and — since #2880 — values
|
|
2148
|
-
* whose spelling is not the declared type. A file that does not parse is left
|
|
2149
|
-
* to the per-type loop that reads it, which names the parse error itself.
|
|
2773
|
+
* Three checks, all definition-driven: the document's table shape, keys no
|
|
2774
|
+
* definition declares (retired keys among them), and — since #2880 — values
|
|
2775
|
+
* whose spelling is not the declared type. A file that does not parse is left
|
|
2776
|
+
* to the per-type loop that reads it, which names the parse error itself.
|
|
2777
|
+
*/
|
|
2778
|
+
/**
|
|
2779
|
+
* The blocks one `[[configs]]` entry carries that its prompt's kind does not
|
|
2780
|
+
* name — #3798. An agent's entry may carry only `[configs.agent]`; a chat or
|
|
2781
|
+
* decisions entry may not carry it. `movedFlat` are the deprecated flat chat
|
|
2782
|
+
* keys the hoist moved under `chat`, named as the author wrote them.
|
|
2783
|
+
*
|
|
2784
|
+
* Only the blocks the other arms of `resolvePromptKind` cannot see are checked
|
|
2785
|
+
* here: a decisions block on a chat entry, and chat keys on a decisions entry,
|
|
2786
|
+
* stay the resolver's refusals with their #3626 wording.
|
|
2787
|
+
*/
|
|
2788
|
+
function promptEntryBlockErrors(kind, entry, movedFlat) {
|
|
2789
|
+
const has = (block) => entry != null && typeof entry === "object" && entry[block] !== undefined;
|
|
2790
|
+
const errors = [];
|
|
2791
|
+
if (kind === "agent") {
|
|
2792
|
+
if (movedFlat.length > 0) {
|
|
2793
|
+
errors.push(`sets ${movedFlat.join(", ")} flat — the deprecated [configs.chat] ` +
|
|
2794
|
+
'spelling — but prompt kind is "agent", so it may carry only a ' +
|
|
2795
|
+
"[configs.agent] block; write them under [configs.agent]");
|
|
2796
|
+
}
|
|
2797
|
+
for (const block of ["chat", "decisions"]) {
|
|
2798
|
+
if (!has(block))
|
|
2799
|
+
continue;
|
|
2800
|
+
errors.push(`carries a [configs.${block}] block, but prompt kind is "agent", so ` +
|
|
2801
|
+
"it may carry only a [configs.agent] block (systemPrompt, " +
|
|
2802
|
+
"temperature, topP, maxTokens, reasoningEffort, reasoningBudget)");
|
|
2803
|
+
}
|
|
2804
|
+
return errors;
|
|
2805
|
+
}
|
|
2806
|
+
if (has("agent")) {
|
|
2807
|
+
errors.push(`carries a [configs.agent] block, but prompt kind is "${kind}", so it ` +
|
|
2808
|
+
`may carry only a [configs.${kind}] block. An agent is its own ` +
|
|
2809
|
+
'prompt: create it under a new key with kind = "agent"');
|
|
2810
|
+
}
|
|
2811
|
+
return errors;
|
|
2812
|
+
}
|
|
2813
|
+
/**
|
|
2814
|
+
* A prompt file's KIND errors — #3626, decidable from the file alone.
|
|
2815
|
+
*
|
|
2816
|
+
* Three refusals, all of them the same rule seen from different angles: a key
|
|
2817
|
+
* set both flat and under `[configs.chat]` (the hoist will not guess), a
|
|
2818
|
+
* `[[configs]]` entry carrying the block of the OTHER kind, and a malformed
|
|
2819
|
+
* decisions declaration. The last two come from `resolvePromptKind`, the same
|
|
2820
|
+
* vendored function the admin routes validate with, so the CLI's refusal and
|
|
2821
|
+
* the server's cannot disagree — and this one lands in the preflight, before
|
|
2822
|
+
* the first mutating call.
|
|
2823
|
+
*/
|
|
2824
|
+
/**
|
|
2825
|
+
* The `[[configs]]` entries of a prompt file as the agent rules read them —
|
|
2826
|
+
* #3799: which provider each named configuration runs on, and whether the file
|
|
2827
|
+
* retires it.
|
|
2828
|
+
*
|
|
2829
|
+
* An archived entry is skipped by the translation rule (a config the server does
|
|
2830
|
+
* not run cannot make the declaration untranslatable), which is what lets a
|
|
2831
|
+
* pulled tree keep a retired config on a provider the current schemas could not
|
|
2832
|
+
* reach. `status` is read through the same default the surface declares, so an
|
|
2833
|
+
* entry that omits the key is live.
|
|
2834
|
+
*/
|
|
2835
|
+
function promptFileConfigFacts(entries) {
|
|
2836
|
+
return (entries || []).map((entry, index) => ({
|
|
2837
|
+
configName: String(entry?.name ?? `configs[${index}]`),
|
|
2838
|
+
provider: String(entry?.provider ?? ""),
|
|
2839
|
+
status: String(entry?.status ?? PROMPT_CONFIG_DEFAULT_STATUS),
|
|
2840
|
+
}));
|
|
2841
|
+
}
|
|
2842
|
+
export function promptKindFileErrors(filePath, tomlData) {
|
|
2843
|
+
const entries = Array.isArray(tomlData?.configs) ? tomlData.configs : [];
|
|
2844
|
+
const errors = [];
|
|
2845
|
+
const fileKind = promptKindOf(tomlData?.prompt);
|
|
2846
|
+
// #3798 — the `[prompt.agent]` declaration's file-decidable half: its shape,
|
|
2847
|
+
// and `[prompt].outputSchema` on an agent. The function half needs app state
|
|
2848
|
+
// and runs in the push preflight (`promptAgentFunctionErrors`).
|
|
2849
|
+
const declaration = resolveAgentDeclaration({
|
|
2850
|
+
kind: tomlData?.prompt?.kind,
|
|
2851
|
+
agent: tomlData?.prompt?.agent,
|
|
2852
|
+
outputSchema: tomlData?.prompt?.outputSchema,
|
|
2853
|
+
});
|
|
2854
|
+
if (declaration.ok === false) {
|
|
2855
|
+
for (const refusal of declaration.refusals) {
|
|
2856
|
+
errors.push(`${filePath}: ${refusal.message}`);
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
else if (fileKind === "agent") {
|
|
2860
|
+
// #3799 — the schemas a CLIENT tool and an event declare are in the file,
|
|
2861
|
+
// so whether each provider the file configures can carry them without
|
|
2862
|
+
// losing meaning is file-decidable too: `config diff` reports the same row
|
|
2863
|
+
// as `config push`. A server tool's schemas are its FUNCTION's and need app
|
|
2864
|
+
// state, so they are checked in the push preflight
|
|
2865
|
+
// (`promptAgentFunctionErrors`). Only after the shape refusals above, so a
|
|
2866
|
+
// malformed declaration is not also reported as untranslatable.
|
|
2867
|
+
for (const refusal of agentSchemaTranslationRefusals({
|
|
2868
|
+
declaration: tomlData?.prompt?.agent,
|
|
2869
|
+
configs: promptFileConfigFacts(entries),
|
|
2870
|
+
})) {
|
|
2871
|
+
errors.push(`${filePath}: ${refusal.message}`);
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
for (const [index, entry] of entries.entries()) {
|
|
2875
|
+
const hoisted = hoistDeprecatedChatKeys(entry);
|
|
2876
|
+
for (const key of hoisted.conflicts) {
|
|
2877
|
+
errors.push(`${filePath}: configs[${index}].${key} is set both flat and under ` +
|
|
2878
|
+
`[configs.chat]; keep the [configs.chat] spelling`);
|
|
2879
|
+
}
|
|
2880
|
+
// #3798 — the block rule for three kinds, stated per entry: an entry may
|
|
2881
|
+
// carry only the block its prompt's kind names. An agent's `[configs.agent]`
|
|
2882
|
+
// holds keys chat also holds, so a stray block would otherwise be read (or
|
|
2883
|
+
// ignored) in silence by the payload builder.
|
|
2884
|
+
const blockErrors = promptEntryBlockErrors(fileKind, entry, hoisted.moved).map((message) => `${filePath}: configs[${index}] ${message}`);
|
|
2885
|
+
if (blockErrors.length > 0) {
|
|
2886
|
+
errors.push(...blockErrors);
|
|
2887
|
+
continue;
|
|
2888
|
+
}
|
|
2889
|
+
if (fileKind === "agent") {
|
|
2890
|
+
const agent = hoisted.entry.agent ?? {};
|
|
2891
|
+
const resolution = resolvePromptKind({
|
|
2892
|
+
kind: "agent",
|
|
2893
|
+
provider: hoisted.entry.provider,
|
|
2894
|
+
model: hoisted.entry.model,
|
|
2895
|
+
config: {
|
|
2896
|
+
systemPrompt: agent.systemPrompt,
|
|
2897
|
+
temperature: agent.temperature,
|
|
2898
|
+
topP: agent.topP,
|
|
2899
|
+
maxTokens: agent.maxTokens,
|
|
2900
|
+
reasoningEffort: agent.reasoningEffort,
|
|
2901
|
+
reasoningBudget: agent.reasoningBudget,
|
|
2902
|
+
// #3801 — a chat key: named, so the refusal says which key an agent
|
|
2903
|
+
// does not take (§D1: an agent has no output schema).
|
|
2904
|
+
strictOutput: agent.strictOutput,
|
|
2905
|
+
},
|
|
2906
|
+
});
|
|
2907
|
+
if (resolution.ok === false) {
|
|
2908
|
+
errors.push(`${filePath}: configs[${index}] — ${resolution.error}`);
|
|
2909
|
+
}
|
|
2910
|
+
continue;
|
|
2911
|
+
}
|
|
2912
|
+
const chat = hoisted.entry.chat ?? {};
|
|
2913
|
+
const decisions = hoisted.entry.decisions ?? {};
|
|
2914
|
+
// #3626 — the FIRST entry of a chat prompt carries the create seed's
|
|
2915
|
+
// placeholder. `buildPromptCreatePayload` sends `{{ input }}` when the
|
|
2916
|
+
// file names no template, which is how a prompt authored without one has
|
|
2917
|
+
// always created; refusing it here would break a file that pushed before
|
|
2918
|
+
// the kind existed. An entry that really names none on the UPDATE path is
|
|
2919
|
+
// still refused, by the server, with this same message.
|
|
2920
|
+
//
|
|
2921
|
+
// CR3626-005 — the fallback is `||`, not `??`, because that is what
|
|
2922
|
+
// `buildPromptCreatePayload` applies (`seed.userPromptTemplate ||
|
|
2923
|
+
// "{{ input }}"`). `config create prompt` scaffolds an EMPTY
|
|
2924
|
+
// `userPromptTemplate`, so reading absence more narrowly than the builder
|
|
2925
|
+
// does refused the file this CLI had just written, for a value the create
|
|
2926
|
+
// would have replaced with the placeholder anyway.
|
|
2927
|
+
const seedsTemplate = index === 0 && fileKind === "chat";
|
|
2928
|
+
const userPromptTemplate = seedsTemplate
|
|
2929
|
+
? chat.userPromptTemplate || "{{ input }}"
|
|
2930
|
+
: chat.userPromptTemplate;
|
|
2931
|
+
const resolution = resolvePromptKind({
|
|
2932
|
+
kind: tomlData?.prompt?.kind,
|
|
2933
|
+
provider: hoisted.entry.provider,
|
|
2934
|
+
model: hoisted.entry.model,
|
|
2935
|
+
config: {
|
|
2936
|
+
systemPrompt: chat.systemPrompt,
|
|
2937
|
+
userPromptTemplate,
|
|
2938
|
+
temperature: chat.temperature,
|
|
2939
|
+
topP: chat.topP,
|
|
2940
|
+
maxTokens: chat.maxTokens,
|
|
2941
|
+
outputFormat: chat.outputFormat,
|
|
2942
|
+
outputSchema: chat.outputSchema,
|
|
2943
|
+
reasoningEffort: chat.reasoningEffort,
|
|
2944
|
+
reasoningBudget: chat.reasoningBudget,
|
|
2945
|
+
strictOutput: chat.strictOutput,
|
|
2946
|
+
questions: decisions.questions,
|
|
2947
|
+
},
|
|
2948
|
+
});
|
|
2949
|
+
if (resolution.ok === false) {
|
|
2950
|
+
errors.push(`${filePath}: configs[${index}] — ${resolution.error}`);
|
|
2951
|
+
continue;
|
|
2952
|
+
}
|
|
2953
|
+
// #3801 — strict output, file-decidable: the entry's own keys, the file's
|
|
2954
|
+
// `[prompt].outputSchema` and the entry's status (an archived entry is not
|
|
2955
|
+
// held to the prompt schema, DSO-3801-002). The route runs the same rule
|
|
2956
|
+
// against what the server holds.
|
|
2957
|
+
if (resolution.kind === "chat") {
|
|
2958
|
+
const strict = resolvePromptStrictOutput({
|
|
2959
|
+
provider: hoisted.entry.provider,
|
|
2960
|
+
strictOutput: chat.strictOutput,
|
|
2961
|
+
outputFormat: chat.outputFormat,
|
|
2962
|
+
configOutputSchema: chat.outputSchema,
|
|
2963
|
+
promptOutputSchema: tomlData?.prompt?.outputSchema,
|
|
2964
|
+
status: hoisted.entry.status ?? PROMPT_CONFIG_DEFAULT_STATUS,
|
|
2965
|
+
});
|
|
2966
|
+
if (strict.ok === false) {
|
|
2967
|
+
errors.push(`${filePath}: configs[${index}] — ${strict.error} (${strict.code})`);
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
return errors;
|
|
2972
|
+
}
|
|
2973
|
+
/** A schema slot counts as declared when it holds anything but nothing. */
|
|
2974
|
+
function declaresSchema(value) {
|
|
2975
|
+
if (value === undefined || value === null)
|
|
2976
|
+
return false;
|
|
2977
|
+
return !(typeof value === "string" && value.trim() === "");
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* The functions an agent's references are checked against at push — #3798,
|
|
2981
|
+
* DSO-3798-002.
|
|
2982
|
+
*
|
|
2983
|
+
* `config push` applies prompts, then (for agents) the server functions leg,
|
|
2984
|
+
* so a reference may be satisfied by a function that is live already or by one
|
|
2985
|
+
* this push creates. What the push BELIEVES is therefore the live listing
|
|
2986
|
+
* overlaid by the function files this push SELECTS (`--only`, every file when
|
|
2987
|
+
* unscoped): a selected file's schemas are what the server will hold after the
|
|
2988
|
+
* push, so it wins over the live row. An UNSELECTED file is not read at all —
|
|
2989
|
+
* its edits will not be applied, and a function only it declares will not
|
|
2990
|
+
* exist afterwards.
|
|
2991
|
+
*
|
|
2992
|
+
* `live` is `null` when the listing could not be fetched: the selected files
|
|
2993
|
+
* still count, and `liveListed` lets the refusal say why every other reference
|
|
2994
|
+
* could not be confirmed. A live row that is archived is a tombstone, not a
|
|
2995
|
+
* function; an inactive one is accepted (the turn engine re-checks
|
|
2996
|
+
* availability on every call).
|
|
2997
|
+
*/
|
|
2998
|
+
export function agentFunctionFactsForPush(input) {
|
|
2999
|
+
const facts = {};
|
|
3000
|
+
for (const row of input.live ?? []) {
|
|
3001
|
+
const key = String(row?.functionKey ?? "");
|
|
3002
|
+
if (!key || row?.status === "archived")
|
|
3003
|
+
continue;
|
|
3004
|
+
facts[key] = {
|
|
3005
|
+
hasInputSchema: declaresSchema(row.inputSchema),
|
|
3006
|
+
hasOutputSchema: declaresSchema(row.outputSchema),
|
|
3007
|
+
// #3799 — the schemas themselves, as stored (a JSON string from the API,
|
|
3008
|
+
// an object from a tree file), so a server tool's two schemas can be
|
|
3009
|
+
// translated for each of the agent's providers.
|
|
3010
|
+
inputSchema: row.inputSchema,
|
|
3011
|
+
outputSchema: row.outputSchema,
|
|
3012
|
+
};
|
|
3013
|
+
}
|
|
3014
|
+
const functionsDir = join(input.configDir, "functions");
|
|
3015
|
+
if (existsSync(functionsDir)) {
|
|
3016
|
+
for (const file of filterFilesBySelection(input.pushSelection, "function", functionsDir, readdirSync(functionsDir).filter(isFunctionDeclarationFile))) {
|
|
3017
|
+
let table;
|
|
3018
|
+
try {
|
|
3019
|
+
table = parseTomlFile(join(functionsDir, file))?.function ?? {};
|
|
3020
|
+
}
|
|
3021
|
+
catch {
|
|
3022
|
+
// Reported by the function preflight with its own message.
|
|
3023
|
+
continue;
|
|
3024
|
+
}
|
|
3025
|
+
const key = table.key || basename(file, ".toml");
|
|
3026
|
+
facts[key] = {
|
|
3027
|
+
hasInputSchema: declaresSchema(table.inputSchema),
|
|
3028
|
+
hasOutputSchema: declaresSchema(table.outputSchema),
|
|
3029
|
+
// #3799 — a SELECTED file's schemas are what the server will hold after
|
|
3030
|
+
// this push, so they are what the translation is checked against.
|
|
3031
|
+
inputSchema: table.inputSchema,
|
|
3032
|
+
outputSchema: table.outputSchema,
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
return { facts, liveListed: input.live !== null };
|
|
3037
|
+
}
|
|
3038
|
+
/**
|
|
3039
|
+
* The function half of an agent's declaration — #3798: every tool, turn
|
|
3040
|
+
* context and history function that the functions this push believes
|
|
3041
|
+
* (`agentFunctionFactsForPush`) do not have, and every tool function lacking an
|
|
3042
|
+
* input or output schema. The same `resolveAgentDeclaration` the admin routes
|
|
3043
|
+
* run; only its function refusals are reported here, because the collector
|
|
3044
|
+
* (`promptKindFileErrors`) already reported the file-decidable ones.
|
|
3045
|
+
*/
|
|
3046
|
+
export function promptAgentFunctionErrors(filePath, tomlData, known) {
|
|
3047
|
+
if (promptKindOf(tomlData?.prompt) !== "agent")
|
|
3048
|
+
return [];
|
|
3049
|
+
const result = resolveAgentDeclaration({
|
|
3050
|
+
kind: "agent",
|
|
3051
|
+
agent: tomlData?.prompt?.agent,
|
|
3052
|
+
functions: known.facts,
|
|
3053
|
+
});
|
|
3054
|
+
const errors = result.ok === false
|
|
3055
|
+
? result.refusals
|
|
3056
|
+
.filter((refusal) => refusal.code === "AGENT_TOOL_FUNCTION_MISSING" ||
|
|
3057
|
+
refusal.code === "AGENT_TOOL_FUNCTION_SCHEMA_MISSING")
|
|
3058
|
+
.map((refusal) => refusal.code === "AGENT_TOOL_FUNCTION_MISSING" && !known.liveListed
|
|
3059
|
+
? `${filePath}: ${refusal.message} (the app's live functions could ` +
|
|
3060
|
+
"not be listed, so only the function files this push applies were " +
|
|
3061
|
+
"checked)"
|
|
3062
|
+
: `${filePath}: ${refusal.message}`)
|
|
3063
|
+
: [];
|
|
3064
|
+
// #3799 — a SERVER tool's schemas are its function's, so translating them
|
|
3065
|
+
// needs the facts this push believes (the live listing overlaid by the
|
|
3066
|
+
// function files it selects, DSO-3798-002). The client-tool and event half is
|
|
3067
|
+
// file-decidable and reported by `promptKindFileErrors`, which `config diff`
|
|
3068
|
+
// runs too; passing the facts here means only the server-tool refusals are
|
|
3069
|
+
// new, because that collector already saw the rest.
|
|
3070
|
+
const entries = Array.isArray(tomlData?.configs) ? tomlData.configs : [];
|
|
3071
|
+
const configs = promptFileConfigFacts(entries);
|
|
3072
|
+
const fileDecidable = new Set(agentSchemaTranslationRefusals({
|
|
3073
|
+
declaration: tomlData?.prompt?.agent,
|
|
3074
|
+
configs,
|
|
3075
|
+
}).map((refusal) => refusal.message));
|
|
3076
|
+
for (const refusal of agentSchemaTranslationRefusals({
|
|
3077
|
+
declaration: tomlData?.prompt?.agent,
|
|
3078
|
+
functions: known.facts,
|
|
3079
|
+
configs,
|
|
3080
|
+
})) {
|
|
3081
|
+
if (fileDecidable.has(refusal.message))
|
|
3082
|
+
continue;
|
|
3083
|
+
errors.push(`${filePath}: ${refusal.message}`);
|
|
3084
|
+
}
|
|
3085
|
+
return errors;
|
|
3086
|
+
}
|
|
3087
|
+
/**
|
|
3088
|
+
* The order `config push` applies prompt files in — #3798. An agent names
|
|
3089
|
+
* server functions that must exist when it is created, and the push applies
|
|
3090
|
+
* prompts before functions, so agent files go AFTER the server-functions leg;
|
|
3091
|
+
* every other file keeps its place. A file that does not parse stays first, so
|
|
3092
|
+
* the leg reports it where it always has.
|
|
3093
|
+
*/
|
|
3094
|
+
export function partitionPromptFilesForPush(promptsDir, files) {
|
|
3095
|
+
const beforeFunctions = [];
|
|
3096
|
+
const afterFunctions = [];
|
|
3097
|
+
for (const file of files) {
|
|
3098
|
+
let kind = "chat";
|
|
3099
|
+
try {
|
|
3100
|
+
kind = promptKindOf(parseTomlFile(join(promptsDir, file))?.prompt);
|
|
3101
|
+
}
|
|
3102
|
+
catch {
|
|
3103
|
+
beforeFunctions.push(file);
|
|
3104
|
+
continue;
|
|
3105
|
+
}
|
|
3106
|
+
(kind === "agent" ? afterFunctions : beforeFunctions).push(file);
|
|
3107
|
+
}
|
|
3108
|
+
return { beforeFunctions, afterFunctions };
|
|
3109
|
+
}
|
|
3110
|
+
/**
|
|
3111
|
+
* Every `prompts/<key>.tests/*.toml` sidecar whose prompt is an agent — #3798
|
|
3112
|
+
* (§D1: prompt test cases are refused for the kind in v1). The admin test-case
|
|
3113
|
+
* routes refuse the same case with `AGENT_TEST_CASE_NOT_SUPPORTED`; this says
|
|
3114
|
+
* so before anything is applied.
|
|
3115
|
+
*/
|
|
3116
|
+
export function promptAgentSidecarErrors(configDir) {
|
|
3117
|
+
const promptsDir = join(configDir, "prompts");
|
|
3118
|
+
const errors = [];
|
|
3119
|
+
for (const filePath of testCaseTomlFiles(configDir)) {
|
|
3120
|
+
const parent = basename(join(filePath, ".."));
|
|
3121
|
+
if (!filePath.startsWith(promptsDir) || !parent.endsWith(".tests"))
|
|
3122
|
+
continue;
|
|
3123
|
+
const key = parent.slice(0, -".tests".length);
|
|
3124
|
+
const owner = join(promptsDir, `${key}.toml`);
|
|
3125
|
+
if (!existsSync(owner))
|
|
3126
|
+
continue;
|
|
3127
|
+
let tomlData;
|
|
3128
|
+
try {
|
|
3129
|
+
tomlData = parseTomlFile(owner);
|
|
3130
|
+
}
|
|
3131
|
+
catch {
|
|
3132
|
+
continue;
|
|
3133
|
+
}
|
|
3134
|
+
if (promptKindOf(tomlData?.prompt) !== "agent")
|
|
3135
|
+
continue;
|
|
3136
|
+
const promptKey = tomlData?.prompt?.key || key;
|
|
3137
|
+
errors.push(`${filePath.slice(configDir.length + 1)}: ` +
|
|
3138
|
+
agentTestCaseRefusal(promptKey, "subject").message);
|
|
3139
|
+
}
|
|
3140
|
+
return errors;
|
|
3141
|
+
}
|
|
3142
|
+
/**
|
|
3143
|
+
* The immutable-`kind` refusal for a prompt update the comparison gate did not
|
|
3144
|
+
* decide — #3626, CR3626-001.
|
|
3145
|
+
*
|
|
3146
|
+
* `kind` is create-only (D3626-008), and the gate refuses a change to it on
|
|
3147
|
+
* every path that compares the two sides. Two update paths do not compare: a
|
|
3148
|
+
* prompt whose DETAIL read failed falls back to the manifest byte-hash gate,
|
|
3149
|
+
* which `--force` writes straight through, and a prompt ADOPTED after a
|
|
3150
|
+
* create's 409 is re-issued as an update without a comparison at all. Both
|
|
3151
|
+
* would otherwise PATCH the file's schemas, access rule and metadata onto a
|
|
3152
|
+
* row of the other kind, and only then have its configs refused against the
|
|
3153
|
+
* kind the update cannot change.
|
|
3154
|
+
*
|
|
3155
|
+
* Returns the gate's own outcome shape, so `recordDeclined` prints the same
|
|
3156
|
+
* two values and the same remedy — create the prompt under a new key.
|
|
3157
|
+
*
|
|
3158
|
+
* `null` when the kinds agree and when the server's kind is simply not KNOWN:
|
|
3159
|
+
* a push that could not read live state at all has nothing to compare, and
|
|
3160
|
+
* that path's contract (`decideDegradedPush`, #2880) is already "decline
|
|
3161
|
+
* unless the operator forces it", stated for every field at once.
|
|
3162
|
+
*/
|
|
3163
|
+
export function promptKindImmutableOutcome(storedKind, localDoc) {
|
|
3164
|
+
if (!storedKind)
|
|
3165
|
+
return null;
|
|
3166
|
+
const localKind = promptKindOf(localDoc?.prompt);
|
|
3167
|
+
if (localKind === storedKind)
|
|
3168
|
+
return null;
|
|
3169
|
+
return {
|
|
3170
|
+
action: "immutable",
|
|
3171
|
+
// The hashes are the gate's evidence for WHICH side moved, and this
|
|
3172
|
+
// refusal does not depend on that: the field cannot be written either way.
|
|
3173
|
+
localHash: "",
|
|
3174
|
+
remoteHash: "",
|
|
3175
|
+
fields: [{ field: "kind", local: localKind, server: storedKind }],
|
|
3176
|
+
immutableFields: ["kind"],
|
|
3177
|
+
};
|
|
3178
|
+
}
|
|
3179
|
+
/**
|
|
3180
|
+
* The one deprecation notice a prompt file using the FLAT chat spelling earns
|
|
3181
|
+
* — #3626 §4, beside the `celContextAccess` notice's precedent.
|
|
3182
|
+
*
|
|
3183
|
+
* Advisory: the push continues and the keys are applied. `null` when the file
|
|
3184
|
+
* is already written in the grouped spelling, so a migrated tree is silent.
|
|
2150
3185
|
*/
|
|
3186
|
+
export function promptFlatChatKeyWarning(filePath, tomlData) {
|
|
3187
|
+
const entries = Array.isArray(tomlData?.configs) ? tomlData.configs : [];
|
|
3188
|
+
const moved = new Set();
|
|
3189
|
+
for (const entry of entries) {
|
|
3190
|
+
for (const key of hoistDeprecatedChatKeys(entry).moved)
|
|
3191
|
+
moved.add(key);
|
|
3192
|
+
}
|
|
3193
|
+
if (moved.size === 0)
|
|
3194
|
+
return null;
|
|
3195
|
+
const named = [...moved].sort().join(", ");
|
|
3196
|
+
return (`${filePath}: ${named} ${moved.size === 1 ? "is" : "are"} written flat ` +
|
|
3197
|
+
"under [[configs]]; this spelling is deprecated — move them under " +
|
|
3198
|
+
"[configs.chat] (`config pull` rewrites the file). Rejecting the flat " +
|
|
3199
|
+
"spelling is tracked in #3642.");
|
|
3200
|
+
}
|
|
2151
3201
|
export function collectConfigFileValidationErrors(configDir) {
|
|
2152
3202
|
const found = [];
|
|
2153
3203
|
for (const { dir, surface, tables } of unknownKeyPreflightTargets()) {
|
|
2154
3204
|
const typeDir = join(configDir, dir);
|
|
2155
3205
|
if (!existsSync(typeDir))
|
|
2156
3206
|
continue;
|
|
2157
|
-
for (const file of readdirSync(typeDir).filter((f) =>
|
|
3207
|
+
for (const file of readdirSync(typeDir).filter((f) =>
|
|
3208
|
+
// #3455 — `functions/` holds one GENERATED `.toml` beside the
|
|
3209
|
+
// declarations: the project's document schema, which is not a config
|
|
3210
|
+
// object and would be read as a function named after the file.
|
|
3211
|
+
dir === "functions" ? isFunctionDeclarationFile(f) : f.endsWith(".toml"))) {
|
|
2158
3212
|
const filePath = join(typeDir, file);
|
|
2159
3213
|
let tomlData;
|
|
2160
3214
|
try {
|
|
@@ -2179,6 +3233,12 @@ export function collectConfigFileValidationErrors(configDir) {
|
|
|
2179
3233
|
for (const table of surface.tables) {
|
|
2180
3234
|
messages.push(...configDeclaredTypeErrors(filePath, tomlData, table));
|
|
2181
3235
|
}
|
|
3236
|
+
// #3626 — the prompt kind's own file-decidable refusals, run by the same
|
|
3237
|
+
// collector so `config push` and `config diff` cannot disagree about
|
|
3238
|
+
// which prompt files are pushable.
|
|
3239
|
+
if (surface.label === "prompt") {
|
|
3240
|
+
messages.push(...promptKindFileErrors(filePath, tomlData));
|
|
3241
|
+
}
|
|
2182
3242
|
if (messages.length === 0)
|
|
2183
3243
|
continue;
|
|
2184
3244
|
found.push({
|
|
@@ -2191,6 +3251,104 @@ export function collectConfigFileValidationErrors(configDir) {
|
|
|
2191
3251
|
}
|
|
2192
3252
|
return found;
|
|
2193
3253
|
}
|
|
3254
|
+
/**
|
|
3255
|
+
* Every `functions/<key>.toml` whose file-decidable errors `config push` would
|
|
3256
|
+
* refuse (#3320).
|
|
3257
|
+
*
|
|
3258
|
+
* A second collector rather than a branch inside
|
|
3259
|
+
* `collectConfigFileValidationErrors`, and deliberately so: push calls that one
|
|
3260
|
+
* UNBOUNDED over the whole tree, which is right for the checks it runs (a
|
|
3261
|
+
* mistyped key anywhere means the tree is not pushable) and wrong for these — a
|
|
3262
|
+
* `config push --only webhook/<key>` must not be blocked by a broken function
|
|
3263
|
+
* the operator did not select (#2645). The push path therefore runs these
|
|
3264
|
+
* inside its own `selectFiles`-bounded function loop, and `config diff`, which
|
|
3265
|
+
* compares the whole tree by construction, runs them from here.
|
|
3266
|
+
*
|
|
3267
|
+
* A file that does not parse is skipped: the per-type loop that reads it names
|
|
3268
|
+
* the parse error itself, and reporting it twice helps nobody.
|
|
3269
|
+
*/
|
|
3270
|
+
export function collectFunctionTomlValidationErrors(configDir) {
|
|
3271
|
+
const found = [];
|
|
3272
|
+
const functionsDir = join(configDir, "functions");
|
|
3273
|
+
if (!existsSync(functionsDir))
|
|
3274
|
+
return found;
|
|
3275
|
+
for (const file of readdirSync(functionsDir).filter(isFunctionDeclarationFile)) {
|
|
3276
|
+
const filePath = join(functionsDir, file);
|
|
3277
|
+
let tomlData;
|
|
3278
|
+
try {
|
|
3279
|
+
tomlData = parseTomlFile(filePath);
|
|
3280
|
+
}
|
|
3281
|
+
catch {
|
|
3282
|
+
continue;
|
|
3283
|
+
}
|
|
3284
|
+
// The DECLARED key, like the apply loop and `--only` selection: a file
|
|
3285
|
+
// whose name and `[function].key` differ is one object, not two.
|
|
3286
|
+
const key = tomlData?.function?.key || basename(file, ".toml");
|
|
3287
|
+
const messages = functionTomlPreflightErrors({
|
|
3288
|
+
filePath,
|
|
3289
|
+
functionKey: key,
|
|
3290
|
+
tomlData,
|
|
3291
|
+
});
|
|
3292
|
+
if (messages.length === 0)
|
|
3293
|
+
continue;
|
|
3294
|
+
found.push({ type: "function", key, filePath, messages });
|
|
3295
|
+
}
|
|
3296
|
+
return found;
|
|
3297
|
+
}
|
|
3298
|
+
/**
|
|
3299
|
+
* The retired runtime keys every `functions/*.toml` in the tree still carries
|
|
3300
|
+
* — #3482, the warning half of the collector above.
|
|
3301
|
+
*
|
|
3302
|
+
* Separate from the errors because it IS separate: a file carrying one of
|
|
3303
|
+
* these pushes, with every other key applied, so it is not a row `config diff`
|
|
3304
|
+
* calls invalid. It is a line an author reads on stderr from both commands, so
|
|
3305
|
+
* the two keep reading identically (the #2880 symmetry the errors collector
|
|
3306
|
+
* exists for, applied to advice rather than refusals).
|
|
3307
|
+
*/
|
|
3308
|
+
export function collectFunctionTomlIgnoredKeyWarnings(configDir) {
|
|
3309
|
+
const found = [];
|
|
3310
|
+
const functionsDir = join(configDir, "functions");
|
|
3311
|
+
if (!existsSync(functionsDir))
|
|
3312
|
+
return found;
|
|
3313
|
+
for (const file of readdirSync(functionsDir).filter(isFunctionDeclarationFile)) {
|
|
3314
|
+
const filePath = join(functionsDir, file);
|
|
3315
|
+
let tomlData;
|
|
3316
|
+
try {
|
|
3317
|
+
tomlData = parseTomlFile(filePath);
|
|
3318
|
+
}
|
|
3319
|
+
catch {
|
|
3320
|
+
continue;
|
|
3321
|
+
}
|
|
3322
|
+
const key = tomlData?.function?.key || basename(file, ".toml");
|
|
3323
|
+
found.push(...functionTomlIgnoredKeyWarnings({ functionKey: key, tomlData }));
|
|
3324
|
+
}
|
|
3325
|
+
return found;
|
|
3326
|
+
}
|
|
3327
|
+
/**
|
|
3328
|
+
* The collectors' rows, one per diff row (#2880 symmetry, #3320).
|
|
3329
|
+
*
|
|
3330
|
+
* Two collectors reach the same function file — the definition-driven one
|
|
3331
|
+
* every per-entity type runs, and the function preflight — and a file can
|
|
3332
|
+
* break both at once (a mistyped `entrty`, which then leaves no `entry`).
|
|
3333
|
+
* Push reports every message from both, so an overlay that let one row's
|
|
3334
|
+
* messages REPLACE the other's would drop a diagnostic push prints, which is
|
|
3335
|
+
* exactly the asymmetry #2880 forbids. Merging by row keeps the two commands
|
|
3336
|
+
* reading identically.
|
|
3337
|
+
*/
|
|
3338
|
+
export function mergeConfigFileValidationErrors(collected) {
|
|
3339
|
+
const byRow = new Map();
|
|
3340
|
+
for (const rejected of collected) {
|
|
3341
|
+
const rowId = `${rejected.type}${rejected.key}`;
|
|
3342
|
+
const merged = byRow.get(rowId);
|
|
3343
|
+
if (!merged) {
|
|
3344
|
+
byRow.set(rowId, { ...rejected, messages: [...rejected.messages] });
|
|
3345
|
+
continue;
|
|
3346
|
+
}
|
|
3347
|
+
// Collection order is the message order, the same order push prints.
|
|
3348
|
+
merged.messages.push(...rejected.messages);
|
|
3349
|
+
}
|
|
3350
|
+
return [...byRow.values()];
|
|
3351
|
+
}
|
|
2194
3352
|
/**
|
|
2195
3353
|
* Every `<key>.tests/*.toml` sidecar under a config directory.
|
|
2196
3354
|
*
|
|
@@ -2287,6 +3445,17 @@ export function workflowPushSendsUpdate(configDir, key, parsedWorkflowToml, sync
|
|
|
2287
3445
|
*
|
|
2288
3446
|
* Returns `null` when the name is present.
|
|
2289
3447
|
*/
|
|
3448
|
+
/**
|
|
3449
|
+
* The preflight error a project's document schema contributes, or `null` —
|
|
3450
|
+
* #3719.
|
|
3451
|
+
*
|
|
3452
|
+
* Only a `refusal` counts: a unique constraint on a stringset field, which no
|
|
3453
|
+
* writer can key consistently, so the push stops before any mutation. Every
|
|
3454
|
+
* other unusable-schema case stays the apply loop's warning (D3455-004).
|
|
3455
|
+
*/
|
|
3456
|
+
export function documentSchemaPreflightError(resolution) {
|
|
3457
|
+
return resolution.refusal;
|
|
3458
|
+
}
|
|
2290
3459
|
export function workflowUpdateNameError(filePath, workflow) {
|
|
2291
3460
|
const name = workflow?.name;
|
|
2292
3461
|
if (typeof name === "string" && name.trim() !== "")
|
|
@@ -3149,6 +4318,114 @@ export function diffProjectedRecords(local, remote) {
|
|
|
3149
4318
|
}
|
|
3150
4319
|
return diffs;
|
|
3151
4320
|
}
|
|
4321
|
+
/**
|
|
4322
|
+
* The paths at which two projected records disagree, named the way the FILE
|
|
4323
|
+
* spells them (#3267) — what a `config diff` Modified row prints.
|
|
4324
|
+
*
|
|
4325
|
+
* `diffProjectedRecords` answers at the granularity of the projection, and
|
|
4326
|
+
* for a database type that is too coarse to act on: the 27 operations arrive
|
|
4327
|
+
* as one `operations` extra, the whole `[models.*]` tree as one `schema`
|
|
4328
|
+
* string. A row that read Modified for a reason the operator could not see —
|
|
4329
|
+
* an empty params set spelled two ways, an operation block the file declares
|
|
4330
|
+
* twice — was undiagnosable from the CLI. So this walks INTO each differing
|
|
4331
|
+
* field: a keyed child collection (`spec.childKeys`) is compared row by row
|
|
4332
|
+
* and named `operations["getInstitution"].params`, with a row only one side
|
|
4333
|
+
* has or that the file repeats named as such; a TOML-string field
|
|
4334
|
+
* (`spec.tomlFields`) is parsed on both sides and named by the path inside it
|
|
4335
|
+
* (`schema (models.Institution.fields.name.required)`); everything else is
|
|
4336
|
+
* walked as a value to its first differing leaf.
|
|
4337
|
+
*
|
|
4338
|
+
* Paths come back in projected-key order, keyed rows in key order, so the
|
|
4339
|
+
* list is stable across runs, and every leaf is its own entry — a schema
|
|
4340
|
+
* that differs in seven model fields is seven paths, so the caller's "+N
|
|
4341
|
+
* more" cap counts them the way it counts operations. A value the walk
|
|
4342
|
+
* cannot descend into (an array of a different length, a scalar against an
|
|
4343
|
+
* object) is named at the level it stopped.
|
|
4344
|
+
*/
|
|
4345
|
+
export function describeProjectedDifferences(spec, local, remote) {
|
|
4346
|
+
const paths = [];
|
|
4347
|
+
for (const { field, local: l, server: r } of diffProjectedRecords(local, remote)) {
|
|
4348
|
+
const childKey = spec.childKeys?.[field];
|
|
4349
|
+
if (childKey && Array.isArray(l) && Array.isArray(r)) {
|
|
4350
|
+
paths.push(...keyedChildDifferences(field, childKey, l, r));
|
|
4351
|
+
continue;
|
|
4352
|
+
}
|
|
4353
|
+
if (spec.tomlFields?.includes(field) && typeof l === "string" && typeof r === "string") {
|
|
4354
|
+
let inner;
|
|
4355
|
+
try {
|
|
4356
|
+
inner = valueDifferences("", parseConfigToml(l), parseConfigToml(r));
|
|
4357
|
+
}
|
|
4358
|
+
catch {
|
|
4359
|
+
// A side that does not parse is named as the whole field below.
|
|
4360
|
+
}
|
|
4361
|
+
if (inner) {
|
|
4362
|
+
if (inner.length === 0) {
|
|
4363
|
+
paths.push(field);
|
|
4364
|
+
}
|
|
4365
|
+
else {
|
|
4366
|
+
paths.push(...inner.map((path) => `${field} (${path})`));
|
|
4367
|
+
}
|
|
4368
|
+
continue;
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
paths.push(...valueDifferences(field, l, r));
|
|
4372
|
+
}
|
|
4373
|
+
return paths;
|
|
4374
|
+
}
|
|
4375
|
+
/** The leaf paths under `path` at which two values differ (see above). */
|
|
4376
|
+
function valueDifferences(path, a, b) {
|
|
4377
|
+
if (canonicalJsonStringify(a) === canonicalJsonStringify(b))
|
|
4378
|
+
return [];
|
|
4379
|
+
const join = (key) => (path ? `${path}.${key}` : key);
|
|
4380
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
4381
|
+
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
|
4382
|
+
return keys.flatMap((key) => valueDifferences(join(key), a[key], b[key]));
|
|
4383
|
+
}
|
|
4384
|
+
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
|
|
4385
|
+
return a.flatMap((item, i) => valueDifferences(`${path}[${i}]`, item, b[i]));
|
|
4386
|
+
}
|
|
4387
|
+
return [path || "(document)"];
|
|
4388
|
+
}
|
|
4389
|
+
/**
|
|
4390
|
+
* A keyed child collection's differences, row by row. A key the file repeats
|
|
4391
|
+
* is named as the cause rather than compared: the server keys rows by it, so
|
|
4392
|
+
* only one of the blocks landed and no push can make the two sides agree
|
|
4393
|
+
* while the file says two things under one name.
|
|
4394
|
+
*/
|
|
4395
|
+
function keyedChildDifferences(field, key, local, remote) {
|
|
4396
|
+
const label = (k) => `${field}[${JSON.stringify(k)}]`;
|
|
4397
|
+
const localByKey = new Map();
|
|
4398
|
+
for (const row of local) {
|
|
4399
|
+
const k = String(row?.[key] ?? "");
|
|
4400
|
+
localByKey.set(k, [...(localByKey.get(k) ?? []), row]);
|
|
4401
|
+
}
|
|
4402
|
+
const remoteByKey = new Map(remote.map((row) => [String(row?.[key] ?? ""), row]));
|
|
4403
|
+
const keys = [...new Set([...localByKey.keys(), ...remoteByKey.keys()])].sort();
|
|
4404
|
+
const paths = [];
|
|
4405
|
+
for (const k of keys) {
|
|
4406
|
+
const rows = localByKey.get(k);
|
|
4407
|
+
const remoteRow = remoteByKey.get(k);
|
|
4408
|
+
if (rows && rows.length > 1) {
|
|
4409
|
+
paths.push(`${label(k)} (declared ${rows.length} times locally)`);
|
|
4410
|
+
}
|
|
4411
|
+
else if (!rows) {
|
|
4412
|
+
paths.push(`${label(k)} (server only)`);
|
|
4413
|
+
}
|
|
4414
|
+
else if (remoteRow === undefined) {
|
|
4415
|
+
paths.push(`${label(k)} (local only)`);
|
|
4416
|
+
}
|
|
4417
|
+
else {
|
|
4418
|
+
paths.push(...valueDifferences(label(k), rows[0], remoteRow));
|
|
4419
|
+
}
|
|
4420
|
+
}
|
|
4421
|
+
return paths;
|
|
4422
|
+
}
|
|
4423
|
+
function isPlainObject(value) {
|
|
4424
|
+
return (value !== null &&
|
|
4425
|
+
typeof value === "object" &&
|
|
4426
|
+
!Array.isArray(value) &&
|
|
4427
|
+
typeof value.toJSON !== "function");
|
|
4428
|
+
}
|
|
3152
4429
|
/**
|
|
3153
4430
|
* The differing fields the definition says an UPDATE does not accept (#2731 B8).
|
|
3154
4431
|
*
|
|
@@ -3167,6 +4444,10 @@ export function classifyPushChange(input) {
|
|
|
3167
4444
|
const { localHash, remoteHash, baselineHash, legacy } = input;
|
|
3168
4445
|
if (localHash === remoteHash)
|
|
3169
4446
|
return "unchanged";
|
|
4447
|
+
// Ahead of both the baseline and the legacy inference, which are ways of
|
|
4448
|
+
// GUESSING what this states outright.
|
|
4449
|
+
if (input.serverUnchangedSinceSync === true)
|
|
4450
|
+
return "local-edited";
|
|
3170
4451
|
if (baselineHash) {
|
|
3171
4452
|
if (remoteHash === baselineHash)
|
|
3172
4453
|
return "local-edited";
|
|
@@ -3330,6 +4611,43 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3330
4611
|
serialize: (record) => serializeEmailTemplate(record),
|
|
3331
4612
|
describe: (entity) => `email template "${entity?.emailType}"`,
|
|
3332
4613
|
},
|
|
4614
|
+
// #3179 — server functions. The compared entity spans the header AND the
|
|
4615
|
+
// keys the immutable version carries (`entry`, `mode`, `limits`, …),
|
|
4616
|
+
// because an author writes one file and a version is never authored
|
|
4617
|
+
// separately: comparing only the header would report a changed entry point as
|
|
4618
|
+
// Synced. The SOURCE bytes are a second axis the table cannot express — they
|
|
4619
|
+
// are compared by `envelopeHash`, which is also what the server dedupes on.
|
|
4620
|
+
"function": {
|
|
4621
|
+
label: "function",
|
|
4622
|
+
table: SERVER_FUNCTION_TABLE,
|
|
4623
|
+
parse: (doc) => functionDiffEntity(doc),
|
|
4624
|
+
// `extra` is the ACTIVE config version, which is where the remote side's
|
|
4625
|
+
// entry/mode/limits live; the header row does not carry them.
|
|
4626
|
+
serialize: (record, _maps, extra) => serializeFunction(record, extra),
|
|
4627
|
+
// The version-carried keys are not fields of the header table, so they
|
|
4628
|
+
// travel as extras — present on both sides or absent on both.
|
|
4629
|
+
extras: (entity) => ({
|
|
4630
|
+
// #3317 — `inputSchema` / `outputSchema` compared as the values they
|
|
4631
|
+
// encode, the rule prompts have had since #2748. A schema reaches the
|
|
4632
|
+
// two sides in either spelling (a native `[function.inputSchema]` table,
|
|
4633
|
+
// or the JSON string the server stores and the fallback pull writes for
|
|
4634
|
+
// a schema TOML cannot hold), and neither spelling is a difference.
|
|
4635
|
+
...jsonFieldsForDiff(SERVER_FUNCTION_TABLE, entity),
|
|
4636
|
+
entry: entity.entry ?? null,
|
|
4637
|
+
// #3281 — the RESOLVED mode (`functionDiffEntity` reads both spellings),
|
|
4638
|
+
// so a legacy `durable = true` file and a server serializing `mode`
|
|
4639
|
+
// compare equal.
|
|
4640
|
+
mode: entity.mode ?? null,
|
|
4641
|
+
limits: entity.limits ?? null,
|
|
4642
|
+
// #3182 — the grants are version-carried too, and a widened grant set is
|
|
4643
|
+
// exactly the change a reviewer must not read as Synced. Sorted because
|
|
4644
|
+
// the row stores a set.
|
|
4645
|
+
capabilities: Array.isArray(entity.capabilities)
|
|
4646
|
+
? [...entity.capabilities].map((c) => String(c)).sort()
|
|
4647
|
+
: [],
|
|
4648
|
+
}),
|
|
4649
|
+
describe: (entity) => `function "${entity?.functionKey}"`,
|
|
4650
|
+
},
|
|
3333
4651
|
// #2757 — webhooks join the registry comparator for the same reason email
|
|
3334
4652
|
// templates did: `config diff` reported existence only, so an edit that
|
|
3335
4653
|
// rerouted a webhook to a different `workflowKey` read as Synced while push
|
|
@@ -3406,7 +4724,17 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3406
4724
|
// (`[prompt].key`) is still read here, because it is what `displayName`
|
|
3407
4725
|
// falls back to. Diff injects the resolved key into a file that omits it,
|
|
3408
4726
|
// exactly as push derives it from the file name.
|
|
3409
|
-
|
|
4727
|
+
//
|
|
4728
|
+
// #3626, SO3626-008 — projected in CREATE mode. `kind` is `writableOn:
|
|
4729
|
+
// ["create"]`, and `buildPayloadFromToml` drops a field not writable in the
|
|
4730
|
+
// mode, so an update-mode projection would leave a CHANGED kind out of the
|
|
4731
|
+
// compared surface entirely: `findImmutableFieldDiffs` would never see it,
|
|
4732
|
+
// diff would call the file Synced, and push would start applying the
|
|
4733
|
+
// prompt's other changes before the server refused a config against the
|
|
4734
|
+
// unchanged kind. The blob-bucket comparator projects in create mode for
|
|
4735
|
+
// exactly this reason (`ttlTier`, below). PATCH payloads still omit `kind`
|
|
4736
|
+
// — this is the comparison's projection, not push's.
|
|
4737
|
+
parse: (doc) => buildPromptPushPayload(doc?.prompt?.key ?? "", doc, { mode: "create" }),
|
|
3410
4738
|
serialize: (record) => serializePrompt(record),
|
|
3411
4739
|
// Read from the DOCUMENT, not the parsed entity: `[[configs]]` is a sibling
|
|
3412
4740
|
// table of `[prompt]`, and the prompt's own push payload does not carry it
|
|
@@ -3603,6 +4931,8 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3603
4931
|
subscriptions: byKey(entity.__subscriptions, "subscriptionKey"),
|
|
3604
4932
|
_unresolvedRuleSetName: entity._unresolvedRuleSetName,
|
|
3605
4933
|
}),
|
|
4934
|
+
childKeys: { operations: "name", subscriptions: "subscriptionKey" },
|
|
4935
|
+
tomlFields: ["schema"],
|
|
3606
4936
|
ruleSetRef: true,
|
|
3607
4937
|
describe: (entity) => `database type "${entity?.databaseType}"`,
|
|
3608
4938
|
},
|
|
@@ -3697,9 +5027,11 @@ export function compareLocalToRemote(spec, localParsed, remoteRecord, maps = {
|
|
|
3697
5027
|
const localHash = computeExpandedContentHash(pair.local);
|
|
3698
5028
|
if (remoteHash === localHash)
|
|
3699
5029
|
return { status: "exists" };
|
|
5030
|
+
// #3267 — WHICH field, ahead of the remedy, so a row that differs for a
|
|
5031
|
+
// reason the operator cannot see in the file is diagnosable from the CLI.
|
|
3700
5032
|
return {
|
|
3701
5033
|
status: "modified",
|
|
3702
|
-
hint: "run `config pull` to rewrite the local TOML to match running state",
|
|
5034
|
+
hint: namedDifferences(describeProjectedDifferences(spec, pair.local, pair.remote)) + "run `config pull` to rewrite the local TOML to match running state",
|
|
3703
5035
|
};
|
|
3704
5036
|
}
|
|
3705
5037
|
catch {
|
|
@@ -3708,6 +5040,24 @@ export function compareLocalToRemote(spec, localParsed, remoteRecord, maps = {
|
|
|
3708
5040
|
return { status: "exists", hint: "content not compared (comparison failed)" };
|
|
3709
5041
|
}
|
|
3710
5042
|
}
|
|
5043
|
+
/**
|
|
5044
|
+
* How many differing paths a Modified row names before saying "+N more"
|
|
5045
|
+
* (#3267). Enough to see the shape of a difference at a glance; the row is
|
|
5046
|
+
* one line of a whole-tree diff, not the place to print 27 operations.
|
|
5047
|
+
*/
|
|
5048
|
+
const NAMED_DIFFERENCE_LIMIT = 5;
|
|
5049
|
+
/**
|
|
5050
|
+
* The `differs in …; ` prefix of a Modified row's hint (#3267), or nothing
|
|
5051
|
+
* when the walk could name no path — the hash still differs, so the row is
|
|
5052
|
+
* still Modified, but the hint then says only what it always said.
|
|
5053
|
+
*/
|
|
5054
|
+
function namedDifferences(paths) {
|
|
5055
|
+
if (paths.length === 0)
|
|
5056
|
+
return "";
|
|
5057
|
+
const shown = paths.slice(0, NAMED_DIFFERENCE_LIMIT).join(", ");
|
|
5058
|
+
const more = paths.length - NAMED_DIFFERENCE_LIMIT;
|
|
5059
|
+
return `differs in ${shown}${more > 0 ? ` (+${more} more)` : ""}; `;
|
|
5060
|
+
}
|
|
3711
5061
|
/** Hash a LOCAL config file's definition-projected field set (#2644). */
|
|
3712
5062
|
export function hashLocalConfigForDiff(spec, parsedToml, maps, extra) {
|
|
3713
5063
|
return computeExpandedContentHash(projectLocalConfig(spec, parsedToml, maps, extra));
|
|
@@ -3769,17 +5119,32 @@ export function decidePushForConfig(input) {
|
|
|
3769
5119
|
fields,
|
|
3770
5120
|
};
|
|
3771
5121
|
}
|
|
5122
|
+
// The recorded server timestamp still being the live one. It is the legacy
|
|
5123
|
+
// path's inference signal, and — where the row's timestamp covers the whole
|
|
5124
|
+
// projection — #3317's outright statement that the server did not move.
|
|
5125
|
+
const liveModifiedAtMatchesStored = entry?.modifiedAt !== undefined &&
|
|
5126
|
+
live.modifiedAt !== undefined &&
|
|
5127
|
+
entry.modifiedAt === live.modifiedAt;
|
|
5128
|
+
// The recorded version pointer still being the live one — or no pointer
|
|
5129
|
+
// recorded, which is no claim. A pointer that moved is a server change by
|
|
5130
|
+
// definition, whatever the two timestamps say (#3317: the baseline's
|
|
5131
|
+
// timestamp comes from a refetch a concurrent activation can precede).
|
|
5132
|
+
const liveActiveConfigMatchesStored = entry?.activeConfigId === undefined ||
|
|
5133
|
+
(live.record?.activeConfigId !== undefined &&
|
|
5134
|
+
live.record?.activeConfigId !== null &&
|
|
5135
|
+
String(entry.activeConfigId) === String(live.record.activeConfigId));
|
|
3772
5136
|
const direction = classifyPushChange({
|
|
3773
5137
|
localHash,
|
|
3774
5138
|
remoteHash,
|
|
3775
5139
|
baselineHash: entry?.semanticHash,
|
|
5140
|
+
serverUnchangedSinceSync: input.serverModifiedAtCoversProjection === true &&
|
|
5141
|
+
liveModifiedAtMatchesStored &&
|
|
5142
|
+
liveActiveConfigMatchesStored,
|
|
3776
5143
|
legacy: {
|
|
3777
5144
|
localBytesMatchStoredContentHash: entry?.contentHash !== undefined &&
|
|
3778
5145
|
localFileHash !== undefined &&
|
|
3779
5146
|
entry.contentHash === localFileHash,
|
|
3780
|
-
liveModifiedAtMatchesStored
|
|
3781
|
-
live.modifiedAt !== undefined &&
|
|
3782
|
-
entry.modifiedAt === live.modifiedAt,
|
|
5147
|
+
liveModifiedAtMatchesStored,
|
|
3783
5148
|
},
|
|
3784
5149
|
});
|
|
3785
5150
|
// #2909 — the adopt, for a type whose push path adopts by key. `entry` is
|
|
@@ -4147,7 +5512,7 @@ export function configSemanticHash(label, filePath, maps) {
|
|
|
4147
5512
|
/**
|
|
4148
5513
|
* The sync state `config push` writes into (#2731 B6, closing #2374).
|
|
4149
5514
|
*
|
|
4150
|
-
* `loadSyncState` answers `null` for an absent
|
|
5515
|
+
* `loadSyncState` answers `null` for an absent sync-state baseline, and
|
|
4151
5516
|
* every per-entity state write in the push loops is guarded by `if (syncState)`
|
|
4152
5517
|
* — so a first push created every resource and recorded none of them. The next
|
|
4153
5518
|
* push then re-entered the create path for everything and depended on 409
|
|
@@ -4335,37 +5700,6 @@ export function unselectedRuleSetIds(params) {
|
|
|
4335
5700
|
}
|
|
4336
5701
|
return seeded;
|
|
4337
5702
|
}
|
|
4338
|
-
// Test case helpers (exported for unit testing)
|
|
4339
|
-
export function slugifyTestCaseName(name) {
|
|
4340
|
-
return name
|
|
4341
|
-
.toLowerCase()
|
|
4342
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
4343
|
-
.replace(/^-+|-+$/g, "")
|
|
4344
|
-
|| "test";
|
|
4345
|
-
}
|
|
4346
|
-
/**
|
|
4347
|
-
* The name to use for `slug`, given the names already taken.
|
|
4348
|
-
*
|
|
4349
|
-
* `occupy` decides what "already taken" means, and `usedSlugs` holds names in
|
|
4350
|
-
* that form (#2896): a file name is occupied CASE-INSENSITIVELY on the
|
|
4351
|
-
* filesystems most of these trees live on, so `Foo` and `foo` are one file even
|
|
4352
|
-
* though they are two strings. The returned name keeps its own casing — only
|
|
4353
|
-
* the occupancy check is normalized.
|
|
4354
|
-
*/
|
|
4355
|
-
export function resolveSlugCollisions(slug, usedSlugs, occupy = (name) => name) {
|
|
4356
|
-
const taken = (candidate) => usedSlugs.has(occupy(candidate));
|
|
4357
|
-
if (!taken(slug)) {
|
|
4358
|
-
usedSlugs.add(occupy(slug));
|
|
4359
|
-
return slug;
|
|
4360
|
-
}
|
|
4361
|
-
let counter = 2;
|
|
4362
|
-
while (taken(`${slug}-${counter}`)) {
|
|
4363
|
-
counter++;
|
|
4364
|
-
}
|
|
4365
|
-
const resolved = `${slug}-${counter}`;
|
|
4366
|
-
usedSlugs.add(occupy(resolved));
|
|
4367
|
-
return resolved;
|
|
4368
|
-
}
|
|
4369
5703
|
export function getTestsDir(configDir, blockType, blockKey) {
|
|
4370
5704
|
// Scripts (blockType "script") live under `transforms/` on disk (their
|
|
4371
5705
|
// bodies are `transforms/<name>.rhai`), so their test cases sit alongside at
|
|
@@ -4620,6 +5954,231 @@ selects = () => true) {
|
|
|
4620
5954
|
: { ok: false };
|
|
4621
5955
|
return { scriptEntities, count: written, presence, listOk: true, serverKeys };
|
|
4622
5956
|
}
|
|
5957
|
+
/**
|
|
5958
|
+
* The manifest row `config push` records for a function it just pushed
|
|
5959
|
+
* (#3317), and whether the server had already moved past that push.
|
|
5960
|
+
*
|
|
5961
|
+
* The header is refetched AFTER the version push, so the row's `modifiedAt`
|
|
5962
|
+
* is the live timestamp — but the version pointer and the envelope are the
|
|
5963
|
+
* push's own: the manifest names what THIS run activated, not whatever the
|
|
5964
|
+
* refetch happened to find. A concurrent activation landing between the push
|
|
5965
|
+
* and the refetch is exactly the case those two must not be conflated in:
|
|
5966
|
+
* recording the refetched pointer would make the other operator's version the
|
|
5967
|
+
* baseline the local file is then read as a plain edit of, and the next push
|
|
5968
|
+
* would roll it back without `--force`. Recording the pushed pointer beside
|
|
5969
|
+
* the live timestamp is what `decidePushForConfig` reads as "the pointer
|
|
5970
|
+
* moved" — a refusal, until the operator pulls or forces.
|
|
5971
|
+
*
|
|
5972
|
+
* `movedTo` names the version the refetched header points at when it is not
|
|
5973
|
+
* the pushed one, so the caller can say so at the moment it is known rather
|
|
5974
|
+
* than leaving it for the next push to report.
|
|
5975
|
+
*/
|
|
5976
|
+
export function pushedFunctionBaseline(input) {
|
|
5977
|
+
const { refreshed, pushedVersion } = input;
|
|
5978
|
+
const pushedConfigId = pushedVersion?.configId !== undefined && pushedVersion?.configId !== null
|
|
5979
|
+
? String(pushedVersion.configId)
|
|
5980
|
+
: undefined;
|
|
5981
|
+
const liveConfigId = refreshed?.activeConfigId !== undefined && refreshed?.activeConfigId !== null
|
|
5982
|
+
? String(refreshed.activeConfigId)
|
|
5983
|
+
: undefined;
|
|
5984
|
+
const movedTo = pushedConfigId !== undefined &&
|
|
5985
|
+
liveConfigId !== undefined &&
|
|
5986
|
+
liveConfigId !== pushedConfigId
|
|
5987
|
+
? liveConfigId
|
|
5988
|
+
: null;
|
|
5989
|
+
return {
|
|
5990
|
+
entity: {
|
|
5991
|
+
id: input.functionId,
|
|
5992
|
+
modifiedAt: refreshed?.modifiedAt ||
|
|
5993
|
+
input.existingModifiedAt ||
|
|
5994
|
+
new Date().toISOString(),
|
|
5995
|
+
contentHash: input.contentHash,
|
|
5996
|
+
semanticHash: refreshed
|
|
5997
|
+
? hashRemoteConfigForDiff(input.spec, refreshed, input.maps, pushedVersion ?? undefined)
|
|
5998
|
+
: undefined,
|
|
5999
|
+
envelopeHash: input.envelopeHash,
|
|
6000
|
+
activeConfigId: pushedConfigId,
|
|
6001
|
+
sources: input.sources,
|
|
6002
|
+
},
|
|
6003
|
+
movedTo,
|
|
6004
|
+
};
|
|
6005
|
+
}
|
|
6006
|
+
/**
|
|
6007
|
+
* Pull server-side `ServerFunction` rows into `functions/` (#3179).
|
|
6008
|
+
*
|
|
6009
|
+
* Two things make this different from every other type's pull:
|
|
6010
|
+
*
|
|
6011
|
+
* 1. **A function is more than its file.** The active config VERSION holds an
|
|
6012
|
+
* envelope with the authored TOML bytes and every source file, so the pull
|
|
6013
|
+
* writes `functions/<key>.toml` plus the TypeScript beside it, and records
|
|
6014
|
+
* the source paths so prune can remove the code with the function.
|
|
6015
|
+
* 2. **Bytes, not a re-serialization.** The stored TOML is written verbatim,
|
|
6016
|
+
* so comments, key order and line endings survive the round trip (F-009).
|
|
6017
|
+
* A function that has never had code pushed has no stored bytes; that one
|
|
6018
|
+
* gets a canonical serialization of its header, which is still a pushable
|
|
6019
|
+
* file.
|
|
6020
|
+
*
|
|
6021
|
+
* A stored source path is never trusted: `planFunctionPull` refuses one that
|
|
6022
|
+
* normalizes outside the tree, and the refusals are reported rather than
|
|
6023
|
+
* swallowed.
|
|
6024
|
+
*/
|
|
6025
|
+
export async function pullFunctions(client, appId, configDir, logger = () => { }, selects = () => true) {
|
|
6026
|
+
const functionsDir = join(configDir, "functions");
|
|
6027
|
+
ensureDir(functionsDir);
|
|
6028
|
+
const functionEntities = {};
|
|
6029
|
+
/** What each pulled version says about the document schema — CR3455-001. */
|
|
6030
|
+
const pulledSchemas = [];
|
|
6031
|
+
let items = [];
|
|
6032
|
+
try {
|
|
6033
|
+
const result = await client.listFunctions(appId);
|
|
6034
|
+
items = result.items || [];
|
|
6035
|
+
}
|
|
6036
|
+
catch {
|
|
6037
|
+
return {
|
|
6038
|
+
functionEntities,
|
|
6039
|
+
count: 0,
|
|
6040
|
+
presence: { ok: false },
|
|
6041
|
+
listOk: false,
|
|
6042
|
+
serverKeys: [],
|
|
6043
|
+
items: [],
|
|
6044
|
+
};
|
|
6045
|
+
}
|
|
6046
|
+
let detailsOk = true;
|
|
6047
|
+
let written = 0;
|
|
6048
|
+
for (const record of items) {
|
|
6049
|
+
const key = record?.functionKey;
|
|
6050
|
+
if (!key)
|
|
6051
|
+
continue;
|
|
6052
|
+
if (!selects(key))
|
|
6053
|
+
continue;
|
|
6054
|
+
// #2803 — a tombstone is not exported as a file. Its key stays reserved,
|
|
6055
|
+
// and the archived-holder message is what says so on the next push.
|
|
6056
|
+
if (String(record.status ?? "active") === "archived")
|
|
6057
|
+
continue;
|
|
6058
|
+
const filePath = join(functionsDir, `${key}.toml`);
|
|
6059
|
+
let entity = {
|
|
6060
|
+
id: record.functionId,
|
|
6061
|
+
modifiedAt: record.modifiedAt || new Date().toISOString(),
|
|
6062
|
+
};
|
|
6063
|
+
if (!record.activeConfigId) {
|
|
6064
|
+
// No code pushed yet: the header is all there is to write.
|
|
6065
|
+
// The logger carries #1446's one warning: a schema holding a `null`
|
|
6066
|
+
// anywhere cannot be a native table, so it is written as a JSON string
|
|
6067
|
+
// and the operator is told which key that happened to.
|
|
6068
|
+
writeFileSync(filePath, serializeFunction(record, undefined, logger));
|
|
6069
|
+
entity.contentHash = computeFileHash(filePath);
|
|
6070
|
+
entity.semanticHash = hashRemoteConfigForDiff(configDiffSpec("function"), record, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() });
|
|
6071
|
+
functionEntities[key] = entity;
|
|
6072
|
+
written += 1;
|
|
6073
|
+
logger(` Wrote functions/${key}.toml (no version pushed yet)`);
|
|
6074
|
+
continue;
|
|
6075
|
+
}
|
|
6076
|
+
let active;
|
|
6077
|
+
let envelope;
|
|
6078
|
+
let newest = null;
|
|
6079
|
+
try {
|
|
6080
|
+
// #3462 — the point read. The listing is a page now, so scanning it for
|
|
6081
|
+
// the id would miss a rolled-back function's active version past page
|
|
6082
|
+
// one; the separate one-row read below is only asking whether a NEWER
|
|
6083
|
+
// version exists, which is what the message after the write says.
|
|
6084
|
+
active = record.activeConfigId
|
|
6085
|
+
? await client.getFunctionConfig(appId, record.functionId, String(record.activeConfigId))
|
|
6086
|
+
: null;
|
|
6087
|
+
if (active) {
|
|
6088
|
+
envelope = await client.getFunctionConfigEnvelope(appId, record.functionId, active.configId);
|
|
6089
|
+
const page = await client.listFunctionConfigs(appId, record.functionId, {
|
|
6090
|
+
limit: 1,
|
|
6091
|
+
});
|
|
6092
|
+
newest = (page.items || [])[0] ?? null;
|
|
6093
|
+
}
|
|
6094
|
+
}
|
|
6095
|
+
catch {
|
|
6096
|
+
detailsOk = false;
|
|
6097
|
+
logger(` Skipped functions/${key}.toml (could not fetch its version)`);
|
|
6098
|
+
continue;
|
|
6099
|
+
}
|
|
6100
|
+
if (!active || !envelope) {
|
|
6101
|
+
detailsOk = false;
|
|
6102
|
+
logger(` Skipped functions/${key}.toml (active version not readable)`);
|
|
6103
|
+
continue;
|
|
6104
|
+
}
|
|
6105
|
+
// #3455 / CR3455-001 — the generated schema copy is ONE file the whole
|
|
6106
|
+
// tree resolves through, so this plan does not decide it. Every pulled
|
|
6107
|
+
// version's answer is collected and the copy is settled once, after the
|
|
6108
|
+
// loop; otherwise the last function applied would delete or overwrite the
|
|
6109
|
+
// copy an earlier one needs.
|
|
6110
|
+
const plan = planFunctionPull({
|
|
6111
|
+
configDir,
|
|
6112
|
+
functionKey: key,
|
|
6113
|
+
envelope,
|
|
6114
|
+
includeDocumentSchema: false,
|
|
6115
|
+
});
|
|
6116
|
+
pulledSchemas.push({
|
|
6117
|
+
functionKey: key,
|
|
6118
|
+
documentSchema: typeof envelope?.documentSchema === "string" && envelope.documentSchema
|
|
6119
|
+
? envelope.documentSchema
|
|
6120
|
+
: null,
|
|
6121
|
+
});
|
|
6122
|
+
for (const refusal of plan.refusals)
|
|
6123
|
+
logger(` ${refusal}`);
|
|
6124
|
+
// #3482 — the retired runtime keys this pull removed on the way out. Not
|
|
6125
|
+
// a refusal: the file IS written, with every other byte unchanged, and
|
|
6126
|
+
// this is what says so rather than editing a developer's file in silence.
|
|
6127
|
+
for (const note of plan.notes)
|
|
6128
|
+
logger(` ${note}`);
|
|
6129
|
+
const applied = applyFunctionPull(plan);
|
|
6130
|
+
for (const refusal of applied.refusals)
|
|
6131
|
+
logger(` ${refusal}`);
|
|
6132
|
+
entity.contentHash = computeFileHash(filePath);
|
|
6133
|
+
entity.semanticHash = hashRemoteConfigForDiff(configDiffSpec("function"), record, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() }, active);
|
|
6134
|
+
entity.envelopeHash = envelope.envelopeHash ?? active.envelopeHash;
|
|
6135
|
+
entity.activeConfigId = active.configId;
|
|
6136
|
+
// Only the files that were really written: a path the write step refused
|
|
6137
|
+
// is not one prune may later delete on this function's behalf.
|
|
6138
|
+
entity.sources = applied.writtenSources;
|
|
6139
|
+
functionEntities[key] = entity;
|
|
6140
|
+
written += 1;
|
|
6141
|
+
logger(` Wrote functions/${key}.toml and ${applied.writtenSources.length} source file(s)`);
|
|
6142
|
+
// #3462 — pull writes the ACTIVE version's bytes, which is right: the tree
|
|
6143
|
+
// should describe what runs. On a rolled-back function that silently
|
|
6144
|
+
// discards a newer version's code from the tree, so the line names both
|
|
6145
|
+
// versions and both remedies rather than leaving the author to discover it
|
|
6146
|
+
// at the next push.
|
|
6147
|
+
const newerLine = newerVersionLine(newest, active.configId, (value) => value);
|
|
6148
|
+
if (newerLine) {
|
|
6149
|
+
logger(` ${pullNewerVersionLine(key, String(active.configId), newest, (value) => value)}`);
|
|
6150
|
+
}
|
|
6151
|
+
}
|
|
6152
|
+
const serverKeys = items
|
|
6153
|
+
.filter((f) => String(f?.status ?? "active") !== "archived")
|
|
6154
|
+
.map((f) => f?.functionKey)
|
|
6155
|
+
.filter(Boolean);
|
|
6156
|
+
// #3455 / CR3455-001 — the tree's one generated schema copy, settled from
|
|
6157
|
+
// every version this pull read. Removing it is only safe when the pull read
|
|
6158
|
+
// them ALL: a selector, or a function whose version could not be fetched,
|
|
6159
|
+
// leaves a version whose schema this pull knows nothing about.
|
|
6160
|
+
const schemaPlan = planDocumentSchemaPull({
|
|
6161
|
+
configDir,
|
|
6162
|
+
functions: pulledSchemas,
|
|
6163
|
+
coversEveryFunction: detailsOk && serverKeys.every((k) => selects(k)),
|
|
6164
|
+
});
|
|
6165
|
+
for (const warning of schemaPlan.warnings)
|
|
6166
|
+
logger(` ${warning}`);
|
|
6167
|
+
const schemaApplied = applyFunctionPull(schemaPlan);
|
|
6168
|
+
for (const refusal of schemaApplied.refusals)
|
|
6169
|
+
logger(` ${refusal}`);
|
|
6170
|
+
const presence = detailsOk
|
|
6171
|
+
? { ok: true, serverKeys }
|
|
6172
|
+
: { ok: false };
|
|
6173
|
+
return {
|
|
6174
|
+
functionEntities,
|
|
6175
|
+
count: written,
|
|
6176
|
+
presence,
|
|
6177
|
+
listOk: true,
|
|
6178
|
+
serverKeys,
|
|
6179
|
+
items,
|
|
6180
|
+
};
|
|
6181
|
+
}
|
|
4623
6182
|
/**
|
|
4624
6183
|
* Every test case a block has, draining the cursor (#2769).
|
|
4625
6184
|
*
|
|
@@ -5478,54 +7037,6 @@ export async function pushTestCasesForBlock(params) {
|
|
|
5478
7037
|
}
|
|
5479
7038
|
return { skipped };
|
|
5480
7039
|
}
|
|
5481
|
-
/**
|
|
5482
|
-
* The file name each live test case belongs in (#2896).
|
|
5483
|
-
*
|
|
5484
|
-
* A KEYED case is named by its key: that is what the key is — the basename the
|
|
5485
|
-
* committed tree carries. An unkeyed one (created by web-admin, an older CLI or
|
|
5486
|
-
* the raw API) keeps the slug-of-name naming, with the collision suffixes
|
|
5487
|
-
* resolved only AFTER the keys have claimed theirs, so a slug can never take a
|
|
5488
|
-
* name a key owns.
|
|
5489
|
-
*
|
|
5490
|
-
* One function for pull, for pairing's adoption pass and for the diff's
|
|
5491
|
-
* remote-only rows, because three commands that name a case's file differently
|
|
5492
|
-
* is the disagreement this issue reports.
|
|
5493
|
-
*
|
|
5494
|
-
* Two names that differ only by CASE are one file on a case-insensitive
|
|
5495
|
-
* filesystem, so occupancy is tracked the way the server's key constraint
|
|
5496
|
-
* normalizes: a keyed `Foo` and an unkeyed case named "foo" get `Foo.toml` and
|
|
5497
|
-
* `foo-2.toml`, rather than one pull silently overwriting the other's sidecar.
|
|
5498
|
-
*/
|
|
5499
|
-
export function testCaseFileBasenames(liveCases, onUnusableKey, isSafeName) {
|
|
5500
|
-
const used = new Set();
|
|
5501
|
-
const occupy = (name) => normalizeTestCaseKey(name);
|
|
5502
|
-
const names = new Map();
|
|
5503
|
-
const unkeyed = [];
|
|
5504
|
-
for (const live of liveCases) {
|
|
5505
|
-
const key = typeof live?.key === "string" ? live.key : "";
|
|
5506
|
-
if (!key) {
|
|
5507
|
-
unkeyed.push(live);
|
|
5508
|
-
continue;
|
|
5509
|
-
}
|
|
5510
|
-
// The key is SERVER data on its way to becoming a path. A server that
|
|
5511
|
-
// stored one this CLI would not have sent must not be able to name a file
|
|
5512
|
-
// outside the sidecar directory.
|
|
5513
|
-
const reason = testCaseKeyError(key) ??
|
|
5514
|
-
(isSafeName && !isSafeName(key)
|
|
5515
|
-
? `test case key "${key}" is not a safe file name here`
|
|
5516
|
-
: null);
|
|
5517
|
-
if (reason) {
|
|
5518
|
-
onUnusableKey?.(live, reason);
|
|
5519
|
-
unkeyed.push(live);
|
|
5520
|
-
continue;
|
|
5521
|
-
}
|
|
5522
|
-
names.set(live, resolveSlugCollisions(key, used, occupy));
|
|
5523
|
-
}
|
|
5524
|
-
for (const live of unkeyed) {
|
|
5525
|
-
names.set(live, resolveSlugCollisions(slugifyTestCaseName(live?.name || "unnamed"), used, occupy));
|
|
5526
|
-
}
|
|
5527
|
-
return names;
|
|
5528
|
-
}
|
|
5529
7040
|
/**
|
|
5530
7041
|
* Whether a sidecar and a live case say the same thing (#2896).
|
|
5531
7042
|
*
|
|
@@ -6163,26 +7674,19 @@ function readSelection(options) {
|
|
|
6163
7674
|
* `registerConfigCommands` created rather than making one of its own.
|
|
6164
7675
|
*
|
|
6165
7676
|
* The module keeps its name and its ~50 exported helpers: `sync.ts`,
|
|
6166
|
-
* `resolveSyncDir`,
|
|
7677
|
+
* `resolveSyncDir`, the sync-state baseline and `primitive/<env>/`
|
|
6167
7678
|
* are internal identifiers, deliberately left alone (#2759 scope).
|
|
6168
7679
|
*/
|
|
6169
7680
|
export function registerConfigSyncCommands(sync) {
|
|
6170
7681
|
// Init
|
|
6171
7682
|
sync
|
|
6172
7683
|
.command("init")
|
|
7684
|
+
.allowExcessArguments(false)
|
|
6173
7685
|
.description("Initialize the config directory structure")
|
|
6174
|
-
.
|
|
6175
|
-
|
|
6176
|
-
|
|
6177
|
-
|
|
6178
|
-
const resolvedAppId = resolveAppId(appId, options);
|
|
6179
|
-
const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
|
|
6180
|
-
if (isAutoResolvedSyncDir(options.dir)) {
|
|
6181
|
-
info(`Using per-environment config directory: ${configDir}`);
|
|
6182
|
-
const legacyHint = checkLegacySyncMigration(configDir);
|
|
6183
|
-
if (legacyHint)
|
|
6184
|
-
warn(legacyHint);
|
|
6185
|
-
}
|
|
7686
|
+
.action(async (options) => {
|
|
7687
|
+
const resolvedAppId = getCurrentAppId();
|
|
7688
|
+
const configDir = resolveSyncDir();
|
|
7689
|
+
info(`Using per-environment config directory: ${configDir}`);
|
|
6186
7690
|
ensureDir(configDir);
|
|
6187
7691
|
ensureDir(join(configDir, "integrations"));
|
|
6188
7692
|
ensureDir(join(configDir, "webhooks"));
|
|
@@ -6191,6 +7695,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6191
7695
|
ensureDir(join(configDir, "prompts"));
|
|
6192
7696
|
ensureDir(join(configDir, "workflows"));
|
|
6193
7697
|
ensureDir(join(configDir, "transforms")); // #892 slice 7 — Rhai scripts
|
|
7698
|
+
ensureDir(join(configDir, "functions")); // #3179 — server functions
|
|
6194
7699
|
ensureDir(join(configDir, "database-type-configs"));
|
|
6195
7700
|
ensureDir(join(configDir, "rule-sets"));
|
|
6196
7701
|
ensureDir(join(configDir, "group-type-configs"));
|
|
@@ -6210,10 +7715,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6210
7715
|
// Pull
|
|
6211
7716
|
sync
|
|
6212
7717
|
.command("pull")
|
|
7718
|
+
.allowExcessArguments(false)
|
|
6213
7719
|
.description("Download configuration from server to TOML files")
|
|
6214
|
-
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
6215
|
-
.option("--app <app-id>", "App ID")
|
|
6216
|
-
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
6217
7720
|
.option("--json", "Output as JSON instead of writing files")
|
|
6218
7721
|
.option("--only <selectors>", "Fetch just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated). Everything else in the slot is left as it is.",
|
|
6219
7722
|
// Collect rather than replace, the same way push does: commander keeps
|
|
@@ -6221,14 +7724,11 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6221
7724
|
// narrow the pull to one object and report success.
|
|
6222
7725
|
(value, previous) => [...(previous ?? []), value], [])
|
|
6223
7726
|
.option("--no-prune", "Keep local files for entities that are absent from the sync export (the next push will re-create them)")
|
|
6224
|
-
.action(async (
|
|
6225
|
-
const resolvedAppId =
|
|
6226
|
-
const configDir = resolveSyncDir(
|
|
6227
|
-
if (
|
|
7727
|
+
.action(async (options) => {
|
|
7728
|
+
const resolvedAppId = getCurrentAppId();
|
|
7729
|
+
const configDir = resolveSyncDir();
|
|
7730
|
+
if (!options.json) {
|
|
6228
7731
|
info(`Using per-environment config directory: ${configDir}`);
|
|
6229
|
-
const legacyHint = checkLegacySyncMigration(configDir);
|
|
6230
|
-
if (legacyHint)
|
|
6231
|
-
warn(legacyHint);
|
|
6232
7732
|
}
|
|
6233
7733
|
// Issue #2760: pull writes the renamed directory and leaves the old one
|
|
6234
7734
|
// untouched beside it, which reads as a successful pull that lost the
|
|
@@ -6428,10 +7928,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6428
7928
|
// perform it without a recoverable backup. No-op on a fresh/empty
|
|
6429
7929
|
// sync dir (nothing to back up). Prune to 28 days after success.
|
|
6430
7930
|
try {
|
|
6431
|
-
const snapshotsRoot = resolveSnapshotsRoot({
|
|
6432
|
-
appId: resolvedAppId,
|
|
6433
|
-
userDir: options.dir,
|
|
6434
|
-
});
|
|
7931
|
+
const snapshotsRoot = resolveSnapshotsRoot({ appId: resolvedAppId });
|
|
6435
7932
|
const snapshot = createSnapshot(configDir, snapshotsRoot);
|
|
6436
7933
|
if (snapshot) {
|
|
6437
7934
|
info(` Snapshot: ${snapshot.path}`);
|
|
@@ -6442,7 +7939,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6442
7939
|
error(`Failed to create a pre-pull snapshot: ${snapErr?.message ?? snapErr}`);
|
|
6443
7940
|
error("Aborting pull before any local file is modified. " +
|
|
6444
7941
|
"Resolve the snapshot error (e.g. disk space / permissions) and retry, " +
|
|
6445
|
-
"
|
|
7942
|
+
"and that the project's .primitive/ is writable.");
|
|
6446
7943
|
process.exit(1);
|
|
6447
7944
|
}
|
|
6448
7945
|
// Ensure directories exist
|
|
@@ -6454,6 +7951,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6454
7951
|
ensureDir(join(configDir, "prompts"));
|
|
6455
7952
|
ensureDir(join(configDir, "workflows"));
|
|
6456
7953
|
ensureDir(join(configDir, "transforms")); // #892 slice 7 — Rhai scripts
|
|
7954
|
+
ensureDir(join(configDir, "functions")); // #3179 — server functions
|
|
6457
7955
|
ensureDir(join(configDir, "database-type-configs"));
|
|
6458
7956
|
ensureDir(join(configDir, "rule-sets"));
|
|
6459
7957
|
ensureDir(join(configDir, "group-type-configs"));
|
|
@@ -6809,6 +8307,20 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6809
8307
|
if (pulledScriptsCount > 0) {
|
|
6810
8308
|
info(` Pulled ${pulledScriptsCount} transform(s)`);
|
|
6811
8309
|
}
|
|
8310
|
+
// Pull server functions — #3179. Writes `functions/<key>.toml` from the
|
|
8311
|
+
// AUTHORED bytes stored in the active version's envelope, plus every
|
|
8312
|
+
// source file beside it, so the round trip is byte-identical.
|
|
8313
|
+
const { functionEntities, count: pulledFunctionsCount, presence: functionPresence, listOk: functionListOk, serverKeys: functionServerKeys, items: functionTombstoneCandidates, } = await pullFunctions(client, resolvedAppId, configDir, info, (key) => {
|
|
8314
|
+
if (!pullSelection)
|
|
8315
|
+
return true;
|
|
8316
|
+
if (!selectionIncludesKey(pullSelection, "function", key))
|
|
8317
|
+
return false;
|
|
8318
|
+
matchedSelectors.add(`function/${key}`);
|
|
8319
|
+
return true;
|
|
8320
|
+
});
|
|
8321
|
+
if (pulledFunctionsCount > 0) {
|
|
8322
|
+
info(` Pulled ${pulledFunctionsCount} function(s)`);
|
|
8323
|
+
}
|
|
6812
8324
|
// Build lookup maps for test case serialization
|
|
6813
8325
|
const configIdToName = new Map();
|
|
6814
8326
|
const promptIdToKey = new Map();
|
|
@@ -6996,6 +8508,21 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6996
8508
|
};
|
|
6997
8509
|
info(` Wrote database-type-configs/${filename}`);
|
|
6998
8510
|
}
|
|
8511
|
+
// #3182 — the typed-handle artifacts are REGENERATED, never pulled:
|
|
8512
|
+
// they are not in the envelope, and a fresh clone must end up with the
|
|
8513
|
+
// same three files a push would have written. Written here, after the
|
|
8514
|
+
// database types, so the declaration describes what this pull wrote.
|
|
8515
|
+
try {
|
|
8516
|
+
const artifacts = writeFunctionTypeArtifacts(configDir);
|
|
8517
|
+
if (artifacts.length > 0) {
|
|
8518
|
+
info(` Wrote function types: ${artifacts.join(", ")}`);
|
|
8519
|
+
}
|
|
8520
|
+
}
|
|
8521
|
+
catch (err) {
|
|
8522
|
+
if (!(err instanceof FunctionSchemaCodegenError))
|
|
8523
|
+
throw err;
|
|
8524
|
+
warn(` Function types not generated: ${err.message}`);
|
|
8525
|
+
}
|
|
6999
8526
|
// Write rule sets
|
|
7000
8527
|
const ruleSetEntities = {};
|
|
7001
8528
|
for (const ruleSet of ruleSets) {
|
|
@@ -7087,6 +8614,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7087
8614
|
? { ok: true, serverKeys: blobBucketItems.map((b) => b.bucketKey) }
|
|
7088
8615
|
: { ok: false },
|
|
7089
8616
|
scripts: scriptPresence,
|
|
8617
|
+
functions: functionPresence,
|
|
7090
8618
|
emailTemplates: emailTemplatePresence,
|
|
7091
8619
|
databaseTypes: databaseTypeConfigsOutcome.ok && databaseTypeDetailsOk
|
|
7092
8620
|
? { ok: true, serverKeys: databaseTypesWithOps.map((d) => d.typeConfig.databaseType) }
|
|
@@ -7153,11 +8681,36 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7153
8681
|
for (const key of plan.unsafeKeys) {
|
|
7154
8682
|
warn(` Skipped pruning ${resourceType.dir} entry "${key}" — its sync-state key does not map to a safe filename`);
|
|
7155
8683
|
}
|
|
8684
|
+
// #3179 — the source files the SURVIVORS still own. Two functions can
|
|
8685
|
+
// import the same module, and both record it; deleting it with one
|
|
8686
|
+
// would leave the other unbuildable while its state called it
|
|
8687
|
+
// current. Both the prior state and this pull's entities are read,
|
|
8688
|
+
// because a function created server-side since the last pull owns its
|
|
8689
|
+
// sources only in the fresh map.
|
|
8690
|
+
const pruningKeys = new Set(plan.keys);
|
|
8691
|
+
const retainedFiles = new Set();
|
|
8692
|
+
const collectRetained = (entities) => {
|
|
8693
|
+
for (const [entityKey, entity] of Object.entries(entities ?? {})) {
|
|
8694
|
+
if (pruningKeys.has(entityKey))
|
|
8695
|
+
continue;
|
|
8696
|
+
for (const path of (entity?.sources ?? [])) {
|
|
8697
|
+
retainedFiles.add(path);
|
|
8698
|
+
}
|
|
8699
|
+
}
|
|
8700
|
+
};
|
|
8701
|
+
collectRetained(priorEntities[resourceType.stateSlot]);
|
|
8702
|
+
if (resourceType.stateSlot === "functions") {
|
|
8703
|
+
collectRetained(functionEntities);
|
|
8704
|
+
}
|
|
7156
8705
|
const result = await applyPrune({
|
|
7157
8706
|
configDir,
|
|
7158
8707
|
resourceType,
|
|
7159
8708
|
keys: plan.keys,
|
|
7160
8709
|
testCaseEntities,
|
|
8710
|
+
// #3179 — a function's TypeScript goes with it. Only the prior sync
|
|
8711
|
+
// state knows where those files are, so it is what names them.
|
|
8712
|
+
extraFilesForKey: (key) => priorEntities[resourceType.stateSlot]?.[key]?.sources ?? [],
|
|
8713
|
+
retainedFiles,
|
|
7161
8714
|
logger: info,
|
|
7162
8715
|
});
|
|
7163
8716
|
prunedCount += result.prunedKeys.length;
|
|
@@ -7173,12 +8726,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7173
8726
|
// the prior entry for that one key — see `preserveUnwrittenEntries`.
|
|
7174
8727
|
const keepPrior = (ok, built, slot) => ok ? built : (priorEntities[slot] ?? built);
|
|
7175
8728
|
preserveUnwrittenEntries(scriptEntities, priorEntities.scripts, scriptServerKeys);
|
|
8729
|
+
preserveUnwrittenEntries(functionEntities, priorEntities.functions, functionServerKeys);
|
|
7176
8730
|
preserveUnwrittenEntries(emailTemplateEntities, priorEntities.emailTemplates, emailTemplatesOutcome.ok
|
|
7177
8731
|
? emailTemplateOverrides.map((t) => t.emailType)
|
|
7178
8732
|
: []);
|
|
7179
8733
|
const finalCronTriggerEntities = keepPrior(cronTriggersOutcome.ok, cronTriggerEntities, "cronTriggers");
|
|
7180
8734
|
const finalBlobBucketEntities = keepPrior(blobBucketsOutcome.ok, blobBucketEntities, "blobBuckets");
|
|
7181
8735
|
const finalScriptEntities = keepPrior(scriptListOk, scriptEntities, "scripts");
|
|
8736
|
+
const finalFunctionEntities = keepPrior(functionListOk, functionEntities, "functions");
|
|
7182
8737
|
const finalEmailTemplateEntities = keepPrior(emailTemplatesOutcome.ok, emailTemplateEntities, "emailTemplates");
|
|
7183
8738
|
const finalDatabaseTypeEntities = keepPrior(databaseTypeConfigsOutcome.ok, databaseTypeEntities, "databaseTypes");
|
|
7184
8739
|
const finalRuleSetEntities = keepPrior(ruleSetsOutcome.ok, ruleSetEntities, "ruleSets");
|
|
@@ -7207,6 +8762,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7207
8762
|
prompts: promptEntities,
|
|
7208
8763
|
workflows: workflowEntities,
|
|
7209
8764
|
scripts: finalScriptEntities,
|
|
8765
|
+
functions: finalFunctionEntities,
|
|
7210
8766
|
emailTemplates: finalEmailTemplateEntities,
|
|
7211
8767
|
databaseTypes: finalDatabaseTypeEntities,
|
|
7212
8768
|
ruleSets: finalRuleSetEntities,
|
|
@@ -7277,6 +8833,17 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7277
8833
|
idOf: (w) => w.workflowId,
|
|
7278
8834
|
}, Object.keys(priorEntities.workflows ?? {})),
|
|
7279
8835
|
],
|
|
8836
|
+
// #3179 — the sixth carrying type reaches the same place. An archived
|
|
8837
|
+
// function keeps its key in the app's SINGLE key namespace, so a
|
|
8838
|
+
// stranded tombstone blocks a workflow, a script or a webhook wanting
|
|
8839
|
+
// that key too, not just another function.
|
|
8840
|
+
[
|
|
8841
|
+
"functions",
|
|
8842
|
+
archivedTombstoneEntries(functionTombstoneCandidates, {
|
|
8843
|
+
keyOf: (f) => f.functionKey,
|
|
8844
|
+
idOf: (f) => f.functionId,
|
|
8845
|
+
}, Object.keys(priorEntities.functions ?? {})),
|
|
8846
|
+
],
|
|
7280
8847
|
]) {
|
|
7281
8848
|
const built = entitiesBySlot[slot];
|
|
7282
8849
|
const resourceType = SYNC_RESOURCE_TYPES.find((rt) => rt.stateSlot === slot);
|
|
@@ -7373,6 +8940,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7373
8940
|
prompts: promptEntities,
|
|
7374
8941
|
workflows: workflowEntities,
|
|
7375
8942
|
scripts: Object.keys(finalScriptEntities).length > 0 ? finalScriptEntities : undefined,
|
|
8943
|
+
functions: Object.keys(finalFunctionEntities).length > 0 ? finalFunctionEntities : undefined,
|
|
7376
8944
|
emailTemplates: Object.keys(finalEmailTemplateEntities).length > 0 ? finalEmailTemplateEntities : undefined,
|
|
7377
8945
|
testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
|
|
7378
8946
|
databaseTypes: Object.keys(finalDatabaseTypeEntities).length > 0 ? finalDatabaseTypeEntities : undefined,
|
|
@@ -7398,6 +8966,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7398
8966
|
keyValue("Prompts", prompts.length);
|
|
7399
8967
|
keyValue("Workflows", workflows.length);
|
|
7400
8968
|
keyValue("Transforms", pulledScriptsCount);
|
|
8969
|
+
keyValue("Functions", pulledFunctionsCount);
|
|
7401
8970
|
keyValue("Email Templates", emailTemplates.length);
|
|
7402
8971
|
keyValue("Test Cases", totalTestCases);
|
|
7403
8972
|
keyValue("Database Types", databaseTypesWithOps.length);
|
|
@@ -7432,10 +9001,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7432
9001
|
// Push
|
|
7433
9002
|
sync
|
|
7434
9003
|
.command("push")
|
|
9004
|
+
.allowExcessArguments(false)
|
|
7435
9005
|
.description("Upload local TOML configuration to server")
|
|
7436
|
-
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
7437
|
-
.option("--app <app-id>", "App ID")
|
|
7438
|
-
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
7439
9006
|
.option("--dry-run", "Show what would be changed without applying")
|
|
7440
9007
|
.option("--force", "Overwrite remote even if modified since last pull")
|
|
7441
9008
|
.option("--only <selectors>", "Apply just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated). Everything else about push is unchanged.",
|
|
@@ -7446,15 +9013,32 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7446
9013
|
.option("--prune", "Delete remote entities whose local TOML was removed since the last sync (issue #1655)")
|
|
7447
9014
|
.option("-y, --yes", "Skip the interactive confirmation before pruning (for CI / automation)")
|
|
7448
9015
|
.option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
|
|
7449
|
-
|
|
7450
|
-
|
|
7451
|
-
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
|
|
7455
|
-
|
|
7456
|
-
|
|
7457
|
-
|
|
9016
|
+
// #3320 — what this command actually guarantees, said where it is run.
|
|
9017
|
+
// Two stages, and only the first is all-or-nothing.
|
|
9018
|
+
.addHelpText("after", `
|
|
9019
|
+
What a push guarantees (two stages):
|
|
9020
|
+
|
|
9021
|
+
Preflight — the local tree only. Unknown keys and declared types, workflow
|
|
9022
|
+
lints, function grant grammar, a function's \`entry\`, the function trigger
|
|
9023
|
+
rules, and a webhook trigger's signing-secret requirement and reference
|
|
9024
|
+
shape. Any error among them aborts the whole run and nothing is applied.
|
|
9025
|
+
Those are the server's own rules, run against your files.
|
|
9026
|
+
|
|
9027
|
+
Apply — incremental and convergent, and there is no rollback. A rejection
|
|
9028
|
+
may stop the remaining work depending on the type; whatever was already
|
|
9029
|
+
applied stays applied; the exit status is non-zero; re-running converges.
|
|
9030
|
+
A refused FUNCTION is recorded and the loop continues, so the rest of the
|
|
9031
|
+
tree still lands.
|
|
9032
|
+
|
|
9033
|
+
Not preflighted, because the file cannot answer it: whether a referenced
|
|
9034
|
+
secret exists, whether a scheme's config is valid under this environment's
|
|
9035
|
+
policy, per-app caps, key collisions. Those surface during apply.
|
|
9036
|
+
`)
|
|
9037
|
+
.option("--no-typecheck", "Ship function code without typechecking it against the declarations this push generates (issue #3331)")
|
|
9038
|
+
.action(async (options) => {
|
|
9039
|
+
const resolvedAppId = getCurrentAppId();
|
|
9040
|
+
const configDir = resolveSyncDir();
|
|
9041
|
+
info(`Using per-environment config directory: ${configDir}`);
|
|
7458
9042
|
// Issue #2760. An un-renamed checkout's `database-types/` is ignored, so
|
|
7459
9043
|
// an ordinary push applies nothing from it — worth a warning. `--prune`
|
|
7460
9044
|
// is the destructive case: it flags every prior-state key whose local
|
|
@@ -7479,14 +9063,6 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7479
9063
|
// `syncCallable` PATCH ordering a second implementation has broken before.
|
|
7480
9064
|
// Everything else about push is unchanged.
|
|
7481
9065
|
const pushSelection = readSelection(options);
|
|
7482
|
-
if (pushSelection) {
|
|
7483
|
-
const missing = unmatchedSelectors(pushSelection, configDir);
|
|
7484
|
-
if (missing.length > 0) {
|
|
7485
|
-
error(`No local file declares: ${missing.join(", ")}. Run 'primitive config pull', or 'primitive config create <type> <key>' to create one.`);
|
|
7486
|
-
process.exit(1);
|
|
7487
|
-
}
|
|
7488
|
-
info(`Applying only: ${pushSelection.raw.join(", ")}`);
|
|
7489
|
-
}
|
|
7490
9066
|
/** Narrow a type's file list to the `--only` selection. */
|
|
7491
9067
|
const selectFiles = (label, dir, files) => filterFilesBySelection(pushSelection, label, dir, files);
|
|
7492
9068
|
/**
|
|
@@ -7520,6 +9096,29 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7520
9096
|
entities: {},
|
|
7521
9097
|
};
|
|
7522
9098
|
}
|
|
9099
|
+
// A `--only` selector that would apply nothing is refused before push
|
|
9100
|
+
// runs, so `--only workflow/typo` fails loudly instead of iterating empty
|
|
9101
|
+
// lists and reporting success.
|
|
9102
|
+
//
|
|
9103
|
+
// Checked HERE, after the baseline is loaded and after the cross-app reset
|
|
9104
|
+
// above, rather than beside `readSelection` where it used to sit (#3398).
|
|
9105
|
+
// `--prune` acts on keys the sync state tracks and NO local file declares,
|
|
9106
|
+
// so a file-only check refused the one command that is supposed to run with
|
|
9107
|
+
// the file gone: `--only workflow/household-forget --prune` reported `No
|
|
9108
|
+
// local file declares: workflow/household-forget` and left no way to retire
|
|
9109
|
+
// one tracked key without restoring its TOML (which prune would then
|
|
9110
|
+
// decline to consider) or dropping `--only` and pushing the whole tree.
|
|
9111
|
+
// The state has to be in hand to tell that key from a typo, and the
|
|
9112
|
+
// cross-app reset has to have happened first: it clears the entity ids, so
|
|
9113
|
+
// there is nothing for prune to retire and a removed key IS a typo again.
|
|
9114
|
+
if (pushSelection) {
|
|
9115
|
+
const missing = unmatchedSelectors(pushSelection, configDir, trackedSelectorKeys(syncState, { prune: !!options.prune }));
|
|
9116
|
+
if (missing.length > 0) {
|
|
9117
|
+
error(`No local file declares: ${missing.join(", ")}. Run 'primitive config pull', or 'primitive config create <type> <key>' to create one.`);
|
|
9118
|
+
process.exit(1);
|
|
9119
|
+
}
|
|
9120
|
+
info(`Applying only: ${pushSelection.raw.join(", ")}`);
|
|
9121
|
+
}
|
|
7523
9122
|
info(`Pushing configuration for app ${resolvedAppId}...`);
|
|
7524
9123
|
// The state this push STARTS from, so the failure path below can tell a
|
|
7525
9124
|
// run that recorded something from one that recorded nothing (#2936).
|
|
@@ -7896,242 +9495,36 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7896
9495
|
for (const [key, id] of promptKeyToId) {
|
|
7897
9496
|
pushTestCaseLookupMaps.promptIdToKey.set(id, key);
|
|
7898
9497
|
}
|
|
7899
|
-
for (const [scoped, id] of [
|
|
7900
|
-
...promptConfigNameToId,
|
|
7901
|
-
...workflowConfigNameToId,
|
|
7902
|
-
...scriptConfigNameToId,
|
|
7903
|
-
...integrationConfigNameToId,
|
|
7904
|
-
]) {
|
|
7905
|
-
// The map is keyed `<blockKey>#<configName>`; the comparison names
|
|
7906
|
-
// the config, not the block.
|
|
7907
|
-
pushTestCaseLookupMaps.configIdToName.set(id, scoped.slice(scoped.indexOf("#") + 1));
|
|
7908
|
-
}
|
|
7909
|
-
};
|
|
7910
|
-
// ── Issue #976 (fix A): front-load ALL client-side TOML validation
|
|
7911
|
-
// before the first mutating call. Push is apply-as-you-go and processes
|
|
7912
|
-
// entities in a fixed sequence (cron triggers are created *before*
|
|
7913
|
-
// workflows are validated). Previously each entity's validation lived
|
|
7914
|
-
// inside its own apply loop and aborted via `process.exit(1)` — so a
|
|
7915
|
-
// known-bad sibling file (e.g. an invalid workflow TOML) could let an
|
|
7916
|
-
// earlier create (a cron trigger) be applied, then abort. The
|
|
7917
|
-
// `process.exit` also bypassed the save-on-failure catch below, so the
|
|
7918
|
-
// orphaned create never reached `.primitive-sync.json` and the retry
|
|
7919
|
-
// re-issued a CREATE that the server rejected with 409 forever.
|
|
7920
|
-
//
|
|
7921
|
-
// Running every validation up-front means a known-bad file aborts
|
|
7922
|
-
// BEFORE anything is created. We collect all errors (not just the first)
|
|
7923
|
-
// and THROW — so the single save-on-failure catch is the only exit path
|
|
7924
|
-
// (no more `process.exit` bypass class). The per-loop validators below
|
|
7925
|
-
// remain as defense-in-depth but are converted to throws too.
|
|
7926
|
-
const preflightValidationErrors = [];
|
|
7927
|
-
// Validate all workflow TOMLs (#685 misnested-header check and the
|
|
7928
|
-
// #1447 system + accessRule lint).
|
|
7929
|
-
const preflightWorkflowsDir = join(configDir, "workflows");
|
|
7930
|
-
if (existsSync(preflightWorkflowsDir)) {
|
|
7931
|
-
// Under `--only` the preflight validates just the selected files:
|
|
7932
|
-
// an unrelated broken workflow elsewhere in the slot must not block
|
|
7933
|
-
// applying the one object the operator asked for.
|
|
7934
|
-
for (const file of selectFiles("workflow", preflightWorkflowsDir, readdirSync(preflightWorkflowsDir).filter((f) => f.endsWith(".toml")))) {
|
|
7935
|
-
const filePath = join(preflightWorkflowsDir, file);
|
|
7936
|
-
try {
|
|
7937
|
-
const tomlData = parseTomlFile(filePath);
|
|
7938
|
-
// #1660 — skip platform-owned workflows (the reserved
|
|
7939
|
-
// `__internal.*` namespace) BEFORE validating, mirroring the
|
|
7940
|
-
// apply loop below. `config push` validates every workflow TOML
|
|
7941
|
-
// here, up-front, before reaching that skip — so without this
|
|
7942
|
-
// second skip a stale/hollow `__internal.*.toml` with an invalid
|
|
7943
|
-
// `steps` shape would abort the whole push in preflight. Resolve
|
|
7944
|
-
// the key the same way the apply loop does (`[workflow].key`,
|
|
7945
|
-
// falling back to the filename) and match on the resolved key,
|
|
7946
|
-
// never the filename alone, so a renamed file is still caught.
|
|
7947
|
-
const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
|
|
7948
|
-
if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
|
|
7949
|
-
continue;
|
|
7950
|
-
}
|
|
7951
|
-
const tomlErrors = validateWorkflowToml(tomlData);
|
|
7952
|
-
if (tomlErrors.length > 0) {
|
|
7953
|
-
preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
|
|
7954
|
-
}
|
|
7955
|
-
// #2643 — non-blocking notes (e.g. `strict` / `strictParams` are
|
|
7956
|
-
// now no-ops). Printed once per file at pre-flight so the author
|
|
7957
|
-
// sees them whether or not the push goes on to apply.
|
|
7958
|
-
for (const note of collectWorkflowTomlNotes(tomlData)) {
|
|
7959
|
-
console.log(` ${filePath}: ${note}`);
|
|
7960
|
-
}
|
|
7961
|
-
const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
|
|
7962
|
-
if (unknownKeyError) {
|
|
7963
|
-
preflightValidationErrors.push(` ${unknownKeyError}`);
|
|
7964
|
-
}
|
|
7965
|
-
// #2743 — the always-send update payload cannot carry a missing
|
|
7966
|
-
// `[workflow].name`. That is a local TOML error like every other
|
|
7967
|
-
// one here, so it belongs in the front-loaded pass (#976): caught
|
|
7968
|
-
// in the apply loop it would abort AFTER earlier resources (app
|
|
7969
|
-
// settings, cron triggers, …) had already been written, and
|
|
7970
|
-
// `--dry-run` — which never reaches the apply — would plan an
|
|
7971
|
-
// update the real push then refuses.
|
|
7972
|
-
//
|
|
7973
|
-
// Scoped to the workflows this push will actually UPDATE. A
|
|
7974
|
-
// create falls back to the manifest key, so an absent name is
|
|
7975
|
-
// legitimate authoring there — and once that create lands, the
|
|
7976
|
-
// same unchanged file must keep pushing clean (the apply loop
|
|
7977
|
-
// skips it on its content hash) instead of failing a push that
|
|
7978
|
-
// was never going to send it.
|
|
7979
|
-
if (workflowPushSendsUpdate(configDir, tomlData.workflow?.key || basename(file, ".toml"), tomlData, syncState, options.force)) {
|
|
7980
|
-
const nameError = workflowUpdateNameError(filePath, tomlData.workflow || {});
|
|
7981
|
-
if (nameError) {
|
|
7982
|
-
preflightValidationErrors.push(` ${nameError}`);
|
|
7983
|
-
}
|
|
7984
|
-
}
|
|
7985
|
-
}
|
|
7986
|
-
catch (err) {
|
|
7987
|
-
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
7988
|
-
}
|
|
7989
|
-
}
|
|
7990
|
-
}
|
|
7991
|
-
// Validate all database-type TOMLs (issue #803 subscription
|
|
7992
|
-
// access/accessRule conflict + issue #752 $params references).
|
|
7993
|
-
const preflightDbTypesDir = join(configDir, "database-type-configs");
|
|
7994
|
-
if (existsSync(preflightDbTypesDir)) {
|
|
7995
|
-
for (const file of selectFiles("database-type-config", preflightDbTypesDir, readdirSync(preflightDbTypesDir).filter((f) => f.endsWith(".toml")))) {
|
|
7996
|
-
const filePath = join(preflightDbTypesDir, file);
|
|
7997
|
-
let rawToml;
|
|
7998
|
-
let tomlData;
|
|
7999
|
-
try {
|
|
8000
|
-
rawToml = readFileSync(filePath, "utf-8");
|
|
8001
|
-
tomlData = parseTomlFile(filePath);
|
|
8002
|
-
}
|
|
8003
|
-
catch (err) {
|
|
8004
|
-
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
8005
|
-
continue;
|
|
8006
|
-
}
|
|
8007
|
-
let operations = [];
|
|
8008
|
-
try {
|
|
8009
|
-
({ operations } = parseDatabaseTypeToml(tomlData));
|
|
8010
|
-
}
|
|
8011
|
-
catch (err) {
|
|
8012
|
-
if (err instanceof SubscriptionAccessKeyConflictError) {
|
|
8013
|
-
preflightValidationErrors.push(` ${file}: ${err.message}`);
|
|
8014
|
-
continue;
|
|
8015
|
-
}
|
|
8016
|
-
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
8017
|
-
continue;
|
|
8018
|
-
}
|
|
8019
|
-
const validation = validateOperations({
|
|
8020
|
-
filePath,
|
|
8021
|
-
rawToml,
|
|
8022
|
-
operations,
|
|
8023
|
-
});
|
|
8024
|
-
for (const e of validation.errors) {
|
|
8025
|
-
preflightValidationErrors.push(` ${formatIssue(e)}`);
|
|
8026
|
-
}
|
|
8027
|
-
// Issue #1422: non-fatal deprecation notice. A `celContextAccess`
|
|
8028
|
-
// / `metadataAccess` gate on a database type config is a single CEL
|
|
8029
|
-
// expression that gates BOTH read and update of the database CEL
|
|
8030
|
-
// context — granting read implicitly grants update. Steer operators
|
|
8031
|
-
// to resource metadata categories (separate readRule/writeRule).
|
|
8032
|
-
// This is advisory only: the push continues and the runtime gate is
|
|
8033
|
-
// unchanged. It fires only when the deprecated key is actually
|
|
8034
|
-
// present, so metadata-category-only configs never trip it.
|
|
8035
|
-
const typeSection = tomlData.type || {};
|
|
8036
|
-
if (typeSection.celContextAccess || typeSection.metadataAccess) {
|
|
8037
|
-
const usedKey = typeSection.celContextAccess
|
|
8038
|
-
? "celContextAccess"
|
|
8039
|
-
: "metadataAccess";
|
|
8040
|
-
warn(` ${file}: \`${usedKey}\` is deprecated — it gates database CEL context READ and WRITE with one expression (read implies update). Prefer a resource metadata category with separate readRule/writeRule (issue #1420). Sync will continue; the runtime gate is unchanged.`);
|
|
8041
|
-
}
|
|
8042
|
-
}
|
|
8043
|
-
}
|
|
8044
|
-
// Validate app.toml settings up-front (issue #1033). A mistyped value
|
|
8045
|
-
// is a hard error that must abort BEFORE any mutation — same
|
|
8046
|
-
// fail-before-mutate contract as the vars preflight below. The apply
|
|
8047
|
-
// block re-parses for the payload.
|
|
8048
|
-
// #2645 — bounded by `--only`, like the workflow and database-type
|
|
8049
|
-
// preflights above. `config push --only workflow/x` is the replacement for
|
|
8050
|
-
// `workflows update --from-file`, so it must work on a tree where
|
|
8051
|
-
// app.toml happens to be mid-edit.
|
|
8052
|
-
const preflightAppTomlPath = join(configDir, "app.toml");
|
|
8053
|
-
if (selectionIncludesApp(pushSelection) && existsSync(preflightAppTomlPath)) {
|
|
8054
|
-
try {
|
|
8055
|
-
const parsedApp = parseTomlFile(preflightAppTomlPath);
|
|
8056
|
-
const appErrors = collectAppSettingsPushErrors(parsedApp, {
|
|
8057
|
-
// #2880 DSO-001 — the same exclusion the apply block computes, so
|
|
8058
|
-
// the preflight validates the body that will actually be sent.
|
|
8059
|
-
excludeName: appSettingsNameExcluded({
|
|
8060
|
-
isCrossAppPush: !!isCrossAppPush,
|
|
8061
|
-
recordedExcludedName: syncState?.entities?.app?.excludedName,
|
|
8062
|
-
fileName: parsedApp?.app?.name,
|
|
8063
|
-
}),
|
|
8064
|
-
});
|
|
8065
|
-
for (const e of appErrors) {
|
|
8066
|
-
preflightValidationErrors.push(` app.toml: ${e}`);
|
|
8067
|
-
}
|
|
8068
|
-
}
|
|
8069
|
-
catch (err) {
|
|
8070
|
-
preflightValidationErrors.push(` app.toml: ${err?.message || String(err)}`);
|
|
9498
|
+
for (const [scoped, id] of [
|
|
9499
|
+
...promptConfigNameToId,
|
|
9500
|
+
...workflowConfigNameToId,
|
|
9501
|
+
...scriptConfigNameToId,
|
|
9502
|
+
...integrationConfigNameToId,
|
|
9503
|
+
]) {
|
|
9504
|
+
// The map is keyed `<blockKey>#<configName>`; the comparison names
|
|
9505
|
+
// the config, not the block.
|
|
9506
|
+
pushTestCaseLookupMaps.configIdToName.set(id, scoped.slice(scoped.indexOf("#") + 1));
|
|
8071
9507
|
}
|
|
8072
|
-
}
|
|
8073
|
-
//
|
|
8074
|
-
//
|
|
8075
|
-
//
|
|
8076
|
-
//
|
|
8077
|
-
//
|
|
8078
|
-
|
|
9508
|
+
};
|
|
9509
|
+
// #3375 — the front-loaded pass now lives in one exported entry point,
|
|
9510
|
+
// `runConfigPushPreflight`, so a rule declared `preflight` can be
|
|
9511
|
+
// proved to be reached from it before anything below mutates. Nothing
|
|
9512
|
+
// about what it checks, the order it checks it, or what it prints
|
|
9513
|
+
// moved with it.
|
|
9514
|
+
const { parsedVars: preflightParsedVars } = await runConfigPushPreflight({
|
|
9515
|
+
configDir,
|
|
9516
|
+
resolvedAppId,
|
|
9517
|
+
pushSelection,
|
|
9518
|
+
syncState,
|
|
9519
|
+
force: !!options.force,
|
|
9520
|
+
isCrossAppPush: !!isCrossAppPush,
|
|
9521
|
+
client,
|
|
9522
|
+
});
|
|
9523
|
+
// Re-derived, not returned: both are one-line functions of inputs the
|
|
9524
|
+
// entry point already took, and widening its return type to carry them
|
|
9525
|
+
// would make the vars apply leg depend on the preflight's internals.
|
|
8079
9526
|
const preflightVarsTomlPath = join(configDir, "vars.toml");
|
|
8080
9527
|
const varsSelected = selectionIncludesAnyVar(pushSelection);
|
|
8081
|
-
let preflightParsedVars = null;
|
|
8082
|
-
if (varsSelected && existsSync(preflightVarsTomlPath)) {
|
|
8083
|
-
try {
|
|
8084
|
-
const parsed = parseTomlFile(preflightVarsTomlPath);
|
|
8085
|
-
// #2645 — under `--only var/<key>` only the selected entries are
|
|
8086
|
-
// checked. The rest of the file is not being written, and aborting
|
|
8087
|
-
// over an entry this push will not touch is the same over-reach as
|
|
8088
|
-
// running the leg at all for an unselected singleton.
|
|
8089
|
-
for (const e of validateVarsFile(parsed, {
|
|
8090
|
-
onlyKeys: pushSelection ? pushSelection.varKeys : undefined,
|
|
8091
|
-
})) {
|
|
8092
|
-
preflightValidationErrors.push(` ${e}`);
|
|
8093
|
-
}
|
|
8094
|
-
preflightParsedVars = parsed;
|
|
8095
|
-
}
|
|
8096
|
-
catch (err) {
|
|
8097
|
-
preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
|
|
8098
|
-
}
|
|
8099
|
-
}
|
|
8100
|
-
// #2644 — reject any key the vendored definition does not recognize,
|
|
8101
|
-
// for every migrated configuration type. Same posture the `[workflow]`
|
|
8102
|
-
// check above has, generalized: a mistyped key fails here, in the
|
|
8103
|
-
// preflight pass, so nothing is applied and no request is issued —
|
|
8104
|
-
// rather than being dropped on the way to the server and reading as a
|
|
8105
|
-
// successful push that changed nothing.
|
|
8106
|
-
// #2880 — the same collector `config diff` runs, so the two commands
|
|
8107
|
-
// cannot disagree about which files are pushable.
|
|
8108
|
-
for (const rejected of collectConfigFileValidationErrors(configDir)) {
|
|
8109
|
-
for (const message of rejected.messages) {
|
|
8110
|
-
preflightValidationErrors.push(` ${message}`);
|
|
8111
|
-
}
|
|
8112
|
-
}
|
|
8113
|
-
// The `<key>.tests/*.toml` sidecars, which sit beside the block they
|
|
8114
|
-
// test rather than in a type directory of their own. Same posture,
|
|
8115
|
-
// reached through the registry's `test-case` surface — and through the
|
|
8116
|
-
// same collector `config diff` runs per sidecar (#2880), so neither
|
|
8117
|
-
// command can refuse a file the other calls Synced.
|
|
8118
|
-
for (const filePath of testCaseTomlFiles(configDir)) {
|
|
8119
|
-
let tomlData;
|
|
8120
|
-
try {
|
|
8121
|
-
tomlData = parseTomlFile(filePath);
|
|
8122
|
-
}
|
|
8123
|
-
catch {
|
|
8124
|
-
// Reported by the per-block test-case loop with its own message.
|
|
8125
|
-
continue;
|
|
8126
|
-
}
|
|
8127
|
-
for (const message of testCaseSidecarPreflightErrors(filePath, tomlData)) {
|
|
8128
|
-
preflightValidationErrors.push(` ${message}`);
|
|
8129
|
-
}
|
|
8130
|
-
}
|
|
8131
|
-
if (preflightValidationErrors.length > 0) {
|
|
8132
|
-
throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
|
|
8133
|
-
preflightValidationErrors.join("\n"));
|
|
8134
|
-
}
|
|
8135
9528
|
// Fetch the current remote config vars up-front — before ANY mutation
|
|
8136
9529
|
// (issue #1423 review pass 3). `planVarsPush` needs this snapshot both
|
|
8137
9530
|
// to detect concurrent remote edits and to decide which already-absent
|
|
@@ -8194,6 +9587,9 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8194
9587
|
upserts: varPlan.upserts.filter((u) => wanted.has(u.key)),
|
|
8195
9588
|
deletions: varPlan.deletions.filter((k) => wanted.has(k)),
|
|
8196
9589
|
conflicts: varPlan.conflicts.filter((c) => wanted.has(c.key)),
|
|
9590
|
+
// A drifted var nobody selected is not this push's business to
|
|
9591
|
+
// report, the same way its writes are not this push's to make.
|
|
9592
|
+
drifted: varPlan.drifted.filter((d) => wanted.has(d.key)),
|
|
8197
9593
|
skippedCount: 0,
|
|
8198
9594
|
};
|
|
8199
9595
|
}
|
|
@@ -8486,6 +9882,13 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8486
9882
|
localModifiedAt: c.localModifiedAt,
|
|
8487
9883
|
});
|
|
8488
9884
|
}
|
|
9885
|
+
// …and var drift into the shared drift accumulator (#3714), so a var
|
|
9886
|
+
// only the server moved prints the `DRIFT var: KEY` row every other
|
|
9887
|
+
// type prints for that shape. It is not a failure: nothing was
|
|
9888
|
+
// written and neither the exit code nor the pushed count moves.
|
|
9889
|
+
for (const d of varPlan.drifted) {
|
|
9890
|
+
drifted.push({ type: "var", key: d.key });
|
|
9891
|
+
}
|
|
8489
9892
|
// Delete removed keys BEFORE upserting new ones (issue #1423 review
|
|
8490
9893
|
// pass 3). At the server's 100-var cap, a replacement (remove one key,
|
|
8491
9894
|
// add another) is a valid final state, but upserting the new key first
|
|
@@ -9766,8 +11169,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
9766
11169
|
}
|
|
9767
11170
|
// Process prompts
|
|
9768
11171
|
const promptsDir = join(configDir, "prompts");
|
|
9769
|
-
|
|
9770
|
-
|
|
11172
|
+
// #3798 — the prompt leg as one function, called twice. `config push`
|
|
11173
|
+
// applies prompts BEFORE server functions, and an agent's tools name
|
|
11174
|
+
// functions the server must hold when the agent is created, so a tree
|
|
11175
|
+
// that pushes an agent together with its tool functions would be
|
|
11176
|
+
// refused on its first push. Chat and decisions files keep their place;
|
|
11177
|
+
// agent files are applied after the server-functions leg below
|
|
11178
|
+
// (`deferredAgentPromptFiles`). Nothing inside the leg changed.
|
|
11179
|
+
const pushPromptFiles = async (files) => {
|
|
9771
11180
|
// #2869 — prompts join the live-state reconcile (#2731 B3). #2748
|
|
9772
11181
|
// gave the type diff's comparator and left push on the manifest byte
|
|
9773
11182
|
// hash, so the two commands answered "changed?" from different
|
|
@@ -9788,13 +11197,26 @@ export function registerConfigSyncCommands(sync) {
|
|
|
9788
11197
|
// manifest byte-hash gate with the type named, like every other
|
|
9789
11198
|
// converted type.
|
|
9790
11199
|
const livePromptIds = new Map();
|
|
11200
|
+
// #3626, CR3626-001 — the KIND the server holds for each prompt,
|
|
11201
|
+
// taken from the same list. `kind` is immutable after create
|
|
11202
|
+
// (D3626-008), and the comparison gate refuses a change to it — but
|
|
11203
|
+
// only on the paths that reach that gate. This map is what the other
|
|
11204
|
+
// two read: a prompt whose DETAIL read failed (degraded to the
|
|
11205
|
+
// byte-hash gate, which `--force` writes through) and a prompt
|
|
11206
|
+
// adopted by key after a 409 (which re-issues the file as an UPDATE
|
|
11207
|
+
// without ever comparing). Both would otherwise PATCH the prompt's
|
|
11208
|
+
// schemas, access rule and metadata onto a row of the other kind
|
|
11209
|
+
// before a config was refused against the kind that did not change.
|
|
11210
|
+
const livePromptKinds = new Map();
|
|
9791
11211
|
let promptsLiveOk = true;
|
|
9792
11212
|
if (files.length > 0) {
|
|
9793
11213
|
try {
|
|
9794
11214
|
const listed = await fetchAll((p) => client.listPrompts(resolvedAppId, p));
|
|
9795
11215
|
for (const p of listed) {
|
|
9796
|
-
if (p?.promptKey)
|
|
9797
|
-
|
|
11216
|
+
if (!p?.promptKey)
|
|
11217
|
+
continue;
|
|
11218
|
+
livePromptIds.set(p.promptKey, p.promptId);
|
|
11219
|
+
livePromptKinds.set(p.promptKey, promptKindOf(p));
|
|
9798
11220
|
}
|
|
9799
11221
|
}
|
|
9800
11222
|
catch (err) {
|
|
@@ -9957,6 +11379,79 @@ export function registerConfigSyncCommands(sync) {
|
|
|
9957
11379
|
// (mirrors `applyWorkflowUpdate`). The adopt branch seeds the
|
|
9958
11380
|
// sync-state entity record before calling this so the stamp lands.
|
|
9959
11381
|
const applyPromptUpdate = async (promptId, expectedModifiedAt) => {
|
|
11382
|
+
// #3799 (CSO-3799-005) — an agent's declaration is checked against
|
|
11383
|
+
// every config the server holds non-archived, so a file that
|
|
11384
|
+
// retires a config AND declares a schema that config's provider
|
|
11385
|
+
// cannot carry must retire it first: sent the other way round,
|
|
11386
|
+
// the prompt PATCH is refused for a config the file has already
|
|
11387
|
+
// archived, and no re-run changes the order. Only the entries
|
|
11388
|
+
// the file archives and the server does not yet — every other
|
|
11389
|
+
// config update still follows the prompt, and decisions prompts
|
|
11390
|
+
// keep their sequence. A config PATCH does not move the
|
|
11391
|
+
// prompt's `modifiedAt`, so the concurrency guard below still
|
|
11392
|
+
// holds.
|
|
11393
|
+
//
|
|
11394
|
+
// #3801 (DSO-3801-001) — the same staging, with a second admission
|
|
11395
|
+
// rule. The admin API refuses clearing a CHAT prompt's
|
|
11396
|
+
// `[prompt].outputSchema` while a config it holds non-archived is
|
|
11397
|
+
// strict, so a file that clears the schema AND turns strict output
|
|
11398
|
+
// off (or retires the config) would be refused on every push if the
|
|
11399
|
+
// prompt went first. Those config updates go ahead of the prompt
|
|
11400
|
+
// PATCH, with the payload they would have been sent anyway. The
|
|
11401
|
+
// read this needs is the one the name→id mapping below makes,
|
|
11402
|
+
// moved ahead of the PATCH, so the request count does not grow.
|
|
11403
|
+
const stagedFirst = new Set();
|
|
11404
|
+
const fileKind = promptKindOf(tomlData?.prompt);
|
|
11405
|
+
const retiring = configs.filter((c) => c?.name &&
|
|
11406
|
+
String(c?.status ?? "").trim().toLowerCase() === "archived");
|
|
11407
|
+
const stagesRetiringAgent = fileKind === "agent" && retiring.length > 0;
|
|
11408
|
+
const clearsPromptSchema = fileKind === "chat" &&
|
|
11409
|
+
!declaresSchema(tomlData?.prompt?.outputSchema);
|
|
11410
|
+
const current = stagesRetiringAgent || clearsPromptSchema
|
|
11411
|
+
? await client.getPrompt(resolvedAppId, promptId)
|
|
11412
|
+
: null;
|
|
11413
|
+
const liveServerConfig = (name) => {
|
|
11414
|
+
const found = (current?.configs ?? []).find((c) => c.configName === name);
|
|
11415
|
+
return found && String(found.status ?? "active") !== "archived"
|
|
11416
|
+
? found
|
|
11417
|
+
: null;
|
|
11418
|
+
};
|
|
11419
|
+
const staged = [];
|
|
11420
|
+
if (stagesRetiringAgent) {
|
|
11421
|
+
for (const tomlConfig of retiring) {
|
|
11422
|
+
const serverConfig = liveServerConfig(tomlConfig.name);
|
|
11423
|
+
if (serverConfig)
|
|
11424
|
+
staged.push({ tomlConfig, serverConfig });
|
|
11425
|
+
}
|
|
11426
|
+
}
|
|
11427
|
+
if (clearsPromptSchema && declaresSchema(current?.outputSchema)) {
|
|
11428
|
+
for (const tomlConfig of configs) {
|
|
11429
|
+
if (!tomlConfig?.name)
|
|
11430
|
+
continue;
|
|
11431
|
+
const serverConfig = liveServerConfig(tomlConfig.name);
|
|
11432
|
+
if (!serverConfig || serverConfig.strictOutput !== true)
|
|
11433
|
+
continue;
|
|
11434
|
+
const chat = hoistDeprecatedChatKeys(tomlConfig).entry.chat ?? {};
|
|
11435
|
+
const retired = String(tomlConfig?.status ?? "").trim().toLowerCase() ===
|
|
11436
|
+
"archived";
|
|
11437
|
+
// An entry that stays strict AND live is refused in preflight
|
|
11438
|
+
// (no schema to send); it is never staged.
|
|
11439
|
+
if (chat.strictOutput === true && !retired)
|
|
11440
|
+
continue;
|
|
11441
|
+
staged.push({ tomlConfig, serverConfig });
|
|
11442
|
+
}
|
|
11443
|
+
}
|
|
11444
|
+
for (const { tomlConfig, serverConfig } of staged) {
|
|
11445
|
+
if (stagedFirst.has(tomlConfig.name))
|
|
11446
|
+
continue;
|
|
11447
|
+
try {
|
|
11448
|
+
await client.updatePromptConfig(resolvedAppId, promptId, serverConfig.configId, buildPromptConfigPayload(tomlConfig, { mode: "update" }));
|
|
11449
|
+
}
|
|
11450
|
+
catch (err) {
|
|
11451
|
+
throw wrapPromptConfigError(err, "update", key, tomlConfig.name);
|
|
11452
|
+
}
|
|
11453
|
+
stagedFirst.add(tomlConfig.name);
|
|
11454
|
+
}
|
|
9960
11455
|
const updated = await client.updatePrompt(resolvedAppId, promptId, buildPromptPushPayload(key, tomlData, { mode: "update" }), expectedModifiedAt);
|
|
9961
11456
|
info(` Updated prompt: ${key}`);
|
|
9962
11457
|
// Record the `modifiedAt` this PATCH just caused, so a later
|
|
@@ -9979,7 +11474,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
9979
11474
|
try {
|
|
9980
11475
|
// Fetch full prompt to get config name→ID mappings
|
|
9981
11476
|
// (updatePrompt response doesn't include configs)
|
|
9982
|
-
|
|
11477
|
+
// #3801 — the read made ahead of the prompt PATCH when the
|
|
11478
|
+
// staging needed it (neither the PATCH nor a staged config
|
|
11479
|
+
// update moves a config's id or the activation).
|
|
11480
|
+
const fullPrompt = current ?? (await client.getPrompt(resolvedAppId, promptId));
|
|
9983
11481
|
if (fullPrompt?.configs) {
|
|
9984
11482
|
for (const config of fullPrompt.configs) {
|
|
9985
11483
|
promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
@@ -9993,6 +11491,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
9993
11491
|
continue;
|
|
9994
11492
|
const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
|
|
9995
11493
|
if (serverConfig) {
|
|
11494
|
+
// Staged ahead of the prompt PATCH, with this same
|
|
11495
|
+
// payload (#3799 CSO-3799-005, #3801 DSO-3801-001).
|
|
11496
|
+
if (stagedFirst.has(configName))
|
|
11497
|
+
continue;
|
|
9996
11498
|
// Update existing config
|
|
9997
11499
|
try {
|
|
9998
11500
|
await client.updatePromptConfig(resolvedAppId, promptId, serverConfig.configId, buildPromptConfigPayload(tomlConfig, { mode: "update" }));
|
|
@@ -10013,6 +11515,9 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10013
11515
|
}
|
|
10014
11516
|
if (newConfig?.configId) {
|
|
10015
11517
|
promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
|
|
11518
|
+
// #3799 — the create body carried the entry's own
|
|
11519
|
+
// `status`, so there is no follow-up patch (#3201's is
|
|
11520
|
+
// retired).
|
|
10016
11521
|
}
|
|
10017
11522
|
}
|
|
10018
11523
|
}
|
|
@@ -10105,6 +11610,18 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10105
11610
|
}
|
|
10106
11611
|
};
|
|
10107
11612
|
if (existingId) {
|
|
11613
|
+
// #3626, CR3626-001 — the kind, before anything is written. The
|
|
11614
|
+
// comparison gate above already refuses a changed `kind`, but it
|
|
11615
|
+
// only ran when the DETAIL read succeeded; a prompt degraded to
|
|
11616
|
+
// the byte-hash gate reaches this line with `--force` and no
|
|
11617
|
+
// comparison at all. The list's kind is enough to answer it, and
|
|
11618
|
+
// answering it here keeps the promise the gate makes: a kind
|
|
11619
|
+
// change is declined before the first mutating call.
|
|
11620
|
+
const degradedKindDecline = promptKindImmutableOutcome(livePrompt ? promptKindOf(livePrompt) : livePromptKinds.get(key), tomlData);
|
|
11621
|
+
if (degradedKindDecline) {
|
|
11622
|
+
recordDeclined("prompt", key, degradedKindDecline, existingEntry?.modifiedAt);
|
|
11623
|
+
continue;
|
|
11624
|
+
}
|
|
10108
11625
|
// Update existing prompt
|
|
10109
11626
|
changes.push({ type: "prompt", action: "update", key });
|
|
10110
11627
|
promptKeyToId.set(key, existingId);
|
|
@@ -10197,6 +11714,19 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10197
11714
|
});
|
|
10198
11715
|
adopted = true;
|
|
10199
11716
|
const adoptedId = existing.promptId;
|
|
11717
|
+
// #3626, CR3626-001 — the adopted prompt's KIND, before the
|
|
11718
|
+
// re-issue writes anything. This path never reaches the
|
|
11719
|
+
// comparison gate (the create is what failed), so without
|
|
11720
|
+
// this an orphaned chat prompt would take a decisions file's
|
|
11721
|
+
// schemas, access rule and metadata by PATCH and only then
|
|
11722
|
+
// refuse its configs against the kind the update cannot
|
|
11723
|
+
// change. The lookup already read the record, so the answer
|
|
11724
|
+
// costs nothing.
|
|
11725
|
+
const adoptKindDecline = promptKindImmutableOutcome(promptKindOf(existing), tomlData);
|
|
11726
|
+
if (adoptKindDecline) {
|
|
11727
|
+
recordDeclined("prompt", key, adoptKindDecline);
|
|
11728
|
+
continue;
|
|
11729
|
+
}
|
|
10200
11730
|
// Seed the sync-state entity record so the shared update body
|
|
10201
11731
|
// can stamp modifiedAt/contentHash into it (sync state has no
|
|
10202
11732
|
// entry for an orphaned prompt), and track the key→ID map.
|
|
@@ -10321,8 +11851,11 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10321
11851
|
throw wrapPromptConfigError(err, "create", key, extraName);
|
|
10322
11852
|
}
|
|
10323
11853
|
if (extraCreated?.configId) {
|
|
10324
|
-
|
|
10325
|
-
|
|
11854
|
+
promptConfigNameToId.set(`${key}#${extraName}`, extraCreated.configId);
|
|
11855
|
+
// #3799 — as on the update path: the create carried the
|
|
11856
|
+
// entry's `status`, so a retired entry is retired when
|
|
11857
|
+
// it is created and the fresh environment never runs a
|
|
11858
|
+
// config the file retired.
|
|
10326
11859
|
}
|
|
10327
11860
|
}
|
|
10328
11861
|
info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
|
|
@@ -10354,6 +11887,17 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10354
11887
|
activated = true;
|
|
10355
11888
|
}
|
|
10356
11889
|
}
|
|
11890
|
+
// #3799 (DSO-3799-005) — the seeded config's `status` is in
|
|
11891
|
+
// the create body (`POST /prompts` honors it now), so #3201's
|
|
11892
|
+
// trailing patch is gone. Activation still runs after every
|
|
11893
|
+
// create (#2645), so the marked entry is what the prompt ends
|
|
11894
|
+
// up serving; when the FIRST entry is the retired one, the
|
|
11895
|
+
// seeded config the prompt points at is created archived and
|
|
11896
|
+
// the activation moves the pointer off it. A push that dies
|
|
11897
|
+
// between the two leaves a prompt pointing at an archived
|
|
11898
|
+
// config — recovered by re-running the push, and the price of
|
|
11899
|
+
// never creating a config in a state the file did not ask for
|
|
11900
|
+
// (which is what an agent's schema check fires on).
|
|
10357
11901
|
// Stamp sync state ONLY after all create work (base prompt +
|
|
10358
11902
|
// every extra config) succeeded. If an extra-config create
|
|
10359
11903
|
// above threw, we never reach here, so the file hash is not
|
|
@@ -10389,6 +11933,13 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10389
11933
|
}
|
|
10390
11934
|
}
|
|
10391
11935
|
}
|
|
11936
|
+
};
|
|
11937
|
+
let deferredAgentPromptFiles = [];
|
|
11938
|
+
if (existsSync(promptsDir)) {
|
|
11939
|
+
const files = selectFiles("prompt", promptsDir, readdirSync(promptsDir).filter((f) => f.endsWith(".toml")));
|
|
11940
|
+
const ordered = partitionPromptFilesForPush(promptsDir, files);
|
|
11941
|
+
deferredAgentPromptFiles = ordered.afterFunctions;
|
|
11942
|
+
await pushPromptFiles(ordered.beforeFunctions);
|
|
10392
11943
|
}
|
|
10393
11944
|
// Process transforms (Rhai scripts) — issue #892, slice 7 of #783.
|
|
10394
11945
|
//
|
|
@@ -12039,7 +13590,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12039
13590
|
//
|
|
12040
13591
|
// But the 409 also fires when the local sync state is merely
|
|
12041
13592
|
// ABSENT for a type that another admin has already FULLY
|
|
12042
|
-
// configured (or whose
|
|
13593
|
+
// configured (or whose sync-state baseline was not copied
|
|
12043
13594
|
// alongside the TOML). PATCHing that without
|
|
12044
13595
|
// `expectedModifiedAt` would silently overwrite the existing
|
|
12045
13596
|
// triggers/access/schema with the local TOML — with no
|
|
@@ -12483,6 +14034,393 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12483
14034
|
}
|
|
12484
14035
|
}
|
|
12485
14036
|
}
|
|
14037
|
+
// Process server functions — #3179.
|
|
14038
|
+
//
|
|
14039
|
+
// A function pushes on TWO axes and they are deliberately independent:
|
|
14040
|
+
//
|
|
14041
|
+
// - the HEADER (`[function]`'s gate, schemas, plus the three
|
|
14042
|
+
// version-carried keys, all compared through the shared gate), and
|
|
14043
|
+
// - the CODE — the authored TOML bytes, every source file and the
|
|
14044
|
+
// built bundle, sent as one immutable config VERSION.
|
|
14045
|
+
//
|
|
14046
|
+
// Independent because a comment-only edit to a source file leaves the
|
|
14047
|
+
// header identical and must still make a version (F-003): gating the
|
|
14048
|
+
// code push on the header comparison would silently discard it. The
|
|
14049
|
+
// code axis compares `envelopeHash`, which is also what the SERVER
|
|
14050
|
+
// dedupes on, so an unchanged push makes no version on either side's
|
|
14051
|
+
// reckoning.
|
|
14052
|
+
//
|
|
14053
|
+
// The bundle is built FIRST, so a build error, a missing entry or an
|
|
14054
|
+
// import that escapes the config tree aborts this function before
|
|
14055
|
+
// anything is sent for it (behavior 12).
|
|
14056
|
+
const functionsPushDir = join(configDir, "functions");
|
|
14057
|
+
if (existsSync(functionsPushDir)) {
|
|
14058
|
+
// #3182 — the typed handle. Written BEFORE the bundles are built, so
|
|
14059
|
+
// an author who runs `config push` after editing a schema gets the
|
|
14060
|
+
// declaration their next edit is typed against, and written from the
|
|
14061
|
+
// TREE rather than from the server: the types describe the schemas
|
|
14062
|
+
// this push is applying. Regenerated every time and never part of the
|
|
14063
|
+
// envelope — `config pull` recreates them the same way.
|
|
14064
|
+
// #3281 — a malformed schema in a function file cannot be typed;
|
|
14065
|
+
// the file's own push below refuses it by name, so the generators
|
|
14066
|
+
// warn here and the rest of the tree still lands.
|
|
14067
|
+
try {
|
|
14068
|
+
if (!options.dryRun) {
|
|
14069
|
+
const artifacts = writeFunctionTypeArtifacts(configDir);
|
|
14070
|
+
if (artifacts.length > 0) {
|
|
14071
|
+
info(` Wrote function types: ${artifacts.join(", ")}`);
|
|
14072
|
+
}
|
|
14073
|
+
}
|
|
14074
|
+
// A tsconfig push will not rewrite (comments are the author's) or
|
|
14075
|
+
// cannot read is still a tsconfig no compiler loads the handle
|
|
14076
|
+
// through. Say so rather than report success over it (CR3182-003).
|
|
14077
|
+
for (const problem of functionTypeWiringProblems(configDir)) {
|
|
14078
|
+
warn(` functions/tsconfig.json: ${problem}`);
|
|
14079
|
+
}
|
|
14080
|
+
}
|
|
14081
|
+
catch (err) {
|
|
14082
|
+
if (!(err instanceof FunctionSchemaCodegenError))
|
|
14083
|
+
throw err;
|
|
14084
|
+
warn(` Function types not generated: ${err.message}`);
|
|
14085
|
+
}
|
|
14086
|
+
// #3331 — the compiler, over the declarations written (or, under
|
|
14087
|
+
// `--dry-run`, planned) just above. Built once for the tree and
|
|
14088
|
+
// consulted per function, so one broken file refuses itself and
|
|
14089
|
+
// nothing else; `--no-typecheck` is the escape hatch, and an
|
|
14090
|
+
// unavailable compiler warns rather than failing the push.
|
|
14091
|
+
let typecheckSetup = null;
|
|
14092
|
+
const functionTypechecker = () => {
|
|
14093
|
+
if (options.typecheck === false)
|
|
14094
|
+
return null;
|
|
14095
|
+
if (typecheckSetup === null) {
|
|
14096
|
+
typecheckSetup = createFunctionTypechecker(configDir);
|
|
14097
|
+
for (const warning of typecheckSetup.warnings)
|
|
14098
|
+
warn(` ${warning}`);
|
|
14099
|
+
}
|
|
14100
|
+
return typecheckSetup.checker;
|
|
14101
|
+
};
|
|
14102
|
+
const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter(isFunctionDeclarationFile));
|
|
14103
|
+
const functionSpec = configDiffSpec("function");
|
|
14104
|
+
const liveFunctions = new Map();
|
|
14105
|
+
let functionsLiveOk = true;
|
|
14106
|
+
if (files.length > 0) {
|
|
14107
|
+
try {
|
|
14108
|
+
const listed = await client.listFunctions(resolvedAppId);
|
|
14109
|
+
for (const f of (listed?.items ?? [])) {
|
|
14110
|
+
if (f?.functionKey)
|
|
14111
|
+
liveFunctions.set(f.functionKey, f);
|
|
14112
|
+
}
|
|
14113
|
+
}
|
|
14114
|
+
catch (err) {
|
|
14115
|
+
functionsLiveOk = false;
|
|
14116
|
+
warnLiveUnavailable("functions", err);
|
|
14117
|
+
}
|
|
14118
|
+
}
|
|
14119
|
+
/** The version a function currently runs, or null. */
|
|
14120
|
+
const liveActiveVersion = async (functionId, activeConfigId) => {
|
|
14121
|
+
if (!activeConfigId)
|
|
14122
|
+
return null;
|
|
14123
|
+
// #3462 — the point read: one call by id, and correct for a
|
|
14124
|
+
// rolled-back function whose active version is past page one.
|
|
14125
|
+
return client.getFunctionConfig(resolvedAppId, functionId, String(activeConfigId));
|
|
14126
|
+
};
|
|
14127
|
+
// #3455 — the tree's document schema is a property of the PROJECT,
|
|
14128
|
+
// not of one function, so an unusable `models/models.toml` is said
|
|
14129
|
+
// once rather than repeated for every function in the tree.
|
|
14130
|
+
const documentSchemaResolution = resolveDocumentSchema(configDir);
|
|
14131
|
+
const documentSchemaWarning = documentSchemaResolution.warning;
|
|
14132
|
+
let documentSchemaWarningShown = false;
|
|
14133
|
+
// Say ONCE that this push drops a document schema a running
|
|
14134
|
+
// version carries — and ask the NEXT function when this one's
|
|
14135
|
+
// version had nothing to lose, so a schema-less function pushed
|
|
14136
|
+
// first cannot silence a schema-bearing sibling. The rule and its
|
|
14137
|
+
// state live in `function-sync.ts`, where they are testable
|
|
14138
|
+
// without a tree and a server.
|
|
14139
|
+
const warnIfDroppingDocumentSchema = createDocumentSchemaDropWarner({
|
|
14140
|
+
treeCarriesSchema: documentSchemaResolution.origin !== "none",
|
|
14141
|
+
readConfig: (functionId, configId) => client.getFunctionConfig(resolvedAppId, functionId, configId),
|
|
14142
|
+
warn: (message) => warn(` ${message}`),
|
|
14143
|
+
});
|
|
14144
|
+
for (const file of files) {
|
|
14145
|
+
const filePath = join(functionsPushDir, file);
|
|
14146
|
+
const tomlData = parseTomlFile(filePath);
|
|
14147
|
+
const key = tomlData.function?.key || basename(file, ".toml");
|
|
14148
|
+
withDerivedIdentity(tomlData, "function", { key });
|
|
14149
|
+
const existingEntry = syncState?.entities?.functions?.[key];
|
|
14150
|
+
const liveFunction = liveFunctions.get(key);
|
|
14151
|
+
// Build first: nothing is sent for a function whose code does not
|
|
14152
|
+
// compile or whose imports leave the tree.
|
|
14153
|
+
let push;
|
|
14154
|
+
try {
|
|
14155
|
+
push = await buildFunctionConfigPush({
|
|
14156
|
+
configDir,
|
|
14157
|
+
functionKey: key,
|
|
14158
|
+
filePath,
|
|
14159
|
+
tomlData,
|
|
14160
|
+
});
|
|
14161
|
+
}
|
|
14162
|
+
catch (err) {
|
|
14163
|
+
if (!(err instanceof FunctionBuildError))
|
|
14164
|
+
throw err;
|
|
14165
|
+
applyFailures.push({
|
|
14166
|
+
type: "function",
|
|
14167
|
+
key,
|
|
14168
|
+
message: err.message,
|
|
14169
|
+
});
|
|
14170
|
+
continue;
|
|
14171
|
+
}
|
|
14172
|
+
// #3455 (D3455-004) — the project declares a document schema this
|
|
14173
|
+
// push cannot carry: over the cap, or not parsing. VISIBLE rather
|
|
14174
|
+
// than refused, because a broken `models/models.toml` is not a
|
|
14175
|
+
// reason to stop deploying code — but silence would leave an
|
|
14176
|
+
// author wondering why a first write still cannot seed.
|
|
14177
|
+
if (documentSchemaWarning && !documentSchemaWarningShown) {
|
|
14178
|
+
documentSchemaWarningShown = true;
|
|
14179
|
+
warn(` ${documentSchemaWarning}`);
|
|
14180
|
+
}
|
|
14181
|
+
// #3331 — and then the types. esbuild erases them, so a build that
|
|
14182
|
+
// succeeded says nothing about whether the code holds against the
|
|
14183
|
+
// declarations this same push generated: `ctx.user` going nullable
|
|
14184
|
+
// (#3279) bundles cleanly and throws on the request path. Refused
|
|
14185
|
+
// here, against THIS function's own sources, before anything is
|
|
14186
|
+
// sent for it and whether or not this is a dry run.
|
|
14187
|
+
const typechecker = functionTypechecker();
|
|
14188
|
+
if (typechecker) {
|
|
14189
|
+
const failure = typechecker.check(key, push.sources.map((source) => source.path));
|
|
14190
|
+
if (failure) {
|
|
14191
|
+
applyFailures.push({
|
|
14192
|
+
type: "function",
|
|
14193
|
+
key,
|
|
14194
|
+
message: failure.message,
|
|
14195
|
+
});
|
|
14196
|
+
continue;
|
|
14197
|
+
}
|
|
14198
|
+
}
|
|
14199
|
+
// The active version, needed by BOTH axes: the header comparison
|
|
14200
|
+
// reads `entry`/`mode`/`limits` off it, and the code axis
|
|
14201
|
+
// compares its `envelopeHash`.
|
|
14202
|
+
let activeVersion = null;
|
|
14203
|
+
let versionOk = functionsLiveOk;
|
|
14204
|
+
if (functionsLiveOk && liveFunction?.functionId) {
|
|
14205
|
+
try {
|
|
14206
|
+
activeVersion = await liveActiveVersion(liveFunction.functionId, liveFunction.activeConfigId);
|
|
14207
|
+
}
|
|
14208
|
+
catch (err) {
|
|
14209
|
+
versionOk = false;
|
|
14210
|
+
warnLiveUnavailable(`function ${key}`, err);
|
|
14211
|
+
}
|
|
14212
|
+
}
|
|
14213
|
+
let headerAction = "skip";
|
|
14214
|
+
if (versionOk && functionsLiveOk) {
|
|
14215
|
+
const gate = decidePushForConfig({
|
|
14216
|
+
spec: functionSpec,
|
|
14217
|
+
maps: pushDiffMaps,
|
|
14218
|
+
localParsed: tomlData,
|
|
14219
|
+
live: liveFunction
|
|
14220
|
+
? {
|
|
14221
|
+
record: liveFunction,
|
|
14222
|
+
// The version-carried keys the remote side is projected
|
|
14223
|
+
// through; the header row does not hold them.
|
|
14224
|
+
extra: activeVersion ?? undefined,
|
|
14225
|
+
modifiedAt: liveFunction.modifiedAt,
|
|
14226
|
+
}
|
|
14227
|
+
: null,
|
|
14228
|
+
entry: existingEntry,
|
|
14229
|
+
localFileHash: computeFileHash(filePath),
|
|
14230
|
+
force: options.force,
|
|
14231
|
+
// #3317 — the header row's `modifiedAt` covers everything
|
|
14232
|
+
// compared here: its own fields, and the `activeConfigId`
|
|
14233
|
+
// pointer whose repointing is the only way the immutable
|
|
14234
|
+
// version behind the extras can change. So an unmoved
|
|
14235
|
+
// timestamp proves the server stood still, and a baseline hash
|
|
14236
|
+
// that disagrees is a stale baseline — the reporter's CONFLICT
|
|
14237
|
+
// over two identical timestamps, which is also what every
|
|
14238
|
+
// manifest written before this issue's schema fix would hit.
|
|
14239
|
+
serverModifiedAtCoversProjection: true,
|
|
14240
|
+
// The create leg adopts by key on the server's 409 (#1006), so
|
|
14241
|
+
// an untracked function the server already holds is that same
|
|
14242
|
+
// adopt reached one step earlier.
|
|
14243
|
+
adoptsUntrackedByKey: true,
|
|
14244
|
+
});
|
|
14245
|
+
if (gate.action === "drift" ||
|
|
14246
|
+
gate.action === "conflict" ||
|
|
14247
|
+
gate.action === "immutable") {
|
|
14248
|
+
recordDeclined("function", key, gate, existingEntry?.modifiedAt);
|
|
14249
|
+
continue;
|
|
14250
|
+
}
|
|
14251
|
+
headerAction = gate.action === "skip"
|
|
14252
|
+
? "skip"
|
|
14253
|
+
: liveFunction
|
|
14254
|
+
? "update"
|
|
14255
|
+
: "create";
|
|
14256
|
+
}
|
|
14257
|
+
else {
|
|
14258
|
+
const degraded = decideDegradedPush({
|
|
14259
|
+
force: options.force,
|
|
14260
|
+
storedContentHash: existingEntry?.contentHash,
|
|
14261
|
+
currentFileHash: computeFileHash(filePath),
|
|
14262
|
+
});
|
|
14263
|
+
if (degraded.action === "decline") {
|
|
14264
|
+
recordDeclined("function", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
14265
|
+
continue;
|
|
14266
|
+
}
|
|
14267
|
+
headerAction = degraded.action === "skip"
|
|
14268
|
+
? "skip"
|
|
14269
|
+
: liveFunction
|
|
14270
|
+
? "update"
|
|
14271
|
+
: "create";
|
|
14272
|
+
}
|
|
14273
|
+
// The code axis. `envelopeHash` is the server's own no-op test, so
|
|
14274
|
+
// matching it here means the call would create nothing.
|
|
14275
|
+
const codeUnchanged = !!liveFunction &&
|
|
14276
|
+
!!activeVersion &&
|
|
14277
|
+
String(activeVersion.envelopeHash ?? "") === push.envelopeHash;
|
|
14278
|
+
if (headerAction === "skip" && codeUnchanged) {
|
|
14279
|
+
skippedCount++;
|
|
14280
|
+
if (syncState && liveFunction?.functionId) {
|
|
14281
|
+
if (!syncState.entities.functions)
|
|
14282
|
+
syncState.entities.functions = {};
|
|
14283
|
+
syncState.entities.functions[key] = {
|
|
14284
|
+
...(existingEntry ?? {}),
|
|
14285
|
+
id: liveFunction.functionId,
|
|
14286
|
+
modifiedAt: liveFunction.modifiedAt ||
|
|
14287
|
+
existingEntry?.modifiedAt ||
|
|
14288
|
+
new Date().toISOString(),
|
|
14289
|
+
contentHash: computeFileHash(filePath),
|
|
14290
|
+
semanticHash: hashRemoteConfigForDiff(functionSpec, liveFunction, pushDiffMaps, activeVersion ?? undefined),
|
|
14291
|
+
envelopeHash: push.envelopeHash,
|
|
14292
|
+
activeConfigId: activeVersion?.configId,
|
|
14293
|
+
sources: push.sources.map((src) => src.path),
|
|
14294
|
+
};
|
|
14295
|
+
}
|
|
14296
|
+
continue;
|
|
14297
|
+
}
|
|
14298
|
+
changes.push({
|
|
14299
|
+
type: "function",
|
|
14300
|
+
action: liveFunction ? "update" : "create",
|
|
14301
|
+
key,
|
|
14302
|
+
});
|
|
14303
|
+
if (options.dryRun)
|
|
14304
|
+
continue;
|
|
14305
|
+
// Every server refusal below is recorded against THIS function and
|
|
14306
|
+
// the loop moves on. A single archived tombstone — or a key the
|
|
14307
|
+
// app's namespace already holds — must not abort a push carrying
|
|
14308
|
+
// unrelated changes: the run reports what it could not apply, exits
|
|
14309
|
+
// non-zero, and everything else in the tree still lands. That is
|
|
14310
|
+
// the same contract `applyFailures` gives every other type; a throw
|
|
14311
|
+
// here would let one refusal block a whole repository.
|
|
14312
|
+
let functionId = liveFunction?.functionId ?? existingEntry?.id;
|
|
14313
|
+
let pushedVersion;
|
|
14314
|
+
try {
|
|
14315
|
+
if (!functionId) {
|
|
14316
|
+
// #3835 — a create the server could not confirm in storage
|
|
14317
|
+
// (`STORAGE_CONSISTENCY_PENDING`) is asked again, not recorded
|
|
14318
|
+
// as not applied. Its row keeps the key, so a retry that meets
|
|
14319
|
+
// it as a 409 is adopted below like any other held key.
|
|
14320
|
+
const created = await retryOnStoragePending(() => client.createFunction(resolvedAppId, buildFunctionCreatePayload(key, tomlData.function ?? {}))).catch(async (err) => {
|
|
14321
|
+
// #1006 — a function the server already holds under this key
|
|
14322
|
+
// is adopted rather than re-created. The conflict may name a
|
|
14323
|
+
// WORKFLOW, a script or a webhook instead: those are the
|
|
14324
|
+
// single key namespace refusing the key, and they are not
|
|
14325
|
+
// adoptable, so `adoptByKeyOnCreate409` re-throws the original
|
|
14326
|
+
// error with its holder named.
|
|
14327
|
+
const existing = await adoptByKeyOnCreate409({
|
|
14328
|
+
err,
|
|
14329
|
+
kind: "function",
|
|
14330
|
+
key,
|
|
14331
|
+
lookup: async () => (await client.listFunctions(resolvedAppId)).items || [],
|
|
14332
|
+
matchKey: (f) => f?.functionKey === key,
|
|
14333
|
+
});
|
|
14334
|
+
return existing;
|
|
14335
|
+
});
|
|
14336
|
+
functionId = created.functionId;
|
|
14337
|
+
info(` Created function: ${key}`);
|
|
14338
|
+
}
|
|
14339
|
+
else if (headerAction !== "skip") {
|
|
14340
|
+
const updatePayload = buildFunctionUpdatePayload(tomlData.function ?? {});
|
|
14341
|
+
if (Object.keys(updatePayload).length > 0) {
|
|
14342
|
+
await client.updateFunction(resolvedAppId, functionId, updatePayload);
|
|
14343
|
+
}
|
|
14344
|
+
info(` Updated function: ${key}`);
|
|
14345
|
+
}
|
|
14346
|
+
// The version. `status` is NEVER in this body: pushing code to a
|
|
14347
|
+
// disabled function updates it and leaves it out of service
|
|
14348
|
+
// (#2803's resurrection guard), and a push to an archived one is
|
|
14349
|
+
// refused by the server with the archived-holder message — which
|
|
14350
|
+
// names the remedies that release its key.
|
|
14351
|
+
pushedVersion = await client.pushFunctionConfig(resolvedAppId, String(functionId), push);
|
|
14352
|
+
}
|
|
14353
|
+
catch (err) {
|
|
14354
|
+
applyFailures.push({
|
|
14355
|
+
type: "function",
|
|
14356
|
+
key,
|
|
14357
|
+
message: String(err?.message ?? err),
|
|
14358
|
+
});
|
|
14359
|
+
continue;
|
|
14360
|
+
}
|
|
14361
|
+
// #3455 (D3455-010) — did the server keep the document schema it
|
|
14362
|
+
// was sent? A server that predates the field drops it silently and
|
|
14363
|
+
// still answers 200, so without this the push reports success over
|
|
14364
|
+
// a version that can never seed. Recorded against THIS function and
|
|
14365
|
+
// NOT reported pushed; the rest of the tree still lands.
|
|
14366
|
+
const retention = documentSchemaRetentionFailure({
|
|
14367
|
+
sentDocumentSchema: typeof push.documentSchema === "string",
|
|
14368
|
+
builtEnvelopeHash: push.envelopeHash,
|
|
14369
|
+
storedEnvelopeHash: pushedVersion?.envelopeHash,
|
|
14370
|
+
});
|
|
14371
|
+
if (retention) {
|
|
14372
|
+
applyFailures.push({ type: "function", key, message: retention });
|
|
14373
|
+
continue;
|
|
14374
|
+
}
|
|
14375
|
+
await warnIfDroppingDocumentSchema(String(functionId), liveFunction?.activeConfigId
|
|
14376
|
+
? String(liveFunction.activeConfigId)
|
|
14377
|
+
: undefined);
|
|
14378
|
+
if (pushedVersion?.created === false) {
|
|
14379
|
+
info(` Function ${key}: code unchanged (no new version)`);
|
|
14380
|
+
}
|
|
14381
|
+
else {
|
|
14382
|
+
info(` Pushed function ${key} version ${pushedVersion?.configId ?? ""}`.trim());
|
|
14383
|
+
}
|
|
14384
|
+
if (syncState) {
|
|
14385
|
+
if (!syncState.entities.functions)
|
|
14386
|
+
syncState.entities.functions = {};
|
|
14387
|
+
let refreshed = liveFunction;
|
|
14388
|
+
try {
|
|
14389
|
+
refreshed = await client.getFunction(resolvedAppId, String(functionId));
|
|
14390
|
+
}
|
|
14391
|
+
catch {
|
|
14392
|
+
// Non-fatal: the baseline falls back to what the listing said.
|
|
14393
|
+
// The next push reconciles it.
|
|
14394
|
+
}
|
|
14395
|
+
// #3317 — the pointer recorded is the one THIS push activated,
|
|
14396
|
+
// whatever the refetch found: a concurrent activation in the
|
|
14397
|
+
// window between the two is a server move the next push must
|
|
14398
|
+
// refuse over, not a baseline it silently rolls back.
|
|
14399
|
+
const baseline = pushedFunctionBaseline({
|
|
14400
|
+
functionId: String(functionId),
|
|
14401
|
+
refreshed,
|
|
14402
|
+
existingModifiedAt: existingEntry?.modifiedAt,
|
|
14403
|
+
contentHash: computeFileHash(filePath),
|
|
14404
|
+
pushedVersion,
|
|
14405
|
+
envelopeHash: push.envelopeHash,
|
|
14406
|
+
sources: push.sources.map((src) => src.path),
|
|
14407
|
+
spec: functionSpec,
|
|
14408
|
+
maps: pushDiffMaps,
|
|
14409
|
+
});
|
|
14410
|
+
if (baseline.movedTo) {
|
|
14411
|
+
warn(` Function ${key}: the server's active version moved to ${baseline.movedTo} ` +
|
|
14412
|
+
`while version ${pushedVersion?.configId} was being recorded; ` +
|
|
14413
|
+
"the next push will report the drift (`config pull` adopts it, `--force` overrides it).");
|
|
14414
|
+
}
|
|
14415
|
+
syncState.entities.functions[key] = baseline.entity;
|
|
14416
|
+
}
|
|
14417
|
+
}
|
|
14418
|
+
}
|
|
14419
|
+
// #3798 — the agent prompts, now that every function they name that
|
|
14420
|
+
// this push applies is on the server (see `pushPromptFiles`).
|
|
14421
|
+
if (deferredAgentPromptFiles.length > 0) {
|
|
14422
|
+
await pushPromptFiles(deferredAgentPromptFiles);
|
|
14423
|
+
}
|
|
12486
14424
|
// Process group type configs
|
|
12487
14425
|
const groupTypeConfigsDir = join(configDir, "group-type-configs");
|
|
12488
14426
|
if (existsSync(groupTypeConfigsDir)) {
|
|
@@ -12664,7 +14602,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12664
14602
|
// this, that CREATE re-hits the server's 409 "Config for this
|
|
12665
14603
|
// group type already exists"
|
|
12666
14604
|
// (group-type-config-controller.ts) on every subsequent push
|
|
12667
|
-
// and the operator has to hand-edit
|
|
14605
|
+
// and the operator has to hand-edit the sync-state baseline to
|
|
12668
14606
|
// recover. The shared helper detects the conflict (409 status /
|
|
12669
14607
|
// "already exists" message), lists the app's configs — the list
|
|
12670
14608
|
// route drains every page server-side (#1668), so the candidate
|
|
@@ -13798,21 +15736,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
13798
15736
|
// Diff
|
|
13799
15737
|
sync
|
|
13800
15738
|
.command("diff")
|
|
15739
|
+
.allowExcessArguments(false)
|
|
13801
15740
|
.description("Show differences between local and remote configuration")
|
|
13802
|
-
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
13803
|
-
.option("--app <app-id>", "App ID")
|
|
13804
|
-
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
13805
15741
|
.option("--only <selectors>", "Compare just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated).", (value, previous) => [...(previous ?? []), value], [])
|
|
13806
|
-
.option("--json", "Output as JSON
|
|
13807
|
-
.action(async (
|
|
13808
|
-
const resolvedAppId =
|
|
13809
|
-
const configDir = resolveSyncDir(
|
|
13810
|
-
|
|
13811
|
-
info(`Using per-environment config directory: ${configDir}`);
|
|
13812
|
-
const legacyHint = checkLegacySyncMigration(configDir);
|
|
13813
|
-
if (legacyHint)
|
|
13814
|
-
warn(legacyHint);
|
|
13815
|
-
}
|
|
15742
|
+
.option("--json", "Output as JSON: the app.toml field diff with --only app, the compared rows otherwise")
|
|
15743
|
+
.action(async (options) => {
|
|
15744
|
+
const resolvedAppId = getCurrentAppId();
|
|
15745
|
+
const configDir = resolveSyncDir();
|
|
15746
|
+
info(`Using per-environment config directory: ${configDir}`);
|
|
13816
15747
|
// Issue #2760: diff compares the renamed directory, so a legacy one is
|
|
13817
15748
|
// simply invisible — an empty diff would otherwise read as "in sync".
|
|
13818
15749
|
const legacyDbTypes = checkLegacyDatabaseTypesDir(configDir);
|
|
@@ -13860,7 +15791,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
13860
15791
|
notCompared.push({ type: label, reason: gateReason(outcome.error) });
|
|
13861
15792
|
return empty;
|
|
13862
15793
|
};
|
|
13863
|
-
const [integrationsOutcome, promptsOutcome, workflowsOutcome, emailTemplatesOutcomeDiff, webhooksOutcome, cronTriggersOutcomeDiff, blobBucketsOutcomeDiff, scriptsOutcomeDiff, varsOutcomeDiff, databaseTypeConfigsOutcomeDiff, ruleSetsOutcomeDiff, groupTypeConfigsOutcomeDiff, collectionTypeConfigsOutcomeDiff, metadataCategoryConfigsOutcomeDiff,] = await Promise.all([
|
|
15794
|
+
const [integrationsOutcome, promptsOutcome, workflowsOutcome, emailTemplatesOutcomeDiff, webhooksOutcome, cronTriggersOutcomeDiff, blobBucketsOutcomeDiff, scriptsOutcomeDiff, functionsOutcomeDiff, varsOutcomeDiff, databaseTypeConfigsOutcomeDiff, ruleSetsOutcomeDiff, groupTypeConfigsOutcomeDiff, collectionTypeConfigsOutcomeDiff, metadataCategoryConfigsOutcomeDiff,] = await Promise.all([
|
|
13864
15795
|
attemptFetch(() => fetchAll((p) => client.listIntegrations(resolvedAppId, p))),
|
|
13865
15796
|
attemptFetch(() => fetchAll((p) => client.listPrompts(resolvedAppId, p))),
|
|
13866
15797
|
attemptFetch(() => fetchAll((p) => client.listWorkflows(resolvedAppId, p))),
|
|
@@ -13869,6 +15800,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
13869
15800
|
attemptFetch(() => client.listCronTriggers(resolvedAppId)),
|
|
13870
15801
|
attemptFetch(() => client.listBlobBuckets(resolvedAppId)),
|
|
13871
15802
|
attemptFetch(() => client.listScripts(resolvedAppId)),
|
|
15803
|
+
attemptFetch(() => client.listFunctions(resolvedAppId)),
|
|
13872
15804
|
attemptFetch(() => client.listAppConfigVars(resolvedAppId)),
|
|
13873
15805
|
attemptFetch(() => client.listDatabaseTypeConfigs(resolvedAppId)),
|
|
13874
15806
|
attemptFetch(() => client.listRuleSets(resolvedAppId)),
|
|
@@ -13884,6 +15816,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
13884
15816
|
const cronTriggerItemsDiff = gate(cronTriggersOutcomeDiff, "cron-triggers", { items: [] }).items || [];
|
|
13885
15817
|
const blobBucketsDiffResult = gate(blobBucketsOutcomeDiff, "blob-buckets", { items: [] });
|
|
13886
15818
|
const scriptItemsDiff = gate(scriptsOutcomeDiff, "transforms", { items: [] }).items || [];
|
|
15819
|
+
const functionItemsDiff = gate(functionsOutcomeDiff, "functions", { items: [] }).items || [];
|
|
13887
15820
|
const remoteVarItemsDiff = gate(varsOutcomeDiff, "vars", []);
|
|
13888
15821
|
const databaseTypeConfigsDiff = gate(databaseTypeConfigsOutcomeDiff, "database-type-configs", []);
|
|
13889
15822
|
const ruleSetsDiff = gate(ruleSetsOutcomeDiff, "rule-sets", []);
|
|
@@ -14188,6 +16121,25 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14188
16121
|
localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
|
|
14189
16122
|
}
|
|
14190
16123
|
}
|
|
16124
|
+
// Server functions — #3179. Two axes, exactly as push has: the
|
|
16125
|
+
// header/version fields through the shared spec, and the CODE through
|
|
16126
|
+
// `envelopeHash`. The code axis is why a function's diff builds the
|
|
16127
|
+
// bundle: a comment-only edit to a source file changes nothing the
|
|
16128
|
+
// table can see and is still a change push will apply.
|
|
16129
|
+
const remoteFunctions = new Map(functionItemsDiff
|
|
16130
|
+
.filter((f) => f?.functionKey && String(f.status ?? "active") !== "archived")
|
|
16131
|
+
.map((f) => [f.functionKey, f]));
|
|
16132
|
+
const localFunctionsParsed = new Map();
|
|
16133
|
+
const functionsDirPath = join(configDir, "functions");
|
|
16134
|
+
if (functionsOutcomeDiff.ok && existsSync(functionsDirPath)) {
|
|
16135
|
+
for (const file of readdirSync(functionsDirPath).filter(isFunctionDeclarationFile)) {
|
|
16136
|
+
const filePath = join(functionsDirPath, file);
|
|
16137
|
+
const tomlData = parseTomlFile(filePath);
|
|
16138
|
+
const key = tomlData.function?.key || basename(file, ".toml");
|
|
16139
|
+
withDerivedIdentity(tomlData, "function", { key });
|
|
16140
|
+
localFunctionsParsed.set(key, { tomlData, filePath });
|
|
16141
|
+
}
|
|
16142
|
+
}
|
|
14191
16143
|
// Config vars — issue #1423. The remote vars were fetched through the
|
|
14192
16144
|
// ok/error gate above (`remoteVarItemsDiff`); the local `vars.toml` is
|
|
14193
16145
|
// read only when that fetch succeeded.
|
|
@@ -14451,6 +16403,108 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14451
16403
|
differences.push({ type: "prompt", key, status: remoteOnlyStatus("prompts", key) });
|
|
14452
16404
|
}
|
|
14453
16405
|
}
|
|
16406
|
+
// Server functions — #3179.
|
|
16407
|
+
const functionSpecDiff = configDiffSpec("function");
|
|
16408
|
+
const functionDiffMaps = {
|
|
16409
|
+
ruleSetIdToName: new Map(),
|
|
16410
|
+
ruleSetNameToId: new Map(),
|
|
16411
|
+
};
|
|
16412
|
+
for (const [key, local] of localFunctionsParsed) {
|
|
16413
|
+
const remote = remoteFunctions.get(key);
|
|
16414
|
+
if (!remote) {
|
|
16415
|
+
differences.push({
|
|
16416
|
+
type: "function",
|
|
16417
|
+
key,
|
|
16418
|
+
status: localOnlyStatus("functions", key),
|
|
16419
|
+
});
|
|
16420
|
+
continue;
|
|
16421
|
+
}
|
|
16422
|
+
let status = "exists";
|
|
16423
|
+
let hint;
|
|
16424
|
+
// The active version carries `entry`, `mode` and `limits`, and its
|
|
16425
|
+
// `envelopeHash` is the code axis.
|
|
16426
|
+
const versionOutcome = await attemptFetch(async () => {
|
|
16427
|
+
if (!remote.activeConfigId)
|
|
16428
|
+
return null;
|
|
16429
|
+
// #3462 — the point read, for the same reason as pull's.
|
|
16430
|
+
return client.getFunctionConfig(resolvedAppId, remote.functionId, String(remote.activeConfigId));
|
|
16431
|
+
});
|
|
16432
|
+
// #3462 — and the newest version, so a tree that matches something
|
|
16433
|
+
// that EXISTS can say so. One row; the listing answers newest first.
|
|
16434
|
+
const newestOutcome = await attemptFetch(async () => {
|
|
16435
|
+
const page = await client.listFunctionConfigs(resolvedAppId, remote.functionId, { limit: 1 });
|
|
16436
|
+
return (page.items || [])[0] ?? null;
|
|
16437
|
+
});
|
|
16438
|
+
if (!versionOutcome.ok) {
|
|
16439
|
+
hint = "content not compared (fetch failed)";
|
|
16440
|
+
}
|
|
16441
|
+
else {
|
|
16442
|
+
const active = versionOutcome.value;
|
|
16443
|
+
const verdict = compareLocalToRemote(functionSpecDiff, local.tomlData, remote, functionDiffMaps, active ?? undefined);
|
|
16444
|
+
status = verdict.status;
|
|
16445
|
+
hint = verdict.hint;
|
|
16446
|
+
if (status === "exists") {
|
|
16447
|
+
// The header agrees; the code is the other half. Building here is
|
|
16448
|
+
// what makes "config diff is clean immediately after pull" a
|
|
16449
|
+
// statement about the SOURCES and not just the TOML.
|
|
16450
|
+
try {
|
|
16451
|
+
const built = await buildFunctionConfigPush({
|
|
16452
|
+
configDir,
|
|
16453
|
+
functionKey: key,
|
|
16454
|
+
filePath: local.filePath,
|
|
16455
|
+
tomlData: local.tomlData,
|
|
16456
|
+
});
|
|
16457
|
+
if (!active ||
|
|
16458
|
+
String(active.envelopeHash ?? "") !== built.envelopeHash) {
|
|
16459
|
+
status = "modified";
|
|
16460
|
+
// #3462 — when the tree matches the NEWEST version rather
|
|
16461
|
+
// than the active one, the function is rolled back and the
|
|
16462
|
+
// generic hint sends an operator looking for an edit they
|
|
16463
|
+
// never made. Naming both versions says what actually
|
|
16464
|
+
// happened and what each command would do about it.
|
|
16465
|
+
const newest = newestOutcome.ok
|
|
16466
|
+
? newestOutcome.value
|
|
16467
|
+
: null;
|
|
16468
|
+
hint =
|
|
16469
|
+
active &&
|
|
16470
|
+
newest &&
|
|
16471
|
+
String(newest.configId) !== String(active.configId) &&
|
|
16472
|
+
String(newest.envelopeHash ?? "") === built.envelopeHash
|
|
16473
|
+
? rollbackDiffHint(String(newest.configId), String(active.configId))
|
|
16474
|
+
: "the built bundle or a source file differs — `config push` " +
|
|
16475
|
+
"creates a new immutable version";
|
|
16476
|
+
}
|
|
16477
|
+
}
|
|
16478
|
+
catch (err) {
|
|
16479
|
+
if (err instanceof FunctionBuildError) {
|
|
16480
|
+
// #2880 — the file is not pushable, so the row says so
|
|
16481
|
+
// rather than reading as Synced. This is the state a
|
|
16482
|
+
// function created through the API and never pushed lands
|
|
16483
|
+
// in: pull can only serialize the header, so the file has
|
|
16484
|
+
// no `entry` and the very next push refuses it. A Synced row
|
|
16485
|
+
// there tells the operator there is nothing to do about a
|
|
16486
|
+
// file nothing can apply.
|
|
16487
|
+
status = VALIDATION_ERROR;
|
|
16488
|
+
hint = err.message.split("\n")[0];
|
|
16489
|
+
}
|
|
16490
|
+
else {
|
|
16491
|
+
hint = "content not compared (comparison failed)";
|
|
16492
|
+
}
|
|
16493
|
+
}
|
|
16494
|
+
}
|
|
16495
|
+
}
|
|
16496
|
+
hint = outOfServiceHint(remote, hint);
|
|
16497
|
+
differences.push({ type: "function", key, status, hint });
|
|
16498
|
+
}
|
|
16499
|
+
for (const key of remoteFunctions.keys()) {
|
|
16500
|
+
if (!localFunctionsParsed.has(key)) {
|
|
16501
|
+
differences.push({
|
|
16502
|
+
type: "function",
|
|
16503
|
+
key,
|
|
16504
|
+
status: remoteOnlyStatus("functions", key),
|
|
16505
|
+
});
|
|
16506
|
+
}
|
|
16507
|
+
}
|
|
14454
16508
|
// Workflows — #1175: content-aware comparison.
|
|
14455
16509
|
//
|
|
14456
16510
|
// For workflows present on BOTH sides we no longer stop at key
|
|
@@ -14688,7 +16742,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14688
16742
|
// below). Content comparison is a preview of `config pull` (pull would
|
|
14689
16743
|
// rewrite the local TOML to match running state), exactly like the
|
|
14690
16744
|
// workflow/transform blocks.
|
|
14691
|
-
const compareConfigType = (typeLabel, slot, localParsed, remoteEntities,
|
|
16745
|
+
const compareConfigType = (typeLabel, slot, localParsed, remoteEntities, compare, contentComparable) => {
|
|
14692
16746
|
for (const [key, parsed] of localParsed) {
|
|
14693
16747
|
if (!remoteEntities.has(key)) {
|
|
14694
16748
|
differences.push({ type: typeLabel, key, status: localOnlyStatus(slot, key) });
|
|
@@ -14703,16 +16757,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14703
16757
|
hint = "content not compared (rule sets unavailable)";
|
|
14704
16758
|
}
|
|
14705
16759
|
else {
|
|
14706
|
-
|
|
14707
|
-
|
|
14708
|
-
|
|
14709
|
-
|
|
14710
|
-
}
|
|
14711
|
-
}
|
|
14712
|
-
catch {
|
|
14713
|
-
// A malformed local file must not abort the whole diff.
|
|
14714
|
-
hint = "content not compared (comparison failed)";
|
|
14715
|
-
}
|
|
16760
|
+
// The shared verdict names the differing field (#3267) and
|
|
16761
|
+
// degrades a malformed local file to "not compared" rather than
|
|
16762
|
+
// aborting the whole diff.
|
|
16763
|
+
({ status, hint } = compare(parsed, remoteEntities.get(key)));
|
|
14716
16764
|
}
|
|
14717
16765
|
differences.push({ type: typeLabel, key, status, hint });
|
|
14718
16766
|
}
|
|
@@ -14731,7 +16779,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14731
16779
|
};
|
|
14732
16780
|
const compareByDefinition = (label, slot, localParsed, remoteEntities, contentComparable) => {
|
|
14733
16781
|
const spec = configDiffSpec(label);
|
|
14734
|
-
compareConfigType(label, slot, localParsed, remoteEntities, (p) =>
|
|
16782
|
+
compareConfigType(label, slot, localParsed, remoteEntities, (p, e) => compareLocalToRemote(spec, p, e, diffMaps), contentComparable);
|
|
14735
16783
|
};
|
|
14736
16784
|
compareByDefinition("rule-set", "ruleSets", localRuleSetsParsed, remoteRuleSets, true);
|
|
14737
16785
|
compareByDefinition("group-type-config", "groupTypeConfigs", localGroupTypeConfigsParsed, remoteGroupTypeConfigs, ruleSetsOk);
|
|
@@ -14763,20 +16811,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14763
16811
|
hint = "content not compared (fetch failed)";
|
|
14764
16812
|
}
|
|
14765
16813
|
else {
|
|
14766
|
-
|
|
14767
|
-
|
|
14768
|
-
|
|
14769
|
-
|
|
14770
|
-
|
|
14771
|
-
|
|
14772
|
-
|
|
14773
|
-
|
|
14774
|
-
hint = "run `config pull` to rewrite the local TOML to match running state";
|
|
14775
|
-
}
|
|
14776
|
-
}
|
|
14777
|
-
catch {
|
|
14778
|
-
hint = "content not compared (comparison failed)";
|
|
14779
|
-
}
|
|
16814
|
+
const operations = Array.isArray(opsOutcome.value) ? opsOutcome.value : [];
|
|
16815
|
+
const subscriptions = Array.isArray(subsOutcome.value) ? subsOutcome.value : [];
|
|
16816
|
+
// The shared verdict (#3267): a Modified row names the
|
|
16817
|
+
// operation, subscription, model field or `[type]` scalar it
|
|
16818
|
+
// differs in, and a comparison that throws degrades this one
|
|
16819
|
+
// row to "not compared". The ops/subscriptions are the remote
|
|
16820
|
+
// side's extra only — the file already carries its own.
|
|
16821
|
+
({ status, hint } = compareLocalToRemote(configDiffSpec("database-type-config"), parsed, remote, diffMaps, { operations, subscriptions }));
|
|
14780
16822
|
}
|
|
14781
16823
|
}
|
|
14782
16824
|
differences.push({ type: "database-type-config", key, status, hint });
|
|
@@ -14917,7 +16959,23 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14917
16959
|
// the same preflight push runs decides these rows, and its message —
|
|
14918
16960
|
// verbatim, so the two commands read identically — replaces whatever
|
|
14919
16961
|
// the comparison had to say about the file.
|
|
14920
|
-
|
|
16962
|
+
// #3320 — the function checks join them. Same posture, same messages:
|
|
16963
|
+
// a function file whose `entry`, mode, triggers or webhook
|
|
16964
|
+
// signing-secret the push preflight refuses is not a row this command
|
|
16965
|
+
// calls Synced, Modified or "local only — new".
|
|
16966
|
+
// Merged first: a file both collectors reject keeps every message push
|
|
16967
|
+
// prints, rather than one collector's hint overwriting the other's.
|
|
16968
|
+
// #3482 — the same advisory `config push` prints, so an author sees
|
|
16969
|
+
// the retired key before they push rather than only during it. Not a
|
|
16970
|
+
// row status: the file is pushable, and saying otherwise would make
|
|
16971
|
+
// `config diff` disagree with the push it predicts.
|
|
16972
|
+
for (const message of collectFunctionTomlIgnoredKeyWarnings(configDir)) {
|
|
16973
|
+
warn(` ${message}`);
|
|
16974
|
+
}
|
|
16975
|
+
for (const rejected of mergeConfigFileValidationErrors([
|
|
16976
|
+
...collectConfigFileValidationErrors(configDir),
|
|
16977
|
+
...collectFunctionTomlValidationErrors(configDir),
|
|
16978
|
+
])) {
|
|
14921
16979
|
const row = differences.find((d) => d.type === rejected.type && d.key === rejected.key);
|
|
14922
16980
|
const hint = rejected.messages.join("; ");
|
|
14923
16981
|
if (row) {
|
|
@@ -14948,6 +17006,22 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14948
17006
|
? selectionIncludesApp(diffSelection)
|
|
14949
17007
|
: selectionIncludesKey(diffSelection, row.type, row.key));
|
|
14950
17008
|
}
|
|
17009
|
+
// #3320 — `--json` over the whole comparison, not just `--only app`.
|
|
17010
|
+
// The Invalid rows this issue adds have to be readable by the thing
|
|
17011
|
+
// that reads a diff programmatically (an editor, a CI job), and the
|
|
17012
|
+
// flag already existed for the app-settings case; a run that asked for
|
|
17013
|
+
// JSON and got a human table was answering a different question.
|
|
17014
|
+
// Emitted after the `--only` filter so a scoped run reports its scope.
|
|
17015
|
+
if (options.json) {
|
|
17016
|
+
console.log(JSON.stringify({
|
|
17017
|
+
appId: resolvedAppId,
|
|
17018
|
+
configDir,
|
|
17019
|
+
differences,
|
|
17020
|
+
testCases: testCaseDiffs,
|
|
17021
|
+
notCompared,
|
|
17022
|
+
}, null, 2));
|
|
17023
|
+
return;
|
|
17024
|
+
}
|
|
14951
17025
|
divider();
|
|
14952
17026
|
const localOnly = differences.filter((d) => d.status === LOCAL_ONLY_NEW);
|
|
14953
17027
|
const localOnlyAbsent = differences.filter((d) => d.status === LOCAL_ONLY_ABSENT);
|
|
@@ -15076,6 +17150,41 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15076
17150
|
console.log(` ${chalk.dim("=")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}${hint}`);
|
|
15077
17151
|
}
|
|
15078
17152
|
}
|
|
17153
|
+
// #3182 — the generated function types are part of what a push would
|
|
17154
|
+
// change, so `config diff` is where their freshness is answered: an
|
|
17155
|
+
// editor or a CI job asks the same command it already asks "what would
|
|
17156
|
+
// push do?", and gets these named alongside everything else.
|
|
17157
|
+
let staleFunctionTypes = [];
|
|
17158
|
+
let wiringProblems = [];
|
|
17159
|
+
try {
|
|
17160
|
+
staleFunctionTypes = staleFunctionTypeArtifacts(configDir);
|
|
17161
|
+
// Freshness is not the only way the handle stops working: a tsconfig
|
|
17162
|
+
// push will not rewrite can be perfectly current and still load none
|
|
17163
|
+
// of it. `config diff` is where CI asks, so it answers both
|
|
17164
|
+
// (CR3182-003).
|
|
17165
|
+
wiringProblems = functionTypeWiringProblems(configDir);
|
|
17166
|
+
}
|
|
17167
|
+
catch (err) {
|
|
17168
|
+
if (!(err instanceof FunctionSchemaCodegenError))
|
|
17169
|
+
throw err;
|
|
17170
|
+
// #3281 — a function file whose schema cannot be typed is reported
|
|
17171
|
+
// like a wiring problem: the generators cannot answer for the tree.
|
|
17172
|
+
wiringProblems = [err.message];
|
|
17173
|
+
}
|
|
17174
|
+
if (staleFunctionTypes.length > 0) {
|
|
17175
|
+
console.log();
|
|
17176
|
+
info("Function types (regenerated by `config push`):");
|
|
17177
|
+
for (const relative of staleFunctionTypes) {
|
|
17178
|
+
console.log(` ${chalk.yellow("~")} ${relative}`);
|
|
17179
|
+
}
|
|
17180
|
+
}
|
|
17181
|
+
if (wiringProblems.length > 0) {
|
|
17182
|
+
console.log();
|
|
17183
|
+
info("Function types (compiler wiring `config push` cannot repair):");
|
|
17184
|
+
for (const problem of wiringProblems) {
|
|
17185
|
+
console.log(` ${chalk.yellow("!")} functions/tsconfig.json: ${problem}`);
|
|
17186
|
+
}
|
|
17187
|
+
}
|
|
15079
17188
|
divider();
|
|
15080
17189
|
keyValue("Local only (new)", localOnly.length);
|
|
15081
17190
|
keyValue("Local only (absent from export)", localOnlyAbsent.length);
|
|
@@ -15113,22 +17222,18 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15113
17222
|
// containing null) fall back to JSON-string per field with a log message.
|
|
15114
17223
|
sync
|
|
15115
17224
|
.command("migrate-toml")
|
|
17225
|
+
.allowExcessArguments(false)
|
|
15116
17226
|
.description("Rewrite database-type and workflow TOML files to native form " +
|
|
15117
17227
|
"([operations.definition] / [[operations.params]] for database types; " +
|
|
15118
17228
|
"[workflow.inputSchema] / [workflow.outputSchema] for workflows)")
|
|
15119
|
-
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
15120
|
-
.option("--app <app-id>", "App ID")
|
|
15121
|
-
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
15122
17229
|
.option("--dry-run", "Show what would change without writing files")
|
|
15123
|
-
.action(async (
|
|
15124
|
-
//
|
|
15125
|
-
//
|
|
15126
|
-
//
|
|
15127
|
-
const resolvedAppId =
|
|
15128
|
-
const configDir = resolveSyncDir(
|
|
15129
|
-
|
|
15130
|
-
info(`Using per-environment config directory: ${configDir}`);
|
|
15131
|
-
}
|
|
17230
|
+
.action(async (options) => {
|
|
17231
|
+
// The app ID is not used for any API call — `migrate-toml` is a purely
|
|
17232
|
+
// local rewrite — but the environment that names it is what names the
|
|
17233
|
+
// tree being rewritten.
|
|
17234
|
+
const resolvedAppId = getCurrentAppId();
|
|
17235
|
+
const configDir = resolveSyncDir();
|
|
17236
|
+
info(`Using per-environment config directory: ${configDir}`);
|
|
15132
17237
|
if (!existsSync(configDir)) {
|
|
15133
17238
|
error(`Config directory not found: ${configDir}`);
|
|
15134
17239
|
process.exit(1);
|
|
@@ -15278,21 +17383,16 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15278
17383
|
// proven), so re-running push/pull is unaffected.
|
|
15279
17384
|
sync
|
|
15280
17385
|
.command("migrate-v2")
|
|
17386
|
+
.allowExcessArguments(false)
|
|
15281
17387
|
.description("Convert legacy per-type sync files (prompts/, integrations/, workflows/, transforms/) into the v2 blocks/<type>/<key>/block.toml layout")
|
|
15282
|
-
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
15283
|
-
.option("--app <app-id>", "App ID")
|
|
15284
|
-
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
15285
17388
|
.option("--dry-run", "Show what would change without writing files")
|
|
15286
17389
|
.option("--force", "Overwrite existing block.toml files instead of skipping them")
|
|
15287
|
-
.action(async (
|
|
15288
|
-
//
|
|
15289
|
-
//
|
|
15290
|
-
|
|
15291
|
-
const
|
|
15292
|
-
|
|
15293
|
-
if (isAutoResolvedSyncDir(options.dir)) {
|
|
15294
|
-
info(`Using per-environment config directory: ${configDir}`);
|
|
15295
|
-
}
|
|
17390
|
+
.action(async (options) => {
|
|
17391
|
+
// As with migrate-toml: a purely local rewrite, whose tree is the
|
|
17392
|
+
// resolved environment's.
|
|
17393
|
+
const resolvedAppId = getCurrentAppId();
|
|
17394
|
+
const configDir = resolveSyncDir();
|
|
17395
|
+
info(`Using per-environment config directory: ${configDir}`);
|
|
15296
17396
|
if (!existsSync(configDir)) {
|
|
15297
17397
|
error(`Config directory not found: ${configDir}`);
|
|
15298
17398
|
process.exit(1);
|
|
@@ -15331,26 +17431,16 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15331
17431
|
// Revert — restore a pre-pull snapshot (issue #578, Phase 1).
|
|
15332
17432
|
sync
|
|
15333
17433
|
.command("revert")
|
|
17434
|
+
.allowExcessArguments(false)
|
|
15334
17435
|
.description("Restore the config directory from a snapshot taken before a previous pull")
|
|
15335
|
-
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
15336
|
-
.option("--app <app-id>", "App ID")
|
|
15337
|
-
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
15338
17436
|
.option("--snapshot <id>", "Snapshot id (timestamp dirname or a unique >=8-char prefix)")
|
|
15339
17437
|
.option("--list", "List available snapshots without restoring")
|
|
15340
17438
|
.option("-y, --yes", "Skip the confirmation prompt")
|
|
15341
|
-
.action(async (
|
|
15342
|
-
const resolvedAppId =
|
|
15343
|
-
const configDir = resolveSyncDir(
|
|
15344
|
-
|
|
15345
|
-
|
|
15346
|
-
});
|
|
15347
|
-
const snapshotsRoot = resolveSnapshotsRoot({
|
|
15348
|
-
appId: resolvedAppId,
|
|
15349
|
-
userDir: options.dir,
|
|
15350
|
-
});
|
|
15351
|
-
if (isAutoResolvedSyncDir(options.dir)) {
|
|
15352
|
-
info(`Using per-environment config directory: ${configDir}`);
|
|
15353
|
-
}
|
|
17439
|
+
.action(async (options) => {
|
|
17440
|
+
const resolvedAppId = getCurrentAppId();
|
|
17441
|
+
const configDir = resolveSyncDir();
|
|
17442
|
+
const snapshotsRoot = resolveSnapshotsRoot({ appId: resolvedAppId });
|
|
17443
|
+
info(`Using per-environment config directory: ${configDir}`);
|
|
15354
17444
|
const snapshots = listSnapshots(snapshotsRoot);
|
|
15355
17445
|
// --list, or no snapshots at all: enumerate and stop.
|
|
15356
17446
|
if (options.list || snapshots.length === 0) {
|
|
@@ -15410,15 +17500,13 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15410
17500
|
}
|
|
15411
17501
|
}
|
|
15412
17502
|
try {
|
|
15413
|
-
|
|
15414
|
-
// it across the full-tree swap so we don't wipe backup history.
|
|
15415
|
-
restoreSnapshot(target.path, configDir, { preserveDir: snapshotsRoot });
|
|
17503
|
+
restoreSnapshot(target.path, configDir);
|
|
15416
17504
|
}
|
|
15417
17505
|
catch (err) {
|
|
15418
17506
|
error(`Restore failed: ${err?.message ?? err}`);
|
|
15419
17507
|
process.exit(1);
|
|
15420
17508
|
}
|
|
15421
|
-
success(`Restored snapshot ${target.id} into ${configDir} (including
|
|
17509
|
+
success(`Restored snapshot ${target.id} into ${configDir} (including ${SYNC_STATE_FILENAME}).`);
|
|
15422
17510
|
info("Run 'primitive config diff' to inspect the restored state versus the server.");
|
|
15423
17511
|
});
|
|
15424
17512
|
}
|