@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/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 { validateGraph, AGENT_TUNABLES } from '../src/shared/graph/validate.mjs';
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 (_req, res) => {
2487
+ app.post('/api/fs/pick-folder', async (req, res) => {
2482
2488
  try {
2483
- res.json(await pickFolderNative());
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({ models: maskedGlobalModels(), plugin: pluginModelsPayload(), predefined: PREDEFINED_MODELS, efforts: EFFORTS });
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
- if (!global && !plugin) return res.status(404).json({ error: `unknown model id ${JSON.stringify(id)}` });
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
- // Validate one node-defaults block against the project-less catalog. Returns an
3351
- // error message, or '' when the block is acceptable. Mirrors setStep's rules so a
3352
- // workflow default can never name something a per-project override could not.
3353
- function nodeDefaultsError(raw, models, where) {
3354
- if (raw == null) return '';
3355
- if (typeof raw !== 'object' || Array.isArray(raw)) return `defaults for ${where} must be an object`;
3356
- const model = typeof raw.model === 'string' ? raw.model.trim() : '';
3357
- const effort = typeof raw.effort === 'string' ? raw.effort.trim() : '';
3358
- const entry = model ? models.find((m) => m.id === model) : null;
3359
- if (model && !entry) return `unknown model "${model}"`;
3360
- // subagentModel is a fixed alias enum, NOT a catalog id: validated via the
3361
- // shared helper so a typo is a 400 with the same message every writer uses.
3362
- const subIssue = subagentModelIssue(raw.subagentModel);
3363
- if (subIssue) return subIssue;
3364
- if (!effort) return '';
3365
- if (!EFFORTS.includes(effort)) return `unknown effort "${effort}"`;
3366
- if (!entry) return 'select a model before choosing an effort';
3367
- if (!entry.efforts.includes(effort)) return `model "${model}" does not support effort "${effort}"`;
3368
- return '';
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
- // The 422 body is the SHARED validator's issue list, by construction: the
3411
- // composer renders exactly what it would have computed locally, so the server
3412
- // and the client can never disagree about why a graph is illegal.
3413
- const graph = {
3414
- id: typeof body.id === 'string' ? body.id : undefined,
3415
- name: typeof body.name === 'string' ? body.name.trim() : '',
3416
- domain: typeof body.domain === 'string' ? body.domain : undefined,
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
- // C-3: a name that slugs onto the reserved wf_default is a caller error, not
3446
- // a server fault — 422, the same code the validator's refusal uses. MAJ-5: a
3447
- // minted id already in use is a 409 carrying that id, so the dialog can offer
3448
- // rename/overwrite. Both bodies carry NO issues/errors array on purpose:
3449
- // app.js's saveWorkflow maps `error` straight into the save dialog's message
3450
- // line, verbatim.
3451
- if (err && err.code === 'RESERVED_NAME') return res.status(422).json({ error: err.message });
3452
- if (err && err.code === 'ID_TAKEN') return res.status(409).json({ error: err.message, id: err.id });
3453
- return res.status(500).json({ error: err && err.message ? err.message : String(err) });
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) { sendPluginError(res, 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).