primitive-admin 1.0.63 → 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 +12 -5
- 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 +2 -1
- 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 +89 -43
- 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 +12 -15
- package/dist/src/commands/init.js +44 -92
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/integrations.js +35 -12
- 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.js +33 -28
- 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 +390 -13
- package/dist/src/commands/sync.js +2613 -451
- 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 +89 -18
- package/dist/src/lib/env-resolver-core.js +145 -23
- 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-config.d.ts +1 -1
- package/dist/src/lib/init-ios-links.d.ts +6 -5
- package/dist/src/lib/init-ios-links.js +9 -8
- 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/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 +1 -1
- package/dist/src/lib/local-test-cases.js +2 -1
- package/dist/src/lib/local-test-cases.js.map +1 -1
- 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 +38 -21
- package/dist/src/lib/project-config.js +57 -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-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/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,9 +33,40 @@ 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";
|
|
@@ -41,7 +76,7 @@ import { slugifyTestCaseName, testCaseFileBasenames, } from "../lib/test-case-fi
|
|
|
41
76
|
// diff and their tests have always imported them from this module.
|
|
42
77
|
export { resolveSlugCollisions, slugifyTestCaseName, testCaseFileBasenames, } from "../lib/test-case-file-names.js";
|
|
43
78
|
import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
44
|
-
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";
|
|
45
80
|
import { confirmPrompt, ConfirmPromptError } from "../lib/confirm-prompt.js";
|
|
46
81
|
import { signingSecretStatusWarning, signingSecretHealthWarning, previousSigningSecretWarning, } from "../lib/signing-secret-status.js";
|
|
47
82
|
import chalk from "chalk";
|
|
@@ -64,7 +99,7 @@ function printStaleInstanceWarning(updated) {
|
|
|
64
99
|
}
|
|
65
100
|
}
|
|
66
101
|
function loadSyncState(configDir) {
|
|
67
|
-
const syncFile =
|
|
102
|
+
const syncFile = syncStateReadPath(configDir);
|
|
68
103
|
if (!existsSync(syncFile)) {
|
|
69
104
|
return null;
|
|
70
105
|
}
|
|
@@ -76,7 +111,7 @@ function loadSyncState(configDir) {
|
|
|
76
111
|
}
|
|
77
112
|
}
|
|
78
113
|
function saveSyncState(configDir, state) {
|
|
79
|
-
const syncFile =
|
|
114
|
+
const syncFile = syncStatePath(configDir);
|
|
80
115
|
writeFileSync(syncFile, JSON.stringify(state, null, 2));
|
|
81
116
|
}
|
|
82
117
|
/**
|
|
@@ -690,6 +725,20 @@ export function validateVarsFile(parsedVars, options = {}) {
|
|
|
690
725
|
* edit — reported as a conflict instead. `force` skips the conflict guard,
|
|
691
726
|
* matching `config push --force` for every other entity.
|
|
692
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
|
+
*
|
|
693
742
|
* Pure and side-effect-free (the caller owns the API calls and sync-state
|
|
694
743
|
* updates) so the guard is unit-testable without a live server. Assumes
|
|
695
744
|
* `parsedVars` has already passed `validateVarsFile` (values are strings). The
|
|
@@ -703,6 +752,7 @@ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
|
|
|
703
752
|
const remoteByKey = new Map(remoteVars.map((r) => [r.key, r]));
|
|
704
753
|
const upserts = [];
|
|
705
754
|
const conflicts = [];
|
|
755
|
+
const drifted = [];
|
|
706
756
|
const deletions = [];
|
|
707
757
|
let skippedCount = 0;
|
|
708
758
|
const localKeys = new Set();
|
|
@@ -710,9 +760,32 @@ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
|
|
|
710
760
|
localKeys.add(key);
|
|
711
761
|
const valueHash = hashVarValue(value);
|
|
712
762
|
const existing = baseline?.[key];
|
|
713
|
-
// Unchanged since last sync —
|
|
714
|
-
// 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.
|
|
715
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.
|
|
716
789
|
skippedCount++;
|
|
717
790
|
continue;
|
|
718
791
|
}
|
|
@@ -800,7 +873,7 @@ export function planVarsPush(parsedVars, baseline, remoteVars, options = {}) {
|
|
|
800
873
|
}
|
|
801
874
|
deletions.push(key);
|
|
802
875
|
}
|
|
803
|
-
return { upserts, deletions, conflicts, skippedCount };
|
|
876
|
+
return { upserts, deletions, conflicts, drifted, skippedCount };
|
|
804
877
|
}
|
|
805
878
|
/**
|
|
806
879
|
* Compute the number of config vars the server would hold after a push plan
|
|
@@ -1594,6 +1667,30 @@ export function buildBlobBucketUpdatePayload(bucket) {
|
|
|
1594
1667
|
}
|
|
1595
1668
|
return updatePayload;
|
|
1596
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
|
+
*/
|
|
1597
1694
|
/**
|
|
1598
1695
|
* Which `[[configs]]` entry a prompt file marks as live (issue #2645).
|
|
1599
1696
|
*
|
|
@@ -1701,17 +1798,43 @@ export function serializePrompt(prompt, logger) {
|
|
|
1701
1798
|
...projectRecordToToml(PROMPT_TABLE, prompt, {
|
|
1702
1799
|
inputSchema: jsonField(promptSubject),
|
|
1703
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),
|
|
1704
1806
|
}),
|
|
1705
1807
|
},
|
|
1706
1808
|
configs: (prompt.configs || []).map((config) => ({
|
|
1707
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,
|
|
1708
1821
|
// Stored as a string on the model and returned parsed; coerce
|
|
1709
1822
|
// defensively so an older server's string still writes as a number.
|
|
1710
1823
|
temperature: (record) => record.temperature != null ? Number(record.temperature) : undefined,
|
|
1711
1824
|
topP: (record) => (record.topP != null ? Number(record.topP) : undefined),
|
|
1712
1825
|
outputSchema: jsonField(`Prompt '${prompt?.promptKey}' config '${config?.configName}'`),
|
|
1713
1826
|
providerConfig: jsonField(`Prompt '${prompt?.promptKey}' config '${config?.configName}'`),
|
|
1714
|
-
|
|
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) }),
|
|
1715
1838
|
// #2645 — the live config has to be identifiable in the repo. Emitted
|
|
1716
1839
|
// only on the active entry (`undefined` elsewhere so the key is omitted),
|
|
1717
1840
|
// which keeps the marker unambiguous by construction.
|
|
@@ -1783,11 +1906,43 @@ export function buildPromptPushPayload(key, tomlData, { mode = "update" } = {})
|
|
|
1783
1906
|
// dropped and leaving a stale rule live. On create it is omitted so
|
|
1784
1907
|
// the push-adopt path (#1006) can still reach the server's 409.
|
|
1785
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
|
+
},
|
|
1786
1937
|
},
|
|
1787
1938
|
}),
|
|
1788
1939
|
};
|
|
1789
1940
|
if (create)
|
|
1790
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;
|
|
1791
1946
|
return payload;
|
|
1792
1947
|
}
|
|
1793
1948
|
/**
|
|
@@ -1817,11 +1972,25 @@ export function buildPromptCreatePayload(key, tomlData) {
|
|
|
1817
1972
|
for (const owned of ["configName", ...SEEDED_CONFIG_DEFERRED]) {
|
|
1818
1973
|
delete seed[owned];
|
|
1819
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
|
+
}
|
|
1820
1989
|
return {
|
|
1821
|
-
...
|
|
1990
|
+
...prompt,
|
|
1822
1991
|
...seed,
|
|
1823
|
-
// The server requires a template; keep the historical
|
|
1824
|
-
// 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.
|
|
1825
1994
|
userPromptTemplate: seed.userPromptTemplate || "{{ input }}",
|
|
1826
1995
|
};
|
|
1827
1996
|
}
|
|
@@ -1852,15 +2021,26 @@ const SEEDED_CONFIG_DEFERRED = ["outputSchema", "description"];
|
|
|
1852
2021
|
* `seededConfigName` is what the server called it — read from the create
|
|
1853
2022
|
* response rather than assumed, so a handler that one day honors the name
|
|
1854
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.
|
|
1855
2030
|
*/
|
|
1856
2031
|
export function seededOnlyPromptConfigFields(firstConfig, seededConfigName = "default") {
|
|
1857
2032
|
const full = buildPromptConfigPayload(firstConfig || {}, { mode: "update" });
|
|
1858
2033
|
const patch = {};
|
|
1859
2034
|
for (const field of SEEDED_CONFIG_DEFERRED) {
|
|
1860
2035
|
const value = full[field];
|
|
1861
|
-
//
|
|
1862
|
-
//
|
|
1863
|
-
|
|
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)
|
|
1864
2044
|
continue;
|
|
1865
2045
|
patch[field] = value;
|
|
1866
2046
|
}
|
|
@@ -1879,62 +2059,62 @@ export function seededOnlyPromptConfigFields(firstConfig, seededConfigName = "de
|
|
|
1879
2059
|
*/
|
|
1880
2060
|
export function buildPromptConfigPayload(tomlConfig, { mode = "update" } = {}) {
|
|
1881
2061
|
const create = mode === "create";
|
|
1882
|
-
|
|
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, {
|
|
1883
2083
|
mode,
|
|
1884
2084
|
values: {
|
|
1885
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),
|
|
1886
2092
|
model: (value) => create ? value || "google/gemini-2.0-flash-001" : value,
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
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,
|
|
1891
2115
|
},
|
|
1892
2116
|
});
|
|
1893
2117
|
}
|
|
1894
|
-
/**
|
|
1895
|
-
* Emit a `type: "json"` field (a JSON Schema, a provider-options blob) as TOML.
|
|
1896
|
-
*
|
|
1897
|
-
* Issue #1446: pull used to `JSON.stringify` schema fields into a single
|
|
1898
|
-
* escaped-JSON line — unreadable and undiffable. We now emit the parsed value
|
|
1899
|
-
* as a native table whenever it round-trips faithfully
|
|
1900
|
-
* (`canEmitNative === null`), falling back to the JSON string only for the
|
|
1901
|
-
* values TOML silently drops (`null` / `undefined` anywhere inside). The empty
|
|
1902
|
-
* object `{}` is a real, distinct value (for a schema: "accepts anything") and
|
|
1903
|
-
* emits as an empty table, so it survives a pull → push cycle instead of being
|
|
1904
|
-
* cleared server-side.
|
|
1905
|
-
*
|
|
1906
|
-
* Returns `undefined` for an absent value (the key is then omitted). The
|
|
1907
|
-
* sponsor-approved decision is to always move to native aggressively — no
|
|
1908
|
-
* form preservation — so this does not consult the existing file's form.
|
|
1909
|
-
*
|
|
1910
|
-
* `subject` names the object in the fallback warning, e.g. `Workflow 'greet'`.
|
|
1911
|
-
*/
|
|
1912
|
-
function serializeJsonField(value, fieldName, subject, log) {
|
|
1913
|
-
if (value === undefined || value === null)
|
|
1914
|
-
return undefined;
|
|
1915
|
-
// JSON fields arrive from the server GET parsed; defensively accept a JSON
|
|
1916
|
-
// string too (e.g. an inline-authored file re-serialized here).
|
|
1917
|
-
let obj = value;
|
|
1918
|
-
if (typeof value === "string") {
|
|
1919
|
-
try {
|
|
1920
|
-
obj = JSON.parse(value);
|
|
1921
|
-
}
|
|
1922
|
-
catch {
|
|
1923
|
-
// Not valid JSON — keep the original string as the emitted value.
|
|
1924
|
-
return value;
|
|
1925
|
-
}
|
|
1926
|
-
}
|
|
1927
|
-
if (obj && typeof obj === "object") {
|
|
1928
|
-
const reason = canEmitNative(obj);
|
|
1929
|
-
if (reason) {
|
|
1930
|
-
log(`${subject}: cannot emit \`${fieldName}\` as native TOML (${reason}); using JSON string.`);
|
|
1931
|
-
return JSON.stringify(obj);
|
|
1932
|
-
}
|
|
1933
|
-
return obj;
|
|
1934
|
-
}
|
|
1935
|
-
// A non-object value (unexpected) — preserve it as a JSON string.
|
|
1936
|
-
return JSON.stringify(obj);
|
|
1937
|
-
}
|
|
1938
2118
|
/** The `[workflow]` case of `serializeJsonField` (#1446, message unchanged). */
|
|
1939
2119
|
function serializeWorkflowSchemaField(value, fieldName, workflowKey, log) {
|
|
1940
2120
|
return serializeJsonField(value, fieldName, `Workflow '${workflowKey}'`, log);
|
|
@@ -2007,11 +2187,19 @@ export function configUnknownKeyError(filePath, tomlData, table) {
|
|
|
2007
2187
|
: []
|
|
2008
2188
|
: [at];
|
|
2009
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();
|
|
2010
2195
|
for (const entry of entries) {
|
|
2011
2196
|
for (const key of findUnknownTomlKeys(entry, table))
|
|
2012
2197
|
unknown.add(key);
|
|
2198
|
+
for (const key of findUnknownGroupedTomlKeys(entry, table)) {
|
|
2199
|
+
groupedUnknown.add(key);
|
|
2200
|
+
}
|
|
2013
2201
|
}
|
|
2014
|
-
if (unknown.size === 0)
|
|
2202
|
+
if (unknown.size === 0 && groupedUnknown.size === 0)
|
|
2015
2203
|
return null;
|
|
2016
2204
|
// #2645/#2803 — a key this CLI removed on purpose gets the guidance stated
|
|
2017
2205
|
// once beside the definitions (`src/config-surface/retired-keys.ts`), not the
|
|
@@ -2028,8 +2216,33 @@ export function configUnknownKeyError(filePath, tomlData, table) {
|
|
|
2028
2216
|
for (const key of retiredHere) {
|
|
2029
2217
|
messages.push(`${filePath}: ${prefix}.${key} — ${retiredConfigKey(prefix, key).toml}`);
|
|
2030
2218
|
}
|
|
2031
|
-
|
|
2032
|
-
|
|
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()));
|
|
2033
2246
|
}
|
|
2034
2247
|
return messages.join("\n");
|
|
2035
2248
|
}
|
|
@@ -2113,6 +2326,11 @@ function localConfigRowKey(label, tomlData, file) {
|
|
|
2113
2326
|
return stated("prompt", "key");
|
|
2114
2327
|
case "workflow":
|
|
2115
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");
|
|
2116
2334
|
case "email-template":
|
|
2117
2335
|
return stated("template", "emailType");
|
|
2118
2336
|
case "database-type-config":
|
|
@@ -2138,6 +2356,410 @@ function localConfigRowKey(label, tomlData, file) {
|
|
|
2138
2356
|
return fallback;
|
|
2139
2357
|
}
|
|
2140
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
|
+
}
|
|
2141
2763
|
/**
|
|
2142
2764
|
* Every per-entity config file whose TOML `config push` would refuse (#2880
|
|
2143
2765
|
* criterion 1, symmetry).
|
|
@@ -2153,13 +2775,440 @@ function localConfigRowKey(label, tomlData, file) {
|
|
|
2153
2775
|
* whose spelling is not the declared type. A file that does not parse is left
|
|
2154
2776
|
* to the per-type loop that reads it, which names the parse error itself.
|
|
2155
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.
|
|
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
|
+
}
|
|
2156
3201
|
export function collectConfigFileValidationErrors(configDir) {
|
|
2157
3202
|
const found = [];
|
|
2158
3203
|
for (const { dir, surface, tables } of unknownKeyPreflightTargets()) {
|
|
2159
3204
|
const typeDir = join(configDir, dir);
|
|
2160
3205
|
if (!existsSync(typeDir))
|
|
2161
3206
|
continue;
|
|
2162
|
-
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"))) {
|
|
2163
3212
|
const filePath = join(typeDir, file);
|
|
2164
3213
|
let tomlData;
|
|
2165
3214
|
try {
|
|
@@ -2184,6 +3233,12 @@ export function collectConfigFileValidationErrors(configDir) {
|
|
|
2184
3233
|
for (const table of surface.tables) {
|
|
2185
3234
|
messages.push(...configDeclaredTypeErrors(filePath, tomlData, table));
|
|
2186
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
|
+
}
|
|
2187
3242
|
if (messages.length === 0)
|
|
2188
3243
|
continue;
|
|
2189
3244
|
found.push({
|
|
@@ -2196,6 +3251,104 @@ export function collectConfigFileValidationErrors(configDir) {
|
|
|
2196
3251
|
}
|
|
2197
3252
|
return found;
|
|
2198
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
|
+
}
|
|
2199
3352
|
/**
|
|
2200
3353
|
* Every `<key>.tests/*.toml` sidecar under a config directory.
|
|
2201
3354
|
*
|
|
@@ -2292,6 +3445,17 @@ export function workflowPushSendsUpdate(configDir, key, parsedWorkflowToml, sync
|
|
|
2292
3445
|
*
|
|
2293
3446
|
* Returns `null` when the name is present.
|
|
2294
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
|
+
}
|
|
2295
3459
|
export function workflowUpdateNameError(filePath, workflow) {
|
|
2296
3460
|
const name = workflow?.name;
|
|
2297
3461
|
if (typeof name === "string" && name.trim() !== "")
|
|
@@ -3154,6 +4318,114 @@ export function diffProjectedRecords(local, remote) {
|
|
|
3154
4318
|
}
|
|
3155
4319
|
return diffs;
|
|
3156
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
|
+
}
|
|
3157
4429
|
/**
|
|
3158
4430
|
* The differing fields the definition says an UPDATE does not accept (#2731 B8).
|
|
3159
4431
|
*
|
|
@@ -3172,6 +4444,10 @@ export function classifyPushChange(input) {
|
|
|
3172
4444
|
const { localHash, remoteHash, baselineHash, legacy } = input;
|
|
3173
4445
|
if (localHash === remoteHash)
|
|
3174
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";
|
|
3175
4451
|
if (baselineHash) {
|
|
3176
4452
|
if (remoteHash === baselineHash)
|
|
3177
4453
|
return "local-edited";
|
|
@@ -3335,6 +4611,43 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3335
4611
|
serialize: (record) => serializeEmailTemplate(record),
|
|
3336
4612
|
describe: (entity) => `email template "${entity?.emailType}"`,
|
|
3337
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
|
+
},
|
|
3338
4651
|
// #2757 — webhooks join the registry comparator for the same reason email
|
|
3339
4652
|
// templates did: `config diff` reported existence only, so an edit that
|
|
3340
4653
|
// rerouted a webhook to a different `workflowKey` read as Synced while push
|
|
@@ -3411,7 +4724,17 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3411
4724
|
// (`[prompt].key`) is still read here, because it is what `displayName`
|
|
3412
4725
|
// falls back to. Diff injects the resolved key into a file that omits it,
|
|
3413
4726
|
// exactly as push derives it from the file name.
|
|
3414
|
-
|
|
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" }),
|
|
3415
4738
|
serialize: (record) => serializePrompt(record),
|
|
3416
4739
|
// Read from the DOCUMENT, not the parsed entity: `[[configs]]` is a sibling
|
|
3417
4740
|
// table of `[prompt]`, and the prompt's own push payload does not carry it
|
|
@@ -3608,6 +4931,8 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3608
4931
|
subscriptions: byKey(entity.__subscriptions, "subscriptionKey"),
|
|
3609
4932
|
_unresolvedRuleSetName: entity._unresolvedRuleSetName,
|
|
3610
4933
|
}),
|
|
4934
|
+
childKeys: { operations: "name", subscriptions: "subscriptionKey" },
|
|
4935
|
+
tomlFields: ["schema"],
|
|
3611
4936
|
ruleSetRef: true,
|
|
3612
4937
|
describe: (entity) => `database type "${entity?.databaseType}"`,
|
|
3613
4938
|
},
|
|
@@ -3702,9 +5027,11 @@ export function compareLocalToRemote(spec, localParsed, remoteRecord, maps = {
|
|
|
3702
5027
|
const localHash = computeExpandedContentHash(pair.local);
|
|
3703
5028
|
if (remoteHash === localHash)
|
|
3704
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.
|
|
3705
5032
|
return {
|
|
3706
5033
|
status: "modified",
|
|
3707
|
-
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",
|
|
3708
5035
|
};
|
|
3709
5036
|
}
|
|
3710
5037
|
catch {
|
|
@@ -3713,6 +5040,24 @@ export function compareLocalToRemote(spec, localParsed, remoteRecord, maps = {
|
|
|
3713
5040
|
return { status: "exists", hint: "content not compared (comparison failed)" };
|
|
3714
5041
|
}
|
|
3715
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
|
+
}
|
|
3716
5061
|
/** Hash a LOCAL config file's definition-projected field set (#2644). */
|
|
3717
5062
|
export function hashLocalConfigForDiff(spec, parsedToml, maps, extra) {
|
|
3718
5063
|
return computeExpandedContentHash(projectLocalConfig(spec, parsedToml, maps, extra));
|
|
@@ -3774,17 +5119,32 @@ export function decidePushForConfig(input) {
|
|
|
3774
5119
|
fields,
|
|
3775
5120
|
};
|
|
3776
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));
|
|
3777
5136
|
const direction = classifyPushChange({
|
|
3778
5137
|
localHash,
|
|
3779
5138
|
remoteHash,
|
|
3780
5139
|
baselineHash: entry?.semanticHash,
|
|
5140
|
+
serverUnchangedSinceSync: input.serverModifiedAtCoversProjection === true &&
|
|
5141
|
+
liveModifiedAtMatchesStored &&
|
|
5142
|
+
liveActiveConfigMatchesStored,
|
|
3781
5143
|
legacy: {
|
|
3782
5144
|
localBytesMatchStoredContentHash: entry?.contentHash !== undefined &&
|
|
3783
5145
|
localFileHash !== undefined &&
|
|
3784
5146
|
entry.contentHash === localFileHash,
|
|
3785
|
-
liveModifiedAtMatchesStored
|
|
3786
|
-
live.modifiedAt !== undefined &&
|
|
3787
|
-
entry.modifiedAt === live.modifiedAt,
|
|
5147
|
+
liveModifiedAtMatchesStored,
|
|
3788
5148
|
},
|
|
3789
5149
|
});
|
|
3790
5150
|
// #2909 — the adopt, for a type whose push path adopts by key. `entry` is
|
|
@@ -4152,7 +5512,7 @@ export function configSemanticHash(label, filePath, maps) {
|
|
|
4152
5512
|
/**
|
|
4153
5513
|
* The sync state `config push` writes into (#2731 B6, closing #2374).
|
|
4154
5514
|
*
|
|
4155
|
-
* `loadSyncState` answers `null` for an absent
|
|
5515
|
+
* `loadSyncState` answers `null` for an absent sync-state baseline, and
|
|
4156
5516
|
* every per-entity state write in the push loops is guarded by `if (syncState)`
|
|
4157
5517
|
* — so a first push created every resource and recorded none of them. The next
|
|
4158
5518
|
* push then re-entered the create path for everything and depended on 409
|
|
@@ -4594,6 +5954,231 @@ selects = () => true) {
|
|
|
4594
5954
|
: { ok: false };
|
|
4595
5955
|
return { scriptEntities, count: written, presence, listOk: true, serverKeys };
|
|
4596
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
|
+
}
|
|
4597
6182
|
/**
|
|
4598
6183
|
* Every test case a block has, draining the cursor (#2769).
|
|
4599
6184
|
*
|
|
@@ -6089,26 +7674,19 @@ function readSelection(options) {
|
|
|
6089
7674
|
* `registerConfigCommands` created rather than making one of its own.
|
|
6090
7675
|
*
|
|
6091
7676
|
* The module keeps its name and its ~50 exported helpers: `sync.ts`,
|
|
6092
|
-
* `resolveSyncDir`,
|
|
7677
|
+
* `resolveSyncDir`, the sync-state baseline and `primitive/<env>/`
|
|
6093
7678
|
* are internal identifiers, deliberately left alone (#2759 scope).
|
|
6094
7679
|
*/
|
|
6095
7680
|
export function registerConfigSyncCommands(sync) {
|
|
6096
7681
|
// Init
|
|
6097
7682
|
sync
|
|
6098
7683
|
.command("init")
|
|
7684
|
+
.allowExcessArguments(false)
|
|
6099
7685
|
.description("Initialize the config directory structure")
|
|
6100
|
-
.
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
const resolvedAppId = resolveAppId(appId, options);
|
|
6105
|
-
const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
|
|
6106
|
-
if (isAutoResolvedSyncDir(options.dir)) {
|
|
6107
|
-
info(`Using per-environment config directory: ${configDir}`);
|
|
6108
|
-
const legacyHint = checkLegacySyncMigration(configDir);
|
|
6109
|
-
if (legacyHint)
|
|
6110
|
-
warn(legacyHint);
|
|
6111
|
-
}
|
|
7686
|
+
.action(async (options) => {
|
|
7687
|
+
const resolvedAppId = getCurrentAppId();
|
|
7688
|
+
const configDir = resolveSyncDir();
|
|
7689
|
+
info(`Using per-environment config directory: ${configDir}`);
|
|
6112
7690
|
ensureDir(configDir);
|
|
6113
7691
|
ensureDir(join(configDir, "integrations"));
|
|
6114
7692
|
ensureDir(join(configDir, "webhooks"));
|
|
@@ -6117,6 +7695,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6117
7695
|
ensureDir(join(configDir, "prompts"));
|
|
6118
7696
|
ensureDir(join(configDir, "workflows"));
|
|
6119
7697
|
ensureDir(join(configDir, "transforms")); // #892 slice 7 — Rhai scripts
|
|
7698
|
+
ensureDir(join(configDir, "functions")); // #3179 — server functions
|
|
6120
7699
|
ensureDir(join(configDir, "database-type-configs"));
|
|
6121
7700
|
ensureDir(join(configDir, "rule-sets"));
|
|
6122
7701
|
ensureDir(join(configDir, "group-type-configs"));
|
|
@@ -6136,10 +7715,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6136
7715
|
// Pull
|
|
6137
7716
|
sync
|
|
6138
7717
|
.command("pull")
|
|
7718
|
+
.allowExcessArguments(false)
|
|
6139
7719
|
.description("Download configuration from server to TOML files")
|
|
6140
|
-
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
6141
|
-
.option("--app <app-id>", "App ID")
|
|
6142
|
-
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
6143
7720
|
.option("--json", "Output as JSON instead of writing files")
|
|
6144
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.",
|
|
6145
7722
|
// Collect rather than replace, the same way push does: commander keeps
|
|
@@ -6147,14 +7724,11 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6147
7724
|
// narrow the pull to one object and report success.
|
|
6148
7725
|
(value, previous) => [...(previous ?? []), value], [])
|
|
6149
7726
|
.option("--no-prune", "Keep local files for entities that are absent from the sync export (the next push will re-create them)")
|
|
6150
|
-
.action(async (
|
|
6151
|
-
const resolvedAppId =
|
|
6152
|
-
const configDir = resolveSyncDir(
|
|
6153
|
-
if (
|
|
7727
|
+
.action(async (options) => {
|
|
7728
|
+
const resolvedAppId = getCurrentAppId();
|
|
7729
|
+
const configDir = resolveSyncDir();
|
|
7730
|
+
if (!options.json) {
|
|
6154
7731
|
info(`Using per-environment config directory: ${configDir}`);
|
|
6155
|
-
const legacyHint = checkLegacySyncMigration(configDir);
|
|
6156
|
-
if (legacyHint)
|
|
6157
|
-
warn(legacyHint);
|
|
6158
7732
|
}
|
|
6159
7733
|
// Issue #2760: pull writes the renamed directory and leaves the old one
|
|
6160
7734
|
// untouched beside it, which reads as a successful pull that lost the
|
|
@@ -6354,10 +7928,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6354
7928
|
// perform it without a recoverable backup. No-op on a fresh/empty
|
|
6355
7929
|
// sync dir (nothing to back up). Prune to 28 days after success.
|
|
6356
7930
|
try {
|
|
6357
|
-
const snapshotsRoot = resolveSnapshotsRoot({
|
|
6358
|
-
appId: resolvedAppId,
|
|
6359
|
-
userDir: options.dir,
|
|
6360
|
-
});
|
|
7931
|
+
const snapshotsRoot = resolveSnapshotsRoot({ appId: resolvedAppId });
|
|
6361
7932
|
const snapshot = createSnapshot(configDir, snapshotsRoot);
|
|
6362
7933
|
if (snapshot) {
|
|
6363
7934
|
info(` Snapshot: ${snapshot.path}`);
|
|
@@ -6368,7 +7939,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6368
7939
|
error(`Failed to create a pre-pull snapshot: ${snapErr?.message ?? snapErr}`);
|
|
6369
7940
|
error("Aborting pull before any local file is modified. " +
|
|
6370
7941
|
"Resolve the snapshot error (e.g. disk space / permissions) and retry, " +
|
|
6371
|
-
"
|
|
7942
|
+
"and that the project's .primitive/ is writable.");
|
|
6372
7943
|
process.exit(1);
|
|
6373
7944
|
}
|
|
6374
7945
|
// Ensure directories exist
|
|
@@ -6380,6 +7951,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6380
7951
|
ensureDir(join(configDir, "prompts"));
|
|
6381
7952
|
ensureDir(join(configDir, "workflows"));
|
|
6382
7953
|
ensureDir(join(configDir, "transforms")); // #892 slice 7 — Rhai scripts
|
|
7954
|
+
ensureDir(join(configDir, "functions")); // #3179 — server functions
|
|
6383
7955
|
ensureDir(join(configDir, "database-type-configs"));
|
|
6384
7956
|
ensureDir(join(configDir, "rule-sets"));
|
|
6385
7957
|
ensureDir(join(configDir, "group-type-configs"));
|
|
@@ -6735,6 +8307,20 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6735
8307
|
if (pulledScriptsCount > 0) {
|
|
6736
8308
|
info(` Pulled ${pulledScriptsCount} transform(s)`);
|
|
6737
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
|
+
}
|
|
6738
8324
|
// Build lookup maps for test case serialization
|
|
6739
8325
|
const configIdToName = new Map();
|
|
6740
8326
|
const promptIdToKey = new Map();
|
|
@@ -6922,6 +8508,21 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6922
8508
|
};
|
|
6923
8509
|
info(` Wrote database-type-configs/${filename}`);
|
|
6924
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
|
+
}
|
|
6925
8526
|
// Write rule sets
|
|
6926
8527
|
const ruleSetEntities = {};
|
|
6927
8528
|
for (const ruleSet of ruleSets) {
|
|
@@ -7013,6 +8614,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7013
8614
|
? { ok: true, serverKeys: blobBucketItems.map((b) => b.bucketKey) }
|
|
7014
8615
|
: { ok: false },
|
|
7015
8616
|
scripts: scriptPresence,
|
|
8617
|
+
functions: functionPresence,
|
|
7016
8618
|
emailTemplates: emailTemplatePresence,
|
|
7017
8619
|
databaseTypes: databaseTypeConfigsOutcome.ok && databaseTypeDetailsOk
|
|
7018
8620
|
? { ok: true, serverKeys: databaseTypesWithOps.map((d) => d.typeConfig.databaseType) }
|
|
@@ -7079,11 +8681,36 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7079
8681
|
for (const key of plan.unsafeKeys) {
|
|
7080
8682
|
warn(` Skipped pruning ${resourceType.dir} entry "${key}" — its sync-state key does not map to a safe filename`);
|
|
7081
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
|
+
}
|
|
7082
8705
|
const result = await applyPrune({
|
|
7083
8706
|
configDir,
|
|
7084
8707
|
resourceType,
|
|
7085
8708
|
keys: plan.keys,
|
|
7086
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,
|
|
7087
8714
|
logger: info,
|
|
7088
8715
|
});
|
|
7089
8716
|
prunedCount += result.prunedKeys.length;
|
|
@@ -7099,12 +8726,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7099
8726
|
// the prior entry for that one key — see `preserveUnwrittenEntries`.
|
|
7100
8727
|
const keepPrior = (ok, built, slot) => ok ? built : (priorEntities[slot] ?? built);
|
|
7101
8728
|
preserveUnwrittenEntries(scriptEntities, priorEntities.scripts, scriptServerKeys);
|
|
8729
|
+
preserveUnwrittenEntries(functionEntities, priorEntities.functions, functionServerKeys);
|
|
7102
8730
|
preserveUnwrittenEntries(emailTemplateEntities, priorEntities.emailTemplates, emailTemplatesOutcome.ok
|
|
7103
8731
|
? emailTemplateOverrides.map((t) => t.emailType)
|
|
7104
8732
|
: []);
|
|
7105
8733
|
const finalCronTriggerEntities = keepPrior(cronTriggersOutcome.ok, cronTriggerEntities, "cronTriggers");
|
|
7106
8734
|
const finalBlobBucketEntities = keepPrior(blobBucketsOutcome.ok, blobBucketEntities, "blobBuckets");
|
|
7107
8735
|
const finalScriptEntities = keepPrior(scriptListOk, scriptEntities, "scripts");
|
|
8736
|
+
const finalFunctionEntities = keepPrior(functionListOk, functionEntities, "functions");
|
|
7108
8737
|
const finalEmailTemplateEntities = keepPrior(emailTemplatesOutcome.ok, emailTemplateEntities, "emailTemplates");
|
|
7109
8738
|
const finalDatabaseTypeEntities = keepPrior(databaseTypeConfigsOutcome.ok, databaseTypeEntities, "databaseTypes");
|
|
7110
8739
|
const finalRuleSetEntities = keepPrior(ruleSetsOutcome.ok, ruleSetEntities, "ruleSets");
|
|
@@ -7133,6 +8762,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7133
8762
|
prompts: promptEntities,
|
|
7134
8763
|
workflows: workflowEntities,
|
|
7135
8764
|
scripts: finalScriptEntities,
|
|
8765
|
+
functions: finalFunctionEntities,
|
|
7136
8766
|
emailTemplates: finalEmailTemplateEntities,
|
|
7137
8767
|
databaseTypes: finalDatabaseTypeEntities,
|
|
7138
8768
|
ruleSets: finalRuleSetEntities,
|
|
@@ -7203,6 +8833,17 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7203
8833
|
idOf: (w) => w.workflowId,
|
|
7204
8834
|
}, Object.keys(priorEntities.workflows ?? {})),
|
|
7205
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
|
+
],
|
|
7206
8847
|
]) {
|
|
7207
8848
|
const built = entitiesBySlot[slot];
|
|
7208
8849
|
const resourceType = SYNC_RESOURCE_TYPES.find((rt) => rt.stateSlot === slot);
|
|
@@ -7299,6 +8940,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7299
8940
|
prompts: promptEntities,
|
|
7300
8941
|
workflows: workflowEntities,
|
|
7301
8942
|
scripts: Object.keys(finalScriptEntities).length > 0 ? finalScriptEntities : undefined,
|
|
8943
|
+
functions: Object.keys(finalFunctionEntities).length > 0 ? finalFunctionEntities : undefined,
|
|
7302
8944
|
emailTemplates: Object.keys(finalEmailTemplateEntities).length > 0 ? finalEmailTemplateEntities : undefined,
|
|
7303
8945
|
testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
|
|
7304
8946
|
databaseTypes: Object.keys(finalDatabaseTypeEntities).length > 0 ? finalDatabaseTypeEntities : undefined,
|
|
@@ -7324,6 +8966,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7324
8966
|
keyValue("Prompts", prompts.length);
|
|
7325
8967
|
keyValue("Workflows", workflows.length);
|
|
7326
8968
|
keyValue("Transforms", pulledScriptsCount);
|
|
8969
|
+
keyValue("Functions", pulledFunctionsCount);
|
|
7327
8970
|
keyValue("Email Templates", emailTemplates.length);
|
|
7328
8971
|
keyValue("Test Cases", totalTestCases);
|
|
7329
8972
|
keyValue("Database Types", databaseTypesWithOps.length);
|
|
@@ -7358,10 +9001,8 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7358
9001
|
// Push
|
|
7359
9002
|
sync
|
|
7360
9003
|
.command("push")
|
|
9004
|
+
.allowExcessArguments(false)
|
|
7361
9005
|
.description("Upload local TOML configuration to server")
|
|
7362
|
-
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
7363
|
-
.option("--app <app-id>", "App ID")
|
|
7364
|
-
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
7365
9006
|
.option("--dry-run", "Show what would be changed without applying")
|
|
7366
9007
|
.option("--force", "Overwrite remote even if modified since last pull")
|
|
7367
9008
|
.option("--only <selectors>", "Apply just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated). Everything else about push is unchanged.",
|
|
@@ -7372,15 +9013,32 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7372
9013
|
.option("--prune", "Delete remote entities whose local TOML was removed since the last sync (issue #1655)")
|
|
7373
9014
|
.option("-y, --yes", "Skip the interactive confirmation before pruning (for CI / automation)")
|
|
7374
9015
|
.option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
|
|
7375
|
-
|
|
7376
|
-
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
|
|
7381
|
-
|
|
7382
|
-
|
|
7383
|
-
|
|
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}`);
|
|
7384
9042
|
// Issue #2760. An un-renamed checkout's `database-types/` is ignored, so
|
|
7385
9043
|
// an ordinary push applies nothing from it — worth a warning. `--prune`
|
|
7386
9044
|
// is the destructive case: it flags every prior-state key whose local
|
|
@@ -7405,14 +9063,6 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7405
9063
|
// `syncCallable` PATCH ordering a second implementation has broken before.
|
|
7406
9064
|
// Everything else about push is unchanged.
|
|
7407
9065
|
const pushSelection = readSelection(options);
|
|
7408
|
-
if (pushSelection) {
|
|
7409
|
-
const missing = unmatchedSelectors(pushSelection, configDir);
|
|
7410
|
-
if (missing.length > 0) {
|
|
7411
|
-
error(`No local file declares: ${missing.join(", ")}. Run 'primitive config pull', or 'primitive config create <type> <key>' to create one.`);
|
|
7412
|
-
process.exit(1);
|
|
7413
|
-
}
|
|
7414
|
-
info(`Applying only: ${pushSelection.raw.join(", ")}`);
|
|
7415
|
-
}
|
|
7416
9066
|
/** Narrow a type's file list to the `--only` selection. */
|
|
7417
9067
|
const selectFiles = (label, dir, files) => filterFilesBySelection(pushSelection, label, dir, files);
|
|
7418
9068
|
/**
|
|
@@ -7446,6 +9096,29 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7446
9096
|
entities: {},
|
|
7447
9097
|
};
|
|
7448
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
|
+
}
|
|
7449
9122
|
info(`Pushing configuration for app ${resolvedAppId}...`);
|
|
7450
9123
|
// The state this push STARTS from, so the failure path below can tell a
|
|
7451
9124
|
// run that recorded something from one that recorded nothing (#2936).
|
|
@@ -7822,242 +9495,36 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7822
9495
|
for (const [key, id] of promptKeyToId) {
|
|
7823
9496
|
pushTestCaseLookupMaps.promptIdToKey.set(id, key);
|
|
7824
9497
|
}
|
|
7825
|
-
for (const [scoped, id] of [
|
|
7826
|
-
...promptConfigNameToId,
|
|
7827
|
-
...workflowConfigNameToId,
|
|
7828
|
-
...scriptConfigNameToId,
|
|
7829
|
-
...integrationConfigNameToId,
|
|
7830
|
-
]) {
|
|
7831
|
-
// The map is keyed `<blockKey>#<configName>`; the comparison names
|
|
7832
|
-
// the config, not the block.
|
|
7833
|
-
pushTestCaseLookupMaps.configIdToName.set(id, scoped.slice(scoped.indexOf("#") + 1));
|
|
7834
|
-
}
|
|
7835
|
-
};
|
|
7836
|
-
// ── Issue #976 (fix A): front-load ALL client-side TOML validation
|
|
7837
|
-
// before the first mutating call. Push is apply-as-you-go and processes
|
|
7838
|
-
// entities in a fixed sequence (cron triggers are created *before*
|
|
7839
|
-
// workflows are validated). Previously each entity's validation lived
|
|
7840
|
-
// inside its own apply loop and aborted via `process.exit(1)` — so a
|
|
7841
|
-
// known-bad sibling file (e.g. an invalid workflow TOML) could let an
|
|
7842
|
-
// earlier create (a cron trigger) be applied, then abort. The
|
|
7843
|
-
// `process.exit` also bypassed the save-on-failure catch below, so the
|
|
7844
|
-
// orphaned create never reached `.primitive-sync.json` and the retry
|
|
7845
|
-
// re-issued a CREATE that the server rejected with 409 forever.
|
|
7846
|
-
//
|
|
7847
|
-
// Running every validation up-front means a known-bad file aborts
|
|
7848
|
-
// BEFORE anything is created. We collect all errors (not just the first)
|
|
7849
|
-
// and THROW — so the single save-on-failure catch is the only exit path
|
|
7850
|
-
// (no more `process.exit` bypass class). The per-loop validators below
|
|
7851
|
-
// remain as defense-in-depth but are converted to throws too.
|
|
7852
|
-
const preflightValidationErrors = [];
|
|
7853
|
-
// Validate all workflow TOMLs (#685 misnested-header check and the
|
|
7854
|
-
// #1447 system + accessRule lint).
|
|
7855
|
-
const preflightWorkflowsDir = join(configDir, "workflows");
|
|
7856
|
-
if (existsSync(preflightWorkflowsDir)) {
|
|
7857
|
-
// Under `--only` the preflight validates just the selected files:
|
|
7858
|
-
// an unrelated broken workflow elsewhere in the slot must not block
|
|
7859
|
-
// applying the one object the operator asked for.
|
|
7860
|
-
for (const file of selectFiles("workflow", preflightWorkflowsDir, readdirSync(preflightWorkflowsDir).filter((f) => f.endsWith(".toml")))) {
|
|
7861
|
-
const filePath = join(preflightWorkflowsDir, file);
|
|
7862
|
-
try {
|
|
7863
|
-
const tomlData = parseTomlFile(filePath);
|
|
7864
|
-
// #1660 — skip platform-owned workflows (the reserved
|
|
7865
|
-
// `__internal.*` namespace) BEFORE validating, mirroring the
|
|
7866
|
-
// apply loop below. `config push` validates every workflow TOML
|
|
7867
|
-
// here, up-front, before reaching that skip — so without this
|
|
7868
|
-
// second skip a stale/hollow `__internal.*.toml` with an invalid
|
|
7869
|
-
// `steps` shape would abort the whole push in preflight. Resolve
|
|
7870
|
-
// the key the same way the apply loop does (`[workflow].key`,
|
|
7871
|
-
// falling back to the filename) and match on the resolved key,
|
|
7872
|
-
// never the filename alone, so a renamed file is still caught.
|
|
7873
|
-
const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
|
|
7874
|
-
if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
|
|
7875
|
-
continue;
|
|
7876
|
-
}
|
|
7877
|
-
const tomlErrors = validateWorkflowToml(tomlData);
|
|
7878
|
-
if (tomlErrors.length > 0) {
|
|
7879
|
-
preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
|
|
7880
|
-
}
|
|
7881
|
-
// #2643 — non-blocking notes (e.g. `strict` / `strictParams` are
|
|
7882
|
-
// now no-ops). Printed once per file at pre-flight so the author
|
|
7883
|
-
// sees them whether or not the push goes on to apply.
|
|
7884
|
-
for (const note of collectWorkflowTomlNotes(tomlData)) {
|
|
7885
|
-
console.log(` ${filePath}: ${note}`);
|
|
7886
|
-
}
|
|
7887
|
-
const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
|
|
7888
|
-
if (unknownKeyError) {
|
|
7889
|
-
preflightValidationErrors.push(` ${unknownKeyError}`);
|
|
7890
|
-
}
|
|
7891
|
-
// #2743 — the always-send update payload cannot carry a missing
|
|
7892
|
-
// `[workflow].name`. That is a local TOML error like every other
|
|
7893
|
-
// one here, so it belongs in the front-loaded pass (#976): caught
|
|
7894
|
-
// in the apply loop it would abort AFTER earlier resources (app
|
|
7895
|
-
// settings, cron triggers, …) had already been written, and
|
|
7896
|
-
// `--dry-run` — which never reaches the apply — would plan an
|
|
7897
|
-
// update the real push then refuses.
|
|
7898
|
-
//
|
|
7899
|
-
// Scoped to the workflows this push will actually UPDATE. A
|
|
7900
|
-
// create falls back to the manifest key, so an absent name is
|
|
7901
|
-
// legitimate authoring there — and once that create lands, the
|
|
7902
|
-
// same unchanged file must keep pushing clean (the apply loop
|
|
7903
|
-
// skips it on its content hash) instead of failing a push that
|
|
7904
|
-
// was never going to send it.
|
|
7905
|
-
if (workflowPushSendsUpdate(configDir, tomlData.workflow?.key || basename(file, ".toml"), tomlData, syncState, options.force)) {
|
|
7906
|
-
const nameError = workflowUpdateNameError(filePath, tomlData.workflow || {});
|
|
7907
|
-
if (nameError) {
|
|
7908
|
-
preflightValidationErrors.push(` ${nameError}`);
|
|
7909
|
-
}
|
|
7910
|
-
}
|
|
7911
|
-
}
|
|
7912
|
-
catch (err) {
|
|
7913
|
-
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
7914
|
-
}
|
|
7915
|
-
}
|
|
7916
|
-
}
|
|
7917
|
-
// Validate all database-type TOMLs (issue #803 subscription
|
|
7918
|
-
// access/accessRule conflict + issue #752 $params references).
|
|
7919
|
-
const preflightDbTypesDir = join(configDir, "database-type-configs");
|
|
7920
|
-
if (existsSync(preflightDbTypesDir)) {
|
|
7921
|
-
for (const file of selectFiles("database-type-config", preflightDbTypesDir, readdirSync(preflightDbTypesDir).filter((f) => f.endsWith(".toml")))) {
|
|
7922
|
-
const filePath = join(preflightDbTypesDir, file);
|
|
7923
|
-
let rawToml;
|
|
7924
|
-
let tomlData;
|
|
7925
|
-
try {
|
|
7926
|
-
rawToml = readFileSync(filePath, "utf-8");
|
|
7927
|
-
tomlData = parseTomlFile(filePath);
|
|
7928
|
-
}
|
|
7929
|
-
catch (err) {
|
|
7930
|
-
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
7931
|
-
continue;
|
|
7932
|
-
}
|
|
7933
|
-
let operations = [];
|
|
7934
|
-
try {
|
|
7935
|
-
({ operations } = parseDatabaseTypeToml(tomlData));
|
|
7936
|
-
}
|
|
7937
|
-
catch (err) {
|
|
7938
|
-
if (err instanceof SubscriptionAccessKeyConflictError) {
|
|
7939
|
-
preflightValidationErrors.push(` ${file}: ${err.message}`);
|
|
7940
|
-
continue;
|
|
7941
|
-
}
|
|
7942
|
-
preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
|
|
7943
|
-
continue;
|
|
7944
|
-
}
|
|
7945
|
-
const validation = validateOperations({
|
|
7946
|
-
filePath,
|
|
7947
|
-
rawToml,
|
|
7948
|
-
operations,
|
|
7949
|
-
});
|
|
7950
|
-
for (const e of validation.errors) {
|
|
7951
|
-
preflightValidationErrors.push(` ${formatIssue(e)}`);
|
|
7952
|
-
}
|
|
7953
|
-
// Issue #1422: non-fatal deprecation notice. A `celContextAccess`
|
|
7954
|
-
// / `metadataAccess` gate on a database type config is a single CEL
|
|
7955
|
-
// expression that gates BOTH read and update of the database CEL
|
|
7956
|
-
// context — granting read implicitly grants update. Steer operators
|
|
7957
|
-
// to resource metadata categories (separate readRule/writeRule).
|
|
7958
|
-
// This is advisory only: the push continues and the runtime gate is
|
|
7959
|
-
// unchanged. It fires only when the deprecated key is actually
|
|
7960
|
-
// present, so metadata-category-only configs never trip it.
|
|
7961
|
-
const typeSection = tomlData.type || {};
|
|
7962
|
-
if (typeSection.celContextAccess || typeSection.metadataAccess) {
|
|
7963
|
-
const usedKey = typeSection.celContextAccess
|
|
7964
|
-
? "celContextAccess"
|
|
7965
|
-
: "metadataAccess";
|
|
7966
|
-
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.`);
|
|
7967
|
-
}
|
|
7968
|
-
}
|
|
7969
|
-
}
|
|
7970
|
-
// Validate app.toml settings up-front (issue #1033). A mistyped value
|
|
7971
|
-
// is a hard error that must abort BEFORE any mutation — same
|
|
7972
|
-
// fail-before-mutate contract as the vars preflight below. The apply
|
|
7973
|
-
// block re-parses for the payload.
|
|
7974
|
-
// #2645 — bounded by `--only`, like the workflow and database-type
|
|
7975
|
-
// preflights above. `config push --only workflow/x` is the replacement for
|
|
7976
|
-
// `workflows update --from-file`, so it must work on a tree where
|
|
7977
|
-
// app.toml happens to be mid-edit.
|
|
7978
|
-
const preflightAppTomlPath = join(configDir, "app.toml");
|
|
7979
|
-
if (selectionIncludesApp(pushSelection) && existsSync(preflightAppTomlPath)) {
|
|
7980
|
-
try {
|
|
7981
|
-
const parsedApp = parseTomlFile(preflightAppTomlPath);
|
|
7982
|
-
const appErrors = collectAppSettingsPushErrors(parsedApp, {
|
|
7983
|
-
// #2880 DSO-001 — the same exclusion the apply block computes, so
|
|
7984
|
-
// the preflight validates the body that will actually be sent.
|
|
7985
|
-
excludeName: appSettingsNameExcluded({
|
|
7986
|
-
isCrossAppPush: !!isCrossAppPush,
|
|
7987
|
-
recordedExcludedName: syncState?.entities?.app?.excludedName,
|
|
7988
|
-
fileName: parsedApp?.app?.name,
|
|
7989
|
-
}),
|
|
7990
|
-
});
|
|
7991
|
-
for (const e of appErrors) {
|
|
7992
|
-
preflightValidationErrors.push(` app.toml: ${e}`);
|
|
7993
|
-
}
|
|
7994
|
-
}
|
|
7995
|
-
catch (err) {
|
|
7996
|
-
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));
|
|
7997
9507
|
}
|
|
7998
|
-
}
|
|
7999
|
-
//
|
|
8000
|
-
//
|
|
8001
|
-
//
|
|
8002
|
-
//
|
|
8003
|
-
//
|
|
8004
|
-
|
|
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.
|
|
8005
9526
|
const preflightVarsTomlPath = join(configDir, "vars.toml");
|
|
8006
9527
|
const varsSelected = selectionIncludesAnyVar(pushSelection);
|
|
8007
|
-
let preflightParsedVars = null;
|
|
8008
|
-
if (varsSelected && existsSync(preflightVarsTomlPath)) {
|
|
8009
|
-
try {
|
|
8010
|
-
const parsed = parseTomlFile(preflightVarsTomlPath);
|
|
8011
|
-
// #2645 — under `--only var/<key>` only the selected entries are
|
|
8012
|
-
// checked. The rest of the file is not being written, and aborting
|
|
8013
|
-
// over an entry this push will not touch is the same over-reach as
|
|
8014
|
-
// running the leg at all for an unselected singleton.
|
|
8015
|
-
for (const e of validateVarsFile(parsed, {
|
|
8016
|
-
onlyKeys: pushSelection ? pushSelection.varKeys : undefined,
|
|
8017
|
-
})) {
|
|
8018
|
-
preflightValidationErrors.push(` ${e}`);
|
|
8019
|
-
}
|
|
8020
|
-
preflightParsedVars = parsed;
|
|
8021
|
-
}
|
|
8022
|
-
catch (err) {
|
|
8023
|
-
preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
|
|
8024
|
-
}
|
|
8025
|
-
}
|
|
8026
|
-
// #2644 — reject any key the vendored definition does not recognize,
|
|
8027
|
-
// for every migrated configuration type. Same posture the `[workflow]`
|
|
8028
|
-
// check above has, generalized: a mistyped key fails here, in the
|
|
8029
|
-
// preflight pass, so nothing is applied and no request is issued —
|
|
8030
|
-
// rather than being dropped on the way to the server and reading as a
|
|
8031
|
-
// successful push that changed nothing.
|
|
8032
|
-
// #2880 — the same collector `config diff` runs, so the two commands
|
|
8033
|
-
// cannot disagree about which files are pushable.
|
|
8034
|
-
for (const rejected of collectConfigFileValidationErrors(configDir)) {
|
|
8035
|
-
for (const message of rejected.messages) {
|
|
8036
|
-
preflightValidationErrors.push(` ${message}`);
|
|
8037
|
-
}
|
|
8038
|
-
}
|
|
8039
|
-
// The `<key>.tests/*.toml` sidecars, which sit beside the block they
|
|
8040
|
-
// test rather than in a type directory of their own. Same posture,
|
|
8041
|
-
// reached through the registry's `test-case` surface — and through the
|
|
8042
|
-
// same collector `config diff` runs per sidecar (#2880), so neither
|
|
8043
|
-
// command can refuse a file the other calls Synced.
|
|
8044
|
-
for (const filePath of testCaseTomlFiles(configDir)) {
|
|
8045
|
-
let tomlData;
|
|
8046
|
-
try {
|
|
8047
|
-
tomlData = parseTomlFile(filePath);
|
|
8048
|
-
}
|
|
8049
|
-
catch {
|
|
8050
|
-
// Reported by the per-block test-case loop with its own message.
|
|
8051
|
-
continue;
|
|
8052
|
-
}
|
|
8053
|
-
for (const message of testCaseSidecarPreflightErrors(filePath, tomlData)) {
|
|
8054
|
-
preflightValidationErrors.push(` ${message}`);
|
|
8055
|
-
}
|
|
8056
|
-
}
|
|
8057
|
-
if (preflightValidationErrors.length > 0) {
|
|
8058
|
-
throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
|
|
8059
|
-
preflightValidationErrors.join("\n"));
|
|
8060
|
-
}
|
|
8061
9528
|
// Fetch the current remote config vars up-front — before ANY mutation
|
|
8062
9529
|
// (issue #1423 review pass 3). `planVarsPush` needs this snapshot both
|
|
8063
9530
|
// to detect concurrent remote edits and to decide which already-absent
|
|
@@ -8120,6 +9587,9 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8120
9587
|
upserts: varPlan.upserts.filter((u) => wanted.has(u.key)),
|
|
8121
9588
|
deletions: varPlan.deletions.filter((k) => wanted.has(k)),
|
|
8122
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)),
|
|
8123
9593
|
skippedCount: 0,
|
|
8124
9594
|
};
|
|
8125
9595
|
}
|
|
@@ -8412,6 +9882,13 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8412
9882
|
localModifiedAt: c.localModifiedAt,
|
|
8413
9883
|
});
|
|
8414
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
|
+
}
|
|
8415
9892
|
// Delete removed keys BEFORE upserting new ones (issue #1423 review
|
|
8416
9893
|
// pass 3). At the server's 100-var cap, a replacement (remove one key,
|
|
8417
9894
|
// add another) is a valid final state, but upserting the new key first
|
|
@@ -9692,8 +11169,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
9692
11169
|
}
|
|
9693
11170
|
// Process prompts
|
|
9694
11171
|
const promptsDir = join(configDir, "prompts");
|
|
9695
|
-
|
|
9696
|
-
|
|
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) => {
|
|
9697
11180
|
// #2869 — prompts join the live-state reconcile (#2731 B3). #2748
|
|
9698
11181
|
// gave the type diff's comparator and left push on the manifest byte
|
|
9699
11182
|
// hash, so the two commands answered "changed?" from different
|
|
@@ -9714,13 +11197,26 @@ export function registerConfigSyncCommands(sync) {
|
|
|
9714
11197
|
// manifest byte-hash gate with the type named, like every other
|
|
9715
11198
|
// converted type.
|
|
9716
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();
|
|
9717
11211
|
let promptsLiveOk = true;
|
|
9718
11212
|
if (files.length > 0) {
|
|
9719
11213
|
try {
|
|
9720
11214
|
const listed = await fetchAll((p) => client.listPrompts(resolvedAppId, p));
|
|
9721
11215
|
for (const p of listed) {
|
|
9722
|
-
if (p?.promptKey)
|
|
9723
|
-
|
|
11216
|
+
if (!p?.promptKey)
|
|
11217
|
+
continue;
|
|
11218
|
+
livePromptIds.set(p.promptKey, p.promptId);
|
|
11219
|
+
livePromptKinds.set(p.promptKey, promptKindOf(p));
|
|
9724
11220
|
}
|
|
9725
11221
|
}
|
|
9726
11222
|
catch (err) {
|
|
@@ -9883,6 +11379,79 @@ export function registerConfigSyncCommands(sync) {
|
|
|
9883
11379
|
// (mirrors `applyWorkflowUpdate`). The adopt branch seeds the
|
|
9884
11380
|
// sync-state entity record before calling this so the stamp lands.
|
|
9885
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
|
+
}
|
|
9886
11455
|
const updated = await client.updatePrompt(resolvedAppId, promptId, buildPromptPushPayload(key, tomlData, { mode: "update" }), expectedModifiedAt);
|
|
9887
11456
|
info(` Updated prompt: ${key}`);
|
|
9888
11457
|
// Record the `modifiedAt` this PATCH just caused, so a later
|
|
@@ -9905,7 +11474,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
9905
11474
|
try {
|
|
9906
11475
|
// Fetch full prompt to get config name→ID mappings
|
|
9907
11476
|
// (updatePrompt response doesn't include configs)
|
|
9908
|
-
|
|
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));
|
|
9909
11481
|
if (fullPrompt?.configs) {
|
|
9910
11482
|
for (const config of fullPrompt.configs) {
|
|
9911
11483
|
promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
@@ -9919,6 +11491,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
9919
11491
|
continue;
|
|
9920
11492
|
const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
|
|
9921
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;
|
|
9922
11498
|
// Update existing config
|
|
9923
11499
|
try {
|
|
9924
11500
|
await client.updatePromptConfig(resolvedAppId, promptId, serverConfig.configId, buildPromptConfigPayload(tomlConfig, { mode: "update" }));
|
|
@@ -9939,6 +11515,9 @@ export function registerConfigSyncCommands(sync) {
|
|
|
9939
11515
|
}
|
|
9940
11516
|
if (newConfig?.configId) {
|
|
9941
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).
|
|
9942
11521
|
}
|
|
9943
11522
|
}
|
|
9944
11523
|
}
|
|
@@ -10031,6 +11610,18 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10031
11610
|
}
|
|
10032
11611
|
};
|
|
10033
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
|
+
}
|
|
10034
11625
|
// Update existing prompt
|
|
10035
11626
|
changes.push({ type: "prompt", action: "update", key });
|
|
10036
11627
|
promptKeyToId.set(key, existingId);
|
|
@@ -10123,6 +11714,19 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10123
11714
|
});
|
|
10124
11715
|
adopted = true;
|
|
10125
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
|
+
}
|
|
10126
11730
|
// Seed the sync-state entity record so the shared update body
|
|
10127
11731
|
// can stamp modifiedAt/contentHash into it (sync state has no
|
|
10128
11732
|
// entry for an orphaned prompt), and track the key→ID map.
|
|
@@ -10247,8 +11851,11 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10247
11851
|
throw wrapPromptConfigError(err, "create", key, extraName);
|
|
10248
11852
|
}
|
|
10249
11853
|
if (extraCreated?.configId) {
|
|
10250
|
-
|
|
10251
|
-
|
|
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.
|
|
10252
11859
|
}
|
|
10253
11860
|
}
|
|
10254
11861
|
info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
|
|
@@ -10280,6 +11887,17 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10280
11887
|
activated = true;
|
|
10281
11888
|
}
|
|
10282
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).
|
|
10283
11901
|
// Stamp sync state ONLY after all create work (base prompt +
|
|
10284
11902
|
// every extra config) succeeded. If an extra-config create
|
|
10285
11903
|
// above threw, we never reach here, so the file hash is not
|
|
@@ -10315,6 +11933,13 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10315
11933
|
}
|
|
10316
11934
|
}
|
|
10317
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);
|
|
10318
11943
|
}
|
|
10319
11944
|
// Process transforms (Rhai scripts) — issue #892, slice 7 of #783.
|
|
10320
11945
|
//
|
|
@@ -11965,7 +13590,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
11965
13590
|
//
|
|
11966
13591
|
// But the 409 also fires when the local sync state is merely
|
|
11967
13592
|
// ABSENT for a type that another admin has already FULLY
|
|
11968
|
-
// configured (or whose
|
|
13593
|
+
// configured (or whose sync-state baseline was not copied
|
|
11969
13594
|
// alongside the TOML). PATCHing that without
|
|
11970
13595
|
// `expectedModifiedAt` would silently overwrite the existing
|
|
11971
13596
|
// triggers/access/schema with the local TOML — with no
|
|
@@ -12409,6 +14034,393 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12409
14034
|
}
|
|
12410
14035
|
}
|
|
12411
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
|
+
}
|
|
12412
14424
|
// Process group type configs
|
|
12413
14425
|
const groupTypeConfigsDir = join(configDir, "group-type-configs");
|
|
12414
14426
|
if (existsSync(groupTypeConfigsDir)) {
|
|
@@ -12590,7 +14602,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12590
14602
|
// this, that CREATE re-hits the server's 409 "Config for this
|
|
12591
14603
|
// group type already exists"
|
|
12592
14604
|
// (group-type-config-controller.ts) on every subsequent push
|
|
12593
|
-
// and the operator has to hand-edit
|
|
14605
|
+
// and the operator has to hand-edit the sync-state baseline to
|
|
12594
14606
|
// recover. The shared helper detects the conflict (409 status /
|
|
12595
14607
|
// "already exists" message), lists the app's configs — the list
|
|
12596
14608
|
// route drains every page server-side (#1668), so the candidate
|
|
@@ -13724,21 +15736,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
13724
15736
|
// Diff
|
|
13725
15737
|
sync
|
|
13726
15738
|
.command("diff")
|
|
15739
|
+
.allowExcessArguments(false)
|
|
13727
15740
|
.description("Show differences between local and remote configuration")
|
|
13728
|
-
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
13729
|
-
.option("--app <app-id>", "App ID")
|
|
13730
|
-
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
13731
15741
|
.option("--only <selectors>", "Compare just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated).", (value, previous) => [...(previous ?? []), value], [])
|
|
13732
|
-
.option("--json", "Output as JSON
|
|
13733
|
-
.action(async (
|
|
13734
|
-
const resolvedAppId =
|
|
13735
|
-
const configDir = resolveSyncDir(
|
|
13736
|
-
|
|
13737
|
-
info(`Using per-environment config directory: ${configDir}`);
|
|
13738
|
-
const legacyHint = checkLegacySyncMigration(configDir);
|
|
13739
|
-
if (legacyHint)
|
|
13740
|
-
warn(legacyHint);
|
|
13741
|
-
}
|
|
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}`);
|
|
13742
15747
|
// Issue #2760: diff compares the renamed directory, so a legacy one is
|
|
13743
15748
|
// simply invisible — an empty diff would otherwise read as "in sync".
|
|
13744
15749
|
const legacyDbTypes = checkLegacyDatabaseTypesDir(configDir);
|
|
@@ -13786,7 +15791,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
13786
15791
|
notCompared.push({ type: label, reason: gateReason(outcome.error) });
|
|
13787
15792
|
return empty;
|
|
13788
15793
|
};
|
|
13789
|
-
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([
|
|
13790
15795
|
attemptFetch(() => fetchAll((p) => client.listIntegrations(resolvedAppId, p))),
|
|
13791
15796
|
attemptFetch(() => fetchAll((p) => client.listPrompts(resolvedAppId, p))),
|
|
13792
15797
|
attemptFetch(() => fetchAll((p) => client.listWorkflows(resolvedAppId, p))),
|
|
@@ -13795,6 +15800,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
13795
15800
|
attemptFetch(() => client.listCronTriggers(resolvedAppId)),
|
|
13796
15801
|
attemptFetch(() => client.listBlobBuckets(resolvedAppId)),
|
|
13797
15802
|
attemptFetch(() => client.listScripts(resolvedAppId)),
|
|
15803
|
+
attemptFetch(() => client.listFunctions(resolvedAppId)),
|
|
13798
15804
|
attemptFetch(() => client.listAppConfigVars(resolvedAppId)),
|
|
13799
15805
|
attemptFetch(() => client.listDatabaseTypeConfigs(resolvedAppId)),
|
|
13800
15806
|
attemptFetch(() => client.listRuleSets(resolvedAppId)),
|
|
@@ -13810,6 +15816,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
13810
15816
|
const cronTriggerItemsDiff = gate(cronTriggersOutcomeDiff, "cron-triggers", { items: [] }).items || [];
|
|
13811
15817
|
const blobBucketsDiffResult = gate(blobBucketsOutcomeDiff, "blob-buckets", { items: [] });
|
|
13812
15818
|
const scriptItemsDiff = gate(scriptsOutcomeDiff, "transforms", { items: [] }).items || [];
|
|
15819
|
+
const functionItemsDiff = gate(functionsOutcomeDiff, "functions", { items: [] }).items || [];
|
|
13813
15820
|
const remoteVarItemsDiff = gate(varsOutcomeDiff, "vars", []);
|
|
13814
15821
|
const databaseTypeConfigsDiff = gate(databaseTypeConfigsOutcomeDiff, "database-type-configs", []);
|
|
13815
15822
|
const ruleSetsDiff = gate(ruleSetsOutcomeDiff, "rule-sets", []);
|
|
@@ -14114,6 +16121,25 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14114
16121
|
localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
|
|
14115
16122
|
}
|
|
14116
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
|
+
}
|
|
14117
16143
|
// Config vars — issue #1423. The remote vars were fetched through the
|
|
14118
16144
|
// ok/error gate above (`remoteVarItemsDiff`); the local `vars.toml` is
|
|
14119
16145
|
// read only when that fetch succeeded.
|
|
@@ -14377,6 +16403,108 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14377
16403
|
differences.push({ type: "prompt", key, status: remoteOnlyStatus("prompts", key) });
|
|
14378
16404
|
}
|
|
14379
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
|
+
}
|
|
14380
16508
|
// Workflows — #1175: content-aware comparison.
|
|
14381
16509
|
//
|
|
14382
16510
|
// For workflows present on BOTH sides we no longer stop at key
|
|
@@ -14614,7 +16742,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14614
16742
|
// below). Content comparison is a preview of `config pull` (pull would
|
|
14615
16743
|
// rewrite the local TOML to match running state), exactly like the
|
|
14616
16744
|
// workflow/transform blocks.
|
|
14617
|
-
const compareConfigType = (typeLabel, slot, localParsed, remoteEntities,
|
|
16745
|
+
const compareConfigType = (typeLabel, slot, localParsed, remoteEntities, compare, contentComparable) => {
|
|
14618
16746
|
for (const [key, parsed] of localParsed) {
|
|
14619
16747
|
if (!remoteEntities.has(key)) {
|
|
14620
16748
|
differences.push({ type: typeLabel, key, status: localOnlyStatus(slot, key) });
|
|
@@ -14629,16 +16757,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14629
16757
|
hint = "content not compared (rule sets unavailable)";
|
|
14630
16758
|
}
|
|
14631
16759
|
else {
|
|
14632
|
-
|
|
14633
|
-
|
|
14634
|
-
|
|
14635
|
-
|
|
14636
|
-
}
|
|
14637
|
-
}
|
|
14638
|
-
catch {
|
|
14639
|
-
// A malformed local file must not abort the whole diff.
|
|
14640
|
-
hint = "content not compared (comparison failed)";
|
|
14641
|
-
}
|
|
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)));
|
|
14642
16764
|
}
|
|
14643
16765
|
differences.push({ type: typeLabel, key, status, hint });
|
|
14644
16766
|
}
|
|
@@ -14657,7 +16779,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14657
16779
|
};
|
|
14658
16780
|
const compareByDefinition = (label, slot, localParsed, remoteEntities, contentComparable) => {
|
|
14659
16781
|
const spec = configDiffSpec(label);
|
|
14660
|
-
compareConfigType(label, slot, localParsed, remoteEntities, (p) =>
|
|
16782
|
+
compareConfigType(label, slot, localParsed, remoteEntities, (p, e) => compareLocalToRemote(spec, p, e, diffMaps), contentComparable);
|
|
14661
16783
|
};
|
|
14662
16784
|
compareByDefinition("rule-set", "ruleSets", localRuleSetsParsed, remoteRuleSets, true);
|
|
14663
16785
|
compareByDefinition("group-type-config", "groupTypeConfigs", localGroupTypeConfigsParsed, remoteGroupTypeConfigs, ruleSetsOk);
|
|
@@ -14689,20 +16811,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14689
16811
|
hint = "content not compared (fetch failed)";
|
|
14690
16812
|
}
|
|
14691
16813
|
else {
|
|
14692
|
-
|
|
14693
|
-
|
|
14694
|
-
|
|
14695
|
-
|
|
14696
|
-
|
|
14697
|
-
|
|
14698
|
-
|
|
14699
|
-
|
|
14700
|
-
hint = "run `config pull` to rewrite the local TOML to match running state";
|
|
14701
|
-
}
|
|
14702
|
-
}
|
|
14703
|
-
catch {
|
|
14704
|
-
hint = "content not compared (comparison failed)";
|
|
14705
|
-
}
|
|
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 }));
|
|
14706
16822
|
}
|
|
14707
16823
|
}
|
|
14708
16824
|
differences.push({ type: "database-type-config", key, status, hint });
|
|
@@ -14843,7 +16959,23 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14843
16959
|
// the same preflight push runs decides these rows, and its message —
|
|
14844
16960
|
// verbatim, so the two commands read identically — replaces whatever
|
|
14845
16961
|
// the comparison had to say about the file.
|
|
14846
|
-
|
|
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
|
+
])) {
|
|
14847
16979
|
const row = differences.find((d) => d.type === rejected.type && d.key === rejected.key);
|
|
14848
16980
|
const hint = rejected.messages.join("; ");
|
|
14849
16981
|
if (row) {
|
|
@@ -14874,6 +17006,22 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14874
17006
|
? selectionIncludesApp(diffSelection)
|
|
14875
17007
|
: selectionIncludesKey(diffSelection, row.type, row.key));
|
|
14876
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
|
+
}
|
|
14877
17025
|
divider();
|
|
14878
17026
|
const localOnly = differences.filter((d) => d.status === LOCAL_ONLY_NEW);
|
|
14879
17027
|
const localOnlyAbsent = differences.filter((d) => d.status === LOCAL_ONLY_ABSENT);
|
|
@@ -15002,6 +17150,41 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15002
17150
|
console.log(` ${chalk.dim("=")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}${hint}`);
|
|
15003
17151
|
}
|
|
15004
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
|
+
}
|
|
15005
17188
|
divider();
|
|
15006
17189
|
keyValue("Local only (new)", localOnly.length);
|
|
15007
17190
|
keyValue("Local only (absent from export)", localOnlyAbsent.length);
|
|
@@ -15039,22 +17222,18 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15039
17222
|
// containing null) fall back to JSON-string per field with a log message.
|
|
15040
17223
|
sync
|
|
15041
17224
|
.command("migrate-toml")
|
|
17225
|
+
.allowExcessArguments(false)
|
|
15042
17226
|
.description("Rewrite database-type and workflow TOML files to native form " +
|
|
15043
17227
|
"([operations.definition] / [[operations.params]] for database types; " +
|
|
15044
17228
|
"[workflow.inputSchema] / [workflow.outputSchema] for workflows)")
|
|
15045
|
-
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
15046
|
-
.option("--app <app-id>", "App ID")
|
|
15047
|
-
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
15048
17229
|
.option("--dry-run", "Show what would change without writing files")
|
|
15049
|
-
.action(async (
|
|
15050
|
-
//
|
|
15051
|
-
//
|
|
15052
|
-
//
|
|
15053
|
-
const resolvedAppId =
|
|
15054
|
-
const configDir = resolveSyncDir(
|
|
15055
|
-
|
|
15056
|
-
info(`Using per-environment config directory: ${configDir}`);
|
|
15057
|
-
}
|
|
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}`);
|
|
15058
17237
|
if (!existsSync(configDir)) {
|
|
15059
17238
|
error(`Config directory not found: ${configDir}`);
|
|
15060
17239
|
process.exit(1);
|
|
@@ -15204,21 +17383,16 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15204
17383
|
// proven), so re-running push/pull is unaffected.
|
|
15205
17384
|
sync
|
|
15206
17385
|
.command("migrate-v2")
|
|
17386
|
+
.allowExcessArguments(false)
|
|
15207
17387
|
.description("Convert legacy per-type sync files (prompts/, integrations/, workflows/, transforms/) into the v2 blocks/<type>/<key>/block.toml layout")
|
|
15208
|
-
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
15209
|
-
.option("--app <app-id>", "App ID")
|
|
15210
|
-
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
15211
17388
|
.option("--dry-run", "Show what would change without writing files")
|
|
15212
17389
|
.option("--force", "Overwrite existing block.toml files instead of skipping them")
|
|
15213
|
-
.action(async (
|
|
15214
|
-
//
|
|
15215
|
-
//
|
|
15216
|
-
|
|
15217
|
-
const
|
|
15218
|
-
|
|
15219
|
-
if (isAutoResolvedSyncDir(options.dir)) {
|
|
15220
|
-
info(`Using per-environment config directory: ${configDir}`);
|
|
15221
|
-
}
|
|
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}`);
|
|
15222
17396
|
if (!existsSync(configDir)) {
|
|
15223
17397
|
error(`Config directory not found: ${configDir}`);
|
|
15224
17398
|
process.exit(1);
|
|
@@ -15257,26 +17431,16 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15257
17431
|
// Revert — restore a pre-pull snapshot (issue #578, Phase 1).
|
|
15258
17432
|
sync
|
|
15259
17433
|
.command("revert")
|
|
17434
|
+
.allowExcessArguments(false)
|
|
15260
17435
|
.description("Restore the config directory from a snapshot taken before a previous pull")
|
|
15261
|
-
.argument("[app-id]", "App ID (uses current app if not specified)")
|
|
15262
|
-
.option("--app <app-id>", "App ID")
|
|
15263
|
-
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
15264
17436
|
.option("--snapshot <id>", "Snapshot id (timestamp dirname or a unique >=8-char prefix)")
|
|
15265
17437
|
.option("--list", "List available snapshots without restoring")
|
|
15266
17438
|
.option("-y, --yes", "Skip the confirmation prompt")
|
|
15267
|
-
.action(async (
|
|
15268
|
-
const resolvedAppId =
|
|
15269
|
-
const configDir = resolveSyncDir(
|
|
15270
|
-
|
|
15271
|
-
|
|
15272
|
-
});
|
|
15273
|
-
const snapshotsRoot = resolveSnapshotsRoot({
|
|
15274
|
-
appId: resolvedAppId,
|
|
15275
|
-
userDir: options.dir,
|
|
15276
|
-
});
|
|
15277
|
-
if (isAutoResolvedSyncDir(options.dir)) {
|
|
15278
|
-
info(`Using per-environment config directory: ${configDir}`);
|
|
15279
|
-
}
|
|
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}`);
|
|
15280
17444
|
const snapshots = listSnapshots(snapshotsRoot);
|
|
15281
17445
|
// --list, or no snapshots at all: enumerate and stop.
|
|
15282
17446
|
if (options.list || snapshots.length === 0) {
|
|
@@ -15336,15 +17500,13 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15336
17500
|
}
|
|
15337
17501
|
}
|
|
15338
17502
|
try {
|
|
15339
|
-
|
|
15340
|
-
// it across the full-tree swap so we don't wipe backup history.
|
|
15341
|
-
restoreSnapshot(target.path, configDir, { preserveDir: snapshotsRoot });
|
|
17503
|
+
restoreSnapshot(target.path, configDir);
|
|
15342
17504
|
}
|
|
15343
17505
|
catch (err) {
|
|
15344
17506
|
error(`Restore failed: ${err?.message ?? err}`);
|
|
15345
17507
|
process.exit(1);
|
|
15346
17508
|
}
|
|
15347
|
-
success(`Restored snapshot ${target.id} into ${configDir} (including
|
|
17509
|
+
success(`Restored snapshot ${target.id} into ${configDir} (including ${SYNC_STATE_FILENAME}).`);
|
|
15348
17510
|
info("Run 'primitive config diff' to inspect the restored state versus the server.");
|
|
15349
17511
|
});
|
|
15350
17512
|
}
|