primitive-admin 1.1.0-alpha.37 → 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) {
@@ -771,17 +771,33 @@ export function parseTestCaseToml(tomlData) {
771
771
  /**
772
772
  * Pull server-side `Script` rows into `transforms/*.rhai` and record
773
773
  * each in the returned sync-state map. Issue #892 slice 7 + codex
774
- * follow-up on PR #893. Exported so the unit test can drive it
775
- * 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`.
776
777
  *
777
- * Idempotency: every call writes the body the server returned, so
778
- * re-running `sync pull` on an unchanged server overwrites with the
779
- * same bytes and produces the same `contentHash`. The result map
780
- * always reflects the current server state for the writes performed.
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.
785
+ *
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).
781
795
  *
782
796
  * Older-server graceful path: if `listScripts` rejects (e.g. older
783
797
  * server without the route), the caller catches and treats it as
784
- * "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.
785
801
  */
786
802
  export async function pullScripts(client, appId, configDir, logger = () => { }) {
787
803
  const transformsDir = join(configDir, "transforms");
@@ -795,22 +811,49 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
795
811
  catch {
796
812
  return { scriptEntities, count: 0 };
797
813
  }
814
+ let written = 0;
798
815
  for (const script of items) {
799
816
  const name = script?.name;
800
817
  if (!name)
801
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
+ }
802
843
  const filename = `${name}.rhai`;
803
844
  const filePath = join(transformsDir, filename);
804
- const body = typeof script.body === "string" ? script.body : "";
805
- writeFileSync(filePath, body);
845
+ writeFileSync(filePath, activeConfig.body);
806
846
  scriptEntities[name] = {
807
847
  id: script.scriptId,
808
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.
809
851
  contentHash: computeFileHash(filePath),
810
852
  };
853
+ written += 1;
811
854
  logger(` Wrote transforms/${filename}`);
812
855
  }
813
- return { scriptEntities, count: items.length };
856
+ return { scriptEntities, count: written };
814
857
  }
815
858
  async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
816
859
  let testCases;
@@ -2751,6 +2794,109 @@ Directory Structure:
2751
2794
  }
2752
2795
  continue;
2753
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
+ };
2754
2900
  if (existingId) {
2755
2901
  // Update existing workflow
2756
2902
  changes.push({ type: "workflow", action: "update", key });
@@ -2759,95 +2905,10 @@ Directory Structure:
2759
2905
  ? undefined
2760
2906
  : syncState?.entities?.workflows?.[key]?.modifiedAt;
2761
2907
  try {
2762
- // Update workflow metadata and schemas.
2763
- //
2764
- // Sync-callable ordering (#807, codex review): the metadata
2765
- // PATCH must NOT carry `syncCallable: true` here. The server
2766
- // re-validates `syncCallable: true` against the workflow's
2767
- // CURRENTLY-active server steps (`loadCurrentActiveSteps`),
2768
- // not the steps being pushed in the same sync. So when a
2769
- // single TOML edit both enables `syncCallable` and replaces
2770
- // sync-incompatible active steps with compatible ones, a
2771
- // combined metadata-first PATCH would be rejected against the
2772
- // stale steps. Defer `syncCallable` to a second PATCH issued
2773
- // AFTER the config/step update lands, so it validates against
2774
- // the new (compatible) active steps. `requiresClientApply`
2775
- // has no step-dependent validation, so it stays in the first
2776
- // PATCH unchanged.
2777
- const updated = await client.updateWorkflow(resolvedAppId, existingId, {
2778
- name: workflow.name,
2779
- description: workflow.description,
2780
- status: workflow.status,
2781
- accessRule: workflow.accessRule !== undefined ? (workflow.accessRule || null) : undefined,
2782
- // #1081 — workflow principal mode. Absent key → undefined
2783
- // (server leaves it untouched); explicit value/empty is
2784
- // forwarded so it round-trips.
2785
- runAs: workflow.runAs !== undefined ? (workflow.runAs || null) : undefined,
2786
- perUserMaxRunning: workflow.perUserMaxRunning,
2787
- perUserMaxQueued: workflow.perUserMaxQueued,
2788
- dequeueOrder: workflow.dequeueOrder,
2789
- // Sync-callable flags (#807): pass through as-is. An absent
2790
- // TOML key is `undefined` (dropped by JSON.stringify, so the
2791
- // server's hasOwnProperty guard leaves the value untouched);
2792
- // an explicit `false` is preserved as a meaningful value.
2793
- requiresClientApply: workflow.requiresClientApply,
2794
- inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
2795
- outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
2796
- }, expectedModifiedAt);
2797
- // Track the latest workflow `modifiedAt` for the sync-state
2798
- // write below. The first PATCH bumps it; the `syncCallable`
2799
- // PATCH (if any) bumps it again. The config/step update does
2800
- // NOT touch the workflow definition's `modifiedAt`.
2801
- let latestModifiedAt = updated?.workflow?.modifiedAt;
2802
- // Update active configuration steps (or draft for legacy).
2803
- // Issue #687: name the slot we touched so the dev-loop
2804
- // user can confirm before previewing.
2805
- let updateSlotLabel = "active config";
2806
- if (existingActiveConfigId) {
2807
- await client.updateWorkflowConfig(resolvedAppId, existingId, existingActiveConfigId, {
2808
- steps,
2809
- });
2810
- }
2811
- else {
2812
- // Fallback to draft update for legacy workflows
2813
- await client.updateWorkflowDraft(resolvedAppId, existingId, {
2814
- steps,
2815
- inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
2816
- });
2817
- updateSlotLabel = "draft (legacy)";
2818
- }
2819
- // Second PATCH: apply `syncCallable` now that the new steps
2820
- // are active (#807). Only sent when the TOML actually carries
2821
- // the key — an absent key stays `undefined` and we skip the
2822
- // call entirely, preserving the no-clobber discipline. Chain
2823
- // `expectedModifiedAt` off the first PATCH's returned value so
2824
- // optimistic concurrency stays intact (the step update above
2825
- // doesn't change the workflow definition's `modifiedAt`).
2826
- if (workflow.syncCallable !== undefined) {
2827
- const syncCallableUpdated = await client.updateWorkflow(resolvedAppId, existingId, { syncCallable: workflow.syncCallable },
2828
- // Mirror the first PATCH's concurrency posture: `--force`
2829
- // skips the check (undefined), otherwise reuse the fresh
2830
- // `modifiedAt` from the first PATCH.
2831
- options.force ? undefined : latestModifiedAt);
2832
- if (syncCallableUpdated?.workflow?.modifiedAt) {
2833
- latestModifiedAt = syncCallableUpdated.workflow.modifiedAt;
2834
- }
2835
- }
2836
- info(` Updated workflow: ${key} (${updateSlotLabel})`);
2837
- // Update sync state with new modifiedAt. Store the *expanded*
2838
- // content hash so future fragment-only edits are detected.
2839
- if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
2840
- syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
2841
- syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
2842
- }
2843
- // Fetch full workflow to get config name→ID mappings
2844
- // (updateWorkflow response doesn't include configs)
2845
- const fullWorkflow = await client.getWorkflow(resolvedAppId, existingId);
2846
- if (fullWorkflow?.configs) {
2847
- for (const config of fullWorkflow.configs) {
2848
- workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
2849
- }
2850
- }
2908
+ await applyWorkflowUpdate(existingId, {
2909
+ expectedModifiedAt,
2910
+ activeConfigId: existingActiveConfigId,
2911
+ });
2851
2912
  }
2852
2913
  catch (err) {
2853
2914
  if (err instanceof ConflictError) {
@@ -2936,7 +2997,116 @@ Directory Structure:
2936
2997
  }
2937
2998
  }
2938
2999
  catch (err) {
2939
- 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
+ }
2940
3110
  }
2941
3111
  }
2942
3112
  }
@@ -4180,13 +4350,9 @@ Directory Structure:
4180
4350
  // Don't mask the original error
4181
4351
  }
4182
4352
  }
4183
- error(err.message);
4184
- // Print structured server-side validation details (issue #684).
4185
- if (err instanceof ApiError && Array.isArray(err.details) && err.details.length > 0) {
4186
- for (const detail of err.details) {
4187
- console.error(` - ${typeof detail === "string" ? detail : JSON.stringify(detail)}`);
4188
- }
4189
- }
4353
+ // Print message + structured server-side validation details
4354
+ // (issue #684; now via the shared #1173 helper).
4355
+ printApiError(err);
4190
4356
  process.exit(1);
4191
4357
  }
4192
4358
  });
@@ -4309,6 +4475,30 @@ Directory Structure:
4309
4475
  localEmailTemplates.add(emailType);
4310
4476
  }
4311
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
+ }
4312
4502
  // Compare
4313
4503
  const differences = [];
4314
4504
  // Integrations
@@ -4464,6 +4654,55 @@ Directory Structure:
4464
4654
  differences.push({ type: "email-template", key, status: "remote only" });
4465
4655
  }
4466
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
+ }
4467
4706
  // Compare test cases for synced prompts and workflows
4468
4707
  const testCaseDiffs = [];
4469
4708
  // Helper to compare test cases for a block