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.
- package/dist/bin/primitive.js +0 -0
- package/dist/src/commands/sync.js +504 -20
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/tokens.js +177 -0
- package/dist/src/commands/tokens.js.map +1 -0
- package/dist/src/commands/workflows.js +376 -2
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.js +25 -0
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/fetch.js +7 -4
- package/dist/src/lib/fetch.js.map +1 -1
- package/package.json +4 -2
package/dist/bin/primitive.js
CHANGED
|
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
|
-
|
|
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
|
-
|
|
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
|
|
137
|
-
app.toml
|
|
138
|
-
integrations/*.toml
|
|
139
|
-
prompts/*.toml
|
|
140
|
-
|
|
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) =>
|
|
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
|
|
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);
|