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.
- 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 +23 -7
- package/dist/src/commands/sync.js +347 -108
- 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 +9 -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) {
|
|
@@ -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
|
|
775
|
-
* 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`.
|
|
776
777
|
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4184
|
-
//
|
|
4185
|
-
|
|
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
|