primitive-admin 1.0.6 → 1.0.8

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.
File without changes
@@ -1,6 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync, rmdirSync, statSync } from "fs";
2
2
  import { join, basename } from "path";
3
3
  import * as TOML from "@iarna/toml";
4
+ import { lookup as mimeLookup } from "mime-types";
4
5
  import { ApiClient, ConflictError } from "../lib/api-client.js";
5
6
  import { getCurrentAppId, getServerUrl } from "../lib/config.js";
6
7
  import { success, error, info, warn, keyValue, json, divider, } from "../lib/output.js";
@@ -99,19 +100,38 @@ function serializePrompt(prompt) {
99
100
  };
100
101
  return TOML.stringify(data);
101
102
  }
102
- function serializeWorkflow(workflow, draft) {
103
+ function serializeWorkflow(workflow, draft, configs) {
104
+ // Find the active config or use the first one
105
+ const activeConfigId = workflow.activeConfigId;
106
+ const activeConfig = configs?.find((c) => c.configId === activeConfigId) || configs?.[0];
107
+ // Determine which steps to use (prefer config steps if non-empty, else draft steps)
108
+ const configSteps = activeConfig?.steps;
109
+ const draftSteps = draft?.steps;
110
+ const steps = (configSteps && configSteps.length > 0) ? configSteps : (draftSteps || []);
103
111
  const data = {
104
112
  workflow: {
105
113
  key: workflow.workflowKey,
106
114
  name: workflow.name,
107
115
  description: workflow.description,
108
116
  status: workflow.status,
117
+ activeConfigId: activeConfigId,
109
118
  perUserMaxRunning: workflow.perUserMaxRunning,
110
119
  perUserMaxQueued: workflow.perUserMaxQueued,
111
120
  dequeueOrder: workflow.dequeueOrder,
112
- inputSchema: draft?.inputSchema ? JSON.stringify(draft.inputSchema) : undefined,
121
+ // Schemas are at workflow level
122
+ inputSchema: workflow.inputSchema ? JSON.stringify(workflow.inputSchema) : undefined,
123
+ outputSchema: workflow.outputSchema ? JSON.stringify(workflow.outputSchema) : undefined,
113
124
  },
114
- steps: draft?.steps || [],
125
+ // Use active config steps, fall back to draft
126
+ steps,
127
+ // Include all configurations
128
+ configs: configs?.map((config) => ({
129
+ id: config.configId,
130
+ name: config.configName,
131
+ description: config.description,
132
+ status: config.status,
133
+ isActive: config.configId === activeConfigId,
134
+ })) || [],
115
135
  };
116
136
  return TOML.stringify(data);
117
137
  }
@@ -120,6 +140,320 @@ function parseTomlFile(filePath) {
120
140
  const content = readFileSync(filePath, "utf-8");
121
141
  return TOML.parse(content);
122
142
  }
143
+ // Test case helpers (exported for unit testing)
144
+ export function slugifyTestCaseName(name) {
145
+ return name
146
+ .toLowerCase()
147
+ .replace(/[^a-z0-9]+/g, "-")
148
+ .replace(/^-+|-+$/g, "")
149
+ || "test";
150
+ }
151
+ export function resolveSlugCollisions(slug, usedSlugs) {
152
+ if (!usedSlugs.has(slug)) {
153
+ usedSlugs.add(slug);
154
+ return slug;
155
+ }
156
+ let counter = 2;
157
+ while (usedSlugs.has(`${slug}-${counter}`)) {
158
+ counter++;
159
+ }
160
+ const resolved = `${slug}-${counter}`;
161
+ usedSlugs.add(resolved);
162
+ return resolved;
163
+ }
164
+ function getTestsDir(configDir, blockType, blockKey) {
165
+ const typeDir = blockType === "prompt" ? "prompts" : "workflows";
166
+ return join(configDir, typeDir, `${blockKey}.tests`);
167
+ }
168
+ export function serializeTestCase(testCase) {
169
+ const data = {
170
+ test: {
171
+ name: testCase.name || "",
172
+ description: testCase.description || "",
173
+ inputVariables: testCase.inputVariables || "{}",
174
+ configId: testCase.configId || "",
175
+ evaluatorPromptId: testCase.evaluatorPromptId || "",
176
+ evaluatorConfigId: testCase.evaluatorConfigId || "",
177
+ expectedOutputPattern: testCase.expectedOutputPattern || "",
178
+ expectedOutputContains: testCase.expectedOutputContains || "[]",
179
+ expectedJsonSubset: testCase.expectedJsonSubset || "{}",
180
+ },
181
+ };
182
+ return TOML.stringify(data);
183
+ }
184
+ export function parseTestCaseToml(tomlData) {
185
+ const test = tomlData.test || {};
186
+ const payload = {
187
+ name: test.name,
188
+ };
189
+ if (test.inputVariables && test.inputVariables !== "{}" && test.inputVariables !== "") {
190
+ try {
191
+ payload.inputVariables = JSON.parse(test.inputVariables);
192
+ }
193
+ catch {
194
+ payload.inputVariables = {};
195
+ }
196
+ }
197
+ else {
198
+ payload.inputVariables = {};
199
+ }
200
+ if (test.description && test.description !== "") {
201
+ payload.description = test.description;
202
+ }
203
+ if (test.configId && test.configId !== "") {
204
+ payload.configId = test.configId;
205
+ }
206
+ if (test.evaluatorPromptId && test.evaluatorPromptId !== "") {
207
+ payload.evaluatorPromptId = test.evaluatorPromptId;
208
+ }
209
+ if (test.evaluatorConfigId && test.evaluatorConfigId !== "") {
210
+ payload.evaluatorConfigId = test.evaluatorConfigId;
211
+ }
212
+ if (test.expectedOutputPattern && test.expectedOutputPattern !== "") {
213
+ payload.expectedOutputPattern = test.expectedOutputPattern;
214
+ }
215
+ if (test.expectedOutputContains && test.expectedOutputContains !== "[]" && test.expectedOutputContains !== "") {
216
+ try {
217
+ payload.expectedOutputContains = JSON.parse(test.expectedOutputContains);
218
+ }
219
+ catch {
220
+ // ignore
221
+ }
222
+ }
223
+ if (test.expectedJsonSubset && test.expectedJsonSubset !== "{}" && test.expectedJsonSubset !== "") {
224
+ try {
225
+ payload.expectedJsonSubset = JSON.parse(test.expectedJsonSubset);
226
+ }
227
+ catch {
228
+ // ignore
229
+ }
230
+ }
231
+ return payload;
232
+ }
233
+ async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities) {
234
+ let testCases;
235
+ try {
236
+ const result = await client.listTestCases(appId, blockType, blockId);
237
+ testCases = result.items || [];
238
+ }
239
+ catch {
240
+ return 0;
241
+ }
242
+ if (testCases.length === 0)
243
+ return 0;
244
+ const testsDir = getTestsDir(configDir, blockType, blockKey);
245
+ ensureDir(testsDir);
246
+ const usedSlugs = new Set();
247
+ const pulledSlugs = new Set();
248
+ for (const tc of testCases) {
249
+ const baseSlug = slugifyTestCaseName(tc.name || "unnamed");
250
+ const slug = resolveSlugCollisions(baseSlug, usedSlugs);
251
+ pulledSlugs.add(slug);
252
+ // Write test case TOML
253
+ const tomlPath = join(testsDir, `${slug}.toml`);
254
+ writeFileSync(tomlPath, serializeTestCase(tc));
255
+ // Handle attachments
256
+ let attachmentFilenames = [];
257
+ try {
258
+ const attachResult = await client.listTestCaseAttachments(appId, blockType, blockId, tc.testCaseId);
259
+ const attachments = attachResult.attachments || [];
260
+ if (attachments.length > 0) {
261
+ const attachDir = join(testsDir, slug);
262
+ ensureDir(attachDir);
263
+ for (const att of attachments) {
264
+ try {
265
+ const downloaded = await client.downloadTestCaseAttachment(appId, blockType, blockId, tc.testCaseId, att.filename);
266
+ writeFileSync(join(attachDir, att.filename), downloaded.data);
267
+ attachmentFilenames.push(att.filename);
268
+ }
269
+ catch (err) {
270
+ // Log but skip individual attachment failures
271
+ console.error(` Warning: Failed to download attachment "${att.filename}": ${err.message || err}`);
272
+ }
273
+ }
274
+ }
275
+ }
276
+ catch (err) {
277
+ // Log but skip attachment listing failures
278
+ console.error(` Warning: Failed to list attachments for test case "${tc.name}": ${err.message || err}`);
279
+ }
280
+ // Track in sync state
281
+ const stateKey = `${blockType}/${blockKey}/${slug}`;
282
+ testCaseEntities[stateKey] = {
283
+ id: tc.testCaseId,
284
+ blockType,
285
+ blockId,
286
+ blockKey,
287
+ slug,
288
+ modifiedAt: tc.modifiedAt || tc.createdAt || new Date().toISOString(),
289
+ attachmentFilenames: attachmentFilenames.length > 0 ? attachmentFilenames : undefined,
290
+ };
291
+ }
292
+ // Clean up local files for remotely-deleted test cases
293
+ if (existsSync(testsDir)) {
294
+ const localFiles = readdirSync(testsDir).filter((f) => f.endsWith(".toml"));
295
+ for (const file of localFiles) {
296
+ const localSlug = basename(file, ".toml");
297
+ if (!pulledSlugs.has(localSlug)) {
298
+ // Remove orphaned TOML
299
+ unlinkSync(join(testsDir, file));
300
+ // Remove orphaned attachment dir if exists
301
+ const attachDir = join(testsDir, localSlug);
302
+ if (existsSync(attachDir)) {
303
+ try {
304
+ const attachFiles = readdirSync(attachDir);
305
+ for (const af of attachFiles) {
306
+ unlinkSync(join(attachDir, af));
307
+ }
308
+ rmdirSync(attachDir);
309
+ }
310
+ catch {
311
+ // ignore cleanup errors
312
+ }
313
+ }
314
+ }
315
+ }
316
+ }
317
+ return testCases.length;
318
+ }
319
+ async function pushTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, syncState, dryRun, changes) {
320
+ const testsDir = getTestsDir(configDir, blockType, blockKey);
321
+ if (!existsSync(testsDir))
322
+ return;
323
+ const files = readdirSync(testsDir).filter((f) => f.endsWith(".toml"));
324
+ if (files.length === 0)
325
+ return;
326
+ const TEN_MB = 10 * 1024 * 1024;
327
+ for (const file of files) {
328
+ const slug = basename(file, ".toml");
329
+ const filePath = join(testsDir, file);
330
+ const tomlData = parseTomlFile(filePath);
331
+ const testPayload = parseTestCaseToml(tomlData);
332
+ const stateKey = `${blockType}/${blockKey}/${slug}`;
333
+ const existingEntry = syncState?.entities?.testCases?.[stateKey];
334
+ if (existingEntry?.id) {
335
+ // Update existing test case
336
+ changes.push({ type: "test-case", action: "update", key: `${blockKey}/${slug}` });
337
+ if (!dryRun) {
338
+ try {
339
+ await client.updateTestCase(appId, blockType, blockId, existingEntry.id, testPayload);
340
+ info(` Updated test case: ${blockKey}/${slug}`);
341
+ }
342
+ catch (err) {
343
+ warn(` Failed to update test case ${blockKey}/${slug}: ${err.message}`);
344
+ continue;
345
+ }
346
+ }
347
+ }
348
+ else {
349
+ // Create new test case
350
+ if (!testPayload.name) {
351
+ warn(` Skipping test case ${blockKey}/${slug}: missing name`);
352
+ continue;
353
+ }
354
+ changes.push({ type: "test-case", action: "create", key: `${blockKey}/${slug}` });
355
+ if (!dryRun) {
356
+ try {
357
+ const created = await client.createTestCase(appId, blockType, blockId, testPayload);
358
+ info(` Created test case: ${blockKey}/${slug}`);
359
+ // Record in sync state
360
+ if (syncState) {
361
+ if (!syncState.entities.testCases) {
362
+ syncState.entities.testCases = {};
363
+ }
364
+ syncState.entities.testCases[stateKey] = {
365
+ id: created.testCaseId,
366
+ blockType,
367
+ blockId,
368
+ blockKey,
369
+ slug,
370
+ modifiedAt: created.modifiedAt || created.createdAt || new Date().toISOString(),
371
+ };
372
+ }
373
+ }
374
+ catch (err) {
375
+ warn(` Failed to create test case ${blockKey}/${slug}: ${err.message}`);
376
+ continue;
377
+ }
378
+ }
379
+ }
380
+ // Handle attachments (only on actual push, not dry run)
381
+ if (!dryRun) {
382
+ const testCaseId = existingEntry?.id || syncState?.entities?.testCases?.[stateKey]?.id;
383
+ if (!testCaseId)
384
+ continue;
385
+ const attachDir = join(testsDir, slug);
386
+ if (!existsSync(attachDir))
387
+ continue;
388
+ const localAttachFiles = readdirSync(attachDir).filter((f) => {
389
+ const fullPath = join(attachDir, f);
390
+ try {
391
+ return statSync(fullPath).isFile();
392
+ }
393
+ catch {
394
+ return false;
395
+ }
396
+ });
397
+ const prevAttachments = new Set(existingEntry?.attachmentFilenames || []);
398
+ for (const attachFile of localAttachFiles) {
399
+ if (prevAttachments.has(attachFile)) {
400
+ // Already synced, skip
401
+ continue;
402
+ }
403
+ const attachPath = join(attachDir, attachFile);
404
+ const stats = statSync(attachPath);
405
+ if (stats.size > TEN_MB) {
406
+ warn(` Skipping attachment ${attachFile} (exceeds 10MB limit): ${formatFileSize(stats.size)}`);
407
+ continue;
408
+ }
409
+ try {
410
+ const data = readFileSync(attachPath);
411
+ const contentType = mimeLookup(attachPath) || "application/octet-stream";
412
+ await client.uploadTestCaseAttachment(appId, blockType, blockId, testCaseId, attachFile, data, contentType);
413
+ changes.push({ type: "attachment", action: "upload", key: `${blockKey}/${slug}/${attachFile}` });
414
+ info(` Uploaded attachment: ${blockKey}/${slug}/${attachFile}`);
415
+ // Update sync state attachment tracking
416
+ if (syncState?.entities?.testCases?.[stateKey]) {
417
+ const entry = syncState.entities.testCases[stateKey];
418
+ if (!entry.attachmentFilenames)
419
+ entry.attachmentFilenames = [];
420
+ entry.attachmentFilenames.push(attachFile);
421
+ }
422
+ }
423
+ catch (err) {
424
+ warn(` Failed to upload attachment ${attachFile}: ${err.message}`);
425
+ }
426
+ }
427
+ // Delete attachments that were removed locally
428
+ for (const prevFile of prevAttachments) {
429
+ if (!localAttachFiles.includes(prevFile)) {
430
+ try {
431
+ await client.deleteTestCaseAttachment(appId, blockType, blockId, testCaseId, prevFile);
432
+ changes.push({ type: "attachment", action: "delete", key: `${blockKey}/${slug}/${prevFile}` });
433
+ info(` Deleted attachment: ${blockKey}/${slug}/${prevFile}`);
434
+ // Update sync state
435
+ if (syncState?.entities?.testCases?.[stateKey]) {
436
+ const entry = syncState.entities.testCases[stateKey];
437
+ if (entry.attachmentFilenames) {
438
+ entry.attachmentFilenames = entry.attachmentFilenames.filter((f) => f !== prevFile);
439
+ }
440
+ }
441
+ }
442
+ catch (err) {
443
+ warn(` Failed to delete attachment ${prevFile}: ${err.message}`);
444
+ }
445
+ }
446
+ }
447
+ }
448
+ }
449
+ }
450
+ function formatFileSize(bytes) {
451
+ if (bytes < 1024)
452
+ return `${bytes} B`;
453
+ if (bytes < 1024 * 1024)
454
+ return `${(bytes / 1024).toFixed(1)} KB`;
455
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
456
+ }
123
457
  export function registerSyncCommands(program) {
124
458
  const sync = program
125
459
  .command("sync")
@@ -133,11 +467,13 @@ Examples:
133
467
 
134
468
  Directory Structure:
135
469
  config/
136
- .primitive-sync.json # Sync state (auto-managed)
137
- app.toml # App settings
138
- integrations/*.toml # Integration configs
139
- prompts/*.toml # Prompt configs
140
- workflows/*.toml # Workflow definitions
470
+ .primitive-sync.json # Sync state (auto-managed)
471
+ app.toml # App settings
472
+ integrations/*.toml # Integration configs
473
+ prompts/*.toml # Prompt configs
474
+ prompts/{key}.tests/*.toml # Prompt test cases
475
+ workflows/*.toml # Workflow definitions
476
+ workflows/{key}.tests/*.toml # Workflow test cases
141
477
  `);
142
478
  // Init
143
479
  sync
@@ -187,7 +523,27 @@ Directory Structure:
187
523
  // Fetch details for each entity
188
524
  const integrations = await Promise.all(integrationsResult.items.map((i) => client.getIntegration(resolvedAppId, i.integrationId)));
189
525
  const prompts = await Promise.all(promptsResult.items.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
190
- const workflows = await Promise.all(workflowsResult.items.map((w) => client.getWorkflow(resolvedAppId, w.workflowId)));
526
+ const workflows = await Promise.all(workflowsResult.items.map(async (w) => {
527
+ const workflowData = await client.getWorkflow(resolvedAppId, w.workflowId);
528
+ // Fetch active config with steps if available
529
+ const activeConfigId = workflowData.workflow?.activeConfigId;
530
+ if (activeConfigId) {
531
+ try {
532
+ const activeConfig = await client.getWorkflowConfig(resolvedAppId, w.workflowId, activeConfigId);
533
+ // Replace the config in the configs array with the one that has steps
534
+ if (workflowData.configs && activeConfig) {
535
+ const configIndex = workflowData.configs.findIndex((c) => c.configId === activeConfigId);
536
+ if (configIndex >= 0) {
537
+ workflowData.configs[configIndex] = activeConfig;
538
+ }
539
+ }
540
+ }
541
+ catch {
542
+ // Ignore errors fetching config steps
543
+ }
544
+ }
545
+ return workflowData;
546
+ }));
191
547
  if (options.json) {
192
548
  json({ settings, integrations, prompts, workflows });
193
549
  return;
@@ -226,15 +582,33 @@ Directory Structure:
226
582
  }
227
583
  // Write workflows
228
584
  const workflowEntities = {};
229
- for (const { workflow, draft } of workflows) {
585
+ for (const { workflow, draft, configs } of workflows) {
230
586
  const filename = `${workflow.workflowKey}.toml`;
231
- writeFileSync(join(configDir, "workflows", filename), serializeWorkflow(workflow, draft));
587
+ writeFileSync(join(configDir, "workflows", filename), serializeWorkflow(workflow, draft, configs || []));
232
588
  workflowEntities[workflow.workflowKey] = {
233
589
  id: workflow.workflowId,
234
590
  modifiedAt: workflow.modifiedAt || new Date().toISOString(),
591
+ activeConfigId: workflow.activeConfigId,
235
592
  };
236
593
  info(` Wrote workflows/${filename}`);
237
594
  }
595
+ // Pull test cases for prompts and workflows
596
+ const testCaseEntities = {};
597
+ let totalTestCases = 0;
598
+ for (const prompt of prompts) {
599
+ const count = await pullTestCasesForBlock(client, resolvedAppId, "prompt", prompt.promptId, prompt.promptKey, configDir, testCaseEntities);
600
+ if (count > 0) {
601
+ info(` Wrote ${count} test case(s) for prompt: ${prompt.promptKey}`);
602
+ }
603
+ totalTestCases += count;
604
+ }
605
+ for (const { workflow } of workflows) {
606
+ const count = await pullTestCasesForBlock(client, resolvedAppId, "workflow", workflow.workflowId, workflow.workflowKey, configDir, testCaseEntities);
607
+ if (count > 0) {
608
+ info(` Wrote ${count} test case(s) for workflow: ${workflow.workflowKey}`);
609
+ }
610
+ totalTestCases += count;
611
+ }
238
612
  // Save sync state
239
613
  const state = {
240
614
  appId: resolvedAppId,
@@ -245,6 +619,7 @@ Directory Structure:
245
619
  integrations: integrationEntities,
246
620
  prompts: promptEntities,
247
621
  workflows: workflowEntities,
622
+ testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
248
623
  },
249
624
  };
250
625
  saveSyncState(configDir, state);
@@ -253,6 +628,7 @@ Directory Structure:
253
628
  keyValue("Integrations", integrations.length);
254
629
  keyValue("Prompts", prompts.length);
255
630
  keyValue("Workflows", workflows.length);
631
+ keyValue("Test Cases", totalTestCases);
256
632
  }
257
633
  catch (err) {
258
634
  error(err.message);
@@ -466,6 +842,7 @@ Directory Structure:
466
842
  const key = workflow.key || basename(file, ".toml");
467
843
  const steps = tomlData.steps || [];
468
844
  const existingId = syncState?.entities?.workflows?.[key]?.id;
845
+ const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
469
846
  if (existingId) {
470
847
  // Update existing workflow
471
848
  changes.push({ type: "workflow", action: "update", key });
@@ -474,6 +851,7 @@ Directory Structure:
474
851
  ? undefined
475
852
  : syncState?.entities?.workflows?.[key]?.modifiedAt;
476
853
  try {
854
+ // Update workflow metadata and schemas
477
855
  const updated = await client.updateWorkflow(resolvedAppId, existingId, {
478
856
  name: workflow.name,
479
857
  description: workflow.description,
@@ -481,12 +859,22 @@ Directory Structure:
481
859
  perUserMaxRunning: workflow.perUserMaxRunning,
482
860
  perUserMaxQueued: workflow.perUserMaxQueued,
483
861
  dequeueOrder: workflow.dequeueOrder,
484
- }, expectedModifiedAt);
485
- // Update draft (no conflict detection - always overwrite)
486
- await client.updateWorkflowDraft(resolvedAppId, existingId, {
487
- steps,
488
862
  inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : null,
489
- });
863
+ outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : null,
864
+ }, expectedModifiedAt);
865
+ // Update active configuration steps (or draft for legacy)
866
+ if (existingActiveConfigId) {
867
+ await client.updateWorkflowConfig(resolvedAppId, existingId, existingActiveConfigId, {
868
+ steps,
869
+ });
870
+ }
871
+ else {
872
+ // Fallback to draft update for legacy workflows
873
+ await client.updateWorkflowDraft(resolvedAppId, existingId, {
874
+ steps,
875
+ inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : null,
876
+ });
877
+ }
490
878
  info(` Updated workflow: ${key}`);
491
879
  // Update sync state with new modifiedAt
492
880
  if (syncState?.entities?.workflows?.[key] && updated?.workflow?.modifiedAt) {
@@ -509,7 +897,7 @@ Directory Structure:
509
897
  }
510
898
  }
511
899
  else {
512
- // Create new workflow
900
+ // Create new workflow (automatically creates default config)
513
901
  changes.push({ type: "workflow", action: "create", key });
514
902
  if (!options.dryRun) {
515
903
  const created = await client.createWorkflow(resolvedAppId, {
@@ -518,12 +906,13 @@ Directory Structure:
518
906
  description: workflow.description,
519
907
  steps,
520
908
  inputSchema: workflow.inputSchema ? JSON.parse(workflow.inputSchema) : undefined,
909
+ outputSchema: workflow.outputSchema ? JSON.parse(workflow.outputSchema) : undefined,
521
910
  perUserMaxRunning: workflow.perUserMaxRunning,
522
911
  perUserMaxQueued: workflow.perUserMaxQueued,
523
912
  dequeueOrder: workflow.dequeueOrder,
524
913
  });
525
914
  info(` Created workflow: ${key}`);
526
- // Add new entity to sync state
915
+ // Add new entity to sync state (including activeConfigId)
527
916
  if (syncState && created?.workflow?.workflowId && created?.workflow?.modifiedAt) {
528
917
  if (!syncState.entities.workflows) {
529
918
  syncState.entities.workflows = {};
@@ -531,18 +920,44 @@ Directory Structure:
531
920
  syncState.entities.workflows[key] = {
532
921
  id: created.workflow.workflowId,
533
922
  modifiedAt: created.workflow.modifiedAt,
923
+ activeConfigId: created.workflow.activeConfigId,
534
924
  };
535
925
  }
536
926
  }
537
927
  }
538
928
  }
539
929
  }
930
+ // Push test cases for prompts and workflows
931
+ const promptsDir2 = join(configDir, "prompts");
932
+ if (existsSync(promptsDir2)) {
933
+ for (const file of readdirSync(promptsDir2).filter((f) => f.endsWith(".toml"))) {
934
+ const tomlData = parseTomlFile(join(promptsDir2, file));
935
+ const promptKey = tomlData.prompt?.key || basename(file, ".toml");
936
+ const blockId = syncState?.entities?.prompts?.[promptKey]?.id;
937
+ if (blockId) {
938
+ await pushTestCasesForBlock(client, resolvedAppId, "prompt", blockId, promptKey, configDir, syncState, options.dryRun, changes);
939
+ }
940
+ }
941
+ }
942
+ const workflowsDir2 = join(configDir, "workflows");
943
+ if (existsSync(workflowsDir2)) {
944
+ for (const file of readdirSync(workflowsDir2).filter((f) => f.endsWith(".toml"))) {
945
+ const tomlData = parseTomlFile(join(workflowsDir2, file));
946
+ const workflowKey = tomlData.workflow?.key || basename(file, ".toml");
947
+ const blockId = syncState?.entities?.workflows?.[workflowKey]?.id;
948
+ if (blockId) {
949
+ await pushTestCasesForBlock(client, resolvedAppId, "workflow", blockId, workflowKey, configDir, syncState, options.dryRun, changes);
950
+ }
951
+ }
952
+ }
540
953
  divider();
541
954
  if (options.dryRun) {
542
955
  info("Dry run - no changes applied.");
543
956
  console.log("\nChanges that would be made:");
544
957
  for (const change of changes) {
545
- const color = change.action === "create" ? chalk.green : chalk.yellow;
958
+ const color = change.action === "create" ? chalk.green
959
+ : change.action === "delete" ? chalk.red
960
+ : chalk.yellow;
546
961
  console.log(` ${color(change.action)} ${change.type}: ${change.key}`);
547
962
  }
548
963
  }
@@ -689,6 +1104,53 @@ Directory Structure:
689
1104
  differences.push({ type: "workflow", key, status: "remote only" });
690
1105
  }
691
1106
  }
1107
+ // Compare test cases for synced prompts and workflows
1108
+ const testCaseDiffs = [];
1109
+ // Helper to compare test cases for a block
1110
+ const compareTestCases = async (blockType, blockId, blockKey) => {
1111
+ // Get remote test cases
1112
+ let remoteTestNames;
1113
+ try {
1114
+ const result = await client.listTestCases(resolvedAppId, blockType, blockId);
1115
+ const items = result.items || [];
1116
+ remoteTestNames = new Set(items.map((tc) => slugifyTestCaseName(tc.name || "unnamed")));
1117
+ }
1118
+ catch {
1119
+ return;
1120
+ }
1121
+ // Get local test case files
1122
+ const testsDir = getTestsDir(configDir, blockType, blockKey);
1123
+ const localTestSlugs = new Set();
1124
+ if (existsSync(testsDir)) {
1125
+ for (const f of readdirSync(testsDir).filter((f) => f.endsWith(".toml"))) {
1126
+ localTestSlugs.add(basename(f, ".toml"));
1127
+ }
1128
+ }
1129
+ for (const slug of localTestSlugs) {
1130
+ if (!remoteTestNames.has(slug)) {
1131
+ testCaseDiffs.push({ blockType, blockKey, slug, status: "local only" });
1132
+ }
1133
+ else {
1134
+ testCaseDiffs.push({ blockType, blockKey, slug, status: "exists" });
1135
+ }
1136
+ }
1137
+ for (const slug of remoteTestNames) {
1138
+ if (!localTestSlugs.has(slug)) {
1139
+ testCaseDiffs.push({ blockType, blockKey, slug, status: "remote only" });
1140
+ }
1141
+ }
1142
+ };
1143
+ // Compare test cases for synced blocks
1144
+ for (const p of promptsResult.items) {
1145
+ if (localPrompts.has(p.promptKey)) {
1146
+ await compareTestCases("prompt", p.promptId, p.promptKey);
1147
+ }
1148
+ }
1149
+ for (const w of workflowsResult.items) {
1150
+ if (localWorkflows.has(w.workflowKey)) {
1151
+ await compareTestCases("workflow", w.workflowId, w.workflowKey);
1152
+ }
1153
+ }
692
1154
  divider();
693
1155
  const localOnly = differences.filter((d) => d.status === "local only");
694
1156
  const remoteOnly = differences.filter((d) => d.status === "remote only");
@@ -713,10 +1175,32 @@ Directory Structure:
713
1175
  console.log(` ${chalk.dim("=")} ${d.type}: ${d.key}`);
714
1176
  }
715
1177
  }
1178
+ // Show test case differences
1179
+ const tcLocalOnly = testCaseDiffs.filter((d) => d.status === "local only");
1180
+ const tcRemoteOnly = testCaseDiffs.filter((d) => d.status === "remote only");
1181
+ const tcSynced = testCaseDiffs.filter((d) => d.status === "exists");
1182
+ if (tcLocalOnly.length > 0 || tcRemoteOnly.length > 0) {
1183
+ console.log();
1184
+ info("Test Cases:");
1185
+ for (const d of tcLocalOnly) {
1186
+ console.log(` ${chalk.green("+")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}`);
1187
+ }
1188
+ for (const d of tcRemoteOnly) {
1189
+ console.log(` ${chalk.red("-")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}`);
1190
+ }
1191
+ for (const d of tcSynced) {
1192
+ console.log(` ${chalk.dim("=")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}`);
1193
+ }
1194
+ }
716
1195
  divider();
717
1196
  keyValue("Local only", localOnly.length);
718
1197
  keyValue("Remote only", remoteOnly.length);
719
1198
  keyValue("Synced", existing.length);
1199
+ if (testCaseDiffs.length > 0) {
1200
+ keyValue("Test Cases (local only)", tcLocalOnly.length);
1201
+ keyValue("Test Cases (remote only)", tcRemoteOnly.length);
1202
+ keyValue("Test Cases (synced)", tcSynced.length);
1203
+ }
720
1204
  }
721
1205
  catch (err) {
722
1206
  error(err.message);