primitive-admin 1.1.0-alpha.64 → 1.1.0-alpha.65

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 (54) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +68 -6
  2. package/dist/bin/primitive.js +2 -0
  3. package/dist/bin/primitive.js.map +1 -1
  4. package/dist/src/commands/config.js +11 -4
  5. package/dist/src/commands/config.js.map +1 -1
  6. package/dist/src/commands/cron-triggers.js +25 -28
  7. package/dist/src/commands/cron-triggers.js.map +1 -1
  8. package/dist/src/commands/feature-flags.d.ts +14 -0
  9. package/dist/src/commands/feature-flags.js +116 -0
  10. package/dist/src/commands/feature-flags.js.map +1 -0
  11. package/dist/src/commands/integrations.js +55 -2
  12. package/dist/src/commands/integrations.js.map +1 -1
  13. package/dist/src/commands/prompts.js +58 -3
  14. package/dist/src/commands/prompts.js.map +1 -1
  15. package/dist/src/commands/rule-sets.d.ts +1 -1
  16. package/dist/src/commands/rule-sets.js +1 -0
  17. package/dist/src/commands/rule-sets.js.map +1 -1
  18. package/dist/src/commands/sync.d.ts +69 -12
  19. package/dist/src/commands/sync.js +497 -114
  20. package/dist/src/commands/sync.js.map +1 -1
  21. package/dist/src/commands/users.js +92 -1
  22. package/dist/src/commands/users.js.map +1 -1
  23. package/dist/src/commands/webhooks.js +59 -2
  24. package/dist/src/commands/webhooks.js.map +1 -1
  25. package/dist/src/commands/workflows.js +18 -19
  26. package/dist/src/commands/workflows.js.map +1 -1
  27. package/dist/src/lib/api-client.d.ts +51 -4
  28. package/dist/src/lib/api-client.js +71 -7
  29. package/dist/src/lib/api-client.js.map +1 -1
  30. package/dist/src/lib/config-object-descriptor.js +6 -14
  31. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  32. package/dist/src/lib/config-surface.d.ts +2 -1
  33. package/dist/src/lib/config-surface.js +5 -1
  34. package/dist/src/lib/config-surface.js.map +1 -1
  35. package/dist/src/lib/generated-config-surfaces.d.ts +33 -0
  36. package/dist/src/lib/generated-config-surfaces.js +240 -55
  37. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  38. package/dist/src/lib/log-inspection.d.ts +2 -2
  39. package/dist/src/lib/log-inspection.js +3 -3
  40. package/dist/src/lib/log-inspection.js.map +1 -1
  41. package/dist/src/lib/object-status-filter.d.ts +22 -0
  42. package/dist/src/lib/object-status-filter.js +45 -0
  43. package/dist/src/lib/object-status-filter.js.map +1 -0
  44. package/dist/src/lib/swift-codegen/generator.js +2 -4
  45. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  46. package/dist/src/lib/sync-resource-types.js +13 -2
  47. package/dist/src/lib/sync-resource-types.js.map +1 -1
  48. package/dist/src/lib/workflow-defaults.d.ts +1 -2
  49. package/dist/src/lib/workflow-defaults.js +3 -3
  50. package/dist/src/lib/workflow-defaults.js.map +1 -1
  51. package/dist/src/lib/workflow-payload.js +0 -6
  52. package/dist/src/lib/workflow-payload.js.map +1 -1
  53. package/dist/src/types/index.d.ts +1 -0
  54. package/package.json +1 -1
@@ -27,7 +27,7 @@ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../
27
27
  // payload builder so the diff normalizer and the wire cannot disagree.
28
28
  import { WORKFLOW_MODEL_DEFAULTS } from "../lib/workflow-defaults.js";
29
29
  // #2644 — the one definition per configuration object, vendored from the server.
30
- 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, findMisshapenTables, findUnknownDocumentKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
30
+ 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, findMisshapenTables, findUnknownDocumentKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredConfigKey, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
31
31
  import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
32
32
  import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfaces.js";
33
33
  import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
@@ -151,7 +151,7 @@ export function shouldPushExpandedFile(parsed, storedHash) {
151
151
  * value that pushes successfully hashes equal to its read-back — see
152
152
  * `normalizeWorkflowTomlDefaults`.
153
153
  */
154
- const WORKFLOW_LOWERCASED_FIELDS = ["status", "dequeueOrder"];
154
+ const WORKFLOW_LOWERCASED_FIELDS = ["dequeueOrder"];
155
155
  const WORKFLOW_FLOORED_FIELDS = [
156
156
  "perUserMaxRunning",
157
157
  "perUserMaxQueued",
@@ -221,15 +221,15 @@ export function resolveActiveWorkflowConfig(workflow, configs) {
221
221
  * server's accepted-value canonicalization
222
222
  * (`src/workflows/config/workflow-field-handlers.ts`). A value the server
223
223
  * would REJECT is left alone: push fails loudly for those, so there is
224
- * nothing to preview. #2743 — without this, `status = "ACTIVE"` or
224
+ * nothing to preview. #2743 — without this, `dequeueOrder = "LIFO"` or
225
225
  * `perUserMaxRunning = 4.9` pushes fine and then reports Modified forever.
226
226
  * 4. Fill the model defaults (`WORKFLOW_MODEL_DEFAULTS`) for what the file
227
227
  * omits. `perAppMax*` / `queueTtlSeconds` joined that list in #1177: once
228
228
  * the pull serializer emitted them (a GET always returns them, they carry
229
229
  * a non-null model default), an unchanged workflow hashed unequal to a
230
230
  * local TOML that omits them — a false `modified` that made `config pull`
231
- * rewrite the file just to inject defaults. `status` joined it in #2743,
232
- * when push began sending `draft` for an omitted status.
231
+ * rewrite the file just to inject defaults. (`status` was on this list
232
+ * until #2803 took availability off the config wire entirely.)
233
233
  * 5. Fill `activeConfigName` with the name the server itself creates
234
234
  * (`default`), so a hand-authored file that never named a config compares
235
235
  * equal to the workflow that pushing it produces (#2743). This is the
@@ -1016,7 +1016,7 @@ export function workflowActiveConfigNameUnresolvable(configDir, key, localParsed
1016
1016
  * repeats forever because no push changes it. But the repo does not describe
1017
1017
  * which configuration is live, and only `config pull` records it, so the fact is
1018
1018
  * carried as a hint on the Synced row — the same way an operational disable is
1019
- * (`operationalDisableHint`). The server-created `default` is the shape a
1019
+ * (`outOfServiceHint`). The server-created `default` is the shape a
1020
1020
  * hand-authored file already means, so it is silent.
1021
1021
  *
1022
1022
  * Pure and non-throwing, like every other diff predicate. Returns the existing
@@ -1032,16 +1032,70 @@ export function unmanagedActiveConfigHint(localParsed, workflow, configs, existi
1032
1032
  `it alone — run \`config pull\` to record it in [workflow].activeConfigName`;
1033
1033
  return existingHint ? `${existingHint}; ${note}` : note;
1034
1034
  }
1035
+ /**
1036
+ * Drop the archived rows from a pull listing (#2803).
1037
+ *
1038
+ * A tombstone is retired, not configuration. Exporting one wrote a file the
1039
+ * repo then claimed to describe — and, once `status` left the TOML surface, a
1040
+ * file whose very next push would fail on the `status` line it carried.
1041
+ * `config push --prune` is the path that reclaims an archived row; pull's job
1042
+ * is to describe what is configured.
1043
+ */
1044
+ export function skipArchivedForPull(items) {
1045
+ return (items || []).filter((item) => String(item?.status ?? "").toLowerCase() !== "archived");
1046
+ }
1047
+ /**
1048
+ * The sync-state entries a pull keeps for the archived rows it did NOT export
1049
+ * (#2803).
1050
+ *
1051
+ * Pull removes a tombstone's file, and prune candidates come only from prior
1052
+ * sync state — so dropping the state entry with the file would strand the row:
1053
+ * no file to delete, no managed key to prune, and (for webhooks and cron
1054
+ * triggers) a cap slot and a reserved key nothing could ever reclaim, which is
1055
+ * the exact failure #2232 fixed. So the pull records the tombstone instead: no
1056
+ * file, but a managed entry carrying the archived row's CURRENT `modifiedAt`,
1057
+ * so the next opt-in, confirmed `config push --prune` point-reads it, finds it
1058
+ * unchanged, and hard-deletes it — the reclamation path the model promises.
1059
+ *
1060
+ * Only keys the config tree already managed are recorded. A row archived
1061
+ * server-side that this repo never described is not this repo's to delete.
1062
+ *
1063
+ * Pure, so it is unit-testable without a live server.
1064
+ */
1065
+ export function archivedTombstoneEntries(items, select, managedKeys) {
1066
+ const managed = new Set(managedKeys);
1067
+ const entries = {};
1068
+ for (const item of items || []) {
1069
+ if (String(item?.status ?? "").toLowerCase() !== "archived")
1070
+ continue;
1071
+ const key = select.keyOf(item);
1072
+ const id = select.idOf(item);
1073
+ if (!key || !id || !managed.has(key))
1074
+ continue;
1075
+ entries[key] = { id, modifiedAt: item.modifiedAt || "" };
1076
+ }
1077
+ return entries;
1078
+ }
1035
1079
  /**
1036
1080
  * The `config diff` annotation for an object an operator took out of service
1037
- * (issue #2645, criterion 9).
1081
+ * (issue #2645, criterion 9; #2803).
1082
+ *
1083
+ * Availability is deliberately not a TOML key, so an object that is out of
1084
+ * service is NOT drift — the committed configuration still describes it
1085
+ * exactly. Saying so out loud is the point: the reason the control exists is
1086
+ * that an out-of-band write used to make `config diff` report a difference
1087
+ * nobody intentionally created (#1976). Reporting it as a hint on an
1088
+ * otherwise-`Synced` row is what keeps that promise visible instead of merely
1089
+ * true.
1090
+ *
1091
+ * Reads either spelling: the canonical `status: "inactive"` for a type that has
1092
+ * cut over to #2803's single control, and the superseded
1093
+ * `operationallyDisabled` boolean for one that has not yet.
1038
1094
  *
1039
- * `operationallyDisabled` is deliberately not a TOML key, so a disabled object
1040
- * is NOT drift — the committed configuration still describes it exactly. Saying
1041
- * so out loud is the point: the reason the toggle exists is that an out-of-band
1042
- * write used to make `config diff` report a difference nobody intentionally
1043
- * created (#1976). Reporting it as a hint on an otherwise-`Synced` row is what
1044
- * keeps that promise visible instead of merely true.
1095
+ * An ARCHIVED row gets its own annotation. `config pull` no longer exports
1096
+ * tombstones, so without one an archived webhook or integration whose file is
1097
+ * still in the tree read as plain `Synced` — the one status where "the server
1098
+ * has exactly what the file says" is most misleading.
1045
1099
  *
1046
1100
  * When the row already carries a hint — a real content difference — the toggle
1047
1101
  * is APPENDED rather than substituted: the drift is the part that needs acting
@@ -1049,12 +1103,19 @@ export function unmanagedActiveConfigHint(localParsed, workflow, configs, existi
1049
1103
  *
1050
1104
  * Pure, so it is unit-testable without a live server.
1051
1105
  */
1052
- export function operationalDisableHint(remote, existingHint) {
1053
- if (remote?.operationallyDisabled !== true)
1106
+ export function outOfServiceHint(remote, existingHint) {
1107
+ const stored = String(remote?.status ?? "").toLowerCase();
1108
+ if (stored === "archived") {
1109
+ return existingHint
1110
+ ? `${existingHint}; archived`
1111
+ : "archived; `config push --prune` reclaims it";
1112
+ }
1113
+ const outOfService = remote?.operationallyDisabled === true || stored === "inactive";
1114
+ if (!outOfService)
1054
1115
  return existingHint;
1055
1116
  return existingHint
1056
- ? `${existingHint}; operationally disabled`
1057
- : "operationally disabled; configuration unchanged";
1117
+ ? `${existingHint}; inactive`
1118
+ : "inactive; configuration unchanged";
1058
1119
  }
1059
1120
  /**
1060
1121
  * Serialize an integration DETAIL response to its `integrations/<key>.toml`.
@@ -1829,23 +1890,6 @@ export function rewriteWorkflowSchemasToNative(rawToml, workflowKey = "", log =
1829
1890
  * are checked one by one and the unknown keys reported together, so a typo in
1830
1891
  * the third `[[configs]]` block is as loud as one in the first.
1831
1892
  */
1832
- /**
1833
- * Keys deliberately RETIRED from a table's TOML surface (#2645), each with the
1834
- * guidance the generic unrecognized-key hint would get wrong: "upgrade the
1835
- * CLI" is exactly backwards for a key this CLI removed on purpose. Every
1836
- * pre-#2645 pull wrote a `state` line into cron-trigger files, so existing
1837
- * repos hit this on their first push after upgrading — the failure is
1838
- * deliberate (sponsor decision at the approval gate, 2026-08-15): the line has
1839
- * to be removed, not silently ignored, so the file never claims a pause state
1840
- * push does not apply.
1841
- */
1842
- const RETIRED_TOML_KEYS = {
1843
- cronTrigger: {
1844
- state: "`state` is no longer authored in TOML (#2645). Pausing is " +
1845
- "`primitive cron-triggers pause|resume`, and the remaining lifecycle " +
1846
- "values are server-owned. Delete the line and push again.",
1847
- },
1848
- };
1849
1893
  export function configUnknownKeyError(filePath, tomlData, table) {
1850
1894
  const at = table.tomlPath.reduce((node, segment) => (node == null ? node : node[segment]), tomlData);
1851
1895
  const entries = table.repeated
@@ -1860,13 +1904,20 @@ export function configUnknownKeyError(filePath, tomlData, table) {
1860
1904
  }
1861
1905
  if (unknown.size === 0)
1862
1906
  return null;
1907
+ // #2645/#2803 — a key this CLI removed on purpose gets the guidance stated
1908
+ // once beside the definitions (`src/config-surface/retired-keys.ts`), not the
1909
+ // generic "check the spelling, or upgrade the CLI" hint, which is exactly
1910
+ // backwards for it. The server's create/update handlers read the same table.
1863
1911
  const prefix = table.tomlPath.join(".");
1864
- const retired = RETIRED_TOML_KEYS[prefix] ?? {};
1865
- const retiredHere = [...unknown].filter((k) => k in retired).sort();
1866
- const trulyUnknown = [...unknown].filter((k) => !(k in retired)).sort();
1912
+ const retiredHere = [...unknown]
1913
+ .filter((k) => retiredConfigKey(prefix, k))
1914
+ .sort();
1915
+ const trulyUnknown = [...unknown]
1916
+ .filter((k) => !retiredConfigKey(prefix, k))
1917
+ .sort();
1867
1918
  const messages = [];
1868
1919
  for (const key of retiredHere) {
1869
- messages.push(`${filePath}: ${prefix}.${key} — ${retired[key]}`);
1920
+ messages.push(`${filePath}: ${prefix}.${key} — ${retiredConfigKey(prefix, key).toml}`);
1870
1921
  }
1871
1922
  if (trulyUnknown.length > 0) {
1872
1923
  messages.push(unrecognizedPushKeysMessage(filePath, table.tomlPath, trulyUnknown));
@@ -2096,7 +2147,7 @@ export function serializeWorkflow(workflow, draft, configs, logger) {
2096
2147
  // `key` and `activeConfigName` are authored keys that are NOT part of the
2097
2148
  // write field surface — declared in the definition's `tomlOnlyKeys`, with the
2098
2149
  // reason there. They keep their historical positions (first, and right after
2099
- // `status`) so migrating to the definition does not reshuffle existing files.
2150
+ // `description`) so migrating to the definition does not reshuffle files.
2100
2151
  const workflowTable = { key: workflow.workflowKey };
2101
2152
  for (const field of WORKFLOW_TABLE.fields) {
2102
2153
  const produce = PULL_VALUE[field.field];
@@ -2116,7 +2167,10 @@ export function serializeWorkflow(workflow, draft, configs, logger) {
2116
2167
  ? undefined
2117
2168
  : value;
2118
2169
  }
2119
- if (field.field === "status") {
2170
+ // #2803 — this used to hang off `status`, which has left the field surface
2171
+ // (availability is server-owned). `description` is now the key it follows,
2172
+ // which keeps `activeConfigName` in the same place in existing files.
2173
+ if (field.field === "description") {
2120
2174
  workflowTable.activeConfigName = activeConfigName;
2121
2175
  }
2122
2176
  }
@@ -2764,6 +2818,39 @@ function normalizeRuleSetRefForDiff(entity, ruleSetNameToId, label) {
2764
2818
  entity._unresolvedRuleSetName = referencedName;
2765
2819
  }
2766
2820
  }
2821
+ /**
2822
+ * A field's value as the comparison reads it — the VALUE, not its spelling
2823
+ * (#2869).
2824
+ *
2825
+ * Only a declared `number` needs this, and only because the server genuinely
2826
+ * accepts both spellings: `temperature` and `topP` are `StringField`s on the
2827
+ * model, stored as strings and returned parsed, so a file authored
2828
+ * `temperature = "0.2"` and a server holding `0.2` describe one value. Compared
2829
+ * as spellings it is a `Modified` no push can clear — push sends the string,
2830
+ * the server stores it, pull writes the number back, and the difference
2831
+ * returns — so the operator's only remedy is to overwrite the local file.
2832
+ *
2833
+ * A string that is not a number is left exactly as it is: `temperature =
2834
+ * "warm"` is a real difference from `0.2`, and one the server will reject.
2835
+ *
2836
+ * "Is a number" is decided by the grammar the SERVER reads the string with —
2837
+ * `parseFloat`, on the way back out — not by JavaScript's `Number`, which is
2838
+ * wider: `Number("0x1")` is 1 where `parseFloat("0x1")` is 0, so treating that
2839
+ * file as equal to a server holding 1 would skip a push whose payload means
2840
+ * something else, re-creating in a corner the very silence this issue is about.
2841
+ * So only what `parseFloat` consumes WHOLE — sign, digits, one dot, an
2842
+ * exponent — normalizes; anything else stays the string it is.
2843
+ */
2844
+ const DECIMAL_NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
2845
+ function comparableFieldValue(type, value) {
2846
+ if (type !== "number" || typeof value !== "string")
2847
+ return value;
2848
+ const trimmed = value.trim();
2849
+ if (!DECIMAL_NUMBER.test(trimmed))
2850
+ return value;
2851
+ const asNumber = Number(trimmed);
2852
+ return Number.isFinite(asNumber) ? asNumber : value;
2853
+ }
2767
2854
  /**
2768
2855
  * `config diff`'s hash for one configuration object, over the field set its
2769
2856
  * DEFINITION declares (#2644 criterion 8).
@@ -2785,7 +2872,7 @@ function normalizeRuleSetRefForDiff(entity, ruleSetNameToId, label) {
2785
2872
  export function projectConfigForComparison(table, entity, extras = {}) {
2786
2873
  const projected = {};
2787
2874
  for (const field of table.fields) {
2788
- const value = entity?.[field.field];
2875
+ const value = comparableFieldValue(field.type, entity?.[field.field]);
2789
2876
  if (value === undefined)
2790
2877
  continue;
2791
2878
  // #2731 — `emit: "whenSet"` says pull OMITS the key when the value is
@@ -4955,6 +5042,9 @@ export function registerConfigSyncCommands(sync) {
4955
5042
  }
4956
5043
  info(`Pulling configuration for app ${resolvedAppId}...`);
4957
5044
  try {
5045
+ // The unfiltered listings, kept so the archived rows this pull declines
5046
+ // to export can still be recorded as tombstones in sync state (#2803).
5047
+ const integrationTombstoneCandidates = [];
4958
5048
  // Fetch all data. Issue #1659: prune decides whether a local file's
4959
5049
  // entity still exists server-side, so every fetch that establishes
4960
5050
  // presence must report success or failure explicitly — an error that
@@ -4965,7 +5055,14 @@ export function registerConfigSyncCommands(sync) {
4965
5055
  // an explicit outcome instead.
4966
5056
  const [settings, integrationItems, promptItems, workflowItems, emailTemplatesOutcome] = await Promise.all([
4967
5057
  client.getAppSettings(resolvedAppId).catch(() => null),
4968
- fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
5058
+ // #2803 — an archived integration is retired, not configuration, so
5059
+ // pull does not export it (same rule as webhooks). Its tombstone is
5060
+ // recorded in sync state (see `archivedTombstoneEntries`) so
5061
+ // `--prune` can still reclaim it.
5062
+ fetchAll((p) => client.listIntegrations(resolvedAppId, p)).then((items) => {
5063
+ integrationTombstoneCandidates.push(...items);
5064
+ return skipArchivedForPull(items);
5065
+ }),
4969
5066
  fetchAll((p) => client.listPrompts(resolvedAppId, p)),
4970
5067
  fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
4971
5068
  attemptFetch(() => client.listEmailTemplates(resolvedAppId)),
@@ -5232,8 +5329,14 @@ export function registerConfigSyncCommands(sync) {
5232
5329
  };
5233
5330
  info(` Wrote integrations/${filename}`);
5234
5331
  }
5235
- // Pull webhooks
5236
- const webhookItems = await fetchAll((p) => client.listWebhooks(resolvedAppId, p));
5332
+ // Pull webhooks. #2803 — an ARCHIVED webhook is retired, not
5333
+ // configuration, so it is not exported: writing a tombstone into the
5334
+ // config tree produced a file whose own next push would fail (status
5335
+ // is no longer authorable), and left the archived row looking like
5336
+ // something the repo still describes. `--prune` is what reclaims it —
5337
+ // which is why the tombstone still gets a sync-state entry below.
5338
+ const webhookTombstoneCandidates = await fetchAll((p) => client.listWebhooks(resolvedAppId, p));
5339
+ const webhookItems = skipArchivedForPull(webhookTombstoneCandidates);
5237
5340
  const webhooks = selectPull("webhook", await Promise.all(webhookItems.map((w) => client.getWebhook(resolvedAppId, w.webhookId))), (w) => w.webhookKey);
5238
5341
  const webhooksDir = join(configDir, "webhooks");
5239
5342
  mkdirSync(webhooksDir, { recursive: true });
@@ -5872,6 +5975,73 @@ export function registerConfigSyncCommands(sync) {
5872
5975
  }
5873
5976
  }
5874
5977
  }
5978
+ // #2803 — the tombstones. This pull exported no file for an archived
5979
+ // webhook or integration and (above) removed the one a prior pull had
5980
+ // written, so without an entry here the row would drop out of managed
5981
+ // state entirely: `config push --prune` collects its candidates from
5982
+ // this map, and a row it cannot see is a row nothing can reclaim. The
5983
+ // entry carries the archived row's CURRENT `modifiedAt` so the prune
5984
+ // point-read reads "unchanged" rather than "edited out-of-band".
5985
+ for (const [slot, tombstones] of [
5986
+ [
5987
+ "integrations",
5988
+ archivedTombstoneEntries(integrationTombstoneCandidates, {
5989
+ keyOf: (i) => i.integrationKey,
5990
+ idOf: (i) => i.integrationId,
5991
+ }, Object.keys(priorEntities.integrations ?? {})),
5992
+ ],
5993
+ [
5994
+ "webhooks",
5995
+ archivedTombstoneEntries(webhookTombstoneCandidates, {
5996
+ keyOf: (w) => w.webhookKey,
5997
+ idOf: (w) => w.webhookId,
5998
+ }, Object.keys(priorEntities.webhooks ?? {})),
5999
+ ],
6000
+ ]) {
6001
+ const built = entitiesBySlot[slot];
6002
+ const resourceType = SYNC_RESOURCE_TYPES.find((rt) => rt.stateSlot === slot);
6003
+ if (!built || !resourceType)
6004
+ continue;
6005
+ for (const [key, entry] of Object.entries(tombstones)) {
6006
+ if (key in built)
6007
+ continue;
6008
+ if (!selectionIncludesKey(pullSelection, resourceType.label, key)) {
6009
+ continue;
6010
+ }
6011
+ built[key] = entry;
6012
+ info(` No ${resourceType.dir}/${resourceType.fileFromKey(key)} — ` +
6013
+ "the entity is archived; `config push --prune` reclaims it");
6014
+ }
6015
+ }
6016
+ // Cron triggers reach the same place by a different road: the app-API
6017
+ // listing filters archived rows server-side, so pull cannot see a
6018
+ // tombstone in the list at all. The managed keys the listing dropped are
6019
+ // point-read by id instead — a row that still answers is archived (a
6020
+ // real delete 404s), and gets the same tombstone entry.
6021
+ if (cronTriggersOutcome.ok) {
6022
+ const listedCronKeys = new Set(cronTriggerItems.map((t) => t.triggerKey));
6023
+ for (const [key, prior] of Object.entries((priorEntities.cronTriggers ?? {}))) {
6024
+ if (listedCronKeys.has(key))
6025
+ continue;
6026
+ if (key in finalCronTriggerEntities)
6027
+ continue;
6028
+ if (!prior?.id)
6029
+ continue;
6030
+ if (!selectionIncludesKey(pullSelection, "cron-trigger", key)) {
6031
+ continue;
6032
+ }
6033
+ const detail = await attemptFetch(() => client.getCronTrigger(resolvedAppId, prior.id));
6034
+ const row = detail.ok ? detail.value : null;
6035
+ if (String(row?.status ?? "").toLowerCase() !== "archived")
6036
+ continue;
6037
+ finalCronTriggerEntities[key] = {
6038
+ id: prior.id,
6039
+ modifiedAt: row.modifiedAt || "",
6040
+ };
6041
+ info(` No cron-triggers/${key}.toml — the trigger is archived; ` +
6042
+ "`config push --prune` reclaims it");
6043
+ }
6044
+ }
5875
6045
  // Under `--only`, every object this pull did not look at keeps its
5876
6046
  // prior state entry. Its file was left exactly as it was, so dropping
5877
6047
  // the record would make the next pull treat a managed file as
@@ -7605,11 +7775,59 @@ export function registerConfigSyncCommands(sync) {
7605
7775
  const promptsDir = join(configDir, "prompts");
7606
7776
  if (existsSync(promptsDir)) {
7607
7777
  const files = selectFiles("prompt", promptsDir, readdirSync(promptsDir).filter((f) => f.endsWith(".toml")));
7778
+ // #2869 — prompts join the live-state reconcile (#2731 B3). #2748
7779
+ // gave the type diff's comparator and left push on the manifest byte
7780
+ // hash, so the two commands answered "changed?" from different
7781
+ // sources and disagreed in both directions: a comment moved the bytes
7782
+ // and push planned an update diff read as Synced, and a file whose
7783
+ // bytes had not moved was skipped however far its CONTENT had drifted
7784
+ // from the server, so a reported `Modified` could never be pushed
7785
+ // away. Both are the same fix — push decides from the projection diff
7786
+ // compares, against the entity diff compares it to.
7787
+ const promptSpec = configDiffSpec("prompt");
7788
+ // The list says WHICH prompts the server has (and under which id);
7789
+ // the `[[configs]]` the comparison needs are only on the detail, so
7790
+ // that is fetched inside the loop for the selected file that needs
7791
+ // it. A `--only` push therefore reads only what it was asked to push.
7792
+ //
7793
+ // A failed list is NOT an empty list: treating it as one would send
7794
+ // every prompt down the create path. It degrades the section to the
7795
+ // manifest byte-hash gate with the type named, like every other
7796
+ // converted type.
7797
+ const livePromptIds = new Map();
7798
+ let promptsLiveOk = true;
7799
+ if (files.length > 0) {
7800
+ try {
7801
+ const listed = await fetchAll((p) => client.listPrompts(resolvedAppId, p));
7802
+ for (const p of listed) {
7803
+ if (p?.promptKey)
7804
+ livePromptIds.set(p.promptKey, p.promptId);
7805
+ }
7806
+ }
7807
+ catch (err) {
7808
+ promptsLiveOk = false;
7809
+ warnLiveUnavailable("prompts", err);
7810
+ }
7811
+ }
7812
+ /** One prompt as the server holds it NOW — the baseline after an
7813
+ * apply, and the record a late 409 is reported against. The DETAIL,
7814
+ * because the list carries no `[[configs]]`. */
7815
+ const refetchLivePrompt = async (promptId) => {
7816
+ const full = await client.getPrompt(resolvedAppId, promptId);
7817
+ return full ? { record: full } : null;
7818
+ };
7608
7819
  for (const file of files) {
7609
7820
  const filePath = join(promptsDir, file);
7610
7821
  const tomlData = parseTomlFile(filePath);
7611
- const prompt = tomlData.prompt || {};
7612
- const key = prompt.key || basename(file, ".toml");
7822
+ const key = tomlData.prompt?.key || basename(file, ".toml");
7823
+ // The gate re-parses the DOCUMENT, so the key the file left to its
7824
+ // name has to be in it (see `withDerivedIdentity`): `displayName`
7825
+ // falls back to the key, so a file stating neither would otherwise
7826
+ // project an empty name against the derived one the server holds —
7827
+ // an update no push can clear, since the update writes that same
7828
+ // derived name back. Diff injects the same value before it hashes.
7829
+ withDerivedIdentity(tomlData, "prompt", { key });
7830
+ const prompt = tomlData.prompt;
7613
7831
  const configs = tomlData.configs || [];
7614
7832
  // #2645 — resolve the `active = true` marker BEFORE anything is
7615
7833
  // written. Two marked entries is a file-level mistake, and finding
@@ -7633,16 +7851,89 @@ export function registerConfigSyncCommands(sync) {
7633
7851
  });
7634
7852
  if (promptRuleWarning)
7635
7853
  warn(` ${promptRuleWarning}`);
7636
- const existingId = syncState?.entities?.prompts?.[key]?.id;
7637
- // Skip if file hasn't changed since last sync
7638
- if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.prompts?.[key]?.contentHash)) {
7854
+ const existingEntry = syncState?.entities?.prompts?.[key];
7855
+ const livePromptId = livePromptIds.get(key);
7856
+ // A detail read that fails degrades THIS prompt to the byte-hash
7857
+ // gate, never the section — its neighbours still reconcile.
7858
+ let promptLiveOk = promptsLiveOk;
7859
+ let livePrompt = undefined;
7860
+ if (promptLiveOk && livePromptId) {
7861
+ try {
7862
+ livePrompt = await client.getPrompt(resolvedAppId, livePromptId);
7863
+ }
7864
+ catch (err) {
7865
+ promptLiveOk = false;
7866
+ warnLiveUnavailable(`prompt ${key}`, err);
7867
+ }
7868
+ }
7869
+ let gate;
7870
+ if (promptLiveOk) {
7871
+ gate = decidePushForConfig({
7872
+ spec: promptSpec,
7873
+ // Prompts carry no rule-set reference, so these maps are inert
7874
+ // for this type — shared with `stampRemoteSemanticHash` so the
7875
+ // gate and the baseline it compares against cannot diverge.
7876
+ maps: pushDiffMaps,
7877
+ localParsed: tomlData,
7878
+ live: livePrompt
7879
+ ? { record: livePrompt, modifiedAt: livePrompt.modifiedAt }
7880
+ : null,
7881
+ entry: existingEntry,
7882
+ localFileHash: computeFileHash(filePath),
7883
+ force: options.force,
7884
+ });
7885
+ if (gate.action === "skip") {
7886
+ skippedCount++;
7887
+ // The config name→ID mappings a test case may need are fetched
7888
+ // by the test legs' `loadBlockConfigs`, on the first case that
7889
+ // names one — a read that no longer depends on this file being
7890
+ // pushed.
7891
+ if (livePromptId)
7892
+ promptKeyToId.set(key, livePromptId);
7893
+ // A skip decided against live state always HAS a live prompt
7894
+ // (there is nothing to compare against otherwise), so the row
7895
+ // records the server's own id.
7896
+ if (syncState && livePromptId) {
7897
+ if (!syncState.entities.prompts)
7898
+ syncState.entities.prompts = {};
7899
+ syncState.entities.prompts[key] = {
7900
+ id: livePromptId,
7901
+ modifiedAt: livePrompt?.modifiedAt ||
7902
+ existingEntry?.modifiedAt ||
7903
+ new Date().toISOString(),
7904
+ contentHash: computeFileHash(filePath),
7905
+ // The two sides agree HERE, so this is the baseline the
7906
+ // next run classifies against — which is what stops a
7907
+ // comment-only edit from re-reporting forever.
7908
+ semanticHash: gate.remoteHash,
7909
+ };
7910
+ }
7911
+ continue;
7912
+ }
7913
+ if (gate.action === "drift" ||
7914
+ gate.action === "conflict" ||
7915
+ gate.action === "immutable") {
7916
+ recordDeclined("prompt", key, gate, existingEntry?.modifiedAt);
7917
+ continue;
7918
+ }
7919
+ }
7920
+ else if (!options.force &&
7921
+ existingEntry?.id &&
7922
+ !shouldPushFile(filePath, existingEntry.contentHash)) {
7923
+ // Degraded: the manifest byte-hash gate this section has always
7924
+ // fallen back to when the server cannot be read.
7639
7925
  skippedCount++;
7640
- promptKeyToId.set(key, existingId);
7641
- // The config name→ID mappings a test case may need are fetched by
7642
- // the test legs' `loadBlockConfigs`, on the first case that names
7643
- // one — a read that no longer depends on this file being pushed.
7926
+ promptKeyToId.set(key, existingEntry.id);
7644
7927
  continue;
7645
7928
  }
7929
+ // Live state decides create-vs-update: a prompt the manifest never
7930
+ // recorded is adopted, and one the manifest still names but the
7931
+ // server no longer has goes to the create path (#1659).
7932
+ const existingId = resolveExistingId({
7933
+ liveOk: promptLiveOk,
7934
+ liveId: livePromptId,
7935
+ manifestId: existingEntry?.id,
7936
+ });
7646
7937
  // Shared update body for both the in-manifest update branch and the
7647
7938
  // adopt-by-key recovery branch (#1006). Re-issues the prompt as an
7648
7939
  // UPDATE and syncs its configs (update existing by name, create
@@ -7663,66 +7954,119 @@ export function registerConfigSyncCommands(sync) {
7663
7954
  if (syncState?.entities?.prompts?.[key] && updated?.modifiedAt) {
7664
7955
  syncState.entities.prompts[key].modifiedAt = updated.modifiedAt;
7665
7956
  }
7666
- // Fetch full prompt to get config name→ID mappings
7667
- // (updatePrompt response doesn't include configs)
7668
- const fullPrompt = await client.getPrompt(resolvedAppId, promptId);
7669
- if (fullPrompt?.configs) {
7670
- for (const config of fullPrompt.configs) {
7671
- promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
7672
- }
7673
- }
7674
- // Update existing prompt configs with TOML values
7675
- if (fullPrompt?.configs && configs.length > 0) {
7676
- for (const tomlConfig of configs) {
7677
- const configName = tomlConfig.name;
7678
- if (!configName)
7679
- continue;
7680
- const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
7681
- if (serverConfig) {
7682
- // Update existing config
7683
- await client.updatePromptConfig(resolvedAppId, promptId, serverConfig.configId, buildPromptConfigPayload(tomlConfig, { mode: "update" }));
7957
+ // #2869 — a prompt is applied by SEVERAL requests: the prompt,
7958
+ // then each `[[configs]]` entry, then the activation. A failure
7959
+ // among them leaves the server holding PART of the file, so the
7960
+ // baseline has to follow it that far. Left at the pre-push state
7961
+ // it would say "both sides moved" on the retry, and an operator
7962
+ // would have to `--force` past a half-applied push of this run's
7963
+ // own making. The content hash still waits for the whole apply,
7964
+ // so the retry still has an edit to send.
7965
+ try {
7966
+ // Fetch full prompt to get config name→ID mappings
7967
+ // (updatePrompt response doesn't include configs)
7968
+ const fullPrompt = await client.getPrompt(resolvedAppId, promptId);
7969
+ if (fullPrompt?.configs) {
7970
+ for (const config of fullPrompt.configs) {
7971
+ promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
7684
7972
  }
7685
- else {
7686
- // Create new config that doesn't exist on server yet
7687
- const newConfig = await client.createPromptConfig(resolvedAppId, promptId, buildPromptConfigPayload(tomlConfig, { mode: "create" }));
7688
- if (newConfig?.configId) {
7689
- promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
7973
+ }
7974
+ // Update existing prompt configs with TOML values
7975
+ if (fullPrompt?.configs && configs.length > 0) {
7976
+ for (const tomlConfig of configs) {
7977
+ const configName = tomlConfig.name;
7978
+ if (!configName)
7979
+ continue;
7980
+ const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
7981
+ if (serverConfig) {
7982
+ // Update existing config
7983
+ await client.updatePromptConfig(resolvedAppId, promptId, serverConfig.configId, buildPromptConfigPayload(tomlConfig, { mode: "update" }));
7984
+ }
7985
+ else {
7986
+ // Create new config that doesn't exist on server yet
7987
+ const newConfig = await client.createPromptConfig(resolvedAppId, promptId, buildPromptConfigPayload(tomlConfig, { mode: "create" }));
7988
+ if (newConfig?.configId) {
7989
+ promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
7990
+ }
7690
7991
  }
7691
7992
  }
7993
+ info(` Synced ${configs.length} config(s) for prompt: ${key}`);
7692
7994
  }
7693
- info(` Synced ${configs.length} config(s) for prompt: ${key}`);
7694
- }
7695
- // #2645 — the `active = true` marker is authoritative on update
7696
- // too, not only on create. Without this the file could name a live
7697
- // config that was not live, which is the drift the marker exists
7698
- // to make visible in the first place.
7699
- const activeName = activeConfigNameForPrompt;
7700
- let activated = false;
7701
- if (activeName) {
7702
- const targetId = promptConfigNameToId.get(`${key}#${activeName}`);
7703
- if (!targetId) {
7704
- throw new Error(`${filePath}: active = true names configuration "${activeName}", ` +
7705
- `which has no [[configs]] entry on the server.`);
7995
+ // #2645 — the `active = true` marker is authoritative on update
7996
+ // too, not only on create. Without this the file could name a live
7997
+ // config that was not live, which is the drift the marker exists
7998
+ // to make visible in the first place.
7999
+ const activeName = activeConfigNameForPrompt;
8000
+ let activated = false;
8001
+ if (activeName) {
8002
+ const targetId = promptConfigNameToId.get(`${key}#${activeName}`);
8003
+ if (!targetId) {
8004
+ throw new Error(`${filePath}: active = true names configuration "${activeName}", ` +
8005
+ `which has no [[configs]] entry on the server.`);
8006
+ }
8007
+ if (targetId !== fullPrompt?.activeConfigId) {
8008
+ await client.activatePromptConfig(resolvedAppId, promptId, targetId);
8009
+ info(` Activated config "${activeName}" for prompt: ${key}`);
8010
+ activated = true;
8011
+ }
7706
8012
  }
7707
- if (targetId !== fullPrompt?.activeConfigId) {
7708
- await client.activatePromptConfig(resolvedAppId, promptId, targetId);
7709
- info(` Activated config "${activeName}" for prompt: ${key}`);
7710
- activated = true;
8013
+ // Everything this push owed the server has landed, so the file is
8014
+ // now applied. Activation writes the prompt record itself, so its
8015
+ // `modifiedAt` has moved again since the PATCH above — re-read it
8016
+ // rather than leave the next push to conflict on a stale token.
8017
+ const promptEntity = syncState?.entities?.prompts?.[key];
8018
+ if (promptEntity) {
8019
+ if (activated) {
8020
+ const refreshed = await client.getPrompt(resolvedAppId, promptId);
8021
+ if (refreshed?.modifiedAt) {
8022
+ promptEntity.modifiedAt = refreshed.modifiedAt;
8023
+ }
8024
+ }
8025
+ promptEntity.contentHash = computeFileHash(filePath);
8026
+ // #2869 B4 — the baseline reads back what the server now holds,
8027
+ // prompt and configs together. The PATCH response carries no
8028
+ // `[[configs]]`, and the entries were applied by their own
8029
+ // requests after it, so only a re-read describes the state both
8030
+ // sides now agree on; a baseline taken from the local file
8031
+ // would stamp values the handlers normalized and make the next
8032
+ // legitimate edit read as a conflict this push created.
8033
+ let applied = null;
8034
+ promptEntity.semanticHash = await stampRemoteSemanticHash(promptSpec, undefined, async () => {
8035
+ const current = await refetchLivePrompt(promptId);
8036
+ applied = current?.record ?? null;
8037
+ return current;
8038
+ });
8039
+ // #2869 — that same read says whether the update actually
8040
+ // CLOSED the difference. Push updates and creates the
8041
+ // `[[configs]]` the file lists; it deletes none and clears no
8042
+ // activation, so a file omitting an entry the server holds
8043
+ // (or naming no active config) is a difference the update
8044
+ // cannot apply. Reported as a clean update it becomes the
8045
+ // disagreement this issue is about one level down: push says
8046
+ // it applied the file, and the next `config diff` reports the
8047
+ // same row again. So name what survived the push.
8048
+ if (applied) {
8049
+ const residual = diffProjectedRecords(projectLocalConfig(promptSpec, tomlData, pushDiffMaps), projectRemoteConfig(promptSpec, applied, pushDiffMaps)).map((d) => d.field);
8050
+ if (residual.length > 0) {
8051
+ warn(` Prompt ${key} still differs from the server after this push ` +
8052
+ `(${residual.join(", ")}) — push does not delete a [[configs]] entry ` +
8053
+ `the file omits, or clear an activation, so config diff keeps ` +
8054
+ `reporting it. Run primitive config pull to take the server's ` +
8055
+ `version, or remove the configuration server-side.`);
8056
+ }
8057
+ }
7711
8058
  }
7712
8059
  }
7713
- // Everything this push owed the server has landed, so the file is
7714
- // now applied. Activation writes the prompt record itself, so its
7715
- // `modifiedAt` has moved again since the PATCH above — re-read it
7716
- // rather than leave the next push to conflict on a stale token.
7717
- const promptEntity = syncState?.entities?.prompts?.[key];
7718
- if (promptEntity) {
7719
- if (activated) {
7720
- const refreshed = await client.getPrompt(resolvedAppId, promptId);
7721
- if (refreshed?.modifiedAt) {
7722
- promptEntity.modifiedAt = refreshed.modifiedAt;
7723
- }
8060
+ catch (err) {
8061
+ const partialEntity = syncState?.entities?.prompts?.[key];
8062
+ if (partialEntity) {
8063
+ const partial = await stampRemoteSemanticHash(promptSpec, undefined, () => refetchLivePrompt(promptId));
8064
+ // A baseline that cannot be read is left alone rather than
8065
+ // cleared: the legacy signals are a better answer than none.
8066
+ if (partial)
8067
+ partialEntity.semanticHash = partial;
7724
8068
  }
7725
- promptEntity.contentHash = computeFileHash(filePath);
8069
+ throw err;
7726
8070
  }
7727
8071
  };
7728
8072
  if (existingId) {
@@ -7730,9 +8074,29 @@ export function registerConfigSyncCommands(sync) {
7730
8074
  changes.push({ type: "prompt", action: "update", key });
7731
8075
  promptKeyToId.set(key, existingId);
7732
8076
  if (!options.dryRun) {
8077
+ // A prompt adopted from live state has no manifest row yet, and
8078
+ // `applyPromptUpdate` stamps INTO that row — without it the
8079
+ // push would apply and record nothing, and the next run would
8080
+ // re-decide from no baseline at all.
8081
+ if (syncState) {
8082
+ if (!syncState.entities.prompts)
8083
+ syncState.entities.prompts = {};
8084
+ syncState.entities.prompts[key] = {
8085
+ ...(existingEntry ?? {
8086
+ modifiedAt: livePrompt?.modifiedAt || "",
8087
+ }),
8088
+ id: existingId,
8089
+ };
8090
+ }
7733
8091
  const expectedModifiedAt = options.force
7734
8092
  ? undefined
7735
- : syncState?.entities?.prompts?.[key]?.modifiedAt;
8093
+ : gate?.action === "update"
8094
+ ? // The guard is the LIVE timestamp the gate read, not the
8095
+ // manifest's: the reconcile just read the server, so this
8096
+ // closes the window between that read and this write
8097
+ // rather than conflicting on a token a prior push moved.
8098
+ gate.expectedModifiedAt
8099
+ : existingEntry?.modifiedAt;
7736
8100
  try {
7737
8101
  await applyPromptUpdate(existingId, expectedModifiedAt);
7738
8102
  }
@@ -7927,6 +8291,11 @@ export function registerConfigSyncCommands(sync) {
7927
8291
  id: created.promptId,
7928
8292
  modifiedAt,
7929
8293
  contentHash: computeFileHash(filePath),
8294
+ // #2869 B4 — the SERVER's record, read back with the
8295
+ // configs this create seeded and patched, so a value the
8296
+ // handlers normalized is not stamped as the state both
8297
+ // sides agreed on.
8298
+ semanticHash: await stampRemoteSemanticHash(promptSpec, undefined, () => refetchLivePrompt(created.promptId)),
7930
8299
  };
7931
8300
  }
7932
8301
  }
@@ -11555,12 +11924,20 @@ export function registerConfigSyncCommands(sync) {
11555
11924
  // local-only split above.
11556
11925
  const remoteOnlyStatus = (slot, key) => classifyRemoteOnly(syncState?.entities, slot, key);
11557
11926
  // Integrations
11927
+ const remoteIntegrationRecords = new Map(integrationItems.map((i) => [i.integrationKey, i]));
11558
11928
  for (const key of localIntegrations) {
11559
11929
  if (!remoteIntegrations.has(key)) {
11560
11930
  differences.push({ type: "integration", key, status: localOnlyStatus("integrations", key) });
11561
11931
  }
11562
11932
  else {
11563
- differences.push({ type: "integration", key, status: "exists" });
11933
+ // #2803 — availability is operator state, not drift, and it is the
11934
+ // same annotation every carrying type gets.
11935
+ differences.push({
11936
+ type: "integration",
11937
+ key,
11938
+ status: "exists",
11939
+ hint: outOfServiceHint(remoteIntegrationRecords.get(key)),
11940
+ });
11564
11941
  }
11565
11942
  }
11566
11943
  for (const key of remoteIntegrations) {
@@ -11615,6 +11992,9 @@ export function registerConfigSyncCommands(sync) {
11615
11992
  hint = "content not compared (comparison failed)";
11616
11993
  }
11617
11994
  }
11995
+ // #2803 — a paused/archived webhook is operator state, appended to
11996
+ // whatever the content comparison already had to say.
11997
+ hint = outOfServiceHint(detailOutcome.ok ? detailOutcome.value : undefined, hint);
11618
11998
  differences.push({ type: "webhook", key, status, hint });
11619
11999
  }
11620
12000
  for (const key of remoteWebhooks) {
@@ -11634,7 +12014,7 @@ export function registerConfigSyncCommands(sync) {
11634
12014
  type: "cron-trigger",
11635
12015
  key,
11636
12016
  status: "exists",
11637
- hint: operationalDisableHint(remoteCronTriggerRecords.get(key)),
12017
+ hint: outOfServiceHint(remoteCronTriggerRecords.get(key)),
11638
12018
  });
11639
12019
  }
11640
12020
  }
@@ -11704,6 +12084,9 @@ export function registerConfigSyncCommands(sync) {
11704
12084
  hint = "content not compared (comparison failed)";
11705
12085
  }
11706
12086
  }
12087
+ // #2803 — an inactive prompt is operator state, appended to whatever
12088
+ // the content comparison already had to say.
12089
+ hint = outOfServiceHint(detailOutcome.ok ? detailOutcome.value : undefined, hint);
11707
12090
  differences.push({ type: "prompt", key, status, hint });
11708
12091
  }
11709
12092
  for (const key of remotePrompts) {
@@ -11737,8 +12120,8 @@ export function registerConfigSyncCommands(sync) {
11737
12120
  // canonicalizes on the way in. Only an explicitly-set non-default
11738
12121
  // server value reads as Modified.
11739
12122
  const remoteWorkflowIds = new Map(workflowItems.map((w) => [w.workflowKey, w.workflowId]));
11740
- // #2645 — the list rows carry `operationallyDisabled`, so the toggle is
11741
- // readable without a second fetch per workflow.
12123
+ // #2645/#2803 — the list rows carry the canonical `status`, so
12124
+ // availability is readable without a second fetch per workflow.
11742
12125
  const remoteWorkflowRecords = new Map(workflowItems.map((w) => [w.workflowKey, w]));
11743
12126
  for (const key of localWorkflows) {
11744
12127
  if (!remoteWorkflows.has(key)) {
@@ -11823,10 +12206,10 @@ export function registerConfigSyncCommands(sync) {
11823
12206
  status = "exists";
11824
12207
  hint = "content not compared (fetch failed)";
11825
12208
  }
11826
- // #2645 criterion 9 — a disabled workflow is not drift; its TOML
11827
- // still describes it exactly. Annotate the row so the operator state
11828
- // is visible without being mistaken for a difference to reconcile.
11829
- hint = operationalDisableHint(remoteWorkflowRecords.get(key), hint);
12209
+ // #2645 criterion 9 / #2803 — an inactive workflow is not drift; its
12210
+ // TOML still describes it exactly. Annotate the row so the operator
12211
+ // state is visible without being mistaken for a difference.
12212
+ hint = outOfServiceHint(remoteWorkflowRecords.get(key), hint);
11830
12213
  differences.push({ type: "workflow", key, status, hint });
11831
12214
  }
11832
12215
  for (const key of remoteWorkflows) {