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.
@@ -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. Exported so the unit test can drive it
707
- * against a stubbed `client.listScripts`.
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 returned, so
710
- * re-running `sync pull` on an unchanged server overwrites with the
711
- * same bytes and produces the same `contentHash`. The result map
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
- const body = typeof script.body === "string" ? script.body : "";
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: items.length };
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
- // Update workflow metadata and schemas.
2695
- //
2696
- // Sync-callable ordering (#807, codex review): the metadata
2697
- // PATCH must NOT carry `syncCallable: true` here. The server
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
- throw wrapEntityError(err, "create", "workflow", key);
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
- error(err.message);
4116
- // Print structured server-side validation details (issue #684).
4117
- if (err instanceof ApiError && Array.isArray(err.details) && err.details.length > 0) {
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
- else {
4317
- differences.push({ type: "workflow", key, status: "exists" });
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
- console.log(` ${chalk.dim("=")} ${d.type}: ${d.key}`);
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);