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.
- package/assets/skill/skills/primitive-platform/SKILL.md +68 -6
- package/dist/bin/primitive.js +2 -0
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/config.js +11 -4
- package/dist/src/commands/config.js.map +1 -1
- package/dist/src/commands/cron-triggers.js +25 -28
- package/dist/src/commands/cron-triggers.js.map +1 -1
- package/dist/src/commands/feature-flags.d.ts +14 -0
- package/dist/src/commands/feature-flags.js +116 -0
- package/dist/src/commands/feature-flags.js.map +1 -0
- package/dist/src/commands/integrations.js +55 -2
- package/dist/src/commands/integrations.js.map +1 -1
- package/dist/src/commands/prompts.js +58 -3
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.d.ts +1 -1
- package/dist/src/commands/rule-sets.js +1 -0
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/sync.d.ts +69 -12
- package/dist/src/commands/sync.js +497 -114
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/users.js +92 -1
- package/dist/src/commands/users.js.map +1 -1
- package/dist/src/commands/webhooks.js +59 -2
- package/dist/src/commands/webhooks.js.map +1 -1
- package/dist/src/commands/workflows.js +18 -19
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +51 -4
- package/dist/src/lib/api-client.js +71 -7
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/config-object-descriptor.js +6 -14
- package/dist/src/lib/config-object-descriptor.js.map +1 -1
- package/dist/src/lib/config-surface.d.ts +2 -1
- package/dist/src/lib/config-surface.js +5 -1
- package/dist/src/lib/config-surface.js.map +1 -1
- package/dist/src/lib/generated-config-surfaces.d.ts +33 -0
- package/dist/src/lib/generated-config-surfaces.js +240 -55
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- package/dist/src/lib/log-inspection.d.ts +2 -2
- package/dist/src/lib/log-inspection.js +3 -3
- package/dist/src/lib/log-inspection.js.map +1 -1
- package/dist/src/lib/object-status-filter.d.ts +22 -0
- package/dist/src/lib/object-status-filter.js +45 -0
- package/dist/src/lib/object-status-filter.js.map +1 -0
- package/dist/src/lib/swift-codegen/generator.js +2 -4
- package/dist/src/lib/swift-codegen/generator.js.map +1 -1
- package/dist/src/lib/sync-resource-types.js +13 -2
- package/dist/src/lib/sync-resource-types.js.map +1 -1
- package/dist/src/lib/workflow-defaults.d.ts +1 -2
- package/dist/src/lib/workflow-defaults.js +3 -3
- package/dist/src/lib/workflow-defaults.js.map +1 -1
- package/dist/src/lib/workflow-payload.js +0 -6
- package/dist/src/lib/workflow-payload.js.map +1 -1
- package/dist/src/types/index.d.ts +1 -0
- 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 = ["
|
|
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, `
|
|
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`
|
|
232
|
-
*
|
|
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
|
-
* (`
|
|
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
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
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
|
|
1053
|
-
|
|
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};
|
|
1057
|
-
: "
|
|
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
|
|
1865
|
-
|
|
1866
|
-
|
|
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} — ${
|
|
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
|
-
// `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
7612
|
-
|
|
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
|
|
7637
|
-
|
|
7638
|
-
|
|
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,
|
|
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
|
-
//
|
|
7667
|
-
//
|
|
7668
|
-
|
|
7669
|
-
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
|
|
7676
|
-
|
|
7677
|
-
|
|
7678
|
-
|
|
7679
|
-
|
|
7680
|
-
|
|
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
|
-
|
|
7686
|
-
|
|
7687
|
-
|
|
7688
|
-
|
|
7689
|
-
|
|
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
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
|
|
7698
|
-
|
|
7699
|
-
|
|
7700
|
-
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
|
|
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
|
-
|
|
7708
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
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
|
-
|
|
7714
|
-
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7719
|
-
|
|
7720
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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:
|
|
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 `
|
|
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 —
|
|
11827
|
-
// still describes it exactly. Annotate the row so the operator
|
|
11828
|
-
// is visible without being mistaken for a difference
|
|
11829
|
-
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) {
|