@worca/app 1.2.0-rc.2 → 1.2.0-rc.3
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/README.md +21 -1
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +183 -8
- package/src/core/ask/models.mjs +7 -1
- package/src/core/ask/turn.mjs +5 -0
- package/src/core/config.mjs +33 -4
- package/src/core/folder-dialog.mjs +21 -12
- package/src/core/marketplaces.mjs +13 -0
- package/src/core/model-env.mjs +41 -0
- package/src/core/model-test.mjs +2 -1
- package/src/core/plugin-manifest.mjs +45 -8
- package/src/core/run-harness.mjs +7 -0
- package/src/core/settings.mjs +61 -0
- package/src/core/title.mjs +72 -9
- package/src/core/ui-instance.mjs +31 -4
- package/src/core/workflow-export.mjs +1169 -0
- package/src/core/workflow-share.mjs +191 -0
- package/src/core/workflows.mjs +35 -4
- package/src/shared/graph/validate.mjs +5 -3
- package/ui/public/app.js +653 -22
- package/ui/public/ask-panel.mjs +4 -2
- package/ui/public/export-slug.mjs +30 -0
- package/ui/public/graph/composer.mjs +24 -3
- package/ui/public/graph/inspector.mjs +4 -1
- package/ui/public/index.html +127 -1
- package/ui/public/models-view.mjs +34 -3
- package/ui/public/style.css +65 -5
- package/ui/server.mjs +214 -75
package/ui/server.mjs
CHANGED
|
@@ -42,7 +42,10 @@ import {
|
|
|
42
42
|
askMaxTurns, askMaxBudgetUsd, setAskMaxTurns, setAskMaxBudgetUsd, assertAskLimitInputs,
|
|
43
43
|
chatPrefs, setChatPrefs,
|
|
44
44
|
debugSpawnEnabled as storedDebugSpawnEnabled, effectiveDebugSpawn, setDebugSpawnEnabled, assertDebugSpawnInput, SETTINGS_POST_KEYS,
|
|
45
|
+
titleModel as storedTitleModel, setTitleModel, assertTitleModelInput,
|
|
46
|
+
hideBuiltinModels, setHideBuiltinModels, assertHideBuiltinModelsInput,
|
|
45
47
|
} from '../src/core/settings.mjs';
|
|
48
|
+
import { describeTitleModel } from '../src/core/title.mjs';
|
|
46
49
|
import {
|
|
47
50
|
ASK_ID_RE, createThread as askCreateThread, getThread as askGetThread,
|
|
48
51
|
listThreads as askListThreads, updateThread as askUpdateThread,
|
|
@@ -85,7 +88,7 @@ import { pickFolderNative } from '../src/core/folder-dialog.mjs';
|
|
|
85
88
|
import { listFolders } from '../src/core/fs-browse.mjs';
|
|
86
89
|
import {
|
|
87
90
|
readConfig, setStep, addCustomModel, removeCustomModel, listModels,
|
|
88
|
-
PREDEFINED_MODELS, agentSteps, EFFORTS,
|
|
91
|
+
PREDEFINED_MODELS, agentSteps, EFFORTS, catalogHasModel,
|
|
89
92
|
readRunConfig, setNodeModel, setFeedbackCycles, setWireCycles, setActiveWorkflow, resetWorkflowConfig,
|
|
90
93
|
globalModelRefs, removeGlobalModelAndRefs, promoteCustomModel, costUnreliableModelIds,
|
|
91
94
|
} from '../src/core/config.mjs';
|
|
@@ -107,7 +110,10 @@ import {
|
|
|
107
110
|
} from '../src/core/workflows.mjs';
|
|
108
111
|
import { registryPortsFn } from '../src/core/graph/registry-ports.mjs';
|
|
109
112
|
import { sweepV1Runs, V1_RUN_RETIRED } from '../src/core/db.mjs';
|
|
110
|
-
import {
|
|
113
|
+
import { exportWorkflow, exportWorkflowPlugin, ON_CONFLICT_MODES, RESOLUTION_CHOICES } from '../src/core/workflow-export.mjs';
|
|
114
|
+
import {
|
|
115
|
+
saveGraphWorkflow, importGraphWorkflow, exportGraphJson, workflowFileSlug, nodeDefaultsError,
|
|
116
|
+
} from '../src/core/workflow-share.mjs';
|
|
111
117
|
import { loadAgentRegistry } from '../src/core/agent-registry.mjs';
|
|
112
118
|
import {
|
|
113
119
|
listLocalBranches, currentBranch, isValidSourceRef, sweepRunRoots, sweepLegacyWorktreesAll,
|
|
@@ -126,7 +132,7 @@ import { createAgentGen } from '../src/core/agent-gen.mjs';
|
|
|
126
132
|
import { listAgents, readAgent, createAgent, updateAgent, deleteAgent, AGENT_KEY_RE } from '../src/core/agent-store.mjs';
|
|
127
133
|
import {
|
|
128
134
|
listInstalledPlugins, installPlugin, updatePlugin, uninstallPlugin,
|
|
129
|
-
setPluginEnabled, doctorPlugin,
|
|
135
|
+
setPluginEnabled, doctorPlugin, linkPlugin,
|
|
130
136
|
listOrphanPluginData, purgePluginData,
|
|
131
137
|
} from '../src/core/plugin-store.mjs';
|
|
132
138
|
import { fetchCandidate } from '../src/core/plugin-repo.mjs';
|
|
@@ -149,7 +155,7 @@ import { createNotifier } from '../src/core/chat/notifier.mjs';
|
|
|
149
155
|
import { TokenBucket } from '../src/core/chat/rate-limiter.mjs';
|
|
150
156
|
import { renderTest } from '../src/core/chat/renderers.mjs';
|
|
151
157
|
import { readPluginsLock, pluginCurrentDir } from '../src/core/plugins-lock.mjs';
|
|
152
|
-
import { normalizeManifest, PLUGIN_NAME_RE as MANIFEST_PLUGIN_NAME_RE } from '../src/core/plugin-manifest.mjs';
|
|
158
|
+
import { normalizeManifest, validatePluginDir, PLUGIN_NAME_RE as MANIFEST_PLUGIN_NAME_RE } from '../src/core/plugin-manifest.mjs';
|
|
153
159
|
import { listTaskSources, retryWriteback } from '../src/core/sources.mjs';
|
|
154
160
|
import { callSource, PluginOpError } from '../src/core/plugin-shim.mjs';
|
|
155
161
|
import { HLJS_GRAMMAR_IDS } from './public/hljs-loader.mjs';
|
|
@@ -2478,9 +2484,11 @@ app.delete('/api/projects', async (req, res) => {
|
|
|
2478
2484
|
// user's machine); when it reports `unsupported` the UI falls back to an
|
|
2479
2485
|
// in-app modal fed by GET /api/fs/dirs. Localhost-only like every route here
|
|
2480
2486
|
// (global isLocalRequest middleware).
|
|
2481
|
-
app.post('/api/fs/pick-folder', async (
|
|
2487
|
+
app.post('/api/fs/pick-folder', async (req, res) => {
|
|
2482
2488
|
try {
|
|
2483
|
-
|
|
2489
|
+
// `purpose` only picks the dialog title from a closed set (folder-dialog.mjs).
|
|
2490
|
+
const purpose = typeof req.body?.purpose === 'string' ? req.body.purpose : undefined;
|
|
2491
|
+
res.json(await pickFolderNative({ purpose }));
|
|
2484
2492
|
} catch (err) {
|
|
2485
2493
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2486
2494
|
}
|
|
@@ -2788,6 +2796,9 @@ const settingsState = () => ({
|
|
|
2788
2796
|
askMaxBudgetUsd: askMaxBudgetUsd(),
|
|
2789
2797
|
debugSpawnEnabled: storedDebugSpawnEnabled(), // what is STORED (the checkbox)
|
|
2790
2798
|
debugSpawnEffective: effectiveDebugSpawn(), // what the next spawn will DO, and why
|
|
2799
|
+
titleModel: storedTitleModel(), // the STORED id (the select), null = run's model
|
|
2800
|
+
titleModelEffective: describeTitleModel(), // env override / stale id, for the hint line (#422)
|
|
2801
|
+
hideBuiltinModels: hideBuiltinModels(),
|
|
2791
2802
|
});
|
|
2792
2803
|
|
|
2793
2804
|
// ---------------------------------------------------------------------------
|
|
@@ -2847,6 +2858,12 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2847
2858
|
const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
|
|
2848
2859
|
const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
|
|
2849
2860
|
const hasDebugSpawnKey = has('debugSpawnEnabled');
|
|
2861
|
+
const hasTitleModelKey = has('titleModel');
|
|
2862
|
+
const hasHideBuiltinKey = has('hideBuiltinModels');
|
|
2863
|
+
// #422: the title model is a SELECT over the catalog, so an id that is not a
|
|
2864
|
+
// catalog member is a client bug (or a stale option) — refuse it here rather
|
|
2865
|
+
// than store an id resolveModelEnv could never route.
|
|
2866
|
+
const titleModelInput = hasTitleModelKey ? (body.titleModel ?? '') : undefined;
|
|
2850
2867
|
// Normalize the budget keys first, then validate them as a SET before ANY write.
|
|
2851
2868
|
// Each setter persists on its own, so a two-key POST whose second key is invalid
|
|
2852
2869
|
// used to answer 400 with the first key already on disk, no budget-changed
|
|
@@ -2868,6 +2885,13 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2868
2885
|
assertCostLimitInputs(budget);
|
|
2869
2886
|
assertAskLimitInputs(ask);
|
|
2870
2887
|
if (hasDebugSpawnKey) assertDebugSpawnInput(body.debugSpawnEnabled);
|
|
2888
|
+
if (hasTitleModelKey) {
|
|
2889
|
+
assertTitleModelInput(titleModelInput);
|
|
2890
|
+
if (titleModelInput !== '' && titleModelInput !== null && !catalogHasModel(titleModelInput)) {
|
|
2891
|
+
throw new Error(`unknown model ${JSON.stringify(String(titleModelInput))} — pick one from the catalog`);
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
if (hasHideBuiltinKey) assertHideBuiltinModelsInput(body.hideBuiltinModels);
|
|
2871
2895
|
// Root first: it is the one key whose setter can still fail AFTER the asserts
|
|
2872
2896
|
// above (an unusable path), so every other key's write must come after it or
|
|
2873
2897
|
// a mixed POST would answer 400 with those keys already applied on disk.
|
|
@@ -2886,10 +2910,12 @@ app.post('/api/settings', async (req, res) => {
|
|
|
2886
2910
|
if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
|
|
2887
2911
|
if (has('askMaxBudgetUsd')) await setAskMaxBudgetUsd(ask.askMaxBudgetUsd);
|
|
2888
2912
|
if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
|
|
2913
|
+
if (hasTitleModelKey) await setTitleModel(titleModelInput);
|
|
2914
|
+
if (hasHideBuiltinKey) await setHideBuiltinModels(body.hideBuiltinModels);
|
|
2889
2915
|
if (hasBudgetKey) emitChanged('budget-changed');
|
|
2890
2916
|
// Other open tabs repaint their Settings cards (a stale tab could otherwise
|
|
2891
2917
|
// "save" its old checkbox state over this one with no feedback to either).
|
|
2892
|
-
if (hasAskKey || hasDebugSpawnKey) emitChanged('settings-changed');
|
|
2918
|
+
if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey) emitChanged('settings-changed');
|
|
2893
2919
|
res.json({ ...settingsState(), chat: chatPrefs() });
|
|
2894
2920
|
} catch (err) {
|
|
2895
2921
|
// The setters throw only on an unusable path -> client error (400).
|
|
@@ -3110,7 +3136,10 @@ const pluginModelsPayload = () => {
|
|
|
3110
3136
|
};
|
|
3111
3137
|
|
|
3112
3138
|
app.get('/api/models', (req, res) => {
|
|
3113
|
-
res.json({
|
|
3139
|
+
res.json({
|
|
3140
|
+
models: maskedGlobalModels(), plugin: pluginModelsPayload(), predefined: PREDEFINED_MODELS, efforts: EFFORTS,
|
|
3141
|
+
hideBuiltinModels: hideBuiltinModels(), // the Models-view checkbox (#422)
|
|
3142
|
+
});
|
|
3114
3143
|
});
|
|
3115
3144
|
|
|
3116
3145
|
app.post('/api/models', async (req, res) => {
|
|
@@ -3323,7 +3352,10 @@ app.post('/api/models/:id/test', async (req, res) => {
|
|
|
3323
3352
|
const lc = id.toLowerCase();
|
|
3324
3353
|
const global = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
|
|
3325
3354
|
const plugin = global ? null : listPluginModels().find((m) => m.id.toLowerCase() === lc);
|
|
3326
|
-
|
|
3355
|
+
// A built-in is testable too (#422): the Title-generation card offers it, and
|
|
3356
|
+
// a first-party id with no routing env is exactly the spawn a run would make.
|
|
3357
|
+
const builtin = !global && !plugin && PREDEFINED_MODELS.some((m) => m.id.toLowerCase() === lc);
|
|
3358
|
+
if (!global && !plugin && !builtin) return res.status(404).json({ error: `unknown model id ${JSON.stringify(id)}` });
|
|
3327
3359
|
if (plugin && plugin.secrets.length) {
|
|
3328
3360
|
// Don't burn a spawn guaranteed to fail — resolveModelEnv drops unset secrets.
|
|
3329
3361
|
const unset = pluginModelSecretStatus(plugin.plugin)
|
|
@@ -3347,25 +3379,35 @@ app.post('/api/models/:id/test', async (req, res) => {
|
|
|
3347
3379
|
// /api/projects + /api/config delegation pattern: thin handlers, validation and
|
|
3348
3380
|
// atomic persistence owned by src/core/workflows.mjs + workflow-validator.mjs.
|
|
3349
3381
|
// ---------------------------------------------------------------------------
|
|
3350
|
-
//
|
|
3351
|
-
//
|
|
3352
|
-
//
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
const
|
|
3357
|
-
const
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
//
|
|
3361
|
-
//
|
|
3362
|
-
|
|
3363
|
-
if (
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3382
|
+
// nodeDefaultsError (one node-defaults block vs the project-less catalog) lives
|
|
3383
|
+
// in src/core/workflow-share.mjs now, so `worca workflow import` applies the
|
|
3384
|
+
// same gate as the routes below.
|
|
3385
|
+
|
|
3386
|
+
/** Error -> HTTP for the shared save/import/JSON path (workflow-share.mjs codes). */
|
|
3387
|
+
function sendWorkflowShareError(res, err) {
|
|
3388
|
+
const code = err && err.code;
|
|
3389
|
+
const message = err && err.message ? err.message : String(err);
|
|
3390
|
+
if (code === 'BAD_REQUEST' || code === 'UNSUPPORTED') return badRequest(res, message);
|
|
3391
|
+
// The 422 body is the SHARED validator's issue list, by construction: the
|
|
3392
|
+
// composer renders exactly what it would have computed locally, so the server
|
|
3393
|
+
// and the client can never disagree about why a graph is illegal. `summary`
|
|
3394
|
+
// is the one-line V4 fold for surfaces with no issue list (the Import button).
|
|
3395
|
+
if (code === 'INVALID_GRAPH') {
|
|
3396
|
+
return res.status(422).json({
|
|
3397
|
+
error: message, errors: err.errors || [], warnings: err.warnings || [],
|
|
3398
|
+
...(err.summary ? { summary: err.summary } : {}),
|
|
3399
|
+
});
|
|
3400
|
+
}
|
|
3401
|
+
// C-3: a name that slugs onto the reserved wf_default is a caller error, not
|
|
3402
|
+
// a server fault — 422, the same code the validator's refusal uses. MAJ-5: a
|
|
3403
|
+
// minted id already in use is a 409 carrying that id, so the dialog can offer
|
|
3404
|
+
// rename/overwrite. Both bodies carry NO issues/errors array on purpose:
|
|
3405
|
+
// app.js's saveWorkflow maps `error` straight into the save dialog's message
|
|
3406
|
+
// line, verbatim.
|
|
3407
|
+
if (code === 'RESERVED_NAME') return res.status(422).json({ error: message });
|
|
3408
|
+
if (code === 'ID_TAKEN') return res.status(409).json({ error: message, id: err.id });
|
|
3409
|
+
if (code === 'NOT_FOUND') return res.status(404).json({ error: message });
|
|
3410
|
+
return res.status(500).json({ error: message });
|
|
3369
3411
|
}
|
|
3370
3412
|
|
|
3371
3413
|
app.get('/api/workflows', async (req, res) => {
|
|
@@ -3394,63 +3436,61 @@ app.get('/api/workflows/:id', async (req, res) => {
|
|
|
3394
3436
|
if (err && (err.code === 'NOT_FOUND' || err.code === 'ARCHIVED')) {
|
|
3395
3437
|
return res.status(404).json({ error: err.message });
|
|
3396
3438
|
}
|
|
3439
|
+
// The row exists but its plugin is disabled: a conflict with the plugin's
|
|
3440
|
+
// state, not a missing row — the message names the fix.
|
|
3441
|
+
if (err && err.code === 'PLUGIN_DISABLED') return res.status(409).json({ error: err.message });
|
|
3397
3442
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3398
3443
|
}
|
|
3399
3444
|
});
|
|
3400
3445
|
|
|
3401
3446
|
app.post('/api/workflows', async (req, res) => {
|
|
3402
|
-
const body = req.body || {};
|
|
3403
3447
|
// The v1 pipeline format is RETIRED: only graphs are accepted (spec §10.2).
|
|
3404
3448
|
// The whole v1 arm (its steps-borne node defaults, validateWorkflow and
|
|
3405
3449
|
// writeWorkflow) died with it — nothing reaches the v1 store through the API.
|
|
3406
|
-
if (body.version !== 2) {
|
|
3407
|
-
return badRequest(res, 'v1 pipeline templates are no longer accepted — save a graph (version 2)');
|
|
3408
|
-
}
|
|
3409
3450
|
// ── v2 graph save ──────────────────────────────────────────────────────────
|
|
3410
|
-
//
|
|
3411
|
-
//
|
|
3412
|
-
// and
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
nodes: Array.isArray(body.nodes) ? body.nodes : [],
|
|
3418
|
-
wires: Array.isArray(body.wires) ? body.wires : [],
|
|
3419
|
-
...(body.canvas && typeof body.canvas === 'object' ? { canvas: body.canvas } : {}),
|
|
3420
|
-
};
|
|
3421
|
-
if (!graph.name) return badRequest(res, 'name is required');
|
|
3422
|
-
try {
|
|
3423
|
-
// Catalog validation FIRST: a v2 node's `config` IS its defaults block (§4),
|
|
3424
|
-
// so a value the per-project override could not name must not ride in
|
|
3425
|
-
// through a template save. nodeDefaultsError checks the tunables (model +
|
|
3426
|
-
// effort against the catalog, subagentModel against the alias enum), so
|
|
3427
|
-
// only AGENT_TUNABLES are handed to it — topology keys (awaitAll, arity,
|
|
3428
|
-
// planStoreSeed) never are.
|
|
3429
|
-
const models = await listModels('');
|
|
3430
|
-
for (const n of graph.nodes) {
|
|
3431
|
-
if (!n || n.kind !== 'agent' || !n.config || typeof n.config !== 'object') continue;
|
|
3432
|
-
const picked = Object.fromEntries(
|
|
3433
|
-
AGENT_TUNABLES.filter((k) => k in n.config).map((k) => [k, n.config[k]]));
|
|
3434
|
-
const bad = nodeDefaultsError(picked, models, `node "${n.id}"`);
|
|
3435
|
-
if (bad) return badRequest(res, bad);
|
|
3436
|
-
}
|
|
3437
|
-
const portsFn = registryPortsFn(loadAgentRegistry(AGENTS_DIR));
|
|
3438
|
-
const { errors, warnings } = validateGraph({ ...graph, version: 2 }, portsFn);
|
|
3439
|
-
if (errors.length) return res.status(422).json({ error: 'invalid graph', errors, warnings });
|
|
3440
|
-
// rejectCollision (MAJ-5): the body carried no id, so wf_<slug(name)> is a
|
|
3441
|
-
// GUESS — it must never silently replace a pipeline the user can see.
|
|
3442
|
-
const workflow = await writeGraphWorkflow(graph, { rejectCollision: true });
|
|
3451
|
+
// Catalog check + shared validator + rejectCollision persistence live in
|
|
3452
|
+
// workflow-share.mjs (saveGraphWorkflow): the SAME path the JSON import
|
|
3453
|
+
// route and `worca workflow import` take, so they can never drift. A body
|
|
3454
|
+
// WITHOUT an id mints wf_<slug(name)> — a GUESS that must never silently
|
|
3455
|
+
// replace a pipeline the user can see (MAJ-5 -> 409).
|
|
3456
|
+
try {
|
|
3457
|
+
const { workflow, warnings } = await saveGraphWorkflow(req.body || {}, { agentsDir: AGENTS_DIR });
|
|
3443
3458
|
return res.status(201).json({ workflow, warnings });
|
|
3444
3459
|
} catch (err) {
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3460
|
+
return sendWorkflowShareError(res, err);
|
|
3461
|
+
}
|
|
3462
|
+
});
|
|
3463
|
+
|
|
3464
|
+
// ---------------------------------------------------------------------------
|
|
3465
|
+
// Share a workflow as JSON (issue #421). GET .../json is the unstamped v2 graph
|
|
3466
|
+
// of the STORED row (no id/origin/timestamps; `canvas` kept), served as a
|
|
3467
|
+
// download; POST /import-json feeds one back through the shared validator,
|
|
3468
|
+
// minting an id and suffixing the name (`Name (2)`) on a collision — never
|
|
3469
|
+
// overwrite. Registered BEFORE the /:id routes so the segment can never be read
|
|
3470
|
+
// as an id. (Not `/import`: test/shared-graph-purity.test.mjs scans ui/public
|
|
3471
|
+
// for `import '<spec>'` and a bare `import'` in app.js's fetch URL trips it.)
|
|
3472
|
+
// ---------------------------------------------------------------------------
|
|
3473
|
+
app.post('/api/workflows/import-json', async (req, res) => {
|
|
3474
|
+
const body = req.body || {};
|
|
3475
|
+
const src = body.workflow && typeof body.workflow === 'object' && !Array.isArray(body.workflow) ? body.workflow : null;
|
|
3476
|
+
if (!src) return badRequest(res, 'workflow (the exported JSON object) is required');
|
|
3477
|
+
try {
|
|
3478
|
+
const r = await importGraphWorkflow(src, {
|
|
3479
|
+
name: typeof body.name === 'string' ? body.name : undefined, agentsDir: AGENTS_DIR,
|
|
3480
|
+
});
|
|
3481
|
+
return res.status(201).json(r);
|
|
3482
|
+
} catch (err) {
|
|
3483
|
+
return sendWorkflowShareError(res, err);
|
|
3484
|
+
}
|
|
3485
|
+
});
|
|
3486
|
+
|
|
3487
|
+
app.get('/api/workflows/:id/json', async (req, res) => {
|
|
3488
|
+
try {
|
|
3489
|
+
const payload = await exportGraphJson(req.params.id);
|
|
3490
|
+
res.setHeader('Content-Disposition', `attachment; filename="${workflowFileSlug(req.params.id)}.json"`);
|
|
3491
|
+
res.type('application/json').send(JSON.stringify(payload, null, 2) + '\n');
|
|
3492
|
+
} catch (err) {
|
|
3493
|
+
sendWorkflowShareError(res, err);
|
|
3454
3494
|
}
|
|
3455
3495
|
});
|
|
3456
3496
|
|
|
@@ -3498,6 +3538,72 @@ app.delete('/api/workflows/:id', async (req, res) => {
|
|
|
3498
3538
|
}
|
|
3499
3539
|
});
|
|
3500
3540
|
|
|
3541
|
+
function workflowExportErrorStatus(code) {
|
|
3542
|
+
if (code === 'NOT_FOUND') return 404;
|
|
3543
|
+
if (code === 'BAD_REQUEST' || code === 'UNSUPPORTED' || code === 'MISSING_SKILL') return 400;
|
|
3544
|
+
if (code === 'INVALID_GRAPH') return 422;
|
|
3545
|
+
if (code === 'CONFLICT' || code === 'CANCELLED') return 409;
|
|
3546
|
+
return 500;
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
app.post('/api/workflows/:id/export', async (req, res) => {
|
|
3550
|
+
const body = req.body || {};
|
|
3551
|
+
const destination = body.destination;
|
|
3552
|
+
if (destination !== 'global' && destination !== 'project' && destination !== 'plugin') {
|
|
3553
|
+
return badRequest(res, "destination must be 'global', 'project' or 'plugin'");
|
|
3554
|
+
}
|
|
3555
|
+
// ── destination 'plugin' (#421): a plugin folder the recipient links/reimports.
|
|
3556
|
+
// Same Plan/Apply shape as the Claude Code export, so the modal renders both.
|
|
3557
|
+
if (destination === 'plugin') {
|
|
3558
|
+
const pluginDir = resolveProjectDir(body.pluginDir);
|
|
3559
|
+
if (!pluginDir) return badRequest(res, 'pluginDir is required for a plugin export');
|
|
3560
|
+
try {
|
|
3561
|
+
const result = await exportWorkflowPlugin({
|
|
3562
|
+
workflowId: req.params.id, targetDir: pluginDir,
|
|
3563
|
+
pluginName: typeof body.pluginName === 'string' ? body.pluginName : undefined,
|
|
3564
|
+
keepVersion: !!body.keepVersion, dryRun: !!body.dryRun,
|
|
3565
|
+
});
|
|
3566
|
+
return res.json(result);
|
|
3567
|
+
} catch (err) {
|
|
3568
|
+
return res.status(workflowExportErrorStatus(err && err.code)).json({
|
|
3569
|
+
error: err && err.message ? err.message : String(err),
|
|
3570
|
+
...(Array.isArray(err?.errors) ? { errors: err.errors } : {}),
|
|
3571
|
+
});
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
let projectDir;
|
|
3575
|
+
if (destination === 'project') {
|
|
3576
|
+
projectDir = resolveProjectDir(body.projectDir);
|
|
3577
|
+
if (!projectDir) return badRequest(res, 'projectDir is required for a project export');
|
|
3578
|
+
}
|
|
3579
|
+
// Validate conflict handling the same way the CLI does — the write loop only fails safe if
|
|
3580
|
+
// it never sees a bogus value. An unrecognized onConflict/resolution is a caller error, not
|
|
3581
|
+
// a silent overwrite.
|
|
3582
|
+
if (body.onConflict !== undefined && !ON_CONFLICT_MODES.includes(body.onConflict)) {
|
|
3583
|
+
return badRequest(res, `onConflict must be one of: ${ON_CONFLICT_MODES.join(', ')}`);
|
|
3584
|
+
}
|
|
3585
|
+
if (body.resolutions !== undefined) {
|
|
3586
|
+
if (!body.resolutions || typeof body.resolutions !== 'object' || Array.isArray(body.resolutions)) {
|
|
3587
|
+
return badRequest(res, 'resolutions must be an object keyed by path');
|
|
3588
|
+
}
|
|
3589
|
+
for (const [path, choice] of Object.entries(body.resolutions)) {
|
|
3590
|
+
if (!RESOLUTION_CHOICES.includes(choice)) {
|
|
3591
|
+
return badRequest(res, `invalid resolution ${JSON.stringify(choice)} for ${path} (allowed: ${RESOLUTION_CHOICES.join(', ')})`);
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
}
|
|
3595
|
+
try {
|
|
3596
|
+
const result = await exportWorkflow({
|
|
3597
|
+
workflowId: req.params.id, destination, projectDir,
|
|
3598
|
+
slug: body.slug, includeAgents: body.includeAgents !== false,
|
|
3599
|
+
dryRun: !!body.dryRun, onConflict: body.onConflict, resolutions: body.resolutions,
|
|
3600
|
+
});
|
|
3601
|
+
res.json(result); // Plan {created,noop,updated,conflicts,warnings,orphans} OR Apply {written,skipped,...}
|
|
3602
|
+
} catch (err) {
|
|
3603
|
+
res.status(workflowExportErrorStatus(err && err.code)).json({ error: err && err.message ? err.message : String(err) });
|
|
3604
|
+
}
|
|
3605
|
+
});
|
|
3606
|
+
|
|
3501
3607
|
// ---------------------------------------------------------------------------
|
|
3502
3608
|
// Guardrail sets (global store, table guardrail_sets). The built-ins
|
|
3503
3609
|
// Permissive / Normal / Strict are VIRTUAL (GUARDRAIL_PRESETS) — the server
|
|
@@ -4610,12 +4716,45 @@ app.get('/api/marketplaces', (req, res) => {
|
|
|
4610
4716
|
} catch (err) { sendPluginError(res, err); }
|
|
4611
4717
|
});
|
|
4612
4718
|
|
|
4719
|
+
// Dev-mode link of a LOCAL plugin folder — the `worca plugin link <dir>` path
|
|
4720
|
+
// (validate, symlink current/, import its workflow templates). Reached directly
|
|
4721
|
+
// via POST /api/plugins/link, and by POST /api/marketplaces when the "marketplace"
|
|
4722
|
+
// handed in turns out to be a single plugin folder (the natural thing to paste
|
|
4723
|
+
// after `Export… → Worca plugin`, issue #421).
|
|
4724
|
+
async function linkPluginDir(dir) {
|
|
4725
|
+
const abs = resolveProjectDir(dir);
|
|
4726
|
+
if (!abs) throw Object.assign(new Error('dir is required'), { code: 'BAD_REQUEST' });
|
|
4727
|
+
const v = validatePluginDir(abs);
|
|
4728
|
+
if (!v.ok || !v.manifest) {
|
|
4729
|
+
const lines = v.problems.filter((p) => p.level === 'error').map((p) => p.message);
|
|
4730
|
+
throw Object.assign(new Error(`cannot link ${abs}: ${lines.join('; ') || 'not a valid plugin folder'}`), { code: 'BAD_REQUEST' });
|
|
4731
|
+
}
|
|
4732
|
+
let out;
|
|
4733
|
+
try { out = await linkPlugin(v.manifest.name, abs); }
|
|
4734
|
+
catch (err) { throw Object.assign(err instanceof Error ? err : new Error(String(err)), { code: err?.code || 'BAD_REQUEST' }); }
|
|
4735
|
+
reloadChatWorkers(v.manifest.name);
|
|
4736
|
+
return out; // { ok, name, dir, workflows: { imported, skipped } }
|
|
4737
|
+
}
|
|
4738
|
+
|
|
4739
|
+
app.post('/api/plugins/link', async (req, res) => {
|
|
4740
|
+
try {
|
|
4741
|
+
res.json(await linkPluginDir(req.body && typeof req.body.dir === 'string' ? req.body.dir : ''));
|
|
4742
|
+
} catch (err) { sendPluginError(res, err); }
|
|
4743
|
+
});
|
|
4744
|
+
|
|
4613
4745
|
app.post('/api/marketplaces', async (req, res) => {
|
|
4614
4746
|
const url = req.body && typeof req.body.url === 'string' ? req.body.url.trim() : '';
|
|
4615
4747
|
if (!url) return badRequest(res, 'url is required');
|
|
4616
4748
|
try {
|
|
4617
4749
|
res.json({ ok: true, marketplace: withInstalled([await addMarketplace(url)])[0] });
|
|
4618
|
-
} catch (err) {
|
|
4750
|
+
} catch (err) {
|
|
4751
|
+
if (err && err.code === 'PLUGIN_FOLDER') {
|
|
4752
|
+
// Not a marketplace but a plugin folder: link it, and SAY that is what happened.
|
|
4753
|
+
try { return res.json({ ok: true, linked: true, plugin: await linkPluginDir(err.dir) }); }
|
|
4754
|
+
catch (e) { return sendPluginError(res, e); }
|
|
4755
|
+
}
|
|
4756
|
+
sendPluginError(res, err);
|
|
4757
|
+
}
|
|
4619
4758
|
});
|
|
4620
4759
|
|
|
4621
4760
|
// refresh-all (a distinct path from :id/refresh, so registration order is irrelevant).
|