primitive-admin 1.1.0-alpha.36 → 1.1.0-alpha.38
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/dist/src/commands/database-types.js +11 -1
- package/dist/src/commands/database-types.js.map +1 -1
- package/dist/src/commands/databases.js +6 -0
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/init.js +77 -45
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/sync.d.ts +53 -7
- package/dist/src/commands/sync.js +493 -112
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.js +16 -3
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +3 -1
- package/dist/src/lib/api-client.js +3 -2
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/generated-allowlist.js +23 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/lib/init-config.d.ts +13 -0
- package/dist/src/lib/init-config.js +19 -0
- package/dist/src/lib/init-config.js.map +1 -1
- package/dist/src/lib/output.d.ts +17 -0
- package/dist/src/lib/output.js +38 -0
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/template.d.ts +9 -5
- package/dist/src/lib/template.js +58 -45
- package/dist/src/lib/template.js.map +1 -1
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@ import { resolveSyncDir, resolveSnapshotsRoot, isAutoResolvedSyncDir, checkLegac
|
|
|
11
11
|
import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
|
|
12
12
|
import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
|
|
13
13
|
import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
|
|
14
|
-
import { success, error, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
14
|
+
import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
15
15
|
import { confirmPrompt } from "../lib/confirm-prompt.js";
|
|
16
16
|
import chalk from "chalk";
|
|
17
17
|
function ensureDir(dirPath) {
|
|
@@ -131,6 +131,74 @@ export function shouldPushExpandedFile(parsed, storedHash) {
|
|
|
131
131
|
return true;
|
|
132
132
|
return computeExpandedContentHash(parsed) !== storedHash;
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Model defaults for the *exact five* workflow-level fields that
|
|
136
|
+
* `serializeWorkflow` emits unconditionally (`sync.ts` workflow serializer:
|
|
137
|
+
* `perUserMaxRunning`, `perUserMaxQueued`, `dequeueOrder`,
|
|
138
|
+
* `requiresClientApply`, `syncCallable`). These mirror `models.yaml`
|
|
139
|
+
* (`WorkflowDefinition`): `perUserMaxRunning=4`, `perUserMaxQueued=100`,
|
|
140
|
+
* `dequeueOrder="fifo"`, `requiresClientApply=true`, `syncCallable=false`.
|
|
141
|
+
*
|
|
142
|
+
* This is deliberately NOT a generic models.yaml default importer — the model
|
|
143
|
+
* carries many defaults the serializer never writes (`perAppMax*`,
|
|
144
|
+
* `queueTtlSeconds`, …). Only the serializer-emitted surface matters for a
|
|
145
|
+
* content comparison, because the remote side is hashed via `serializeWorkflow`
|
|
146
|
+
* and therefore only ever contains these five.
|
|
147
|
+
*/
|
|
148
|
+
const WORKFLOW_SERIALIZER_DEFAULTS = {
|
|
149
|
+
perUserMaxRunning: 4,
|
|
150
|
+
perUserMaxQueued: 100,
|
|
151
|
+
dequeueOrder: "fifo",
|
|
152
|
+
requiresClientApply: true,
|
|
153
|
+
syncCallable: false,
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Normalize a parsed workflow TOML object so that any of the five
|
|
157
|
+
* serializer-emitted fields the *local* file omits are filled in with the
|
|
158
|
+
* model default. Used by `diff` so a hand-authored workflow that omits e.g.
|
|
159
|
+
* `perUserMaxRunning` compares equal to a server that defaulted it to 4
|
|
160
|
+
* (the #1175 false-positive guard) — while a server value that was
|
|
161
|
+
* *explicitly set* to a non-default (e.g. 8) still shows as Modified.
|
|
162
|
+
*
|
|
163
|
+
* Returns a shallow clone with a normalized `workflow` table; the input is
|
|
164
|
+
* not mutated. Non-workflow TOML (no `workflow` table) is returned unchanged.
|
|
165
|
+
*/
|
|
166
|
+
export function normalizeWorkflowTomlDefaults(parsed) {
|
|
167
|
+
if (!parsed || typeof parsed !== "object" || !parsed.workflow) {
|
|
168
|
+
return parsed;
|
|
169
|
+
}
|
|
170
|
+
const workflow = { ...parsed.workflow };
|
|
171
|
+
for (const [field, defaultValue] of Object.entries(WORKFLOW_SERIALIZER_DEFAULTS)) {
|
|
172
|
+
if (workflow[field] === undefined || workflow[field] === null) {
|
|
173
|
+
workflow[field] = defaultValue;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { ...parsed, workflow };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Canonical content hash of a workflow TOML for `diff`'s content comparison.
|
|
180
|
+
* Applies `normalizeWorkflowTomlDefaults` first so omitted-vs-defaulted fields
|
|
181
|
+
* don't spuriously diff, then runs the same `computeExpandedContentHash` that
|
|
182
|
+
* pull stores and push compares. Both the local file and the
|
|
183
|
+
* `serializeWorkflow`-produced remote form flow through this single function,
|
|
184
|
+
* so the two sides are normalized identically by construction.
|
|
185
|
+
*/
|
|
186
|
+
export function hashWorkflowTomlForDiff(parsed) {
|
|
187
|
+
return computeExpandedContentHash(normalizeWorkflowTomlDefaults(parsed));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Build the canonical content hash for a *server* workflow (as returned by
|
|
191
|
+
* `getWorkflow` + active `getWorkflowConfig`), mirroring exactly what a fresh
|
|
192
|
+
* `sync pull` would write to disk. Serializes via `serializeWorkflow`, parses
|
|
193
|
+
* the resulting TOML (remote serialized TOML never carries `include`s, so a
|
|
194
|
+
* plain `TOML.parse` is sufficient — no fragment path needed), then hashes
|
|
195
|
+
* through `hashWorkflowTomlForDiff` so it lines up with the local-file hash.
|
|
196
|
+
*/
|
|
197
|
+
export function hashRemoteWorkflowForDiff(workflow, draft, configs) {
|
|
198
|
+
const serialized = serializeWorkflow(workflow, draft, configs || []);
|
|
199
|
+
const parsed = TOML.parse(serialized);
|
|
200
|
+
return hashWorkflowTomlForDiff(parsed);
|
|
201
|
+
}
|
|
134
202
|
// TOML serialization helpers
|
|
135
203
|
function serializeAppSettings(settings) {
|
|
136
204
|
const data = {
|
|
@@ -703,17 +771,33 @@ export function parseTestCaseToml(tomlData) {
|
|
|
703
771
|
/**
|
|
704
772
|
* Pull server-side `Script` rows into `transforms/*.rhai` and record
|
|
705
773
|
* each in the returned sync-state map. Issue #892 slice 7 + codex
|
|
706
|
-
* follow-up on PR #893
|
|
707
|
-
* against
|
|
774
|
+
* follow-up on PR #893; body materialization fixed in issue #1196.
|
|
775
|
+
* Exported so the unit test can drive it against stubbed
|
|
776
|
+
* `client.listScripts` / `client.getScript`.
|
|
777
|
+
*
|
|
778
|
+
* Two-call contract: `listScripts` returns the `Script` HEADER only
|
|
779
|
+
* (no `body` — see `serializeScript` in `src/admin-api.ts`). The Rhai
|
|
780
|
+
* source lives on a versioned `ScriptConfig`. So for each listed
|
|
781
|
+
* script we fetch the full record with `getScript`, which returns the
|
|
782
|
+
* header plus serialized `configs` (each carrying its `body`), and
|
|
783
|
+
* write the ACTIVE config's body. Reading the body off the list shape
|
|
784
|
+
* (the old behavior) always produced 0-byte files.
|
|
708
785
|
*
|
|
709
|
-
* Idempotency: every call writes the body the server
|
|
710
|
-
* re-running `sync pull` on an unchanged server overwrites
|
|
711
|
-
* same bytes and produces the same `contentHash`. The result
|
|
712
|
-
* always reflects the current server state for the writes performed.
|
|
786
|
+
* Idempotency: every call writes the active-config body the server
|
|
787
|
+
* returned, so re-running `sync pull` on an unchanged server overwrites
|
|
788
|
+
* with the same bytes and produces the same `contentHash`. The result
|
|
789
|
+
* map always reflects the current server state for the writes performed.
|
|
790
|
+
*
|
|
791
|
+
* `count` is the number of files actually written, not the number of
|
|
792
|
+
* scripts listed: scripts with no active config and scripts whose
|
|
793
|
+
* config fetch fails are skipped (we never clobber a `.rhai` with an
|
|
794
|
+
* empty file).
|
|
713
795
|
*
|
|
714
796
|
* Older-server graceful path: if `listScripts` rejects (e.g. older
|
|
715
797
|
* server without the route), the caller catches and treats it as
|
|
716
|
-
* "no scripts to pull", leaving the directory empty.
|
|
798
|
+
* "no scripts to pull", leaving the directory empty. A per-script
|
|
799
|
+
* `getScript` failure is caught and that one script is skipped without
|
|
800
|
+
* aborting the rest of the pull.
|
|
717
801
|
*/
|
|
718
802
|
export async function pullScripts(client, appId, configDir, logger = () => { }) {
|
|
719
803
|
const transformsDir = join(configDir, "transforms");
|
|
@@ -727,22 +811,49 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
|
|
|
727
811
|
catch {
|
|
728
812
|
return { scriptEntities, count: 0 };
|
|
729
813
|
}
|
|
814
|
+
let written = 0;
|
|
730
815
|
for (const script of items) {
|
|
731
816
|
const name = script?.name;
|
|
732
817
|
if (!name)
|
|
733
818
|
continue;
|
|
819
|
+
// The body lives on the active `ScriptConfig`, not the header. Skip
|
|
820
|
+
// scripts with no active config rather than writing an empty file.
|
|
821
|
+
if (!script.activeConfigId) {
|
|
822
|
+
logger(` Skipped transforms/${name}.rhai (no active config)`);
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
// Fetch the full script (header + serialized configs with bodies).
|
|
826
|
+
// Tolerate a per-script failure: skip this one, keep the rest.
|
|
827
|
+
let full;
|
|
828
|
+
try {
|
|
829
|
+
full = await client.getScript(appId, script.scriptId);
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
logger(` Skipped transforms/${name}.rhai (could not fetch config)`);
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
const configs = Array.isArray(full?.configs) ? full.configs : [];
|
|
836
|
+
const activeConfigId = full?.activeConfigId ?? script.activeConfigId;
|
|
837
|
+
const activeConfig = configs.find((c) => c.configId === activeConfigId) ||
|
|
838
|
+
configs.find((c) => c.status === "active");
|
|
839
|
+
if (!activeConfig || typeof activeConfig.body !== "string") {
|
|
840
|
+
logger(` Skipped transforms/${name}.rhai (no active config body)`);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
734
843
|
const filename = `${name}.rhai`;
|
|
735
844
|
const filePath = join(transformsDir, filename);
|
|
736
|
-
|
|
737
|
-
writeFileSync(filePath, body);
|
|
845
|
+
writeFileSync(filePath, activeConfig.body);
|
|
738
846
|
scriptEntities[name] = {
|
|
739
847
|
id: script.scriptId,
|
|
740
848
|
modifiedAt: script.modifiedAt || new Date().toISOString(),
|
|
849
|
+
// Recompute from the written file (not the server's contentHash) so
|
|
850
|
+
// it matches what `sync push`'s `shouldPushFile` gate recomputes.
|
|
741
851
|
contentHash: computeFileHash(filePath),
|
|
742
852
|
};
|
|
853
|
+
written += 1;
|
|
743
854
|
logger(` Wrote transforms/${filename}`);
|
|
744
855
|
}
|
|
745
|
-
return { scriptEntities, count:
|
|
856
|
+
return { scriptEntities, count: written };
|
|
746
857
|
}
|
|
747
858
|
async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
|
|
748
859
|
let testCases;
|
|
@@ -2683,6 +2794,109 @@ Directory Structure:
|
|
|
2683
2794
|
}
|
|
2684
2795
|
continue;
|
|
2685
2796
|
}
|
|
2797
|
+
// Shared update body for both the in-manifest update branch and the
|
|
2798
|
+
// adopt-by-key recovery branch (#1174). The body is large and
|
|
2799
|
+
// ordering-sensitive (metadata PATCH → config/draft steps →
|
|
2800
|
+
// deferred `syncCallable` PATCH); both call sites MUST run it
|
|
2801
|
+
// identically, so it lives here as a single closure rather than
|
|
2802
|
+
// being duplicated. `activeConfigId` is passed in explicitly: the
|
|
2803
|
+
// update branch reads it from sync state, while the adopt branch
|
|
2804
|
+
// seeds it from a fresh `getWorkflow` (sync state has none for an
|
|
2805
|
+
// out-of-manifest workflow).
|
|
2806
|
+
const applyWorkflowUpdate = async (workflowId, opts) => {
|
|
2807
|
+
const { expectedModifiedAt, activeConfigId } = opts;
|
|
2808
|
+
// Update workflow metadata and schemas.
|
|
2809
|
+
//
|
|
2810
|
+
// Sync-callable ordering (#807, codex review): the metadata
|
|
2811
|
+
// PATCH must NOT carry `syncCallable: true` here. The server
|
|
2812
|
+
// re-validates `syncCallable: true` against the workflow's
|
|
2813
|
+
// CURRENTLY-active server steps (`loadCurrentActiveSteps`),
|
|
2814
|
+
// not the steps being pushed in the same sync. So when a
|
|
2815
|
+
// single TOML edit both enables `syncCallable` and replaces
|
|
2816
|
+
// sync-incompatible active steps with compatible ones, a
|
|
2817
|
+
// combined metadata-first PATCH would be rejected against the
|
|
2818
|
+
// stale steps. Defer `syncCallable` to a second PATCH issued
|
|
2819
|
+
// AFTER the config/step update lands, so it validates against
|
|
2820
|
+
// the new (compatible) active steps. `requiresClientApply`
|
|
2821
|
+
// has no step-dependent validation, so it stays in the first
|
|
2822
|
+
// PATCH unchanged.
|
|
2823
|
+
const updated = await client.updateWorkflow(resolvedAppId, workflowId, {
|
|
2824
|
+
name: workflow.name,
|
|
2825
|
+
description: workflow.description,
|
|
2826
|
+
status: workflow.status,
|
|
2827
|
+
accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
|
|
2828
|
+
// #1081 — workflow principal mode. Absent key → undefined
|
|
2829
|
+
// (server leaves it untouched); explicit value/empty is
|
|
2830
|
+
// forwarded so it round-trips.
|
|
2831
|
+
runAs: workflow.runAs !== undefined ? (workflow.runAs || null) : undefined,
|
|
2832
|
+
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
2833
|
+
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
2834
|
+
dequeueOrder: workflow.dequeueOrder,
|
|
2835
|
+
// Sync-callable flags (#807): pass through as-is. An absent
|
|
2836
|
+
// TOML key is `undefined` (dropped by JSON.stringify, so the
|
|
2837
|
+
// server's hasOwnProperty guard leaves the value untouched);
|
|
2838
|
+
// an explicit `false` is preserved as a meaningful value.
|
|
2839
|
+
requiresClientApply: workflow.requiresClientApply,
|
|
2840
|
+
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
2841
|
+
outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
|
|
2842
|
+
}, expectedModifiedAt);
|
|
2843
|
+
// Track the latest workflow `modifiedAt` for the sync-state
|
|
2844
|
+
// write below. The first PATCH bumps it; the `syncCallable`
|
|
2845
|
+
// PATCH (if any) bumps it again. The config/step update does
|
|
2846
|
+
// NOT touch the workflow definition's `modifiedAt`.
|
|
2847
|
+
let latestModifiedAt = updated?.workflow?.modifiedAt;
|
|
2848
|
+
// Update active configuration steps (or draft for legacy).
|
|
2849
|
+
// Issue #687: name the slot we touched so the dev-loop
|
|
2850
|
+
// user can confirm before previewing.
|
|
2851
|
+
let updateSlotLabel = "active config";
|
|
2852
|
+
if (activeConfigId) {
|
|
2853
|
+
await client.updateWorkflowConfig(resolvedAppId, workflowId, activeConfigId, {
|
|
2854
|
+
steps,
|
|
2855
|
+
});
|
|
2856
|
+
}
|
|
2857
|
+
else {
|
|
2858
|
+
// Fallback to draft update for legacy workflows
|
|
2859
|
+
await client.updateWorkflowDraft(resolvedAppId, workflowId, {
|
|
2860
|
+
steps,
|
|
2861
|
+
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
2862
|
+
});
|
|
2863
|
+
updateSlotLabel = "draft (legacy)";
|
|
2864
|
+
}
|
|
2865
|
+
// Second PATCH: apply `syncCallable` now that the new steps
|
|
2866
|
+
// are active (#807). Only sent when the TOML actually carries
|
|
2867
|
+
// the key — an absent key stays `undefined` and we skip the
|
|
2868
|
+
// call entirely, preserving the no-clobber discipline. Chain
|
|
2869
|
+
// `expectedModifiedAt` off the first PATCH's returned value so
|
|
2870
|
+
// optimistic concurrency stays intact (the step update above
|
|
2871
|
+
// doesn't change the workflow definition's `modifiedAt`).
|
|
2872
|
+
if (workflow.syncCallable !== undefined) {
|
|
2873
|
+
const syncCallableUpdated = await client.updateWorkflow(resolvedAppId, workflowId, { syncCallable: workflow.syncCallable },
|
|
2874
|
+
// Mirror the first PATCH's concurrency posture: `--force`
|
|
2875
|
+
// skips the check (undefined), otherwise reuse the fresh
|
|
2876
|
+
// `modifiedAt` from the first PATCH.
|
|
2877
|
+
options.force ? undefined : latestModifiedAt);
|
|
2878
|
+
if (syncCallableUpdated?.workflow?.modifiedAt) {
|
|
2879
|
+
latestModifiedAt = syncCallableUpdated.workflow.modifiedAt;
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
info(` Updated workflow: ${key} (${updateSlotLabel})`);
|
|
2883
|
+
// Update sync state with new modifiedAt. Store the *expanded*
|
|
2884
|
+
// content hash so future fragment-only edits are detected. The
|
|
2885
|
+
// adopt branch seeds the entity record before calling this, so
|
|
2886
|
+
// the entry exists for both call sites.
|
|
2887
|
+
if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
|
|
2888
|
+
syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
|
|
2889
|
+
syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
|
|
2890
|
+
}
|
|
2891
|
+
// Fetch full workflow to get config name→ID mappings
|
|
2892
|
+
// (updateWorkflow response doesn't include configs)
|
|
2893
|
+
const fullWorkflow = await client.getWorkflow(resolvedAppId, workflowId);
|
|
2894
|
+
if (fullWorkflow?.configs) {
|
|
2895
|
+
for (const config of fullWorkflow.configs) {
|
|
2896
|
+
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
};
|
|
2686
2900
|
if (existingId) {
|
|
2687
2901
|
// Update existing workflow
|
|
2688
2902
|
changes.push({ type: "workflow", action: "update", key });
|
|
@@ -2691,95 +2905,10 @@ Directory Structure:
|
|
|
2691
2905
|
? undefined
|
|
2692
2906
|
: syncState?.entities?.workflows?.[key]?.modifiedAt;
|
|
2693
2907
|
try {
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
// re-validates `syncCallable: true` against the workflow's
|
|
2699
|
-
// CURRENTLY-active server steps (`loadCurrentActiveSteps`),
|
|
2700
|
-
// not the steps being pushed in the same sync. So when a
|
|
2701
|
-
// single TOML edit both enables `syncCallable` and replaces
|
|
2702
|
-
// sync-incompatible active steps with compatible ones, a
|
|
2703
|
-
// combined metadata-first PATCH would be rejected against the
|
|
2704
|
-
// stale steps. Defer `syncCallable` to a second PATCH issued
|
|
2705
|
-
// AFTER the config/step update lands, so it validates against
|
|
2706
|
-
// the new (compatible) active steps. `requiresClientApply`
|
|
2707
|
-
// has no step-dependent validation, so it stays in the first
|
|
2708
|
-
// PATCH unchanged.
|
|
2709
|
-
const updated = await client.updateWorkflow(resolvedAppId, existingId, {
|
|
2710
|
-
name: workflow.name,
|
|
2711
|
-
description: workflow.description,
|
|
2712
|
-
status: workflow.status,
|
|
2713
|
-
accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
|
|
2714
|
-
// #1081 — workflow principal mode. Absent key → undefined
|
|
2715
|
-
// (server leaves it untouched); explicit value/empty is
|
|
2716
|
-
// forwarded so it round-trips.
|
|
2717
|
-
runAs: workflow.runAs !== undefined ? (workflow.runAs || null) : undefined,
|
|
2718
|
-
perUserMaxRunning: workflow.perUserMaxRunning,
|
|
2719
|
-
perUserMaxQueued: workflow.perUserMaxQueued,
|
|
2720
|
-
dequeueOrder: workflow.dequeueOrder,
|
|
2721
|
-
// Sync-callable flags (#807): pass through as-is. An absent
|
|
2722
|
-
// TOML key is `undefined` (dropped by JSON.stringify, so the
|
|
2723
|
-
// server's hasOwnProperty guard leaves the value untouched);
|
|
2724
|
-
// an explicit `false` is preserved as a meaningful value.
|
|
2725
|
-
requiresClientApply: workflow.requiresClientApply,
|
|
2726
|
-
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
2727
|
-
outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
|
|
2728
|
-
}, expectedModifiedAt);
|
|
2729
|
-
// Track the latest workflow `modifiedAt` for the sync-state
|
|
2730
|
-
// write below. The first PATCH bumps it; the `syncCallable`
|
|
2731
|
-
// PATCH (if any) bumps it again. The config/step update does
|
|
2732
|
-
// NOT touch the workflow definition's `modifiedAt`.
|
|
2733
|
-
let latestModifiedAt = updated?.workflow?.modifiedAt;
|
|
2734
|
-
// Update active configuration steps (or draft for legacy).
|
|
2735
|
-
// Issue #687: name the slot we touched so the dev-loop
|
|
2736
|
-
// user can confirm before previewing.
|
|
2737
|
-
let updateSlotLabel = "active config";
|
|
2738
|
-
if (existingActiveConfigId) {
|
|
2739
|
-
await client.updateWorkflowConfig(resolvedAppId, existingId, existingActiveConfigId, {
|
|
2740
|
-
steps,
|
|
2741
|
-
});
|
|
2742
|
-
}
|
|
2743
|
-
else {
|
|
2744
|
-
// Fallback to draft update for legacy workflows
|
|
2745
|
-
await client.updateWorkflowDraft(resolvedAppId, existingId, {
|
|
2746
|
-
steps,
|
|
2747
|
-
inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
|
|
2748
|
-
});
|
|
2749
|
-
updateSlotLabel = "draft (legacy)";
|
|
2750
|
-
}
|
|
2751
|
-
// Second PATCH: apply `syncCallable` now that the new steps
|
|
2752
|
-
// are active (#807). Only sent when the TOML actually carries
|
|
2753
|
-
// the key — an absent key stays `undefined` and we skip the
|
|
2754
|
-
// call entirely, preserving the no-clobber discipline. Chain
|
|
2755
|
-
// `expectedModifiedAt` off the first PATCH's returned value so
|
|
2756
|
-
// optimistic concurrency stays intact (the step update above
|
|
2757
|
-
// doesn't change the workflow definition's `modifiedAt`).
|
|
2758
|
-
if (workflow.syncCallable !== undefined) {
|
|
2759
|
-
const syncCallableUpdated = await client.updateWorkflow(resolvedAppId, existingId, { syncCallable: workflow.syncCallable },
|
|
2760
|
-
// Mirror the first PATCH's concurrency posture: `--force`
|
|
2761
|
-
// skips the check (undefined), otherwise reuse the fresh
|
|
2762
|
-
// `modifiedAt` from the first PATCH.
|
|
2763
|
-
options.force ? undefined : latestModifiedAt);
|
|
2764
|
-
if (syncCallableUpdated?.workflow?.modifiedAt) {
|
|
2765
|
-
latestModifiedAt = syncCallableUpdated.workflow.modifiedAt;
|
|
2766
|
-
}
|
|
2767
|
-
}
|
|
2768
|
-
info(` Updated workflow: ${key} (${updateSlotLabel})`);
|
|
2769
|
-
// Update sync state with new modifiedAt. Store the *expanded*
|
|
2770
|
-
// content hash so future fragment-only edits are detected.
|
|
2771
|
-
if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
|
|
2772
|
-
syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
|
|
2773
|
-
syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
|
|
2774
|
-
}
|
|
2775
|
-
// Fetch full workflow to get config name→ID mappings
|
|
2776
|
-
// (updateWorkflow response doesn't include configs)
|
|
2777
|
-
const fullWorkflow = await client.getWorkflow(resolvedAppId, existingId);
|
|
2778
|
-
if (fullWorkflow?.configs) {
|
|
2779
|
-
for (const config of fullWorkflow.configs) {
|
|
2780
|
-
workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
2781
|
-
}
|
|
2782
|
-
}
|
|
2908
|
+
await applyWorkflowUpdate(existingId, {
|
|
2909
|
+
expectedModifiedAt,
|
|
2910
|
+
activeConfigId: existingActiveConfigId,
|
|
2911
|
+
});
|
|
2783
2912
|
}
|
|
2784
2913
|
catch (err) {
|
|
2785
2914
|
if (err instanceof ConflictError) {
|
|
@@ -2868,7 +2997,116 @@ Directory Structure:
|
|
|
2868
2997
|
}
|
|
2869
2998
|
}
|
|
2870
2999
|
catch (err) {
|
|
2871
|
-
|
|
3000
|
+
// Issue #1174: idempotent create — adopt-by-key on conflict.
|
|
3001
|
+
// A workflow can exist on the server but be absent from local
|
|
3002
|
+
// sync state — e.g. created out-of-band via the admin API
|
|
3003
|
+
// (#971), or orphaned by a prior push that aborted before
|
|
3004
|
+
// recording it. On retry this CREATE path hits the
|
|
3005
|
+
// `workflowKeyPerApp` unique constraint and the server
|
|
3006
|
+
// returns "workflowKey already exists" (HTTP 400; cron uses
|
|
3007
|
+
// 409). Rather than abort the whole push, look up the
|
|
3008
|
+
// existing workflow by key (the list endpoint is app-scoped,
|
|
3009
|
+
// so every item is owned by this app), verify the SAME key,
|
|
3010
|
+
// adopt its id into sync state, and re-issue as an UPDATE so
|
|
3011
|
+
// the push converges. Mirrors the cron-trigger recovery at
|
|
3012
|
+
// ~sync.ts:2627. Unexpected errors still surface via
|
|
3013
|
+
// `wrapEntityError`, matching the db-type precedent.
|
|
3014
|
+
const msg = String(err?.message || err);
|
|
3015
|
+
const isConflict = err?.statusCode === 409 || msg.includes("already exists");
|
|
3016
|
+
if (!isConflict) {
|
|
3017
|
+
throw wrapEntityError(err, "create", "workflow", key);
|
|
3018
|
+
}
|
|
3019
|
+
info(` Workflow already exists on server, adopting by key: ${key}`);
|
|
3020
|
+
let adoptedId;
|
|
3021
|
+
try {
|
|
3022
|
+
// Use fetchAll — listWorkflows is paginated, so a single
|
|
3023
|
+
// page could miss the by-key match on a large app.
|
|
3024
|
+
const items = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
|
|
3025
|
+
// Verify same key + app ownership: the list endpoint only
|
|
3026
|
+
// returns workflows for `resolvedAppId`, so a workflowKey
|
|
3027
|
+
// match is also an ownership match. Never overwrite an
|
|
3028
|
+
// unrelated resource — require exact key equality.
|
|
3029
|
+
const existing = (items || []).find((w) => w?.workflowKey === key);
|
|
3030
|
+
if (existing?.workflowId) {
|
|
3031
|
+
adoptedId = existing.workflowId;
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
catch (lookupErr) {
|
|
3035
|
+
throw wrapEntityError(new Error(`workflow "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "workflow", key);
|
|
3036
|
+
}
|
|
3037
|
+
if (!adoptedId) {
|
|
3038
|
+
// "already exists" but no matching key found on the server —
|
|
3039
|
+
// surface the original error rather than silently
|
|
3040
|
+
// overwriting an unrelated workflow.
|
|
3041
|
+
throw wrapEntityError(err, "create", "workflow", key);
|
|
3042
|
+
}
|
|
3043
|
+
// Out-of-manifest adopt: warn (info-level), matching the cron
|
|
3044
|
+
// adopt line, and seed the sync-state entity record so the
|
|
3045
|
+
// shared update body can write modifiedAt/contentHash into it.
|
|
3046
|
+
if (syncState) {
|
|
3047
|
+
if (!syncState.entities.workflows) {
|
|
3048
|
+
syncState.entities.workflows = {};
|
|
3049
|
+
}
|
|
3050
|
+
if (!syncState.entities.workflows[key]) {
|
|
3051
|
+
syncState.entities.workflows[key] = {
|
|
3052
|
+
id: adoptedId,
|
|
3053
|
+
};
|
|
3054
|
+
}
|
|
3055
|
+
else {
|
|
3056
|
+
syncState.entities.workflows[key].id = adoptedId;
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
// Seed the real activeConfigId from the server before the
|
|
3060
|
+
// shared update body runs. Sync state has none for an
|
|
3061
|
+
// out-of-manifest workflow, so without this the shared body
|
|
3062
|
+
// would wrongly fall back to `updateWorkflowDraft`.
|
|
3063
|
+
let adoptedActiveConfigId;
|
|
3064
|
+
try {
|
|
3065
|
+
const fullWorkflow = await client.getWorkflow(resolvedAppId, adoptedId);
|
|
3066
|
+
adoptedActiveConfigId =
|
|
3067
|
+
fullWorkflow?.workflow?.activeConfigId;
|
|
3068
|
+
}
|
|
3069
|
+
catch (getErr) {
|
|
3070
|
+
throw wrapEntityError(new Error(`workflow "${key}" was adopted but could not be loaded (getWorkflow failed: ${String(getErr?.message || getErr)})`), "update", "workflow", key);
|
|
3071
|
+
}
|
|
3072
|
+
// Persist the fetched activeConfigId into the seeded
|
|
3073
|
+
// sync-state record so it round-trips identically to the
|
|
3074
|
+
// CREATE path (~sync.ts:3387). Without this the *next*
|
|
3075
|
+
// content-changing push reads `existingActiveConfigId ===
|
|
3076
|
+
// undefined` (sync.ts:3187) and wrongly falls back to the
|
|
3077
|
+
// legacy `updateWorkflowDraft` instead of
|
|
3078
|
+
// `updateWorkflowConfig` — a persisted-state divergence from
|
|
3079
|
+
// a normally-created workflow.
|
|
3080
|
+
if (syncState?.entities?.workflows?.[key] &&
|
|
3081
|
+
adoptedActiveConfigId) {
|
|
3082
|
+
syncState.entities.workflows[key].activeConfigId =
|
|
3083
|
+
adoptedActiveConfigId;
|
|
3084
|
+
}
|
|
3085
|
+
// Switch to UPDATE on the adopted workflow. First adopt has no
|
|
3086
|
+
// stored modifiedAt → omit expectedModifiedAt (force the
|
|
3087
|
+
// PATCH), matching the cron adopt + the --force path. An
|
|
3088
|
+
// unexpected ConflictError here is still adoptable resilience
|
|
3089
|
+
// scope, so collect it; any other error surfaces.
|
|
3090
|
+
try {
|
|
3091
|
+
await applyWorkflowUpdate(adoptedId, {
|
|
3092
|
+
expectedModifiedAt: undefined,
|
|
3093
|
+
activeConfigId: adoptedActiveConfigId,
|
|
3094
|
+
});
|
|
3095
|
+
info(` Adopted + updated workflow: ${key}`);
|
|
3096
|
+
}
|
|
3097
|
+
catch (updErr) {
|
|
3098
|
+
if (updErr instanceof ConflictError) {
|
|
3099
|
+
conflicts.push({
|
|
3100
|
+
type: "workflow",
|
|
3101
|
+
key,
|
|
3102
|
+
serverModifiedAt: updErr.serverModifiedAt,
|
|
3103
|
+
localModifiedAt: "unknown",
|
|
3104
|
+
});
|
|
3105
|
+
}
|
|
3106
|
+
else {
|
|
3107
|
+
throw wrapEntityError(updErr, "update", "workflow", key);
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
2872
3110
|
}
|
|
2873
3111
|
}
|
|
2874
3112
|
}
|
|
@@ -4112,13 +4350,9 @@ Directory Structure:
|
|
|
4112
4350
|
// Don't mask the original error
|
|
4113
4351
|
}
|
|
4114
4352
|
}
|
|
4115
|
-
|
|
4116
|
-
//
|
|
4117
|
-
|
|
4118
|
-
for (const detail of err.details) {
|
|
4119
|
-
console.error(` - ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
|
|
4120
|
-
}
|
|
4121
|
-
}
|
|
4353
|
+
// Print message + structured server-side validation details
|
|
4354
|
+
// (issue #684; now via the shared #1173 helper).
|
|
4355
|
+
printApiError(err);
|
|
4122
4356
|
process.exit(1);
|
|
4123
4357
|
}
|
|
4124
4358
|
});
|
|
@@ -4220,12 +4454,17 @@ Directory Structure:
|
|
|
4220
4454
|
localPrompts.add(key);
|
|
4221
4455
|
}
|
|
4222
4456
|
}
|
|
4457
|
+
// #1175: capture the parsed (fragment-expanded) local workflow TOML
|
|
4458
|
+
// per key so the content-aware comparison below can hash it without a
|
|
4459
|
+
// second read.
|
|
4460
|
+
const localWorkflowParsed = new Map();
|
|
4223
4461
|
const workflowsDir = join(configDir, "workflows");
|
|
4224
4462
|
if (existsSync(workflowsDir)) {
|
|
4225
4463
|
for (const file of readdirSync(workflowsDir).filter((f) => f.endsWith(".toml"))) {
|
|
4226
4464
|
const tomlData = parseTomlFile(join(workflowsDir, file));
|
|
4227
4465
|
const key = tomlData.workflow?.key || basename(file, ".toml");
|
|
4228
4466
|
localWorkflows.add(key);
|
|
4467
|
+
localWorkflowParsed.set(key, tomlData);
|
|
4229
4468
|
}
|
|
4230
4469
|
}
|
|
4231
4470
|
const emailTemplatesDirPath = join(configDir, "email-templates");
|
|
@@ -4236,6 +4475,30 @@ Directory Structure:
|
|
|
4236
4475
|
localEmailTemplates.add(emailType);
|
|
4237
4476
|
}
|
|
4238
4477
|
}
|
|
4478
|
+
// Transforms (Rhai scripts) — issue #1196. Before this, `sync diff`
|
|
4479
|
+
// ignored scripts entirely, so a body that drifted between the local
|
|
4480
|
+
// `.rhai` and the server's active config silently read as Synced. We
|
|
4481
|
+
// list the server scripts (header only) and read the local file
|
|
4482
|
+
// contents so the comparison below can hash both sides.
|
|
4483
|
+
let scriptItemsDiff = [];
|
|
4484
|
+
try {
|
|
4485
|
+
const scriptsResult = await client.listScripts(resolvedAppId);
|
|
4486
|
+
scriptItemsDiff = scriptsResult.items || [];
|
|
4487
|
+
}
|
|
4488
|
+
catch {
|
|
4489
|
+
// Older server without the scripts route — treat as no scripts.
|
|
4490
|
+
}
|
|
4491
|
+
const remoteScripts = new Map(scriptItemsDiff
|
|
4492
|
+
.filter((s) => s?.name)
|
|
4493
|
+
.map((s) => [s.name, s]));
|
|
4494
|
+
const localScripts = new Map();
|
|
4495
|
+
const transformsDirPath = join(configDir, "transforms");
|
|
4496
|
+
if (existsSync(transformsDirPath)) {
|
|
4497
|
+
for (const file of readdirSync(transformsDirPath).filter((f) => f.endsWith(".rhai"))) {
|
|
4498
|
+
const name = basename(file, ".rhai");
|
|
4499
|
+
localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
4239
4502
|
// Compare
|
|
4240
4503
|
const differences = [];
|
|
4241
4504
|
// Integrations
|
|
@@ -4308,14 +4571,69 @@ Directory Structure:
|
|
|
4308
4571
|
differences.push({ type: "prompt", key, status: "remote only" });
|
|
4309
4572
|
}
|
|
4310
4573
|
}
|
|
4311
|
-
// Workflows
|
|
4574
|
+
// Workflows — #1175: content-aware comparison.
|
|
4575
|
+
//
|
|
4576
|
+
// For workflows present on BOTH sides we no longer stop at key
|
|
4577
|
+
// existence. We fetch the full remote workflow (mirroring `sync pull`:
|
|
4578
|
+
// `getWorkflow` + active `getWorkflowConfig` for steps), serialize it
|
|
4579
|
+
// through the SAME `serializeWorkflow` pull writes to disk, and hash
|
|
4580
|
+
// both sides through the canonical pull/push hash. A difference is
|
|
4581
|
+
// reported as `modified`, which `diff` frames as a preview of
|
|
4582
|
+
// `sync pull` (pull would rewrite the local file to match running
|
|
4583
|
+
// state). It is NOT framed as "push would send": push *preserves*
|
|
4584
|
+
// omitted remote values (sync.ts updateWorkflow), so a remote
|
|
4585
|
+
// non-default vs a local omission shows Modified even though push
|
|
4586
|
+
// wouldn't change it.
|
|
4587
|
+
//
|
|
4588
|
+
// Default-normalization (see `hashWorkflowTomlForDiff`) means a local
|
|
4589
|
+
// file that omits perUserMaxRunning/Queued etc. reads as Synced when
|
|
4590
|
+
// the server holds the model defaults (4/100/…), and only an
|
|
4591
|
+
// explicitly-set non-default server value reads as Modified.
|
|
4592
|
+
const remoteWorkflowIds = new Map(workflowItems.map((w) => [w.workflowKey, w.workflowId]));
|
|
4312
4593
|
for (const key of localWorkflows) {
|
|
4313
4594
|
if (!remoteWorkflows.has(key)) {
|
|
4314
4595
|
differences.push({ type: "workflow", key, status: "local only" });
|
|
4596
|
+
continue;
|
|
4315
4597
|
}
|
|
4316
|
-
|
|
4317
|
-
|
|
4598
|
+
// Present on both sides → compare content.
|
|
4599
|
+
const workflowId = remoteWorkflowIds.get(key);
|
|
4600
|
+
const localParsed = localWorkflowParsed.get(key);
|
|
4601
|
+
let status = "exists";
|
|
4602
|
+
let hint;
|
|
4603
|
+
try {
|
|
4604
|
+
if (!workflowId || !localParsed) {
|
|
4605
|
+
throw new Error("missing workflow id or local parse");
|
|
4606
|
+
}
|
|
4607
|
+
const remoteData = await client.getWorkflow(resolvedAppId, workflowId);
|
|
4608
|
+
// Fetch active config steps (mirrors pull at the workflows hydrate
|
|
4609
|
+
// block). Failure here is non-fatal — we degrade to key-only.
|
|
4610
|
+
const activeConfigId = remoteData.workflow?.activeConfigId;
|
|
4611
|
+
if (activeConfigId && Array.isArray(remoteData.configs)) {
|
|
4612
|
+
try {
|
|
4613
|
+
const activeConfig = await client.getWorkflowConfig(resolvedAppId, workflowId, activeConfigId);
|
|
4614
|
+
const idx = remoteData.configs.findIndex((c) => c.configId === activeConfigId);
|
|
4615
|
+
if (idx >= 0 && activeConfig) {
|
|
4616
|
+
remoteData.configs[idx] = activeConfig;
|
|
4617
|
+
}
|
|
4618
|
+
}
|
|
4619
|
+
catch {
|
|
4620
|
+
// Ignore — fall back to whatever steps the workflow GET carried.
|
|
4621
|
+
}
|
|
4622
|
+
}
|
|
4623
|
+
const remoteHash = hashRemoteWorkflowForDiff(remoteData.workflow, remoteData.draft, remoteData.configs || []);
|
|
4624
|
+
const localHash = hashWorkflowTomlForDiff(localParsed);
|
|
4625
|
+
if (remoteHash !== localHash) {
|
|
4626
|
+
status = "modified";
|
|
4627
|
+
hint = "run `sync pull` to rewrite the local TOML to match running state";
|
|
4628
|
+
}
|
|
4629
|
+
}
|
|
4630
|
+
catch {
|
|
4631
|
+
// Per-workflow fetch failure must not abort the whole diff. Degrade
|
|
4632
|
+
// gracefully to a key-only "Synced (content not compared)" caveat.
|
|
4633
|
+
status = "exists";
|
|
4634
|
+
hint = "content not compared (fetch failed)";
|
|
4318
4635
|
}
|
|
4636
|
+
differences.push({ type: "workflow", key, status, hint });
|
|
4319
4637
|
}
|
|
4320
4638
|
for (const key of remoteWorkflows) {
|
|
4321
4639
|
if (!localWorkflows.has(key)) {
|
|
@@ -4336,6 +4654,55 @@ Directory Structure:
|
|
|
4336
4654
|
differences.push({ type: "email-template", key, status: "remote only" });
|
|
4337
4655
|
}
|
|
4338
4656
|
}
|
|
4657
|
+
// Transforms (Rhai scripts) — issue #1196. Content-aware, mirroring
|
|
4658
|
+
// the workflow block: for scripts present on both sides we fetch the
|
|
4659
|
+
// server's active-config body (via `getScript`, the same call pull
|
|
4660
|
+
// uses) and compare it to the local `.rhai` contents. A difference is
|
|
4661
|
+
// reported as `modified`, framed as a preview of `sync pull`. A
|
|
4662
|
+
// per-script fetch failure degrades to key-only (Synced, not compared)
|
|
4663
|
+
// rather than aborting the whole diff.
|
|
4664
|
+
for (const [name, localBody] of localScripts) {
|
|
4665
|
+
const remote = remoteScripts.get(name);
|
|
4666
|
+
if (!remote) {
|
|
4667
|
+
differences.push({ type: "transform", key: name, status: "local only" });
|
|
4668
|
+
continue;
|
|
4669
|
+
}
|
|
4670
|
+
let status = "exists";
|
|
4671
|
+
let hint;
|
|
4672
|
+
try {
|
|
4673
|
+
if (!remote.activeConfigId) {
|
|
4674
|
+
// No active config server-side — nothing to compare against.
|
|
4675
|
+
status = "exists";
|
|
4676
|
+
hint = "content not compared (no active config)";
|
|
4677
|
+
}
|
|
4678
|
+
else {
|
|
4679
|
+
const full = await client.getScript(resolvedAppId, remote.scriptId);
|
|
4680
|
+
const configs = Array.isArray(full?.configs) ? full.configs : [];
|
|
4681
|
+
const activeConfigId = full?.activeConfigId ?? remote.activeConfigId;
|
|
4682
|
+
const activeConfig = configs.find((c) => c.configId === activeConfigId) ||
|
|
4683
|
+
configs.find((c) => c.status === "active");
|
|
4684
|
+
if (!activeConfig || typeof activeConfig.body !== "string") {
|
|
4685
|
+
status = "exists";
|
|
4686
|
+
hint = "content not compared (no active config body)";
|
|
4687
|
+
}
|
|
4688
|
+
else if (activeConfig.body !== localBody) {
|
|
4689
|
+
status = "modified";
|
|
4690
|
+
hint = "run `sync pull` to rewrite the local .rhai to match running state";
|
|
4691
|
+
}
|
|
4692
|
+
}
|
|
4693
|
+
}
|
|
4694
|
+
catch {
|
|
4695
|
+
// Per-script fetch failure must not abort the whole diff.
|
|
4696
|
+
status = "exists";
|
|
4697
|
+
hint = "content not compared (fetch failed)";
|
|
4698
|
+
}
|
|
4699
|
+
differences.push({ type: "transform", key: name, status, hint });
|
|
4700
|
+
}
|
|
4701
|
+
for (const name of remoteScripts.keys()) {
|
|
4702
|
+
if (!localScripts.has(name)) {
|
|
4703
|
+
differences.push({ type: "transform", key: name, status: "remote only" });
|
|
4704
|
+
}
|
|
4705
|
+
}
|
|
4339
4706
|
// Compare test cases for synced prompts and workflows
|
|
4340
4707
|
const testCaseDiffs = [];
|
|
4341
4708
|
// Helper to compare test cases for a block
|
|
@@ -4386,6 +4753,7 @@ Directory Structure:
|
|
|
4386
4753
|
divider();
|
|
4387
4754
|
const localOnly = differences.filter((d) => d.status === "local only");
|
|
4388
4755
|
const remoteOnly = differences.filter((d) => d.status === "remote only");
|
|
4756
|
+
const modified = differences.filter((d) => d.status === "modified");
|
|
4389
4757
|
const existing = differences.filter((d) => d.status === "exists");
|
|
4390
4758
|
if (localOnly.length > 0) {
|
|
4391
4759
|
info("Local only (will be created on push):");
|
|
@@ -4401,10 +4769,22 @@ Directory Structure:
|
|
|
4401
4769
|
}
|
|
4402
4770
|
console.log();
|
|
4403
4771
|
}
|
|
4772
|
+
// #1175: workflows whose deployed content differs from the local TOML.
|
|
4773
|
+
// Framed as a preview of `sync pull` — pull would rewrite these files
|
|
4774
|
+
// to match what's actually running (NOT "push would send").
|
|
4775
|
+
if (modified.length > 0) {
|
|
4776
|
+
warn("Modified — `sync pull` would rewrite these to match running state:");
|
|
4777
|
+
for (const d of modified) {
|
|
4778
|
+
const hint = d.hint ? ` ${chalk.dim(`(${d.hint})`)}` : "";
|
|
4779
|
+
console.log(` ${chalk.yellow("~")} ${d.type}: ${d.key}${hint}`);
|
|
4780
|
+
}
|
|
4781
|
+
console.log();
|
|
4782
|
+
}
|
|
4404
4783
|
if (existing.length > 0) {
|
|
4405
4784
|
info("Synced:");
|
|
4406
4785
|
for (const d of existing) {
|
|
4407
|
-
|
|
4786
|
+
const hint = d.hint ? ` ${chalk.dim(`(${d.hint})`)}` : "";
|
|
4787
|
+
console.log(` ${chalk.dim("=")} ${d.type}: ${d.key}${hint}`);
|
|
4408
4788
|
}
|
|
4409
4789
|
}
|
|
4410
4790
|
// Show test case differences
|
|
@@ -4427,6 +4807,7 @@ Directory Structure:
|
|
|
4427
4807
|
divider();
|
|
4428
4808
|
keyValue("Local only", localOnly.length);
|
|
4429
4809
|
keyValue("Remote only", remoteOnly.length);
|
|
4810
|
+
keyValue("Modified", modified.length);
|
|
4430
4811
|
keyValue("Synced", existing.length);
|
|
4431
4812
|
if (testCaseDiffs.length > 0) {
|
|
4432
4813
|
keyValue("Test Cases (local only)", tcLocalOnly.length);
|