primitive-admin 1.1.0-alpha.72 → 1.1.0-alpha.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +18 -25
  2. package/assets/skill/skills/primitive-platform/SKILL.md +30 -14
  3. package/dist/bin/primitive.js +14 -11
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/analytics.js +117 -2
  6. package/dist/src/commands/analytics.js.map +1 -1
  7. package/dist/src/commands/auth.js +13 -107
  8. package/dist/src/commands/auth.js.map +1 -1
  9. package/dist/src/commands/collection-type-configs.js +1 -1
  10. package/dist/src/commands/collection-type-configs.js.map +1 -1
  11. package/dist/src/commands/database-type-configs.js +1 -1
  12. package/dist/src/commands/database-type-configs.js.map +1 -1
  13. package/dist/src/commands/databases.js +1 -1
  14. package/dist/src/commands/databases.js.map +1 -1
  15. package/dist/src/commands/documents.js +749 -29
  16. package/dist/src/commands/documents.js.map +1 -1
  17. package/dist/src/commands/env.js +44 -6
  18. package/dist/src/commands/env.js.map +1 -1
  19. package/dist/src/commands/functions.d.ts +20 -0
  20. package/dist/src/commands/functions.js +329 -0
  21. package/dist/src/commands/functions.js.map +1 -0
  22. package/dist/src/commands/groups.js +1 -1
  23. package/dist/src/commands/groups.js.map +1 -1
  24. package/dist/src/commands/init.js +2 -15
  25. package/dist/src/commands/init.js.map +1 -1
  26. package/dist/src/commands/integrations.js +5 -3
  27. package/dist/src/commands/integrations.js.map +1 -1
  28. package/dist/src/commands/prompts.js +5 -7
  29. package/dist/src/commands/prompts.js.map +1 -1
  30. package/dist/src/commands/rule-sets.js +1 -1
  31. package/dist/src/commands/rule-sets.js.map +1 -1
  32. package/dist/src/commands/scripts.js +6 -7
  33. package/dist/src/commands/scripts.js.map +1 -1
  34. package/dist/src/commands/sync.d.ts +63 -0
  35. package/dist/src/commands/sync.js +650 -2
  36. package/dist/src/commands/sync.js.map +1 -1
  37. package/dist/src/commands/workflows.js +5 -7
  38. package/dist/src/commands/workflows.js.map +1 -1
  39. package/dist/src/lib/api-client.d.ts +156 -1
  40. package/dist/src/lib/api-client.js +219 -3
  41. package/dist/src/lib/api-client.js.map +1 -1
  42. package/dist/src/lib/app-settings-descriptor.js +9 -0
  43. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  44. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +5 -7
  45. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
  46. package/dist/src/lib/config-object-descriptor.js +39 -0
  47. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  48. package/dist/src/lib/config-surface.d.ts +10 -0
  49. package/dist/src/lib/config-surface.js +17 -0
  50. package/dist/src/lib/config-surface.js.map +1 -1
  51. package/dist/src/lib/config.d.ts +13 -2
  52. package/dist/src/lib/config.js +20 -37
  53. package/dist/src/lib/config.js.map +1 -1
  54. package/dist/src/lib/credentials-store.d.ts +4 -12
  55. package/dist/src/lib/credentials-store.js +49 -82
  56. package/dist/src/lib/credentials-store.js.map +1 -1
  57. package/dist/src/lib/env-resolver-core.d.ts +23 -7
  58. package/dist/src/lib/env-resolver-core.js +44 -4
  59. package/dist/src/lib/env-resolver-core.js.map +1 -1
  60. package/dist/src/lib/function-bundle.d.ts +131 -0
  61. package/dist/src/lib/function-bundle.js +321 -0
  62. package/dist/src/lib/function-bundle.js.map +1 -0
  63. package/dist/src/lib/function-sync.d.ts +157 -0
  64. package/dist/src/lib/function-sync.js +371 -0
  65. package/dist/src/lib/function-sync.js.map +1 -0
  66. package/dist/src/lib/function-triggers.d.ts +54 -0
  67. package/dist/src/lib/function-triggers.js +223 -0
  68. package/dist/src/lib/function-triggers.js.map +1 -0
  69. package/dist/src/lib/generated-config-surfaces.d.ts +1 -0
  70. package/dist/src/lib/generated-config-surfaces.js +298 -1
  71. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  72. package/dist/src/lib/init-ios-links.d.ts +6 -5
  73. package/dist/src/lib/init-ios-links.js +8 -7
  74. package/dist/src/lib/init-ios-links.js.map +1 -1
  75. package/dist/src/lib/init-xcode.d.ts +4 -3
  76. package/dist/src/lib/init-xcode.js +28 -5
  77. package/dist/src/lib/init-xcode.js.map +1 -1
  78. package/dist/src/lib/ios-app-id.d.ts +34 -0
  79. package/dist/src/lib/ios-app-id.js +69 -0
  80. package/dist/src/lib/ios-app-id.js.map +1 -0
  81. package/dist/src/lib/project-config.d.ts +15 -1
  82. package/dist/src/lib/project-config.js +31 -3
  83. package/dist/src/lib/project-config.js.map +1 -1
  84. package/dist/src/lib/sync-resource-types.d.ts +21 -2
  85. package/dist/src/lib/sync-resource-types.js +104 -6
  86. package/dist/src/lib/sync-resource-types.js.map +1 -1
  87. package/dist/src/lib/test-case-variables.d.ts +14 -0
  88. package/dist/src/lib/test-case-variables.js +42 -0
  89. package/dist/src/lib/test-case-variables.js.map +1 -1
  90. package/dist/src/lib/workflow-usage.d.ts +198 -0
  91. package/dist/src/lib/workflow-usage.js +312 -0
  92. package/dist/src/lib/workflow-usage.js.map +1 -0
  93. package/dist/src/types/index.d.ts +12 -2
  94. package/package.json +2 -1
@@ -29,9 +29,13 @@ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../
29
29
  // payload builder so the diff normalizer and the wire cannot disagree.
30
30
  import { WORKFLOW_MODEL_DEFAULTS } from "../lib/workflow-defaults.js";
31
31
  // #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";
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, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
33
33
  import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
34
34
  import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfaces.js";
35
+ // #3179 — the function's two halves: the header payloads and the envelope
36
+ // `config push` sends, and the bytes `config pull` writes back.
37
+ import { applyFunctionPull, buildFunctionConfigPush, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
38
+ import { FunctionBuildError } from "../lib/function-bundle.js";
35
39
  import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
36
40
  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
41
  import { normalizeTestCaseKey, testCaseKeyError, } from "../lib/test-case-keys.js";
@@ -1594,6 +1598,32 @@ export function buildBlobBucketUpdatePayload(bucket) {
1594
1598
  }
1595
1599
  return updatePayload;
1596
1600
  }
1601
+ /**
1602
+ * The `[[configs]] status` the server assigns when a file names none (#3201).
1603
+ *
1604
+ * Taken from the definition, not spelled again here: pull omits the key at
1605
+ * this value and push sends this value for the omitted key, and those two
1606
+ * statements have to be the same one or a pulled file would not describe the
1607
+ * state it was pulled from.
1608
+ */
1609
+ const PROMPT_CONFIG_DEFAULT_STATUS = fieldServerDefault(PROMPT_CONFIG_TABLE, "status");
1610
+ /**
1611
+ * The status a freshly CREATED prompt config still owes the file, or null
1612
+ * (#3201).
1613
+ *
1614
+ * `status` is update-only — `POST /prompts` and `POST /prompts/:id/configs`
1615
+ * both assign `active` whatever the body says — so an authored
1616
+ * `status = "archived"` was silently dropped on every create path: a pulled
1617
+ * tree pushed from a clean clone into a fresh environment brought every
1618
+ * retired config back into service. Read through the push payload builder, so
1619
+ * a file that omits the key asks for the default and needs no request.
1620
+ */
1621
+ export function createdConfigStatusPatch(tomlConfig) {
1622
+ const { status } = buildPromptConfigPayload(tomlConfig, { mode: "update" });
1623
+ return typeof status === "string" && status !== PROMPT_CONFIG_DEFAULT_STATUS
1624
+ ? { status }
1625
+ : null;
1626
+ }
1597
1627
  /**
1598
1628
  * Which `[[configs]]` entry a prompt file marks as live (issue #2645).
1599
1629
  *
@@ -1705,6 +1735,17 @@ export function serializePrompt(prompt, logger) {
1705
1735
  },
1706
1736
  configs: (prompt.configs || []).map((config) => ({
1707
1737
  ...projectRecordToToml(PROMPT_CONFIG_TABLE, config, {
1738
+ // #3201 — the per-version key, written only when the config is
1739
+ // RETIRED. `active` is what the create handler assigns and what an
1740
+ // absent key means (the definition's `serverDefault`), so emitting it
1741
+ // put a `status = "active"` line in every pulled prompt file: the one
1742
+ // spelling the availability contract says a config file never carries
1743
+ // (#2803), on the line `config create` scaffolds the `active = true`
1744
+ // marker onto. A retired config still says so — that is what #2645 put
1745
+ // the key in the file for.
1746
+ status: (record) => record?.status === PROMPT_CONFIG_DEFAULT_STATUS
1747
+ ? undefined
1748
+ : record?.status,
1708
1749
  // Stored as a string on the model and returned parsed; coerce
1709
1750
  // defensively so an older server's string still writes as a number.
1710
1751
  temperature: (record) => record.temperature != null ? Number(record.temperature) : undefined,
@@ -1852,6 +1893,13 @@ const SEEDED_CONFIG_DEFERRED = ["outputSchema", "description"];
1852
1893
  * `seededConfigName` is what the server called it — read from the create
1853
1894
  * response rather than assumed, so a handler that one day honors the name
1854
1895
  * produces no needless PATCH.
1896
+ *
1897
+ * #3201 — the entry's `status` is deferred the OTHER way and is deliberately
1898
+ * not here. The create points the prompt's `activeConfigId` at this seeded
1899
+ * config, so retiring it in this early patch would archive the config the
1900
+ * prompt is serving from and leave it there until the marked entry is created
1901
+ * and activated — a window a failed create or activation freezes. It is
1902
+ * applied after activation instead (`createdConfigStatusPatch`).
1855
1903
  */
1856
1904
  export function seededOnlyPromptConfigFields(firstConfig, seededConfigName = "default") {
1857
1905
  const full = buildPromptConfigPayload(firstConfig || {}, { mode: "update" });
@@ -3335,6 +3383,28 @@ const CONFIG_DIFF_SPECS = {
3335
3383
  serialize: (record) => serializeEmailTemplate(record),
3336
3384
  describe: (entity) => `email template "${entity?.emailType}"`,
3337
3385
  },
3386
+ // #3179 — server functions. The compared entity spans the header AND the
3387
+ // three keys the immutable version carries (`entry`, `durable`, `limits`),
3388
+ // because an author writes one file and a version is never authored
3389
+ // separately: comparing only the header would report a changed entry point as
3390
+ // Synced. The SOURCE bytes are a second axis the table cannot express — they
3391
+ // are compared by `envelopeHash`, which is also what the server dedupes on.
3392
+ "function": {
3393
+ label: "function",
3394
+ table: SERVER_FUNCTION_TABLE,
3395
+ parse: (doc) => functionDiffEntity(doc),
3396
+ // `extra` is the ACTIVE config version, which is where the remote side's
3397
+ // entry/durable/limits live; the header row does not carry them.
3398
+ serialize: (record, _maps, extra) => serializeFunction(record, extra),
3399
+ // The version-carried keys are not fields of the header table, so they
3400
+ // travel as extras — present on both sides or absent on both.
3401
+ extras: (entity) => ({
3402
+ entry: entity.entry ?? null,
3403
+ durable: entity.durable === true,
3404
+ limits: entity.limits ?? null,
3405
+ }),
3406
+ describe: (entity) => `function "${entity?.functionKey}"`,
3407
+ },
3338
3408
  // #2757 — webhooks join the registry comparator for the same reason email
3339
3409
  // templates did: `config diff` reported existence only, so an edit that
3340
3410
  // rerouted a webhook to a different `workflowKey` read as Synced while push
@@ -4594,6 +4664,123 @@ selects = () => true) {
4594
4664
  : { ok: false };
4595
4665
  return { scriptEntities, count: written, presence, listOk: true, serverKeys };
4596
4666
  }
4667
+ /**
4668
+ * Pull server-side `ServerFunction` rows into `functions/` (#3179).
4669
+ *
4670
+ * Two things make this different from every other type's pull:
4671
+ *
4672
+ * 1. **A function is more than its file.** The active config VERSION holds an
4673
+ * envelope with the authored TOML bytes and every source file, so the pull
4674
+ * writes `functions/<key>.toml` plus the TypeScript beside it, and records
4675
+ * the source paths so prune can remove the code with the function.
4676
+ * 2. **Bytes, not a re-serialization.** The stored TOML is written verbatim,
4677
+ * so comments, key order and line endings survive the round trip (F-009).
4678
+ * A function that has never had code pushed has no stored bytes; that one
4679
+ * gets a canonical serialization of its header, which is still a pushable
4680
+ * file.
4681
+ *
4682
+ * A stored source path is never trusted: `planFunctionPull` refuses one that
4683
+ * normalizes outside the tree, and the refusals are reported rather than
4684
+ * swallowed.
4685
+ */
4686
+ export async function pullFunctions(client, appId, configDir, logger = () => { }, selects = () => true) {
4687
+ const functionsDir = join(configDir, "functions");
4688
+ ensureDir(functionsDir);
4689
+ const functionEntities = {};
4690
+ let items = [];
4691
+ try {
4692
+ const result = await client.listFunctions(appId);
4693
+ items = result.items || [];
4694
+ }
4695
+ catch {
4696
+ return {
4697
+ functionEntities,
4698
+ count: 0,
4699
+ presence: { ok: false },
4700
+ listOk: false,
4701
+ serverKeys: [],
4702
+ items: [],
4703
+ };
4704
+ }
4705
+ let detailsOk = true;
4706
+ let written = 0;
4707
+ for (const record of items) {
4708
+ const key = record?.functionKey;
4709
+ if (!key)
4710
+ continue;
4711
+ if (!selects(key))
4712
+ continue;
4713
+ // #2803 — a tombstone is not exported as a file. Its key stays reserved,
4714
+ // and the archived-holder message is what says so on the next push.
4715
+ if (String(record.status ?? "active") === "archived")
4716
+ continue;
4717
+ const filePath = join(functionsDir, `${key}.toml`);
4718
+ let entity = {
4719
+ id: record.functionId,
4720
+ modifiedAt: record.modifiedAt || new Date().toISOString(),
4721
+ };
4722
+ if (!record.activeConfigId) {
4723
+ // No code pushed yet: the header is all there is to write.
4724
+ writeFileSync(filePath, serializeFunction(record));
4725
+ entity.contentHash = computeFileHash(filePath);
4726
+ entity.semanticHash = hashRemoteConfigForDiff(configDiffSpec("function"), record, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() });
4727
+ functionEntities[key] = entity;
4728
+ written += 1;
4729
+ logger(` Wrote functions/${key}.toml (no version pushed yet)`);
4730
+ continue;
4731
+ }
4732
+ let active;
4733
+ let envelope;
4734
+ try {
4735
+ const configs = await client.listFunctionConfigs(appId, record.functionId);
4736
+ active = (configs.items || []).find((c) => String(c.configId) === String(record.activeConfigId));
4737
+ if (active) {
4738
+ envelope = await client.getFunctionConfigEnvelope(appId, record.functionId, active.configId);
4739
+ }
4740
+ }
4741
+ catch {
4742
+ detailsOk = false;
4743
+ logger(` Skipped functions/${key}.toml (could not fetch its version)`);
4744
+ continue;
4745
+ }
4746
+ if (!active || !envelope) {
4747
+ detailsOk = false;
4748
+ logger(` Skipped functions/${key}.toml (active version not readable)`);
4749
+ continue;
4750
+ }
4751
+ const plan = planFunctionPull({ configDir, functionKey: key, envelope });
4752
+ for (const refusal of plan.refusals)
4753
+ logger(` ${refusal}`);
4754
+ const applied = applyFunctionPull(plan);
4755
+ for (const refusal of applied.refusals)
4756
+ logger(` ${refusal}`);
4757
+ entity.contentHash = computeFileHash(filePath);
4758
+ entity.semanticHash = hashRemoteConfigForDiff(configDiffSpec("function"), record, { ruleSetIdToName: new Map(), ruleSetNameToId: new Map() }, active);
4759
+ entity.envelopeHash = envelope.envelopeHash ?? active.envelopeHash;
4760
+ entity.activeConfigId = active.configId;
4761
+ // Only the files that were really written: a path the write step refused
4762
+ // is not one prune may later delete on this function's behalf.
4763
+ entity.sources = applied.writtenSources;
4764
+ functionEntities[key] = entity;
4765
+ written += 1;
4766
+ logger(` Wrote functions/${key}.toml and ${applied.writtenSources.length} source file(s)`);
4767
+ }
4768
+ const serverKeys = items
4769
+ .filter((f) => String(f?.status ?? "active") !== "archived")
4770
+ .map((f) => f?.functionKey)
4771
+ .filter(Boolean);
4772
+ const presence = detailsOk
4773
+ ? { ok: true, serverKeys }
4774
+ : { ok: false };
4775
+ return {
4776
+ functionEntities,
4777
+ count: written,
4778
+ presence,
4779
+ listOk: true,
4780
+ serverKeys,
4781
+ items,
4782
+ };
4783
+ }
4597
4784
  /**
4598
4785
  * Every test case a block has, draining the cursor (#2769).
4599
4786
  *
@@ -6117,6 +6304,7 @@ export function registerConfigSyncCommands(sync) {
6117
6304
  ensureDir(join(configDir, "prompts"));
6118
6305
  ensureDir(join(configDir, "workflows"));
6119
6306
  ensureDir(join(configDir, "transforms")); // #892 slice 7 — Rhai scripts
6307
+ ensureDir(join(configDir, "functions")); // #3179 — server functions
6120
6308
  ensureDir(join(configDir, "database-type-configs"));
6121
6309
  ensureDir(join(configDir, "rule-sets"));
6122
6310
  ensureDir(join(configDir, "group-type-configs"));
@@ -6380,6 +6568,7 @@ export function registerConfigSyncCommands(sync) {
6380
6568
  ensureDir(join(configDir, "prompts"));
6381
6569
  ensureDir(join(configDir, "workflows"));
6382
6570
  ensureDir(join(configDir, "transforms")); // #892 slice 7 — Rhai scripts
6571
+ ensureDir(join(configDir, "functions")); // #3179 — server functions
6383
6572
  ensureDir(join(configDir, "database-type-configs"));
6384
6573
  ensureDir(join(configDir, "rule-sets"));
6385
6574
  ensureDir(join(configDir, "group-type-configs"));
@@ -6735,6 +6924,20 @@ export function registerConfigSyncCommands(sync) {
6735
6924
  if (pulledScriptsCount > 0) {
6736
6925
  info(` Pulled ${pulledScriptsCount} transform(s)`);
6737
6926
  }
6927
+ // Pull server functions — #3179. Writes `functions/<key>.toml` from the
6928
+ // AUTHORED bytes stored in the active version's envelope, plus every
6929
+ // source file beside it, so the round trip is byte-identical.
6930
+ const { functionEntities, count: pulledFunctionsCount, presence: functionPresence, listOk: functionListOk, serverKeys: functionServerKeys, items: functionTombstoneCandidates, } = await pullFunctions(client, resolvedAppId, configDir, info, (key) => {
6931
+ if (!pullSelection)
6932
+ return true;
6933
+ if (!selectionIncludesKey(pullSelection, "function", key))
6934
+ return false;
6935
+ matchedSelectors.add(`function/${key}`);
6936
+ return true;
6937
+ });
6938
+ if (pulledFunctionsCount > 0) {
6939
+ info(` Pulled ${pulledFunctionsCount} function(s)`);
6940
+ }
6738
6941
  // Build lookup maps for test case serialization
6739
6942
  const configIdToName = new Map();
6740
6943
  const promptIdToKey = new Map();
@@ -7013,6 +7216,7 @@ export function registerConfigSyncCommands(sync) {
7013
7216
  ? { ok: true, serverKeys: blobBucketItems.map((b) => b.bucketKey) }
7014
7217
  : { ok: false },
7015
7218
  scripts: scriptPresence,
7219
+ functions: functionPresence,
7016
7220
  emailTemplates: emailTemplatePresence,
7017
7221
  databaseTypes: databaseTypeConfigsOutcome.ok && databaseTypeDetailsOk
7018
7222
  ? { ok: true, serverKeys: databaseTypesWithOps.map((d) => d.typeConfig.databaseType) }
@@ -7079,11 +7283,36 @@ export function registerConfigSyncCommands(sync) {
7079
7283
  for (const key of plan.unsafeKeys) {
7080
7284
  warn(` Skipped pruning ${resourceType.dir} entry "${key}" — its sync-state key does not map to a safe filename`);
7081
7285
  }
7286
+ // #3179 — the source files the SURVIVORS still own. Two functions can
7287
+ // import the same module, and both record it; deleting it with one
7288
+ // would leave the other unbuildable while its state called it
7289
+ // current. Both the prior state and this pull's entities are read,
7290
+ // because a function created server-side since the last pull owns its
7291
+ // sources only in the fresh map.
7292
+ const pruningKeys = new Set(plan.keys);
7293
+ const retainedFiles = new Set();
7294
+ const collectRetained = (entities) => {
7295
+ for (const [entityKey, entity] of Object.entries(entities ?? {})) {
7296
+ if (pruningKeys.has(entityKey))
7297
+ continue;
7298
+ for (const path of (entity?.sources ?? [])) {
7299
+ retainedFiles.add(path);
7300
+ }
7301
+ }
7302
+ };
7303
+ collectRetained(priorEntities[resourceType.stateSlot]);
7304
+ if (resourceType.stateSlot === "functions") {
7305
+ collectRetained(functionEntities);
7306
+ }
7082
7307
  const result = await applyPrune({
7083
7308
  configDir,
7084
7309
  resourceType,
7085
7310
  keys: plan.keys,
7086
7311
  testCaseEntities,
7312
+ // #3179 — a function's TypeScript goes with it. Only the prior sync
7313
+ // state knows where those files are, so it is what names them.
7314
+ extraFilesForKey: (key) => priorEntities[resourceType.stateSlot]?.[key]?.sources ?? [],
7315
+ retainedFiles,
7087
7316
  logger: info,
7088
7317
  });
7089
7318
  prunedCount += result.prunedKeys.length;
@@ -7099,12 +7328,14 @@ export function registerConfigSyncCommands(sync) {
7099
7328
  // the prior entry for that one key — see `preserveUnwrittenEntries`.
7100
7329
  const keepPrior = (ok, built, slot) => ok ? built : (priorEntities[slot] ?? built);
7101
7330
  preserveUnwrittenEntries(scriptEntities, priorEntities.scripts, scriptServerKeys);
7331
+ preserveUnwrittenEntries(functionEntities, priorEntities.functions, functionServerKeys);
7102
7332
  preserveUnwrittenEntries(emailTemplateEntities, priorEntities.emailTemplates, emailTemplatesOutcome.ok
7103
7333
  ? emailTemplateOverrides.map((t) => t.emailType)
7104
7334
  : []);
7105
7335
  const finalCronTriggerEntities = keepPrior(cronTriggersOutcome.ok, cronTriggerEntities, "cronTriggers");
7106
7336
  const finalBlobBucketEntities = keepPrior(blobBucketsOutcome.ok, blobBucketEntities, "blobBuckets");
7107
7337
  const finalScriptEntities = keepPrior(scriptListOk, scriptEntities, "scripts");
7338
+ const finalFunctionEntities = keepPrior(functionListOk, functionEntities, "functions");
7108
7339
  const finalEmailTemplateEntities = keepPrior(emailTemplatesOutcome.ok, emailTemplateEntities, "emailTemplates");
7109
7340
  const finalDatabaseTypeEntities = keepPrior(databaseTypeConfigsOutcome.ok, databaseTypeEntities, "databaseTypes");
7110
7341
  const finalRuleSetEntities = keepPrior(ruleSetsOutcome.ok, ruleSetEntities, "ruleSets");
@@ -7133,6 +7364,7 @@ export function registerConfigSyncCommands(sync) {
7133
7364
  prompts: promptEntities,
7134
7365
  workflows: workflowEntities,
7135
7366
  scripts: finalScriptEntities,
7367
+ functions: finalFunctionEntities,
7136
7368
  emailTemplates: finalEmailTemplateEntities,
7137
7369
  databaseTypes: finalDatabaseTypeEntities,
7138
7370
  ruleSets: finalRuleSetEntities,
@@ -7203,6 +7435,17 @@ export function registerConfigSyncCommands(sync) {
7203
7435
  idOf: (w) => w.workflowId,
7204
7436
  }, Object.keys(priorEntities.workflows ?? {})),
7205
7437
  ],
7438
+ // #3179 — the sixth carrying type reaches the same place. An archived
7439
+ // function keeps its key in the app's SINGLE key namespace, so a
7440
+ // stranded tombstone blocks a workflow, a script or a webhook wanting
7441
+ // that key too, not just another function.
7442
+ [
7443
+ "functions",
7444
+ archivedTombstoneEntries(functionTombstoneCandidates, {
7445
+ keyOf: (f) => f.functionKey,
7446
+ idOf: (f) => f.functionId,
7447
+ }, Object.keys(priorEntities.functions ?? {})),
7448
+ ],
7206
7449
  ]) {
7207
7450
  const built = entitiesBySlot[slot];
7208
7451
  const resourceType = SYNC_RESOURCE_TYPES.find((rt) => rt.stateSlot === slot);
@@ -7299,6 +7542,7 @@ export function registerConfigSyncCommands(sync) {
7299
7542
  prompts: promptEntities,
7300
7543
  workflows: workflowEntities,
7301
7544
  scripts: Object.keys(finalScriptEntities).length > 0 ? finalScriptEntities : undefined,
7545
+ functions: Object.keys(finalFunctionEntities).length > 0 ? finalFunctionEntities : undefined,
7302
7546
  emailTemplates: Object.keys(finalEmailTemplateEntities).length > 0 ? finalEmailTemplateEntities : undefined,
7303
7547
  testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
7304
7548
  databaseTypes: Object.keys(finalDatabaseTypeEntities).length > 0 ? finalDatabaseTypeEntities : undefined,
@@ -7324,6 +7568,7 @@ export function registerConfigSyncCommands(sync) {
7324
7568
  keyValue("Prompts", prompts.length);
7325
7569
  keyValue("Workflows", workflows.length);
7326
7570
  keyValue("Transforms", pulledScriptsCount);
7571
+ keyValue("Functions", pulledFunctionsCount);
7327
7572
  keyValue("Email Templates", emailTemplates.length);
7328
7573
  keyValue("Test Cases", totalTestCases);
7329
7574
  keyValue("Database Types", databaseTypesWithOps.length);
@@ -9939,6 +10184,18 @@ export function registerConfigSyncCommands(sync) {
9939
10184
  }
9940
10185
  if (newConfig?.configId) {
9941
10186
  promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
10187
+ // #3201 — the create assigned `active`; a file that
10188
+ // retired this entry says so in the only mode the
10189
+ // server accepts the key in.
10190
+ const statusPatch = createdConfigStatusPatch(tomlConfig);
10191
+ if (statusPatch) {
10192
+ try {
10193
+ await client.updatePromptConfig(resolvedAppId, promptId, newConfig.configId, statusPatch);
10194
+ }
10195
+ catch (err) {
10196
+ throw wrapPromptConfigError(err, "update", key, configName);
10197
+ }
10198
+ }
9942
10199
  }
9943
10200
  }
9944
10201
  }
@@ -10249,6 +10506,19 @@ export function registerConfigSyncCommands(sync) {
10249
10506
  if (extraCreated?.configId) {
10250
10507
  const configName = extraName;
10251
10508
  promptConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
10509
+ // #3201 — as on the update path: the create ignored
10510
+ // the entry's `status`, so a retired one is applied
10511
+ // here or the fresh environment runs a config the
10512
+ // file retired.
10513
+ const statusPatch = createdConfigStatusPatch(extraConfig);
10514
+ if (statusPatch) {
10515
+ try {
10516
+ await client.updatePromptConfig(resolvedAppId, created.promptId, extraCreated.configId, statusPatch);
10517
+ }
10518
+ catch (err) {
10519
+ throw wrapPromptConfigError(err, "update", key, configName);
10520
+ }
10521
+ }
10252
10522
  }
10253
10523
  }
10254
10524
  info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
@@ -10280,6 +10550,25 @@ export function registerConfigSyncCommands(sync) {
10280
10550
  activated = true;
10281
10551
  }
10282
10552
  }
10553
+ // #3201 — the seeded config's own `status`, applied LAST for
10554
+ // the same reason activation runs after the extra creates:
10555
+ // the create points `activeConfigId` at this config, so a
10556
+ // file that retires it is asking for a state that only makes
10557
+ // sense once the entry it marks active exists and is live.
10558
+ // Retiring it earlier would leave the prompt serving from an
10559
+ // archived config for the length of the remaining calls, and
10560
+ // permanently if one of them failed.
10561
+ const seededStatusPatch = seededConfigId
10562
+ ? createdConfigStatusPatch(firstConfig || {})
10563
+ : null;
10564
+ if (created?.promptId && seededConfigId && seededStatusPatch) {
10565
+ try {
10566
+ await client.updatePromptConfig(resolvedAppId, created.promptId, seededConfigId, seededStatusPatch);
10567
+ }
10568
+ catch (err) {
10569
+ throw wrapPromptConfigError(err, "update", key, firstConfigPatch?.configName || seededConfig?.configName);
10570
+ }
10571
+ }
10283
10572
  // Stamp sync state ONLY after all create work (base prompt +
10284
10573
  // every extra config) succeeded. If an extra-config create
10285
10574
  // above threw, we never reach here, so the file hash is not
@@ -10576,6 +10865,261 @@ export function registerConfigSyncCommands(sync) {
10576
10865
  }
10577
10866
  }
10578
10867
  }
10868
+ // Process server functions — #3179.
10869
+ //
10870
+ // A function pushes on TWO axes and they are deliberately independent:
10871
+ //
10872
+ // - the HEADER (`[function]`'s gate, runAs, schemas, plus the three
10873
+ // version-carried keys, all compared through the shared gate), and
10874
+ // - the CODE — the authored TOML bytes, every source file and the
10875
+ // built bundle, sent as one immutable config VERSION.
10876
+ //
10877
+ // Independent because a comment-only edit to a source file leaves the
10878
+ // header identical and must still make a version (F-003): gating the
10879
+ // code push on the header comparison would silently discard it. The
10880
+ // code axis compares `envelopeHash`, which is also what the SERVER
10881
+ // dedupes on, so an unchanged push makes no version on either side's
10882
+ // reckoning.
10883
+ //
10884
+ // The bundle is built FIRST, so a build error, a missing entry or an
10885
+ // import that escapes the config tree aborts this function before
10886
+ // anything is sent for it (behavior 12).
10887
+ const functionsPushDir = join(configDir, "functions");
10888
+ if (existsSync(functionsPushDir)) {
10889
+ const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter((f) => f.endsWith(".toml")));
10890
+ const functionSpec = configDiffSpec("function");
10891
+ const liveFunctions = new Map();
10892
+ let functionsLiveOk = true;
10893
+ if (files.length > 0) {
10894
+ try {
10895
+ const listed = await client.listFunctions(resolvedAppId);
10896
+ for (const f of (listed?.items ?? [])) {
10897
+ if (f?.functionKey)
10898
+ liveFunctions.set(f.functionKey, f);
10899
+ }
10900
+ }
10901
+ catch (err) {
10902
+ functionsLiveOk = false;
10903
+ warnLiveUnavailable("functions", err);
10904
+ }
10905
+ }
10906
+ /** The version a function currently runs, or null. */
10907
+ const liveActiveVersion = async (functionId, activeConfigId) => {
10908
+ if (!activeConfigId)
10909
+ return null;
10910
+ const { items } = await client.listFunctionConfigs(resolvedAppId, functionId);
10911
+ return ((items || []).find((c) => String(c.configId) === String(activeConfigId)) ?? null);
10912
+ };
10913
+ for (const file of files) {
10914
+ const filePath = join(functionsPushDir, file);
10915
+ const tomlData = parseTomlFile(filePath);
10916
+ const key = tomlData.function?.key || basename(file, ".toml");
10917
+ withDerivedIdentity(tomlData, "function", { key });
10918
+ const existingEntry = syncState?.entities?.functions?.[key];
10919
+ const liveFunction = liveFunctions.get(key);
10920
+ // Build first: nothing is sent for a function whose code does not
10921
+ // compile or whose imports leave the tree.
10922
+ let push;
10923
+ try {
10924
+ push = await buildFunctionConfigPush({
10925
+ configDir,
10926
+ functionKey: key,
10927
+ filePath,
10928
+ tomlData,
10929
+ });
10930
+ }
10931
+ catch (err) {
10932
+ if (!(err instanceof FunctionBuildError))
10933
+ throw err;
10934
+ applyFailures.push({
10935
+ type: "function",
10936
+ key,
10937
+ message: err.message,
10938
+ });
10939
+ continue;
10940
+ }
10941
+ // The active version, needed by BOTH axes: the header comparison
10942
+ // reads `entry`/`durable`/`limits` off it, and the code axis
10943
+ // compares its `envelopeHash`.
10944
+ let activeVersion = null;
10945
+ let versionOk = functionsLiveOk;
10946
+ if (functionsLiveOk && liveFunction?.functionId) {
10947
+ try {
10948
+ activeVersion = await liveActiveVersion(liveFunction.functionId, liveFunction.activeConfigId);
10949
+ }
10950
+ catch (err) {
10951
+ versionOk = false;
10952
+ warnLiveUnavailable(`function ${key}`, err);
10953
+ }
10954
+ }
10955
+ let headerAction = "skip";
10956
+ if (versionOk && functionsLiveOk) {
10957
+ const gate = decidePushForConfig({
10958
+ spec: functionSpec,
10959
+ maps: pushDiffMaps,
10960
+ localParsed: tomlData,
10961
+ live: liveFunction
10962
+ ? {
10963
+ record: liveFunction,
10964
+ // The version-carried keys the remote side is projected
10965
+ // through; the header row does not hold them.
10966
+ extra: activeVersion ?? undefined,
10967
+ modifiedAt: liveFunction.modifiedAt,
10968
+ }
10969
+ : null,
10970
+ entry: existingEntry,
10971
+ localFileHash: computeFileHash(filePath),
10972
+ force: options.force,
10973
+ // The create leg adopts by key on the server's 409 (#1006), so
10974
+ // an untracked function the server already holds is that same
10975
+ // adopt reached one step earlier.
10976
+ adoptsUntrackedByKey: true,
10977
+ });
10978
+ if (gate.action === "drift" ||
10979
+ gate.action === "conflict" ||
10980
+ gate.action === "immutable") {
10981
+ recordDeclined("function", key, gate, existingEntry?.modifiedAt);
10982
+ continue;
10983
+ }
10984
+ headerAction = gate.action === "skip"
10985
+ ? "skip"
10986
+ : liveFunction
10987
+ ? "update"
10988
+ : "create";
10989
+ }
10990
+ else {
10991
+ const degraded = decideDegradedPush({
10992
+ force: options.force,
10993
+ storedContentHash: existingEntry?.contentHash,
10994
+ currentFileHash: computeFileHash(filePath),
10995
+ });
10996
+ if (degraded.action === "decline") {
10997
+ recordDeclined("function", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
10998
+ continue;
10999
+ }
11000
+ headerAction = degraded.action === "skip"
11001
+ ? "skip"
11002
+ : liveFunction
11003
+ ? "update"
11004
+ : "create";
11005
+ }
11006
+ // The code axis. `envelopeHash` is the server's own no-op test, so
11007
+ // matching it here means the call would create nothing.
11008
+ const codeUnchanged = !!liveFunction &&
11009
+ !!activeVersion &&
11010
+ String(activeVersion.envelopeHash ?? "") === push.envelopeHash;
11011
+ if (headerAction === "skip" && codeUnchanged) {
11012
+ skippedCount++;
11013
+ if (syncState && liveFunction?.functionId) {
11014
+ if (!syncState.entities.functions)
11015
+ syncState.entities.functions = {};
11016
+ syncState.entities.functions[key] = {
11017
+ ...(existingEntry ?? {}),
11018
+ id: liveFunction.functionId,
11019
+ modifiedAt: liveFunction.modifiedAt ||
11020
+ existingEntry?.modifiedAt ||
11021
+ new Date().toISOString(),
11022
+ contentHash: computeFileHash(filePath),
11023
+ semanticHash: hashRemoteConfigForDiff(functionSpec, liveFunction, pushDiffMaps, activeVersion ?? undefined),
11024
+ envelopeHash: push.envelopeHash,
11025
+ activeConfigId: activeVersion?.configId,
11026
+ sources: push.sources.map((src) => src.path),
11027
+ };
11028
+ }
11029
+ continue;
11030
+ }
11031
+ changes.push({
11032
+ type: "function",
11033
+ action: liveFunction ? "update" : "create",
11034
+ key,
11035
+ });
11036
+ if (options.dryRun)
11037
+ continue;
11038
+ // Every server refusal below is recorded against THIS function and
11039
+ // the loop moves on. A single archived tombstone — or a key the
11040
+ // app's namespace already holds — must not abort a push carrying
11041
+ // unrelated changes: the run reports what it could not apply, exits
11042
+ // non-zero, and everything else in the tree still lands. That is
11043
+ // the same contract `applyFailures` gives every other type; a throw
11044
+ // here would let one refusal block a whole repository.
11045
+ let functionId = liveFunction?.functionId ?? existingEntry?.id;
11046
+ let pushedVersion;
11047
+ try {
11048
+ if (!functionId) {
11049
+ const created = await client.createFunction(resolvedAppId, buildFunctionCreatePayload(key, tomlData.function ?? {})).catch(async (err) => {
11050
+ // #1006 — a function the server already holds under this key
11051
+ // is adopted rather than re-created. The conflict may name a
11052
+ // WORKFLOW, a script or a webhook instead: those are the
11053
+ // single key namespace refusing the key, and they are not
11054
+ // adoptable, so `adoptByKeyOnCreate409` re-throws the original
11055
+ // error with its holder named.
11056
+ const existing = await adoptByKeyOnCreate409({
11057
+ err,
11058
+ kind: "function",
11059
+ key,
11060
+ lookup: async () => (await client.listFunctions(resolvedAppId)).items || [],
11061
+ matchKey: (f) => f?.functionKey === key,
11062
+ });
11063
+ return existing;
11064
+ });
11065
+ functionId = created.functionId;
11066
+ info(` Created function: ${key}`);
11067
+ }
11068
+ else if (headerAction !== "skip") {
11069
+ const updatePayload = buildFunctionUpdatePayload(tomlData.function ?? {});
11070
+ if (Object.keys(updatePayload).length > 0) {
11071
+ await client.updateFunction(resolvedAppId, functionId, updatePayload);
11072
+ }
11073
+ info(` Updated function: ${key}`);
11074
+ }
11075
+ // The version. `status` is NEVER in this body: pushing code to a
11076
+ // disabled function updates it and leaves it out of service
11077
+ // (#2803's resurrection guard), and a push to an archived one is
11078
+ // refused by the server with the archived-holder message — which
11079
+ // names the remedies that release its key.
11080
+ pushedVersion = await client.pushFunctionConfig(resolvedAppId, String(functionId), push);
11081
+ }
11082
+ catch (err) {
11083
+ applyFailures.push({
11084
+ type: "function",
11085
+ key,
11086
+ message: String(err?.message ?? err),
11087
+ });
11088
+ continue;
11089
+ }
11090
+ if (pushedVersion?.created === false) {
11091
+ info(` Function ${key}: code unchanged (no new version)`);
11092
+ }
11093
+ else {
11094
+ info(` Pushed function ${key} version ${pushedVersion?.configId ?? ""}`.trim());
11095
+ }
11096
+ if (syncState) {
11097
+ if (!syncState.entities.functions)
11098
+ syncState.entities.functions = {};
11099
+ let refreshed = liveFunction;
11100
+ try {
11101
+ refreshed = await client.getFunction(resolvedAppId, String(functionId));
11102
+ }
11103
+ catch {
11104
+ // Non-fatal: the baseline falls back to what the listing said.
11105
+ // The next push reconciles it.
11106
+ }
11107
+ syncState.entities.functions[key] = {
11108
+ id: String(functionId),
11109
+ modifiedAt: refreshed?.modifiedAt ||
11110
+ existingEntry?.modifiedAt ||
11111
+ new Date().toISOString(),
11112
+ contentHash: computeFileHash(filePath),
11113
+ semanticHash: refreshed
11114
+ ? hashRemoteConfigForDiff(functionSpec, refreshed, pushDiffMaps, pushedVersion ?? undefined)
11115
+ : undefined,
11116
+ envelopeHash: push.envelopeHash,
11117
+ activeConfigId: pushedVersion?.configId,
11118
+ sources: push.sources.map((src) => src.path),
11119
+ };
11120
+ }
11121
+ }
11122
+ }
10579
11123
  // Process workflows
10580
11124
  const workflowsDir = join(configDir, "workflows");
10581
11125
  if (existsSync(workflowsDir)) {
@@ -13786,7 +14330,7 @@ export function registerConfigSyncCommands(sync) {
13786
14330
  notCompared.push({ type: label, reason: gateReason(outcome.error) });
13787
14331
  return empty;
13788
14332
  };
13789
- const [integrationsOutcome, promptsOutcome, workflowsOutcome, emailTemplatesOutcomeDiff, webhooksOutcome, cronTriggersOutcomeDiff, blobBucketsOutcomeDiff, scriptsOutcomeDiff, varsOutcomeDiff, databaseTypeConfigsOutcomeDiff, ruleSetsOutcomeDiff, groupTypeConfigsOutcomeDiff, collectionTypeConfigsOutcomeDiff, metadataCategoryConfigsOutcomeDiff,] = await Promise.all([
14333
+ const [integrationsOutcome, promptsOutcome, workflowsOutcome, emailTemplatesOutcomeDiff, webhooksOutcome, cronTriggersOutcomeDiff, blobBucketsOutcomeDiff, scriptsOutcomeDiff, functionsOutcomeDiff, varsOutcomeDiff, databaseTypeConfigsOutcomeDiff, ruleSetsOutcomeDiff, groupTypeConfigsOutcomeDiff, collectionTypeConfigsOutcomeDiff, metadataCategoryConfigsOutcomeDiff,] = await Promise.all([
13790
14334
  attemptFetch(() => fetchAll((p) => client.listIntegrations(resolvedAppId, p))),
13791
14335
  attemptFetch(() => fetchAll((p) => client.listPrompts(resolvedAppId, p))),
13792
14336
  attemptFetch(() => fetchAll((p) => client.listWorkflows(resolvedAppId, p))),
@@ -13795,6 +14339,7 @@ export function registerConfigSyncCommands(sync) {
13795
14339
  attemptFetch(() => client.listCronTriggers(resolvedAppId)),
13796
14340
  attemptFetch(() => client.listBlobBuckets(resolvedAppId)),
13797
14341
  attemptFetch(() => client.listScripts(resolvedAppId)),
14342
+ attemptFetch(() => client.listFunctions(resolvedAppId)),
13798
14343
  attemptFetch(() => client.listAppConfigVars(resolvedAppId)),
13799
14344
  attemptFetch(() => client.listDatabaseTypeConfigs(resolvedAppId)),
13800
14345
  attemptFetch(() => client.listRuleSets(resolvedAppId)),
@@ -13810,6 +14355,7 @@ export function registerConfigSyncCommands(sync) {
13810
14355
  const cronTriggerItemsDiff = gate(cronTriggersOutcomeDiff, "cron-triggers", { items: [] }).items || [];
13811
14356
  const blobBucketsDiffResult = gate(blobBucketsOutcomeDiff, "blob-buckets", { items: [] });
13812
14357
  const scriptItemsDiff = gate(scriptsOutcomeDiff, "transforms", { items: [] }).items || [];
14358
+ const functionItemsDiff = gate(functionsOutcomeDiff, "functions", { items: [] }).items || [];
13813
14359
  const remoteVarItemsDiff = gate(varsOutcomeDiff, "vars", []);
13814
14360
  const databaseTypeConfigsDiff = gate(databaseTypeConfigsOutcomeDiff, "database-type-configs", []);
13815
14361
  const ruleSetsDiff = gate(ruleSetsOutcomeDiff, "rule-sets", []);
@@ -14114,6 +14660,25 @@ export function registerConfigSyncCommands(sync) {
14114
14660
  localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
14115
14661
  }
14116
14662
  }
14663
+ // Server functions — #3179. Two axes, exactly as push has: the
14664
+ // header/version fields through the shared spec, and the CODE through
14665
+ // `envelopeHash`. The code axis is why a function's diff builds the
14666
+ // bundle: a comment-only edit to a source file changes nothing the
14667
+ // table can see and is still a change push will apply.
14668
+ const remoteFunctions = new Map(functionItemsDiff
14669
+ .filter((f) => f?.functionKey && String(f.status ?? "active") !== "archived")
14670
+ .map((f) => [f.functionKey, f]));
14671
+ const localFunctionsParsed = new Map();
14672
+ const functionsDirPath = join(configDir, "functions");
14673
+ if (functionsOutcomeDiff.ok && existsSync(functionsDirPath)) {
14674
+ for (const file of readdirSync(functionsDirPath).filter((f) => f.endsWith(".toml"))) {
14675
+ const filePath = join(functionsDirPath, file);
14676
+ const tomlData = parseTomlFile(filePath);
14677
+ const key = tomlData.function?.key || basename(file, ".toml");
14678
+ withDerivedIdentity(tomlData, "function", { key });
14679
+ localFunctionsParsed.set(key, { tomlData, filePath });
14680
+ }
14681
+ }
14117
14682
  // Config vars — issue #1423. The remote vars were fetched through the
14118
14683
  // ok/error gate above (`remoteVarItemsDiff`); the local `vars.toml` is
14119
14684
  // read only when that fetch succeeded.
@@ -14377,6 +14942,89 @@ export function registerConfigSyncCommands(sync) {
14377
14942
  differences.push({ type: "prompt", key, status: remoteOnlyStatus("prompts", key) });
14378
14943
  }
14379
14944
  }
14945
+ // Server functions — #3179.
14946
+ const functionSpecDiff = configDiffSpec("function");
14947
+ const functionDiffMaps = {
14948
+ ruleSetIdToName: new Map(),
14949
+ ruleSetNameToId: new Map(),
14950
+ };
14951
+ for (const [key, local] of localFunctionsParsed) {
14952
+ const remote = remoteFunctions.get(key);
14953
+ if (!remote) {
14954
+ differences.push({
14955
+ type: "function",
14956
+ key,
14957
+ status: localOnlyStatus("functions", key),
14958
+ });
14959
+ continue;
14960
+ }
14961
+ let status = "exists";
14962
+ let hint;
14963
+ // The active version carries `entry`, `durable` and `limits`, and its
14964
+ // `envelopeHash` is the code axis.
14965
+ const versionOutcome = await attemptFetch(async () => {
14966
+ if (!remote.activeConfigId)
14967
+ return null;
14968
+ const { items } = await client.listFunctionConfigs(resolvedAppId, remote.functionId);
14969
+ return ((items || []).find((c) => String(c.configId) === String(remote.activeConfigId)) ?? null);
14970
+ });
14971
+ if (!versionOutcome.ok) {
14972
+ hint = "content not compared (fetch failed)";
14973
+ }
14974
+ else {
14975
+ const active = versionOutcome.value;
14976
+ const verdict = compareLocalToRemote(functionSpecDiff, local.tomlData, remote, functionDiffMaps, active ?? undefined);
14977
+ status = verdict.status;
14978
+ hint = verdict.hint;
14979
+ if (status === "exists") {
14980
+ // The header agrees; the code is the other half. Building here is
14981
+ // what makes "config diff is clean immediately after pull" a
14982
+ // statement about the SOURCES and not just the TOML.
14983
+ try {
14984
+ const built = await buildFunctionConfigPush({
14985
+ configDir,
14986
+ functionKey: key,
14987
+ filePath: local.filePath,
14988
+ tomlData: local.tomlData,
14989
+ });
14990
+ if (!active ||
14991
+ String(active.envelopeHash ?? "") !== built.envelopeHash) {
14992
+ status = "modified";
14993
+ hint =
14994
+ "the built bundle or a source file differs — `config push` " +
14995
+ "creates a new immutable version";
14996
+ }
14997
+ }
14998
+ catch (err) {
14999
+ if (err instanceof FunctionBuildError) {
15000
+ // #2880 — the file is not pushable, so the row says so
15001
+ // rather than reading as Synced. This is the state a
15002
+ // function created through the API and never pushed lands
15003
+ // in: pull can only serialize the header, so the file has
15004
+ // no `entry` and the very next push refuses it. A Synced row
15005
+ // there tells the operator there is nothing to do about a
15006
+ // file nothing can apply.
15007
+ status = VALIDATION_ERROR;
15008
+ hint = err.message.split("\n")[0];
15009
+ }
15010
+ else {
15011
+ hint = "content not compared (comparison failed)";
15012
+ }
15013
+ }
15014
+ }
15015
+ }
15016
+ hint = outOfServiceHint(remote, hint);
15017
+ differences.push({ type: "function", key, status, hint });
15018
+ }
15019
+ for (const key of remoteFunctions.keys()) {
15020
+ if (!localFunctionsParsed.has(key)) {
15021
+ differences.push({
15022
+ type: "function",
15023
+ key,
15024
+ status: remoteOnlyStatus("functions", key),
15025
+ });
15026
+ }
15027
+ }
14380
15028
  // Workflows — #1175: content-aware comparison.
14381
15029
  //
14382
15030
  // For workflows present on BOTH sides we no longer stop at key