primitive-admin 1.0.24 → 1.0.26

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.
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, rmdirSync, statSync } from "fs";
2
2
  import { join, basename } from "path";
3
+ import { createHash } from "crypto";
3
4
  import * as TOML from "@iarna/toml";
4
5
  import { lookup as mimeLookup } from "mime-types";
5
6
  import { ApiClient, ConflictError } from "../lib/api-client.js";
@@ -27,6 +28,15 @@ function saveSyncState(configDir, state) {
27
28
  const syncFile = join(configDir, ".primitive-sync.json");
28
29
  writeFileSync(syncFile, JSON.stringify(state, null, 2));
29
30
  }
31
+ export function computeFileHash(filePath) {
32
+ const content = readFileSync(filePath);
33
+ return createHash("sha256").update(content).digest("hex");
34
+ }
35
+ export function shouldPushFile(filePath, storedHash) {
36
+ if (!storedHash)
37
+ return true;
38
+ return computeFileHash(filePath) !== storedHash;
39
+ }
30
40
  // TOML serialization helpers
31
41
  function serializeAppSettings(settings) {
32
42
  const data = {
@@ -83,7 +93,7 @@ function serializePrompt(prompt) {
83
93
  description: config.description,
84
94
  provider: config.provider,
85
95
  model: config.model,
86
- temperature: config.temperature?.toString(),
96
+ temperature: config.temperature != null ? Number(config.temperature) : undefined,
87
97
  maxTokens: config.maxTokens,
88
98
  outputFormat: config.outputFormat,
89
99
  systemPrompt: config.systemPrompt,
@@ -96,6 +106,7 @@ function serializeWorkflow(workflow, draft, configs) {
96
106
  // Find the active config or use the first one
97
107
  const activeConfigId = workflow.activeConfigId;
98
108
  const activeConfig = configs?.find((c) => c.configId === activeConfigId) || configs?.[0];
109
+ const activeConfigName = activeConfig?.configName || "";
99
110
  // Determine which steps to use (prefer config steps if non-empty, else draft steps)
100
111
  const configSteps = activeConfig?.steps;
101
112
  const draftSteps = draft?.steps;
@@ -106,7 +117,7 @@ function serializeWorkflow(workflow, draft, configs) {
106
117
  name: workflow.name,
107
118
  description: workflow.description,
108
119
  status: workflow.status,
109
- activeConfigId: activeConfigId,
120
+ activeConfigName: activeConfigName,
110
121
  perUserMaxRunning: workflow.perUserMaxRunning,
111
122
  perUserMaxQueued: workflow.perUserMaxQueued,
112
123
  dequeueOrder: workflow.dequeueOrder,
@@ -118,7 +129,6 @@ function serializeWorkflow(workflow, draft, configs) {
118
129
  steps,
119
130
  // Include all configurations
120
131
  configs: configs?.map((config) => ({
121
- id: config.configId,
122
132
  name: config.configName,
123
133
  description: config.description,
124
134
  status: config.status,
@@ -127,6 +137,33 @@ function serializeWorkflow(workflow, draft, configs) {
127
137
  };
128
138
  return TOML.stringify(data);
129
139
  }
140
+ // Database config serialization helpers
141
+ function serializeDatabaseType(typeConfig, operations, ruleSetIdToName) {
142
+ const ruleSetName = typeConfig.ruleSetId ? (ruleSetIdToName.get(typeConfig.ruleSetId) || "") : "";
143
+ const data = {
144
+ type: {
145
+ databaseType: typeConfig.databaseType,
146
+ ruleSetName: ruleSetName,
147
+ },
148
+ };
149
+ if (typeConfig.metadataAccess) {
150
+ data.type.metadataAccess = typeConfig.metadataAccess;
151
+ }
152
+ if (typeConfig.triggers) {
153
+ data.triggers = typeConfig.triggers;
154
+ }
155
+ if (operations.length > 0) {
156
+ data.operations = operations.map((op) => ({
157
+ name: op.name,
158
+ type: op.type,
159
+ modelName: op.modelName,
160
+ access: op.access,
161
+ definition: typeof op.definition === "object" ? JSON.stringify(op.definition) : op.definition,
162
+ ...(op.params ? { params: typeof op.params === "object" ? JSON.stringify(op.params) : op.params } : {}),
163
+ }));
164
+ }
165
+ return TOML.stringify(data);
166
+ }
130
167
  function serializeEmailTemplate(template) {
131
168
  // The API returns { emailType, hasOverride, override: { subject, htmlBody, textBody }, default: { ... } }
132
169
  // We only serialize overrides (custom templates), using the override fields.
@@ -145,11 +182,138 @@ function serializeEmailTemplate(template) {
145
182
  }
146
183
  return TOML.stringify(data);
147
184
  }
185
+ function serializeRuleSet(ruleSet) {
186
+ const data = {
187
+ ruleSet: {
188
+ name: ruleSet.name,
189
+ resourceType: ruleSet.resourceType,
190
+ },
191
+ };
192
+ if (ruleSet.description) {
193
+ data.ruleSet.description = ruleSet.description;
194
+ }
195
+ // Rules are structured as { category: { operation: "expression" } }
196
+ const rules = typeof ruleSet.rules === "string" ? JSON.parse(ruleSet.rules) : (ruleSet.rules || {});
197
+ if (Object.keys(rules).length > 0) {
198
+ data.rules = rules;
199
+ }
200
+ return TOML.stringify(data);
201
+ }
202
+ function serializeGroupTypeConfig(config, ruleSetIdToName) {
203
+ const ruleSetName = config.ruleSetId ? (ruleSetIdToName.get(config.ruleSetId) || "") : "";
204
+ const data = {
205
+ groupTypeConfig: {
206
+ groupType: config.groupType,
207
+ ruleSetName: ruleSetName,
208
+ autoAddCreator: config.autoAddCreator ?? true,
209
+ },
210
+ };
211
+ return TOML.stringify(data);
212
+ }
213
+ export function parseDatabaseTypeToml(tomlData) {
214
+ const typeSection = tomlData.type || {};
215
+ const typeConfig = {
216
+ databaseType: typeSection.databaseType,
217
+ };
218
+ // Support both new key-based reference and legacy ID-based reference
219
+ if (typeSection.ruleSetName && typeSection.ruleSetName !== "") {
220
+ typeConfig._ruleSetName = typeSection.ruleSetName;
221
+ }
222
+ else if (typeSection.ruleSetId && typeSection.ruleSetId !== "") {
223
+ typeConfig.ruleSetId = typeSection.ruleSetId;
224
+ }
225
+ if (typeSection.metadataAccess) {
226
+ typeConfig.metadataAccess = typeSection.metadataAccess;
227
+ }
228
+ if (tomlData.triggers) {
229
+ typeConfig.triggers = tomlData.triggers;
230
+ }
231
+ const operations = (tomlData.operations || []).map((op) => ({
232
+ name: op.name,
233
+ type: op.type,
234
+ modelName: op.modelName,
235
+ access: op.access,
236
+ definition: typeof op.definition === "string" ? JSON.parse(op.definition) : op.definition,
237
+ params: op.params ? (typeof op.params === "string" ? JSON.parse(op.params) : op.params) : null,
238
+ }));
239
+ return { typeConfig, operations };
240
+ }
241
+ function parseRuleSetToml(tomlData) {
242
+ const ruleSetSection = tomlData.ruleSet || {};
243
+ return {
244
+ name: ruleSetSection.name,
245
+ resourceType: ruleSetSection.resourceType,
246
+ description: ruleSetSection.description || null,
247
+ rules: tomlData.rules || {},
248
+ };
249
+ }
250
+ export function parseGroupTypeConfigToml(tomlData) {
251
+ const section = tomlData.groupTypeConfig || {};
252
+ const result = {
253
+ groupType: section.groupType,
254
+ autoAddCreator: section.autoAddCreator ?? true,
255
+ };
256
+ // Support both new key-based reference and legacy ID-based reference
257
+ if (section.ruleSetName && section.ruleSetName !== "") {
258
+ result._ruleSetName = section.ruleSetName;
259
+ }
260
+ else if (section.ruleSetId && section.ruleSetId !== "") {
261
+ result.ruleSetId = section.ruleSetId;
262
+ }
263
+ return result;
264
+ }
148
265
  // Parsing helpers
149
266
  function parseTomlFile(filePath) {
150
267
  const content = readFileSync(filePath, "utf-8");
151
268
  return TOML.parse(content);
152
269
  }
270
+ /**
271
+ * Paginate through a list endpoint, collecting all items.
272
+ */
273
+ export async function fetchAll(listFn, pageSize = 100, maxPages = 100) {
274
+ const all = [];
275
+ let cursor;
276
+ let page = 0;
277
+ do {
278
+ if (++page > maxPages) {
279
+ throw new Error(`fetchAll: too many pages (>${maxPages}), aborting to prevent infinite loop`);
280
+ }
281
+ const result = await listFn({ limit: pageSize, cursor: cursor });
282
+ all.push(...result.items);
283
+ cursor = result.nextCursor || undefined;
284
+ } while (cursor);
285
+ return all;
286
+ }
287
+ /**
288
+ * Safely parse a JSON field that may be a string or already an object.
289
+ * TOML can parse inline tables as objects, so we need to handle both.
290
+ */
291
+ export function safeJsonParse(value) {
292
+ if (value === undefined || value === null)
293
+ return undefined;
294
+ if (typeof value === "string")
295
+ return JSON.parse(value);
296
+ return value; // Already an object (TOML parsed it as a table)
297
+ }
298
+ /**
299
+ * Resolve a key-based rule set name reference to an ID.
300
+ * Throws if the name cannot be resolved and throwOnMissing is true.
301
+ */
302
+ export function resolveRuleSetReference(entityConfig, ruleSetNameToId, entityLabel, options = {}) {
303
+ if (!entityConfig._ruleSetName)
304
+ return;
305
+ const resolvedRuleSetId = ruleSetNameToId.get(entityConfig._ruleSetName);
306
+ if (resolvedRuleSetId) {
307
+ entityConfig.ruleSetId = resolvedRuleSetId;
308
+ }
309
+ else {
310
+ const msg = `Rule set "${entityConfig._ruleSetName}" not found for ${entityLabel}. The entity would be created without a rule set.`;
311
+ if (options.throwOnMissing !== false) {
312
+ throw new Error(msg);
313
+ }
314
+ }
315
+ delete entityConfig._ruleSetName;
316
+ }
153
317
  // Test case helpers (exported for unit testing)
154
318
  export function slugifyTestCaseName(name) {
155
319
  return name
@@ -175,15 +339,22 @@ function getTestsDir(configDir, blockType, blockKey) {
175
339
  const typeDir = blockType === "prompt" ? "prompts" : "workflows";
176
340
  return join(configDir, typeDir, `${blockKey}.tests`);
177
341
  }
178
- export function serializeTestCase(testCase) {
342
+ export function serializeTestCase(testCase, lookupMaps) {
343
+ // Resolve ID references to key-based references
344
+ const configName = testCase.configId && lookupMaps?.configIdToName
345
+ ? (lookupMaps.configIdToName.get(testCase.configId) || "") : "";
346
+ const evaluatorPromptKey = testCase.evaluatorPromptId && lookupMaps?.promptIdToKey
347
+ ? (lookupMaps.promptIdToKey.get(testCase.evaluatorPromptId) || "") : "";
348
+ const evaluatorConfigName = testCase.evaluatorConfigId && lookupMaps?.configIdToName
349
+ ? (lookupMaps.configIdToName.get(testCase.evaluatorConfigId) || "") : "";
179
350
  const data = {
180
351
  test: {
181
352
  name: testCase.name || "",
182
353
  description: testCase.description || "",
183
354
  inputVariables: testCase.inputVariables || "{}",
184
- configId: testCase.configId || "",
185
- evaluatorPromptId: testCase.evaluatorPromptId || "",
186
- evaluatorConfigId: testCase.evaluatorConfigId || "",
355
+ configName: configName,
356
+ evaluatorPromptKey: evaluatorPromptKey,
357
+ evaluatorConfigName: evaluatorConfigName,
187
358
  expectedOutputPattern: testCase.expectedOutputPattern || "",
188
359
  expectedOutputContains: testCase.expectedOutputContains || "[]",
189
360
  expectedJsonSubset: testCase.expectedJsonSubset || "{}",
@@ -210,6 +381,18 @@ export function parseTestCaseToml(tomlData) {
210
381
  if (test.description && test.description !== "") {
211
382
  payload.description = test.description;
212
383
  }
384
+ // Support both new key-based references and legacy ID-based references
385
+ // Key-based references (portable across apps)
386
+ if (test.configName && test.configName !== "") {
387
+ payload._configName = test.configName;
388
+ }
389
+ if (test.evaluatorPromptKey && test.evaluatorPromptKey !== "") {
390
+ payload._evaluatorPromptKey = test.evaluatorPromptKey;
391
+ }
392
+ if (test.evaluatorConfigName && test.evaluatorConfigName !== "") {
393
+ payload._evaluatorConfigName = test.evaluatorConfigName;
394
+ }
395
+ // Legacy ID-based references (backward compat)
213
396
  if (test.configId && test.configId !== "") {
214
397
  payload.configId = test.configId;
215
398
  }
@@ -240,7 +423,7 @@ export function parseTestCaseToml(tomlData) {
240
423
  }
241
424
  return payload;
242
425
  }
243
- async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities) {
426
+ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
244
427
  let testCases;
245
428
  try {
246
429
  const result = await client.listTestCases(appId, blockType, blockId);
@@ -261,7 +444,7 @@ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey
261
444
  pulledSlugs.add(slug);
262
445
  // Write test case TOML
263
446
  const tomlPath = join(testsDir, `${slug}.toml`);
264
- writeFileSync(tomlPath, serializeTestCase(tc));
447
+ writeFileSync(tomlPath, serializeTestCase(tc, lookupMaps));
265
448
  // Handle attachments
266
449
  let attachmentFilenames = [];
267
450
  try {
@@ -297,6 +480,7 @@ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey
297
480
  slug,
298
481
  modifiedAt: tc.modifiedAt || tc.createdAt || new Date().toISOString(),
299
482
  attachmentFilenames: attachmentFilenames.length > 0 ? attachmentFilenames : undefined,
483
+ contentHash: computeFileHash(tomlPath),
300
484
  };
301
485
  }
302
486
  // Clean up local files for remotely-deleted test cases
@@ -326,21 +510,75 @@ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey
326
510
  }
327
511
  return testCases.length;
328
512
  }
329
- async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, syncState, dryRun, changes) {
513
+ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, syncState, dryRun, changes, resolutionMaps, options) {
514
+ let skipped = 0;
330
515
  const testsDir = getTestsDir(configDir, blockType, blockKey);
331
516
  if (!existsSync(testsDir))
332
- return;
517
+ return 0;
333
518
  const files = readdirSync(testsDir).filter((f) => f.endsWith(".toml"));
334
519
  if (files.length === 0)
335
- return;
520
+ return 0;
336
521
  const TEN_MB = 10 * 1024 * 1024;
337
522
  for (const file of files) {
338
523
  const slug = basename(file, ".toml");
339
524
  const filePath = join(testsDir, file);
340
525
  const tomlData = parseTomlFile(filePath);
341
526
  const testPayload = parseTestCaseToml(tomlData);
527
+ // Resolve key-based references to IDs
528
+ if (resolutionMaps) {
529
+ // Resolve configName → configId
530
+ if (testPayload._configName && !testPayload.configId) {
531
+ const configMapKey = `${blockKey}#${testPayload._configName}`;
532
+ const configMap = blockType === "prompt"
533
+ ? resolutionMaps.promptConfigNameToId
534
+ : resolutionMaps.workflowConfigNameToId;
535
+ const resolvedId = configMap.get(configMapKey);
536
+ if (resolvedId) {
537
+ testPayload.configId = resolvedId;
538
+ }
539
+ }
540
+ delete testPayload._configName;
541
+ // Resolve evaluatorPromptKey → evaluatorPromptId
542
+ if (testPayload._evaluatorPromptKey && !testPayload.evaluatorPromptId) {
543
+ const resolvedId = resolutionMaps.promptKeyToId.get(testPayload._evaluatorPromptKey);
544
+ if (resolvedId) {
545
+ testPayload.evaluatorPromptId = resolvedId;
546
+ }
547
+ }
548
+ const evaluatorPromptKey = testPayload._evaluatorPromptKey;
549
+ delete testPayload._evaluatorPromptKey;
550
+ // Resolve evaluatorConfigName → evaluatorConfigId
551
+ if (testPayload._evaluatorConfigName && !testPayload.evaluatorConfigId && evaluatorPromptKey) {
552
+ const configMapKey = `${evaluatorPromptKey}#${testPayload._evaluatorConfigName}`;
553
+ const resolvedId = resolutionMaps.promptConfigNameToId.get(configMapKey);
554
+ if (resolvedId) {
555
+ testPayload.evaluatorConfigId = resolvedId;
556
+ }
557
+ }
558
+ delete testPayload._evaluatorConfigName;
559
+ }
342
560
  const stateKey = `${blockType}/${blockKey}/${slug}`;
343
561
  const existingEntry = syncState?.entities?.testCases?.[stateKey];
562
+ // Skip if file hasn't changed since last sync (also check for attachment changes)
563
+ if (!options?.force && existingEntry?.id && !shouldPushFile(filePath, existingEntry.contentHash)) {
564
+ const attachDir = join(testsDir, slug);
565
+ const localAttachFiles = existsSync(attachDir)
566
+ ? readdirSync(attachDir).filter((f) => { try {
567
+ return statSync(join(attachDir, f)).isFile();
568
+ }
569
+ catch {
570
+ return false;
571
+ } })
572
+ : [];
573
+ const prevAttachFiles = existingEntry.attachmentFilenames || [];
574
+ const attachmentsChanged = localAttachFiles.length !== prevAttachFiles.length ||
575
+ localAttachFiles.some((f) => !prevAttachFiles.includes(f)) ||
576
+ prevAttachFiles.some((f) => !localAttachFiles.includes(f));
577
+ if (!attachmentsChanged) {
578
+ skipped++;
579
+ continue;
580
+ }
581
+ }
344
582
  if (existingEntry?.id) {
345
583
  // Update existing test case
346
584
  changes.push({ type: "test-case", action: "update", key: `${blockKey}/${slug}` });
@@ -348,6 +586,9 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
348
586
  try {
349
587
  await client.updateTestCase(appId, blockType, blockId, existingEntry.id, testPayload);
350
588
  info(` Updated test case: ${blockKey}/${slug}`);
589
+ if (syncState?.entities?.testCases?.[stateKey]) {
590
+ syncState.entities.testCases[stateKey].contentHash = computeFileHash(filePath);
591
+ }
351
592
  }
352
593
  catch (err) {
353
594
  warn(` Failed to update test case ${blockKey}/${slug}: ${err.message}`);
@@ -378,6 +619,7 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
378
619
  blockKey,
379
620
  slug,
380
621
  modifiedAt: created.modifiedAt || created.createdAt || new Date().toISOString(),
622
+ contentHash: computeFileHash(filePath),
381
623
  };
382
624
  }
383
625
  }
@@ -456,6 +698,7 @@ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey
456
698
  }
457
699
  }
458
700
  }
701
+ return skipped;
459
702
  }
460
703
  function formatFileSize(bytes) {
461
704
  if (bytes < 1024)
@@ -477,14 +720,17 @@ Examples:
477
720
 
478
721
  Directory Structure:
479
722
  config/
480
- .primitive-sync.json # Sync state (auto-managed)
481
- app.toml # App settings
482
- integrations/*.toml # Integration configs
483
- prompts/*.toml # Prompt configs
484
- prompts/{key}.tests/*.toml # Prompt test cases
485
- workflows/*.toml # Workflow definitions
486
- workflows/{key}.tests/*.toml # Workflow test cases
487
- email-templates/*.toml # Email template overrides
723
+ .primitive-sync.json # Sync state (auto-managed)
724
+ app.toml # App settings
725
+ integrations/*.toml # Integration configs
726
+ prompts/*.toml # Prompt configs
727
+ prompts/{key}.tests/*.toml # Prompt test cases
728
+ workflows/*.toml # Workflow definitions
729
+ workflows/{key}.tests/*.toml # Workflow test cases
730
+ database-types/*.toml # Database type configs + operations
731
+ rule-sets/*.toml # Access rule sets
732
+ group-type-configs/*.toml # Group type configs
733
+ email-templates/*.toml # Email template overrides
488
734
  `);
489
735
  // Init
490
736
  sync
@@ -500,6 +746,9 @@ Directory Structure:
500
746
  ensureDir(join(configDir, "integrations"));
501
747
  ensureDir(join(configDir, "prompts"));
502
748
  ensureDir(join(configDir, "workflows"));
749
+ ensureDir(join(configDir, "database-types"));
750
+ ensureDir(join(configDir, "rule-sets"));
751
+ ensureDir(join(configDir, "group-type-configs"));
503
752
  ensureDir(join(configDir, "email-templates"));
504
753
  const state = {
505
754
  appId: resolvedAppId,
@@ -526,17 +775,17 @@ Directory Structure:
526
775
  info(`Pulling configuration for app ${resolvedAppId}...`);
527
776
  try {
528
777
  // Fetch all data
529
- const [settings, integrationsResult, promptsResult, workflowsResult, emailTemplatesResult] = await Promise.all([
778
+ const [settings, integrationItems, promptItems, workflowItems, emailTemplatesResult] = await Promise.all([
530
779
  client.getAppSettings(resolvedAppId).catch(() => null),
531
- client.listIntegrations(resolvedAppId, { limit: 100 }),
532
- client.listPrompts(resolvedAppId, { limit: 100 }),
533
- client.listWorkflows(resolvedAppId, { limit: 100 }),
780
+ fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
781
+ fetchAll((p) => client.listPrompts(resolvedAppId, p)),
782
+ fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
534
783
  client.listEmailTemplates(resolvedAppId).catch(() => ({ templates: [] })),
535
784
  ]);
536
785
  // Fetch details for each entity
537
- const integrations = await Promise.all(integrationsResult.items.map((i) => client.getIntegration(resolvedAppId, i.integrationId)));
538
- const prompts = await Promise.all(promptsResult.items.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
539
- const workflows = await Promise.all(workflowsResult.items.map(async (w) => {
786
+ const integrations = await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId)));
787
+ const prompts = await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
788
+ const workflows = await Promise.all(workflowItems.map(async (w) => {
540
789
  const workflowData = await client.getWorkflow(resolvedAppId, w.workflowId);
541
790
  // Fetch active config with steps if available
542
791
  const activeConfigId = workflowData.workflow?.activeConfigId;
@@ -564,25 +813,42 @@ Directory Structure:
564
813
  json({ settings, integrations, prompts, workflows, emailTemplates });
565
814
  return;
566
815
  }
816
+ // Fetch database config resources
817
+ const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult] = await Promise.all([
818
+ client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
819
+ client.listRuleSets(resolvedAppId).catch(() => []),
820
+ client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
821
+ ]);
822
+ // Fetch operations for each database type
823
+ const databaseTypesWithOps = await Promise.all((Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : []).map(async (typeConfig) => {
824
+ const ops = await client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType).catch(() => []);
825
+ return { typeConfig, operations: Array.isArray(ops) ? ops : [] };
826
+ }));
567
827
  // Ensure directories exist
568
828
  ensureDir(configDir);
569
829
  ensureDir(join(configDir, "integrations"));
570
830
  ensureDir(join(configDir, "prompts"));
571
831
  ensureDir(join(configDir, "workflows"));
832
+ ensureDir(join(configDir, "database-types"));
833
+ ensureDir(join(configDir, "rule-sets"));
834
+ ensureDir(join(configDir, "group-type-configs"));
572
835
  ensureDir(join(configDir, "email-templates"));
573
836
  // Write app settings
574
837
  if (settings) {
575
- writeFileSync(join(configDir, "app.toml"), serializeAppSettings(settings));
838
+ const appTomlPath = join(configDir, "app.toml");
839
+ writeFileSync(appTomlPath, serializeAppSettings(settings));
576
840
  info(" Wrote app.toml");
577
841
  }
578
842
  // Write integrations
579
843
  const integrationEntities = {};
580
844
  for (const integration of integrations) {
581
845
  const filename = `${integration.integrationKey}.toml`;
582
- writeFileSync(join(configDir, "integrations", filename), serializeIntegration(integration));
846
+ const filePath = join(configDir, "integrations", filename);
847
+ writeFileSync(filePath, serializeIntegration(integration));
583
848
  integrationEntities[integration.integrationKey] = {
584
849
  id: integration.integrationId,
585
850
  modifiedAt: integration.modifiedAt || new Date().toISOString(),
851
+ contentHash: computeFileHash(filePath),
586
852
  };
587
853
  info(` Wrote integrations/${filename}`);
588
854
  }
@@ -590,10 +856,12 @@ Directory Structure:
590
856
  const promptEntities = {};
591
857
  for (const prompt of prompts) {
592
858
  const filename = `${prompt.promptKey}.toml`;
593
- writeFileSync(join(configDir, "prompts", filename), serializePrompt(prompt));
859
+ const filePath = join(configDir, "prompts", filename);
860
+ writeFileSync(filePath, serializePrompt(prompt));
594
861
  promptEntities[prompt.promptKey] = {
595
862
  id: prompt.promptId,
596
863
  modifiedAt: prompt.modifiedAt || new Date().toISOString(),
864
+ contentHash: computeFileHash(filePath),
597
865
  };
598
866
  info(` Wrote prompts/${filename}`);
599
867
  }
@@ -601,15 +869,37 @@ Directory Structure:
601
869
  const workflowEntities = {};
602
870
  for (const { workflow, draft, configs } of workflows) {
603
871
  const filename = `${workflow.workflowKey}.toml`;
604
- writeFileSync(join(configDir, "workflows", filename), serializeWorkflow(workflow, draft, configs || []));
872
+ const filePath = join(configDir, "workflows", filename);
873
+ writeFileSync(filePath, serializeWorkflow(workflow, draft, configs || []));
605
874
  workflowEntities[workflow.workflowKey] = {
606
875
  id: workflow.workflowId,
607
876
  modifiedAt: workflow.modifiedAt || new Date().toISOString(),
608
877
  activeConfigId: workflow.activeConfigId,
878
+ contentHash: computeFileHash(filePath),
609
879
  };
610
880
  info(` Wrote workflows/${filename}`);
611
881
  }
612
- // Write email templates
882
+ // Build lookup maps for test case serialization
883
+ const configIdToName = new Map();
884
+ const promptIdToKey = new Map();
885
+ for (const prompt of prompts) {
886
+ promptIdToKey.set(prompt.promptId, prompt.promptKey);
887
+ if (prompt.configs) {
888
+ for (const config of prompt.configs) {
889
+ configIdToName.set(config.configId, config.configName);
890
+ }
891
+ }
892
+ }
893
+ // Also include workflow config names
894
+ for (const { configs } of workflows) {
895
+ if (configs) {
896
+ for (const config of configs) {
897
+ configIdToName.set(config.configId, config.configName);
898
+ }
899
+ }
900
+ }
901
+ const testCaseLookupMaps = { configIdToName, promptIdToKey };
902
+ // Write email templates (use detailed emailTemplates, not summaries)
613
903
  const emailTemplateEntities = {};
614
904
  for (const template of emailTemplates) {
615
905
  const filename = `${template.emailType}.toml`;
@@ -637,31 +927,87 @@ Directory Structure:
637
927
  const testCaseEntities = {};
638
928
  let totalTestCases = 0;
639
929
  for (const prompt of prompts) {
640
- const count = await pullTestCasesForBlock(client, resolvedAppId, "prompt", prompt.promptId, prompt.promptKey, configDir, testCaseEntities);
930
+ const count = await pullTestCasesForBlock(client, resolvedAppId, "prompt", prompt.promptId, prompt.promptKey, configDir, testCaseEntities, testCaseLookupMaps);
641
931
  if (count > 0) {
642
932
  info(` Wrote ${count} test case(s) for prompt: ${prompt.promptKey}`);
643
933
  }
644
934
  totalTestCases += count;
645
935
  }
646
936
  for (const { workflow } of workflows) {
647
- const count = await pullTestCasesForBlock(client, resolvedAppId, "workflow", workflow.workflowId, workflow.workflowKey, configDir, testCaseEntities);
937
+ const count = await pullTestCasesForBlock(client, resolvedAppId, "workflow", workflow.workflowId, workflow.workflowKey, configDir, testCaseEntities, testCaseLookupMaps);
648
938
  if (count > 0) {
649
939
  info(` Wrote ${count} test case(s) for workflow: ${workflow.workflowKey}`);
650
940
  }
651
941
  totalTestCases += count;
652
942
  }
943
+ // Build ruleSetId → name map for database types and group type configs
944
+ const ruleSets = Array.isArray(ruleSetsResult) ? ruleSetsResult : [];
945
+ const ruleSetIdToName = new Map();
946
+ for (const ruleSet of ruleSets) {
947
+ ruleSetIdToName.set(ruleSet.ruleSetId, ruleSet.name);
948
+ }
949
+ // Write database types
950
+ const databaseTypeEntities = {};
951
+ for (const { typeConfig, operations } of databaseTypesWithOps) {
952
+ const filename = `${typeConfig.databaseType}.toml`;
953
+ const filePath = join(configDir, "database-types", filename);
954
+ writeFileSync(filePath, serializeDatabaseType(typeConfig, operations, ruleSetIdToName));
955
+ const opsEntities = {};
956
+ for (const op of operations) {
957
+ opsEntities[op.name] = {
958
+ modifiedAt: op.modifiedAt || new Date().toISOString(),
959
+ };
960
+ }
961
+ databaseTypeEntities[typeConfig.databaseType] = {
962
+ databaseType: typeConfig.databaseType,
963
+ modifiedAt: typeConfig.modifiedAt || new Date().toISOString(),
964
+ operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
965
+ contentHash: computeFileHash(filePath),
966
+ };
967
+ info(` Wrote database-types/${filename}`);
968
+ }
969
+ // Write rule sets
970
+ const ruleSetEntities = {};
971
+ for (const ruleSet of ruleSets) {
972
+ const fileKey = (ruleSet.name || ruleSet.ruleSetId).replace(/[/\\:*?"<>|]/g, "_");
973
+ const filename = `${fileKey}.toml`;
974
+ const filePath = join(configDir, "rule-sets", filename);
975
+ writeFileSync(filePath, serializeRuleSet(ruleSet));
976
+ ruleSetEntities[fileKey] = {
977
+ id: ruleSet.ruleSetId,
978
+ modifiedAt: ruleSet.modifiedAt || new Date().toISOString(),
979
+ contentHash: computeFileHash(filePath),
980
+ };
981
+ info(` Wrote rule-sets/${filename}`);
982
+ }
983
+ // Write group type configs
984
+ const groupTypeConfigEntities = {};
985
+ const groupTypeConfigs = Array.isArray(groupTypeConfigsResult) ? groupTypeConfigsResult : [];
986
+ for (const config of groupTypeConfigs) {
987
+ const filename = `${config.groupType}.toml`;
988
+ const filePath = join(configDir, "group-type-configs", filename);
989
+ writeFileSync(filePath, serializeGroupTypeConfig(config, ruleSetIdToName));
990
+ groupTypeConfigEntities[config.groupType] = {
991
+ modifiedAt: config.modifiedAt || new Date().toISOString(),
992
+ contentHash: computeFileHash(filePath),
993
+ };
994
+ info(` Wrote group-type-configs/${filename}`);
995
+ }
653
996
  // Save sync state
654
997
  const state = {
655
998
  appId: resolvedAppId,
656
999
  serverUrl: getServerUrl(),
657
1000
  lastSyncedAt: new Date().toISOString(),
658
1001
  entities: {
659
- app: settings ? { modifiedAt: new Date().toISOString() } : undefined,
1002
+ app: settings ? { modifiedAt: new Date().toISOString(), contentHash: computeFileHash(join(configDir, "app.toml")) } : undefined,
660
1003
  integrations: integrationEntities,
661
1004
  prompts: promptEntities,
662
1005
  workflows: workflowEntities,
663
1006
  emailTemplates: Object.keys(emailTemplateEntities).length > 0 ? emailTemplateEntities : undefined,
664
1007
  testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
1008
+ databaseTypes: Object.keys(databaseTypeEntities).length > 0 ? databaseTypeEntities : undefined,
1009
+ ruleSets: Object.keys(ruleSetEntities).length > 0 ? ruleSetEntities : undefined,
1010
+ groupTypeConfigs: Object.keys(groupTypeConfigEntities).length > 0 ? groupTypeConfigEntities : undefined,
665
1011
  },
666
1012
  };
667
1013
  saveSyncState(configDir, state);
@@ -672,6 +1018,9 @@ Directory Structure:
672
1018
  keyValue("Workflows", workflows.length);
673
1019
  keyValue("Email Templates", emailTemplates.length);
674
1020
  keyValue("Test Cases", totalTestCases);
1021
+ keyValue("Database Types", databaseTypesWithOps.length);
1022
+ keyValue("Rule Sets", ruleSets.length);
1023
+ keyValue("Group Type Configs", groupTypeConfigs.length);
675
1024
  }
676
1025
  catch (err) {
677
1026
  error(err.message);
@@ -695,38 +1044,159 @@ Directory Structure:
695
1044
  error(`Config directory not found: ${configDir}`);
696
1045
  process.exit(1);
697
1046
  }
698
- const syncState = loadSyncState(configDir);
1047
+ let syncState = loadSyncState(configDir);
1048
+ // Detect cross-app push: if pushing to a different app, clear entity IDs
1049
+ // so everything goes through the create path
1050
+ const isCrossAppPush = syncState && syncState.appId !== resolvedAppId;
1051
+ if (isCrossAppPush) {
1052
+ info(`Cross-app push detected: ${syncState.appId} → ${resolvedAppId}`);
1053
+ info("All entities will be created fresh in the target app.");
1054
+ syncState = {
1055
+ appId: resolvedAppId,
1056
+ serverUrl: syncState.serverUrl,
1057
+ lastSyncedAt: new Date().toISOString(),
1058
+ entities: {},
1059
+ };
1060
+ }
699
1061
  info(`Pushing configuration for app ${resolvedAppId}...`);
700
1062
  try {
701
1063
  const changes = [];
1064
+ let skippedCount = 0;
702
1065
  const conflicts = [];
1066
+ // Track name→ID mappings for resolving cross-references during push
1067
+ const ruleSetNameToId = new Map();
1068
+ const promptKeyToId = new Map();
1069
+ const promptConfigNameToId = new Map(); // key: "promptKey#configName"
1070
+ const workflowConfigNameToId = new Map(); // key: "workflowKey#configName"
703
1071
  // Process app settings
704
1072
  const appTomlPath = join(configDir, "app.toml");
705
1073
  if (existsSync(appTomlPath)) {
706
- const tomlData = parseTomlFile(appTomlPath);
707
- const settings = {};
708
- if (tomlData.app) {
709
- Object.assign(settings, tomlData.app);
710
- }
711
- if (tomlData.auth) {
712
- settings.googleOAuthEnabled = tomlData.auth.googleOAuthEnabled;
713
- settings.passkeyEnabled = tomlData.auth.passkeyEnabled;
714
- settings.magicLinkEnabled = tomlData.auth.magicLinkEnabled;
715
- if (tomlData.auth.passkeys) {
716
- settings.passkeyRpConfig = tomlData.auth.passkeys;
717
- }
1074
+ const appChanged = options.force || !syncState?.entities?.app?.contentHash ||
1075
+ shouldPushFile(appTomlPath, syncState.entities.app.contentHash);
1076
+ if (!appChanged) {
1077
+ skippedCount++;
1078
+ info(" Skipped app settings (unchanged)");
718
1079
  }
719
- if (tomlData.cors) {
720
- settings.corsMode = tomlData.cors.mode;
721
- settings.corsAllowedOrigins = tomlData.cors.allowedOrigins;
722
- settings.corsAllowCredentials = tomlData.cors.allowCredentials;
723
- settings.corsAllowedMethods = tomlData.cors.allowedMethods;
724
- settings.corsMaxAge = tomlData.cors.maxAge;
1080
+ else {
1081
+ const tomlData = parseTomlFile(appTomlPath);
1082
+ const settings = {};
1083
+ if (tomlData.app) {
1084
+ Object.assign(settings, tomlData.app);
1085
+ // Don't overwrite the target app's name during cross-app push
1086
+ if (isCrossAppPush) {
1087
+ delete settings.name;
1088
+ }
1089
+ }
1090
+ if (tomlData.auth) {
1091
+ settings.googleOAuthEnabled = tomlData.auth.googleOAuthEnabled;
1092
+ settings.passkeyEnabled = tomlData.auth.passkeyEnabled;
1093
+ settings.magicLinkEnabled = tomlData.auth.magicLinkEnabled;
1094
+ if (tomlData.auth.passkeys) {
1095
+ settings.passkeyRpConfig = tomlData.auth.passkeys;
1096
+ }
1097
+ }
1098
+ if (tomlData.cors) {
1099
+ settings.corsMode = tomlData.cors.mode;
1100
+ settings.corsAllowedOrigins = tomlData.cors.allowedOrigins;
1101
+ settings.corsAllowCredentials = tomlData.cors.allowCredentials;
1102
+ settings.corsAllowedMethods = tomlData.cors.allowedMethods;
1103
+ settings.corsMaxAge = tomlData.cors.maxAge;
1104
+ }
1105
+ changes.push({ type: "app", action: "update", key: "settings" });
1106
+ if (!options.dryRun) {
1107
+ await client.updateAppSettings(resolvedAppId, settings);
1108
+ info(" Updated app settings");
1109
+ // Store content hash after successful push
1110
+ if (syncState) {
1111
+ if (!syncState.entities.app) {
1112
+ syncState.entities.app = { modifiedAt: new Date().toISOString() };
1113
+ }
1114
+ syncState.entities.app.contentHash = computeFileHash(appTomlPath);
1115
+ }
1116
+ }
725
1117
  }
726
- changes.push({ type: "app", action: "update", key: "settings" });
727
- if (!options.dryRun) {
728
- await client.updateAppSettings(resolvedAppId, settings);
729
- info(" Updated app settings");
1118
+ }
1119
+ // Process rule sets first (other entities may reference them by name)
1120
+ const ruleSetsDir = join(configDir, "rule-sets");
1121
+ if (existsSync(ruleSetsDir)) {
1122
+ const files = readdirSync(ruleSetsDir).filter((f) => f.endsWith(".toml"));
1123
+ for (const file of files) {
1124
+ const filePath = join(ruleSetsDir, file);
1125
+ const tomlData = parseTomlFile(filePath);
1126
+ const ruleSetData = parseRuleSetToml(tomlData);
1127
+ const fileKey = basename(file, ".toml");
1128
+ const existingId = syncState?.entities?.ruleSets?.[fileKey]?.id;
1129
+ // Skip if file hasn't changed since last sync
1130
+ if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.ruleSets?.[fileKey]?.contentHash)) {
1131
+ skippedCount++;
1132
+ ruleSetNameToId.set(ruleSetData.name, existingId);
1133
+ continue;
1134
+ }
1135
+ if (existingId) {
1136
+ // Update existing rule set
1137
+ changes.push({ type: "rule-set", action: "update", key: fileKey });
1138
+ if (!options.dryRun) {
1139
+ const expectedModifiedAt = options.force
1140
+ ? undefined
1141
+ : syncState?.entities?.ruleSets?.[fileKey]?.modifiedAt;
1142
+ try {
1143
+ const updated = await client.updateRuleSet(resolvedAppId, existingId, {
1144
+ name: ruleSetData.name,
1145
+ description: ruleSetData.description,
1146
+ rules: ruleSetData.rules,
1147
+ }, expectedModifiedAt);
1148
+ info(` Updated rule set: ${fileKey}`);
1149
+ if (syncState?.entities?.ruleSets?.[fileKey] && updated?.modifiedAt) {
1150
+ syncState.entities.ruleSets[fileKey].modifiedAt = updated.modifiedAt;
1151
+ syncState.entities.ruleSets[fileKey].contentHash = computeFileHash(filePath);
1152
+ }
1153
+ }
1154
+ catch (err) {
1155
+ if (err instanceof ConflictError) {
1156
+ conflicts.push({
1157
+ type: "rule-set",
1158
+ key: fileKey,
1159
+ serverModifiedAt: err.serverModifiedAt,
1160
+ localModifiedAt: expectedModifiedAt || "unknown",
1161
+ });
1162
+ }
1163
+ else {
1164
+ throw err;
1165
+ }
1166
+ }
1167
+ }
1168
+ // Track name→ID for cross-reference resolution
1169
+ ruleSetNameToId.set(ruleSetData.name, existingId);
1170
+ }
1171
+ else {
1172
+ // Create new rule set
1173
+ changes.push({ type: "rule-set", action: "create", key: fileKey });
1174
+ if (!options.dryRun) {
1175
+ const created = await client.createRuleSet(resolvedAppId, {
1176
+ name: ruleSetData.name,
1177
+ resourceType: ruleSetData.resourceType,
1178
+ rules: ruleSetData.rules,
1179
+ description: ruleSetData.description,
1180
+ });
1181
+ info(` Created rule set: ${fileKey}`);
1182
+ if (syncState && created?.ruleSetId) {
1183
+ if (!syncState.entities.ruleSets) {
1184
+ syncState.entities.ruleSets = {};
1185
+ }
1186
+ syncState.entities.ruleSets[fileKey] = {
1187
+ id: created.ruleSetId,
1188
+ modifiedAt: created.modifiedAt || new Date().toISOString(),
1189
+ contentHash: computeFileHash(filePath),
1190
+ };
1191
+ }
1192
+ // Track name→ID for cross-reference resolution
1193
+ ruleSetNameToId.set(ruleSetData.name, created.ruleSetId);
1194
+ }
1195
+ else {
1196
+ // In dry-run mode, use a placeholder so dependent entities can resolve
1197
+ ruleSetNameToId.set(ruleSetData.name, `dry-run-${ruleSetData.name}`);
1198
+ }
1199
+ }
730
1200
  }
731
1201
  }
732
1202
  // Process integrations
@@ -739,6 +1209,11 @@ Directory Structure:
739
1209
  const integration = tomlData.integration || {};
740
1210
  const key = integration.key || basename(file, ".toml");
741
1211
  const existingId = syncState?.entities?.integrations?.[key]?.id;
1212
+ // Skip if file hasn't changed since last sync
1213
+ if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.integrations?.[key]?.contentHash)) {
1214
+ skippedCount++;
1215
+ continue;
1216
+ }
742
1217
  const payload = {
743
1218
  integrationKey: key,
744
1219
  displayName: integration.displayName || key,
@@ -759,6 +1234,7 @@ Directory Structure:
759
1234
  // Update sync state with new modifiedAt
760
1235
  if (syncState?.entities?.integrations?.[key] && updated?.modifiedAt) {
761
1236
  syncState.entities.integrations[key].modifiedAt = updated.modifiedAt;
1237
+ syncState.entities.integrations[key].contentHash = computeFileHash(filePath);
762
1238
  }
763
1239
  }
764
1240
  catch (err) {
@@ -789,6 +1265,7 @@ Directory Structure:
789
1265
  syncState.entities.integrations[key] = {
790
1266
  id: created.integrationId,
791
1267
  modifiedAt: created.modifiedAt,
1268
+ contentHash: computeFileHash(filePath),
792
1269
  };
793
1270
  }
794
1271
  }
@@ -806,9 +1283,26 @@ Directory Structure:
806
1283
  const key = prompt.key || basename(file, ".toml");
807
1284
  const configs = tomlData.configs || [];
808
1285
  const existingId = syncState?.entities?.prompts?.[key]?.id;
1286
+ // Skip if file hasn't changed since last sync
1287
+ if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.prompts?.[key]?.contentHash)) {
1288
+ skippedCount++;
1289
+ promptKeyToId.set(key, existingId);
1290
+ // Only fetch config name→ID mappings if test cases exist for this prompt
1291
+ const promptTestsDir = getTestsDir(configDir, "prompt", key);
1292
+ if (!options.dryRun && existsSync(promptTestsDir)) {
1293
+ const fullPrompt = await client.getPrompt(resolvedAppId, existingId);
1294
+ if (fullPrompt?.configs) {
1295
+ for (const config of fullPrompt.configs) {
1296
+ promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
1297
+ }
1298
+ }
1299
+ }
1300
+ continue;
1301
+ }
809
1302
  if (existingId) {
810
1303
  // Update existing prompt
811
1304
  changes.push({ type: "prompt", action: "update", key });
1305
+ promptKeyToId.set(key, existingId);
812
1306
  if (!options.dryRun) {
813
1307
  const expectedModifiedAt = options.force
814
1308
  ? undefined
@@ -824,6 +1318,55 @@ Directory Structure:
824
1318
  // Update sync state with new modifiedAt
825
1319
  if (syncState?.entities?.prompts?.[key] && updated?.modifiedAt) {
826
1320
  syncState.entities.prompts[key].modifiedAt = updated.modifiedAt;
1321
+ syncState.entities.prompts[key].contentHash = computeFileHash(filePath);
1322
+ }
1323
+ // Fetch full prompt to get config name→ID mappings
1324
+ // (updatePrompt response doesn't include configs)
1325
+ const fullPrompt = await client.getPrompt(resolvedAppId, existingId);
1326
+ if (fullPrompt?.configs) {
1327
+ for (const config of fullPrompt.configs) {
1328
+ promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
1329
+ }
1330
+ }
1331
+ // Update existing prompt configs with TOML values
1332
+ if (fullPrompt?.configs && configs.length > 0) {
1333
+ for (const tomlConfig of configs) {
1334
+ const configName = tomlConfig.name;
1335
+ if (!configName)
1336
+ continue;
1337
+ const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
1338
+ if (serverConfig) {
1339
+ // Update existing config
1340
+ await client.updatePromptConfig(resolvedAppId, existingId, serverConfig.configId, {
1341
+ description: tomlConfig.description,
1342
+ provider: tomlConfig.provider,
1343
+ model: tomlConfig.model,
1344
+ systemPrompt: tomlConfig.systemPrompt,
1345
+ userPromptTemplate: tomlConfig.userPromptTemplate,
1346
+ temperature: tomlConfig.temperature,
1347
+ maxTokens: tomlConfig.maxTokens,
1348
+ outputFormat: tomlConfig.outputFormat,
1349
+ });
1350
+ }
1351
+ else {
1352
+ // Create new config that doesn't exist on server yet
1353
+ const newConfig = await client.createPromptConfig(resolvedAppId, existingId, {
1354
+ configName,
1355
+ description: tomlConfig.description,
1356
+ provider: tomlConfig.provider || "openrouter",
1357
+ model: tomlConfig.model || "google/gemini-2.0-flash-001",
1358
+ systemPrompt: tomlConfig.systemPrompt,
1359
+ userPromptTemplate: tomlConfig.userPromptTemplate,
1360
+ temperature: tomlConfig.temperature,
1361
+ maxTokens: tomlConfig.maxTokens,
1362
+ outputFormat: tomlConfig.outputFormat,
1363
+ });
1364
+ if (newConfig?.configId) {
1365
+ promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
1366
+ }
1367
+ }
1368
+ }
1369
+ info(` Synced ${configs.length} config(s) for prompt: ${key}`);
827
1370
  }
828
1371
  }
829
1372
  catch (err) {
@@ -868,8 +1411,44 @@ Directory Structure:
868
1411
  syncState.entities.prompts[key] = {
869
1412
  id: created.promptId,
870
1413
  modifiedAt: created.modifiedAt,
1414
+ contentHash: computeFileHash(filePath),
871
1415
  };
872
1416
  }
1417
+ // Track prompt key→ID and config name→ID
1418
+ if (created?.promptId) {
1419
+ promptKeyToId.set(key, created.promptId);
1420
+ }
1421
+ if (created?.configs) {
1422
+ for (const config of created.configs) {
1423
+ promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
1424
+ }
1425
+ }
1426
+ // Create additional configs (configs[1..n]) that weren't included in the initial create
1427
+ if (created?.promptId && configs.length > 1) {
1428
+ for (let i = 1; i < configs.length; i++) {
1429
+ const extraConfig = configs[i];
1430
+ const extraCreated = await client.createPromptConfig(resolvedAppId, created.promptId, {
1431
+ configName: extraConfig.name || `config-${i + 1}`,
1432
+ description: extraConfig.description,
1433
+ provider: extraConfig.provider || firstConfig.provider || "openrouter",
1434
+ model: extraConfig.model || firstConfig.model || "google/gemini-2.0-flash-001",
1435
+ systemPrompt: extraConfig.systemPrompt,
1436
+ userPromptTemplate: extraConfig.userPromptTemplate,
1437
+ temperature: extraConfig.temperature,
1438
+ maxTokens: extraConfig.maxTokens,
1439
+ outputFormat: extraConfig.outputFormat,
1440
+ });
1441
+ if (extraCreated?.configId) {
1442
+ const configName = extraConfig.name || `config-${i + 1}`;
1443
+ promptConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
1444
+ }
1445
+ // Activate this config if it was the active one
1446
+ if (extraConfig.isActive && extraCreated?.configId) {
1447
+ await client.activatePromptConfig(resolvedAppId, created.promptId, extraCreated.configId);
1448
+ }
1449
+ }
1450
+ info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
1451
+ }
873
1452
  }
874
1453
  }
875
1454
  }
@@ -886,6 +1465,21 @@ Directory Structure:
886
1465
  const steps = tomlData.steps || [];
887
1466
  const existingId = syncState?.entities?.workflows?.[key]?.id;
888
1467
  const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
1468
+ // Skip if file hasn't changed since last sync
1469
+ if (!options.force && existingId && !shouldPushFile(filePath, syncState?.entities?.workflows?.[key]?.contentHash)) {
1470
+ skippedCount++;
1471
+ // Only fetch config name→ID mappings if test cases exist for this workflow
1472
+ const workflowTestsDir = getTestsDir(configDir, "workflow", key);
1473
+ if (!options.dryRun && existsSync(workflowTestsDir)) {
1474
+ const fullWorkflow = await client.getWorkflow(resolvedAppId, existingId);
1475
+ if (fullWorkflow?.configs) {
1476
+ for (const config of fullWorkflow.configs) {
1477
+ workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
1478
+ }
1479
+ }
1480
+ }
1481
+ continue;
1482
+ }
889
1483
  if (existingId) {
890
1484
  // Update existing workflow
891
1485
  changes.push({ type: "workflow", action: "update", key });
@@ -902,8 +1496,8 @@ Directory Structure:
902
1496
  perUserMaxRunning: workflow.perUserMaxRunning,
903
1497
  perUserMaxQueued: workflow.perUserMaxQueued,
904
1498
  dequeueOrder: workflow.dequeueOrder,
905
- inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : null,
906
- outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : null,
1499
+ inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
1500
+ outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : null,
907
1501
  }, expectedModifiedAt);
908
1502
  // Update active configuration steps (or draft for legacy)
909
1503
  if (existingActiveConfigId) {
@@ -915,13 +1509,22 @@ Directory Structure:
915
1509
  // Fallback to draft update for legacy workflows
916
1510
  await client.updateWorkflowDraft(resolvedAppId, existingId, {
917
1511
  steps,
918
- inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : null,
1512
+ inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : null,
919
1513
  });
920
1514
  }
921
1515
  info(` Updated workflow: ${key}`);
922
1516
  // Update sync state with new modifiedAt
923
1517
  if (syncState?.entities?.workflows?.[key] && updated?.workflow?.modifiedAt) {
924
1518
  syncState.entities.workflows[key].modifiedAt = updated.workflow.modifiedAt;
1519
+ syncState.entities.workflows[key].contentHash = computeFileHash(filePath);
1520
+ }
1521
+ // Fetch full workflow to get config name→ID mappings
1522
+ // (updateWorkflow response doesn't include configs)
1523
+ const fullWorkflow = await client.getWorkflow(resolvedAppId, existingId);
1524
+ if (fullWorkflow?.configs) {
1525
+ for (const config of fullWorkflow.configs) {
1526
+ workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
1527
+ }
925
1528
  }
926
1529
  }
927
1530
  catch (err) {
@@ -948,8 +1551,8 @@ Directory Structure:
948
1551
  name: workflow.name || key,
949
1552
  description: workflow.description,
950
1553
  steps,
951
- inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : undefined,
952
- outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : undefined,
1554
+ inputSchema: workflow.inputSchema ? safeJsonParse(workflow.inputSchema) : undefined,
1555
+ outputSchema: workflow.outputSchema ? safeJsonParse(workflow.outputSchema) : undefined,
953
1556
  perUserMaxRunning: workflow.perUserMaxRunning,
954
1557
  perUserMaxQueued: workflow.perUserMaxQueued,
955
1558
  dequeueOrder: workflow.dequeueOrder,
@@ -964,6 +1567,271 @@ Directory Structure:
964
1567
  id: created.workflow.workflowId,
965
1568
  modifiedAt: created.workflow.modifiedAt,
966
1569
  activeConfigId: created.workflow.activeConfigId,
1570
+ contentHash: computeFileHash(filePath),
1571
+ };
1572
+ }
1573
+ // Track config name→ID mappings
1574
+ if (created?.configs) {
1575
+ for (const config of created.configs) {
1576
+ workflowConfigNameToId.set(`${key}#${config.configName}`, config.configId);
1577
+ }
1578
+ }
1579
+ const workflowId = created?.workflow?.workflowId;
1580
+ const tomlConfigs = tomlData.configs || [];
1581
+ // Create additional workflow configs (configs[1..n]) beyond the default
1582
+ if (workflowId && tomlConfigs.length > 1) {
1583
+ for (let i = 1; i < tomlConfigs.length; i++) {
1584
+ const extraConfig = tomlConfigs[i];
1585
+ const extraCreated = await client.createWorkflowConfig(resolvedAppId, workflowId, {
1586
+ configName: extraConfig.name || `config-${i + 1}`,
1587
+ description: extraConfig.description,
1588
+ steps,
1589
+ });
1590
+ if (extraCreated?.configId) {
1591
+ const configName = extraConfig.name || `config-${i + 1}`;
1592
+ workflowConfigNameToId.set(`${key}#${configName}`, extraCreated.configId);
1593
+ }
1594
+ }
1595
+ info(` Created ${tomlConfigs.length - 1} additional config(s) for workflow: ${key}`);
1596
+ }
1597
+ // Activate the correct config based on activeConfigName from TOML
1598
+ if (workflowId && workflow.activeConfigName) {
1599
+ const activeConfigId = workflowConfigNameToId.get(`${key}#${workflow.activeConfigName}`);
1600
+ if (activeConfigId && activeConfigId !== created?.workflow?.activeConfigId) {
1601
+ await client.activateWorkflowConfig(resolvedAppId, workflowId, activeConfigId);
1602
+ info(` Activated config "${workflow.activeConfigName}" for workflow: ${key}`);
1603
+ }
1604
+ }
1605
+ }
1606
+ }
1607
+ }
1608
+ }
1609
+ // Process database types
1610
+ const dbTypesDir = join(configDir, "database-types");
1611
+ if (existsSync(dbTypesDir)) {
1612
+ const files = readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml"));
1613
+ for (const file of files) {
1614
+ const filePath = join(dbTypesDir, file);
1615
+ const tomlData = parseTomlFile(filePath);
1616
+ const { typeConfig, operations } = parseDatabaseTypeToml(tomlData);
1617
+ const dbType = typeConfig.databaseType || basename(file, ".toml");
1618
+ // Resolve ruleSetName → ruleSetId if using key-based reference
1619
+ resolveRuleSetReference(typeConfig, ruleSetNameToId, `database type ${dbType}`);
1620
+ const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
1621
+ // Skip if file hasn't changed since last sync
1622
+ if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
1623
+ skippedCount++;
1624
+ continue;
1625
+ }
1626
+ if (existingEntry) {
1627
+ // Update existing type config
1628
+ changes.push({ type: "database-type", action: "update", key: dbType });
1629
+ if (!options.dryRun) {
1630
+ const expectedModifiedAt = options.force
1631
+ ? undefined
1632
+ : existingEntry.modifiedAt;
1633
+ try {
1634
+ const updateData = {};
1635
+ if ("ruleSetId" in typeConfig)
1636
+ updateData.ruleSetId = typeConfig.ruleSetId || null;
1637
+ if ("triggers" in typeConfig)
1638
+ updateData.triggers = typeConfig.triggers || null;
1639
+ if ("metadataAccess" in typeConfig)
1640
+ updateData.metadataAccess = typeConfig.metadataAccess || null;
1641
+ const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt);
1642
+ info(` Updated database type: ${dbType}`);
1643
+ if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
1644
+ syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
1645
+ syncState.entities.databaseTypes[dbType].contentHash = computeFileHash(filePath);
1646
+ }
1647
+ }
1648
+ catch (err) {
1649
+ if (err instanceof ConflictError) {
1650
+ conflicts.push({
1651
+ type: "database-type",
1652
+ key: dbType,
1653
+ serverModifiedAt: err.serverModifiedAt,
1654
+ localModifiedAt: expectedModifiedAt || "unknown",
1655
+ });
1656
+ }
1657
+ else {
1658
+ throw err;
1659
+ }
1660
+ }
1661
+ }
1662
+ }
1663
+ else {
1664
+ // Create new type config
1665
+ changes.push({ type: "database-type", action: "create", key: dbType });
1666
+ if (!options.dryRun) {
1667
+ const createData = {
1668
+ databaseType: dbType,
1669
+ };
1670
+ if (typeConfig.ruleSetId)
1671
+ createData.ruleSetId = typeConfig.ruleSetId;
1672
+ if (typeConfig.triggers)
1673
+ createData.triggers = typeConfig.triggers;
1674
+ if (typeConfig.metadataAccess)
1675
+ createData.metadataAccess = typeConfig.metadataAccess;
1676
+ const created = await client.createDatabaseTypeConfig(resolvedAppId, createData);
1677
+ info(` Created database type: ${dbType}`);
1678
+ if (syncState) {
1679
+ if (!syncState.entities.databaseTypes) {
1680
+ syncState.entities.databaseTypes = {};
1681
+ }
1682
+ syncState.entities.databaseTypes[dbType] = {
1683
+ databaseType: dbType,
1684
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
1685
+ contentHash: computeFileHash(filePath),
1686
+ };
1687
+ }
1688
+ }
1689
+ }
1690
+ // Process operations for this type
1691
+ const existingOps = existingEntry?.operations || {};
1692
+ const tomlOpNames = new Set(operations.map((op) => op.name));
1693
+ for (const op of operations) {
1694
+ const existingOp = existingOps[op.name];
1695
+ if (existingOp) {
1696
+ // Update existing operation
1697
+ changes.push({ type: "operation", action: "update", key: `${dbType}/${op.name}` });
1698
+ if (!options.dryRun) {
1699
+ const expectedOpModifiedAt = options.force
1700
+ ? undefined
1701
+ : existingOp.modifiedAt;
1702
+ try {
1703
+ const updated = await client.updateDatabaseTypeOperation(resolvedAppId, dbType, op.name, {
1704
+ modelName: op.modelName,
1705
+ access: op.access,
1706
+ definition: op.definition,
1707
+ params: op.params,
1708
+ }, expectedOpModifiedAt);
1709
+ info(` Updated operation: ${dbType}/${op.name}`);
1710
+ if (syncState?.entities?.databaseTypes?.[dbType]?.operations?.[op.name] && updated?.modifiedAt) {
1711
+ syncState.entities.databaseTypes[dbType].operations[op.name].modifiedAt = updated.modifiedAt;
1712
+ }
1713
+ }
1714
+ catch (err) {
1715
+ if (err instanceof ConflictError) {
1716
+ conflicts.push({
1717
+ type: "operation",
1718
+ key: `${dbType}/${op.name}`,
1719
+ serverModifiedAt: err.serverModifiedAt,
1720
+ localModifiedAt: expectedOpModifiedAt || "unknown",
1721
+ });
1722
+ }
1723
+ else {
1724
+ throw err;
1725
+ }
1726
+ }
1727
+ }
1728
+ }
1729
+ else {
1730
+ // Create new operation
1731
+ changes.push({ type: "operation", action: "create", key: `${dbType}/${op.name}` });
1732
+ if (!options.dryRun) {
1733
+ const created = await client.createDatabaseTypeOperation(resolvedAppId, dbType, {
1734
+ name: op.name,
1735
+ type: op.type,
1736
+ modelName: op.modelName,
1737
+ access: op.access,
1738
+ definition: op.definition,
1739
+ params: op.params,
1740
+ });
1741
+ info(` Created operation: ${dbType}/${op.name}`);
1742
+ if (syncState?.entities?.databaseTypes?.[dbType]) {
1743
+ if (!syncState.entities.databaseTypes[dbType].operations) {
1744
+ syncState.entities.databaseTypes[dbType].operations = {};
1745
+ }
1746
+ syncState.entities.databaseTypes[dbType].operations[op.name] = {
1747
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
1748
+ };
1749
+ }
1750
+ }
1751
+ }
1752
+ }
1753
+ // Delete operations that were removed from TOML
1754
+ for (const existingOpName of Object.keys(existingOps)) {
1755
+ if (!tomlOpNames.has(existingOpName)) {
1756
+ changes.push({ type: "operation", action: "delete", key: `${dbType}/${existingOpName}` });
1757
+ if (!options.dryRun) {
1758
+ await client.deleteDatabaseTypeOperation(resolvedAppId, dbType, existingOpName);
1759
+ info(` Deleted operation: ${dbType}/${existingOpName}`);
1760
+ if (syncState?.entities?.databaseTypes?.[dbType]?.operations) {
1761
+ delete syncState.entities.databaseTypes[dbType].operations[existingOpName];
1762
+ }
1763
+ }
1764
+ }
1765
+ }
1766
+ }
1767
+ }
1768
+ // Process group type configs
1769
+ const groupTypeConfigsDir = join(configDir, "group-type-configs");
1770
+ if (existsSync(groupTypeConfigsDir)) {
1771
+ const files = readdirSync(groupTypeConfigsDir).filter((f) => f.endsWith(".toml"));
1772
+ for (const file of files) {
1773
+ const filePath = join(groupTypeConfigsDir, file);
1774
+ const tomlData = parseTomlFile(filePath);
1775
+ const configData = parseGroupTypeConfigToml(tomlData);
1776
+ const groupType = configData.groupType || basename(file, ".toml");
1777
+ // Resolve ruleSetName → ruleSetId if using key-based reference
1778
+ resolveRuleSetReference(configData, ruleSetNameToId, `group type ${groupType}`);
1779
+ const existingEntry = syncState?.entities?.groupTypeConfigs?.[groupType];
1780
+ // Skip if file hasn't changed since last sync
1781
+ if (!options.force && existingEntry && !shouldPushFile(filePath, existingEntry.contentHash)) {
1782
+ skippedCount++;
1783
+ continue;
1784
+ }
1785
+ if (existingEntry) {
1786
+ // Update existing group type config
1787
+ changes.push({ type: "group-type-config", action: "update", key: groupType });
1788
+ if (!options.dryRun) {
1789
+ const expectedModifiedAt = options.force
1790
+ ? undefined
1791
+ : existingEntry.modifiedAt;
1792
+ try {
1793
+ const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
1794
+ ruleSetId: configData.ruleSetId,
1795
+ autoAddCreator: configData.autoAddCreator,
1796
+ }, expectedModifiedAt);
1797
+ info(` Updated group type config: ${groupType}`);
1798
+ if (syncState?.entities?.groupTypeConfigs?.[groupType] && updated?.modifiedAt) {
1799
+ syncState.entities.groupTypeConfigs[groupType].modifiedAt = updated.modifiedAt;
1800
+ syncState.entities.groupTypeConfigs[groupType].contentHash = computeFileHash(filePath);
1801
+ }
1802
+ }
1803
+ catch (err) {
1804
+ if (err instanceof ConflictError) {
1805
+ conflicts.push({
1806
+ type: "group-type-config",
1807
+ key: groupType,
1808
+ serverModifiedAt: err.serverModifiedAt,
1809
+ localModifiedAt: expectedModifiedAt || "unknown",
1810
+ });
1811
+ }
1812
+ else {
1813
+ throw err;
1814
+ }
1815
+ }
1816
+ }
1817
+ }
1818
+ else {
1819
+ // Create new group type config
1820
+ changes.push({ type: "group-type-config", action: "create", key: groupType });
1821
+ if (!options.dryRun) {
1822
+ const created = await client.createGroupTypeConfig(resolvedAppId, {
1823
+ groupType,
1824
+ ruleSetId: configData.ruleSetId || undefined,
1825
+ autoAddCreator: configData.autoAddCreator,
1826
+ });
1827
+ info(` Created group type config: ${groupType}`);
1828
+ if (syncState) {
1829
+ if (!syncState.entities.groupTypeConfigs) {
1830
+ syncState.entities.groupTypeConfigs = {};
1831
+ }
1832
+ syncState.entities.groupTypeConfigs[groupType] = {
1833
+ modifiedAt: created?.modifiedAt || new Date().toISOString(),
1834
+ contentHash: computeFileHash(filePath),
967
1835
  };
968
1836
  }
969
1837
  }
@@ -1007,6 +1875,11 @@ Directory Structure:
1007
1875
  }
1008
1876
  }
1009
1877
  // Push test cases for prompts and workflows
1878
+ const pushMaps = {
1879
+ promptKeyToId,
1880
+ promptConfigNameToId,
1881
+ workflowConfigNameToId,
1882
+ };
1010
1883
  const promptsDir2 = join(configDir, "prompts");
1011
1884
  if (existsSync(promptsDir2)) {
1012
1885
  for (const file of readdirSync(promptsDir2).filter((f) => f.endsWith(".toml"))) {
@@ -1014,7 +1887,7 @@ Directory Structure:
1014
1887
  const promptKey = tomlData.prompt?.key || basename(file, ".toml");
1015
1888
  const blockId = syncState?.entities?.prompts?.[promptKey]?.id;
1016
1889
  if (blockId) {
1017
- await pushTestCasesForBlock(client, resolvedAppId, "prompt", blockId, promptKey, configDir, syncState, options.dryRun, changes);
1890
+ skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "prompt", blockId, promptKey, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
1018
1891
  }
1019
1892
  }
1020
1893
  }
@@ -1025,7 +1898,7 @@ Directory Structure:
1025
1898
  const workflowKey = tomlData.workflow?.key || basename(file, ".toml");
1026
1899
  const blockId = syncState?.entities?.workflows?.[workflowKey]?.id;
1027
1900
  if (blockId) {
1028
- await pushTestCasesForBlock(client, resolvedAppId, "workflow", blockId, workflowKey, configDir, syncState, options.dryRun, changes);
1901
+ skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "workflow", blockId, workflowKey, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
1029
1902
  }
1030
1903
  }
1031
1904
  }
@@ -1076,10 +1949,23 @@ Directory Structure:
1076
1949
  syncState.lastSyncedAt = new Date().toISOString();
1077
1950
  saveSyncState(configDir, syncState);
1078
1951
  }
1079
- success(`Pushed ${changes.length} changes.`);
1952
+ const pushMsg = skippedCount > 0
1953
+ ? `Pushed ${changes.length} change(s), skipped ${skippedCount} unchanged file(s).`
1954
+ : `Pushed ${changes.length} changes.`;
1955
+ success(pushMsg);
1080
1956
  }
1081
1957
  }
1082
1958
  catch (err) {
1959
+ // Save sync state even on failure so partial progress is preserved
1960
+ if (syncState) {
1961
+ try {
1962
+ syncState.lastSyncedAt = new Date().toISOString();
1963
+ saveSyncState(configDir, syncState);
1964
+ }
1965
+ catch {
1966
+ // Don't mask the original error
1967
+ }
1968
+ }
1083
1969
  error(err.message);
1084
1970
  process.exit(1);
1085
1971
  }
@@ -1103,15 +1989,15 @@ Directory Structure:
1103
1989
  info(`Comparing local configuration with app ${resolvedAppId}...`);
1104
1990
  try {
1105
1991
  // Fetch remote state
1106
- const [integrationsResult, promptsResult, workflowsResult, emailTemplatesResult] = await Promise.all([
1107
- client.listIntegrations(resolvedAppId, { limit: 100 }),
1108
- client.listPrompts(resolvedAppId, { limit: 100 }),
1109
- client.listWorkflows(resolvedAppId, { limit: 100 }),
1992
+ const [integrationItems, promptItems, workflowItems, emailTemplatesResult] = await Promise.all([
1993
+ fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
1994
+ fetchAll((p) => client.listPrompts(resolvedAppId, p)),
1995
+ fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
1110
1996
  client.listEmailTemplates(resolvedAppId).catch(() => ({ templates: [] })),
1111
1997
  ]);
1112
- const remoteIntegrations = new Set(integrationsResult.items.map((i) => i.integrationKey));
1113
- const remotePrompts = new Set(promptsResult.items.map((p) => p.promptKey));
1114
- const remoteWorkflows = new Set(workflowsResult.items.map((w) => w.workflowKey));
1998
+ const remoteIntegrations = new Set(integrationItems.map((i) => i.integrationKey));
1999
+ const remotePrompts = new Set(promptItems.map((p) => p.promptKey));
2000
+ const remoteWorkflows = new Set(workflowItems.map((w) => w.workflowKey));
1115
2001
  const remoteEmailTemplates = new Set((emailTemplatesResult.templates || [])
1116
2002
  .filter((t) => t.hasOverride)
1117
2003
  .map((t) => t.emailType));
@@ -1247,12 +2133,12 @@ Directory Structure:
1247
2133
  }
1248
2134
  };
1249
2135
  // Compare test cases for synced blocks
1250
- for (const p of promptsResult.items) {
2136
+ for (const p of promptItems) {
1251
2137
  if (localPrompts.has(p.promptKey)) {
1252
2138
  await compareTestCases("prompt", p.promptId, p.promptKey);
1253
2139
  }
1254
2140
  }
1255
- for (const w of workflowsResult.items) {
2141
+ for (const w of workflowItems) {
1256
2142
  if (localWorkflows.has(w.workflowKey)) {
1257
2143
  await compareTestCases("workflow", w.workflowId, w.workflowKey);
1258
2144
  }