@worca/app 1.2.0-rc.2 → 1.2.0

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';
@@ -177,7 +183,24 @@ const PUBLIC_DIR = path.join(__dirname, 'public');
177
183
  const AGENTS_DIR = path.join(PROJECT_ROOT, 'agents');
178
184
  const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
179
185
  const require = createRequire(import.meta.url);
180
- const PKG_VERSION = require('../package.json').version;
186
+ const PKG = require('../package.json');
187
+ const PKG_VERSION = PKG.version;
188
+ // Settings ▸ About identity, fixed at module load. repository.url is normalised
189
+ // from npm's git forms (git+https, ssh://git@, git://, scp-style git@host:path)
190
+ // to a browsable https URL; releaseUrl points at the tag the release workflow
191
+ // publishes from (.github/workflows/release-npm-app.yml: worca-app-v<version>).
192
+ const repoWebUrl = (raw) => String(raw || '')
193
+ .replace(/^git\+/, '')
194
+ .replace(/^ssh:\/\/git@/, 'https://')
195
+ .replace(/^git:\/\//, 'https://')
196
+ .replace(/^git@([^:/]+):/, 'https://$1/')
197
+ .replace(/\.git$/, '');
198
+ const APP_REPO_URL = repoWebUrl(PKG.repository && PKG.repository.url);
199
+ const APP_INFO = Object.freeze({
200
+ version: PKG_VERSION || '',
201
+ repoUrl: APP_REPO_URL,
202
+ releaseUrl: APP_REPO_URL && PKG_VERSION ? `${APP_REPO_URL}/releases/tag/worca-app-v${PKG_VERSION}` : '',
203
+ });
181
204
  const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
182
205
  // Primaries plus the sub-language grammars their instances register
183
206
  // (hljs-loader.mjs); a shipped but unmapped grammar stays a plain 404.
@@ -2478,9 +2501,11 @@ app.delete('/api/projects', async (req, res) => {
2478
2501
  // user's machine); when it reports `unsupported` the UI falls back to an
2479
2502
  // in-app modal fed by GET /api/fs/dirs. Localhost-only like every route here
2480
2503
  // (global isLocalRequest middleware).
2481
- app.post('/api/fs/pick-folder', async (_req, res) => {
2504
+ app.post('/api/fs/pick-folder', async (req, res) => {
2482
2505
  try {
2483
- res.json(await pickFolderNative());
2506
+ // `purpose` only picks the dialog title from a closed set (folder-dialog.mjs).
2507
+ const purpose = typeof req.body?.purpose === 'string' ? req.body.purpose : undefined;
2508
+ res.json(await pickFolderNative({ purpose }));
2484
2509
  } catch (err) {
2485
2510
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
2486
2511
  }
@@ -2769,6 +2794,10 @@ app.get('/api/workspaces/:id/runs/:runId/artifact', async (req, res) => {
2769
2794
  // projectsRootDefault : what applies when projectsRoot is blank — the env tier
2770
2795
  // when exported, else defaultRoot(). The UI placeholder.
2771
2796
  // Additive; `default` keeps its `root` meaning.
2797
+ // app : { version, repoUrl, releaseUrl } — static identity for
2798
+ // the About card (APP_INFO, from package.json). GET-only:
2799
+ // it is not a setting, so POST keeps echoing
2800
+ // settingsState() + chat unchanged.
2772
2801
  // POST /api/settings -> set either key and return the resulting full state.
2773
2802
  // Only keys PRESENT in the body are written, so a projectsRoot-only POST can
2774
2803
  // never reset `root` (and vice versa). An explicitly empty value still resets
@@ -2788,6 +2817,9 @@ const settingsState = () => ({
2788
2817
  askMaxBudgetUsd: askMaxBudgetUsd(),
2789
2818
  debugSpawnEnabled: storedDebugSpawnEnabled(), // what is STORED (the checkbox)
2790
2819
  debugSpawnEffective: effectiveDebugSpawn(), // what the next spawn will DO, and why
2820
+ titleModel: storedTitleModel(), // the STORED id (the select), null = run's model
2821
+ titleModelEffective: describeTitleModel(), // env override / stale id, for the hint line (#422)
2822
+ hideBuiltinModels: hideBuiltinModels(),
2791
2823
  });
2792
2824
 
2793
2825
  // ---------------------------------------------------------------------------
@@ -2834,7 +2866,7 @@ app.post('/api/shutdown', (req, res) => {
2834
2866
  });
2835
2867
 
2836
2868
  app.get('/api/settings', (_req, res) => {
2837
- res.json({ ...settingsState(), chat: chatPrefs() });
2869
+ res.json({ ...settingsState(), chat: chatPrefs(), app: APP_INFO });
2838
2870
  });
2839
2871
 
2840
2872
  app.get('/api/budget', (_req, res) => {
@@ -2847,6 +2879,12 @@ app.post('/api/settings', async (req, res) => {
2847
2879
  const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
2848
2880
  const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
2849
2881
  const hasDebugSpawnKey = has('debugSpawnEnabled');
2882
+ const hasTitleModelKey = has('titleModel');
2883
+ const hasHideBuiltinKey = has('hideBuiltinModels');
2884
+ // #422: the title model is a SELECT over the catalog, so an id that is not a
2885
+ // catalog member is a client bug (or a stale option) — refuse it here rather
2886
+ // than store an id resolveModelEnv could never route.
2887
+ const titleModelInput = hasTitleModelKey ? (body.titleModel ?? '') : undefined;
2850
2888
  // Normalize the budget keys first, then validate them as a SET before ANY write.
2851
2889
  // Each setter persists on its own, so a two-key POST whose second key is invalid
2852
2890
  // used to answer 400 with the first key already on disk, no budget-changed
@@ -2868,6 +2906,13 @@ app.post('/api/settings', async (req, res) => {
2868
2906
  assertCostLimitInputs(budget);
2869
2907
  assertAskLimitInputs(ask);
2870
2908
  if (hasDebugSpawnKey) assertDebugSpawnInput(body.debugSpawnEnabled);
2909
+ if (hasTitleModelKey) {
2910
+ assertTitleModelInput(titleModelInput);
2911
+ if (titleModelInput !== '' && titleModelInput !== null && !catalogHasModel(titleModelInput)) {
2912
+ throw new Error(`unknown model ${JSON.stringify(String(titleModelInput))} — pick one from the catalog`);
2913
+ }
2914
+ }
2915
+ if (hasHideBuiltinKey) assertHideBuiltinModelsInput(body.hideBuiltinModels);
2871
2916
  // Root first: it is the one key whose setter can still fail AFTER the asserts
2872
2917
  // above (an unusable path), so every other key's write must come after it or
2873
2918
  // a mixed POST would answer 400 with those keys already applied on disk.
@@ -2886,10 +2931,12 @@ app.post('/api/settings', async (req, res) => {
2886
2931
  if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
2887
2932
  if (has('askMaxBudgetUsd')) await setAskMaxBudgetUsd(ask.askMaxBudgetUsd);
2888
2933
  if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
2934
+ if (hasTitleModelKey) await setTitleModel(titleModelInput);
2935
+ if (hasHideBuiltinKey) await setHideBuiltinModels(body.hideBuiltinModels);
2889
2936
  if (hasBudgetKey) emitChanged('budget-changed');
2890
2937
  // Other open tabs repaint their Settings cards (a stale tab could otherwise
2891
2938
  // "save" its old checkbox state over this one with no feedback to either).
2892
- if (hasAskKey || hasDebugSpawnKey) emitChanged('settings-changed');
2939
+ if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey) emitChanged('settings-changed');
2893
2940
  res.json({ ...settingsState(), chat: chatPrefs() });
2894
2941
  } catch (err) {
2895
2942
  // The setters throw only on an unusable path -> client error (400).
@@ -3110,7 +3157,10 @@ const pluginModelsPayload = () => {
3110
3157
  };
3111
3158
 
3112
3159
  app.get('/api/models', (req, res) => {
3113
- res.json({ models: maskedGlobalModels(), plugin: pluginModelsPayload(), predefined: PREDEFINED_MODELS, efforts: EFFORTS });
3160
+ res.json({
3161
+ models: maskedGlobalModels(), plugin: pluginModelsPayload(), predefined: PREDEFINED_MODELS, efforts: EFFORTS,
3162
+ hideBuiltinModels: hideBuiltinModels(), // the Models-view checkbox (#422)
3163
+ });
3114
3164
  });
3115
3165
 
3116
3166
  app.post('/api/models', async (req, res) => {
@@ -3323,7 +3373,10 @@ app.post('/api/models/:id/test', async (req, res) => {
3323
3373
  const lc = id.toLowerCase();
3324
3374
  const global = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
3325
3375
  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)}` });
3376
+ // A built-in is testable too (#422): the Title-generation card offers it, and
3377
+ // a first-party id with no routing env is exactly the spawn a run would make.
3378
+ const builtin = !global && !plugin && PREDEFINED_MODELS.some((m) => m.id.toLowerCase() === lc);
3379
+ if (!global && !plugin && !builtin) return res.status(404).json({ error: `unknown model id ${JSON.stringify(id)}` });
3327
3380
  if (plugin && plugin.secrets.length) {
3328
3381
  // Don't burn a spawn guaranteed to fail — resolveModelEnv drops unset secrets.
3329
3382
  const unset = pluginModelSecretStatus(plugin.plugin)
@@ -3347,25 +3400,35 @@ app.post('/api/models/:id/test', async (req, res) => {
3347
3400
  // /api/projects + /api/config delegation pattern: thin handlers, validation and
3348
3401
  // atomic persistence owned by src/core/workflows.mjs + workflow-validator.mjs.
3349
3402
  // ---------------------------------------------------------------------------
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 '';
3403
+ // nodeDefaultsError (one node-defaults block vs the project-less catalog) lives
3404
+ // in src/core/workflow-share.mjs now, so `worca workflow import` applies the
3405
+ // same gate as the routes below.
3406
+
3407
+ /** Error -> HTTP for the shared save/import/JSON path (workflow-share.mjs codes). */
3408
+ function sendWorkflowShareError(res, err) {
3409
+ const code = err && err.code;
3410
+ const message = err && err.message ? err.message : String(err);
3411
+ if (code === 'BAD_REQUEST' || code === 'UNSUPPORTED') return badRequest(res, message);
3412
+ // The 422 body is the SHARED validator's issue list, by construction: the
3413
+ // composer renders exactly what it would have computed locally, so the server
3414
+ // and the client can never disagree about why a graph is illegal. `summary`
3415
+ // is the one-line V4 fold for surfaces with no issue list (the Import button).
3416
+ if (code === 'INVALID_GRAPH') {
3417
+ return res.status(422).json({
3418
+ error: message, errors: err.errors || [], warnings: err.warnings || [],
3419
+ ...(err.summary ? { summary: err.summary } : {}),
3420
+ });
3421
+ }
3422
+ // C-3: a name that slugs onto the reserved wf_default is a caller error, not
3423
+ // a server fault — 422, the same code the validator's refusal uses. MAJ-5: a
3424
+ // minted id already in use is a 409 carrying that id, so the dialog can offer
3425
+ // rename/overwrite. Both bodies carry NO issues/errors array on purpose:
3426
+ // app.js's saveWorkflow maps `error` straight into the save dialog's message
3427
+ // line, verbatim.
3428
+ if (code === 'RESERVED_NAME') return res.status(422).json({ error: message });
3429
+ if (code === 'ID_TAKEN') return res.status(409).json({ error: message, id: err.id });
3430
+ if (code === 'NOT_FOUND') return res.status(404).json({ error: message });
3431
+ return res.status(500).json({ error: message });
3369
3432
  }
3370
3433
 
3371
3434
  app.get('/api/workflows', async (req, res) => {
@@ -3394,63 +3457,61 @@ app.get('/api/workflows/:id', async (req, res) => {
3394
3457
  if (err && (err.code === 'NOT_FOUND' || err.code === 'ARCHIVED')) {
3395
3458
  return res.status(404).json({ error: err.message });
3396
3459
  }
3460
+ // The row exists but its plugin is disabled: a conflict with the plugin's
3461
+ // state, not a missing row — the message names the fix.
3462
+ if (err && err.code === 'PLUGIN_DISABLED') return res.status(409).json({ error: err.message });
3397
3463
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
3398
3464
  }
3399
3465
  });
3400
3466
 
3401
3467
  app.post('/api/workflows', async (req, res) => {
3402
- const body = req.body || {};
3403
3468
  // The v1 pipeline format is RETIRED: only graphs are accepted (spec §10.2).
3404
3469
  // The whole v1 arm (its steps-borne node defaults, validateWorkflow and
3405
3470
  // 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
3471
  // ── 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 });
3472
+ // Catalog check + shared validator + rejectCollision persistence live in
3473
+ // workflow-share.mjs (saveGraphWorkflow): the SAME path the JSON import
3474
+ // route and `worca workflow import` take, so they can never drift. A body
3475
+ // WITHOUT an id mints wf_<slug(name)> — a GUESS that must never silently
3476
+ // replace a pipeline the user can see (MAJ-5 -> 409).
3477
+ try {
3478
+ const { workflow, warnings } = await saveGraphWorkflow(req.body || {}, { agentsDir: AGENTS_DIR });
3443
3479
  return res.status(201).json({ workflow, warnings });
3444
3480
  } 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) });
3481
+ return sendWorkflowShareError(res, err);
3482
+ }
3483
+ });
3484
+
3485
+ // ---------------------------------------------------------------------------
3486
+ // Share a workflow as JSON (issue #421). GET .../json is the unstamped v2 graph
3487
+ // of the STORED row (no id/origin/timestamps; `canvas` kept), served as a
3488
+ // download; POST /import-json feeds one back through the shared validator,
3489
+ // minting an id and suffixing the name (`Name (2)`) on a collision — never
3490
+ // overwrite. Registered BEFORE the /:id routes so the segment can never be read
3491
+ // as an id. (Not `/import`: test/shared-graph-purity.test.mjs scans ui/public
3492
+ // for `import '<spec>'` and a bare `import'` in app.js's fetch URL trips it.)
3493
+ // ---------------------------------------------------------------------------
3494
+ app.post('/api/workflows/import-json', async (req, res) => {
3495
+ const body = req.body || {};
3496
+ const src = body.workflow && typeof body.workflow === 'object' && !Array.isArray(body.workflow) ? body.workflow : null;
3497
+ if (!src) return badRequest(res, 'workflow (the exported JSON object) is required');
3498
+ try {
3499
+ const r = await importGraphWorkflow(src, {
3500
+ name: typeof body.name === 'string' ? body.name : undefined, agentsDir: AGENTS_DIR,
3501
+ });
3502
+ return res.status(201).json(r);
3503
+ } catch (err) {
3504
+ return sendWorkflowShareError(res, err);
3505
+ }
3506
+ });
3507
+
3508
+ app.get('/api/workflows/:id/json', async (req, res) => {
3509
+ try {
3510
+ const payload = await exportGraphJson(req.params.id);
3511
+ res.setHeader('Content-Disposition', `attachment; filename="${workflowFileSlug(req.params.id)}.json"`);
3512
+ res.type('application/json').send(JSON.stringify(payload, null, 2) + '\n');
3513
+ } catch (err) {
3514
+ sendWorkflowShareError(res, err);
3454
3515
  }
3455
3516
  });
3456
3517
 
@@ -3498,6 +3559,72 @@ app.delete('/api/workflows/:id', async (req, res) => {
3498
3559
  }
3499
3560
  });
3500
3561
 
3562
+ function workflowExportErrorStatus(code) {
3563
+ if (code === 'NOT_FOUND') return 404;
3564
+ if (code === 'BAD_REQUEST' || code === 'UNSUPPORTED' || code === 'MISSING_SKILL') return 400;
3565
+ if (code === 'INVALID_GRAPH') return 422;
3566
+ if (code === 'CONFLICT' || code === 'CANCELLED') return 409;
3567
+ return 500;
3568
+ }
3569
+
3570
+ app.post('/api/workflows/:id/export', async (req, res) => {
3571
+ const body = req.body || {};
3572
+ const destination = body.destination;
3573
+ if (destination !== 'global' && destination !== 'project' && destination !== 'plugin') {
3574
+ return badRequest(res, "destination must be 'global', 'project' or 'plugin'");
3575
+ }
3576
+ // ── destination 'plugin' (#421): a plugin folder the recipient links/reimports.
3577
+ // Same Plan/Apply shape as the Claude Code export, so the modal renders both.
3578
+ if (destination === 'plugin') {
3579
+ const pluginDir = resolveProjectDir(body.pluginDir);
3580
+ if (!pluginDir) return badRequest(res, 'pluginDir is required for a plugin export');
3581
+ try {
3582
+ const result = await exportWorkflowPlugin({
3583
+ workflowId: req.params.id, targetDir: pluginDir,
3584
+ pluginName: typeof body.pluginName === 'string' ? body.pluginName : undefined,
3585
+ keepVersion: !!body.keepVersion, dryRun: !!body.dryRun,
3586
+ });
3587
+ return res.json(result);
3588
+ } catch (err) {
3589
+ return res.status(workflowExportErrorStatus(err && err.code)).json({
3590
+ error: err && err.message ? err.message : String(err),
3591
+ ...(Array.isArray(err?.errors) ? { errors: err.errors } : {}),
3592
+ });
3593
+ }
3594
+ }
3595
+ let projectDir;
3596
+ if (destination === 'project') {
3597
+ projectDir = resolveProjectDir(body.projectDir);
3598
+ if (!projectDir) return badRequest(res, 'projectDir is required for a project export');
3599
+ }
3600
+ // Validate conflict handling the same way the CLI does — the write loop only fails safe if
3601
+ // it never sees a bogus value. An unrecognized onConflict/resolution is a caller error, not
3602
+ // a silent overwrite.
3603
+ if (body.onConflict !== undefined && !ON_CONFLICT_MODES.includes(body.onConflict)) {
3604
+ return badRequest(res, `onConflict must be one of: ${ON_CONFLICT_MODES.join(', ')}`);
3605
+ }
3606
+ if (body.resolutions !== undefined) {
3607
+ if (!body.resolutions || typeof body.resolutions !== 'object' || Array.isArray(body.resolutions)) {
3608
+ return badRequest(res, 'resolutions must be an object keyed by path');
3609
+ }
3610
+ for (const [path, choice] of Object.entries(body.resolutions)) {
3611
+ if (!RESOLUTION_CHOICES.includes(choice)) {
3612
+ return badRequest(res, `invalid resolution ${JSON.stringify(choice)} for ${path} (allowed: ${RESOLUTION_CHOICES.join(', ')})`);
3613
+ }
3614
+ }
3615
+ }
3616
+ try {
3617
+ const result = await exportWorkflow({
3618
+ workflowId: req.params.id, destination, projectDir,
3619
+ slug: body.slug, includeAgents: body.includeAgents !== false,
3620
+ dryRun: !!body.dryRun, onConflict: body.onConflict, resolutions: body.resolutions,
3621
+ });
3622
+ res.json(result); // Plan {created,noop,updated,conflicts,warnings,orphans} OR Apply {written,skipped,...}
3623
+ } catch (err) {
3624
+ res.status(workflowExportErrorStatus(err && err.code)).json({ error: err && err.message ? err.message : String(err) });
3625
+ }
3626
+ });
3627
+
3501
3628
  // ---------------------------------------------------------------------------
3502
3629
  // Guardrail sets (global store, table guardrail_sets). The built-ins
3503
3630
  // Permissive / Normal / Strict are VIRTUAL (GUARDRAIL_PRESETS) — the server
@@ -4610,12 +4737,45 @@ app.get('/api/marketplaces', (req, res) => {
4610
4737
  } catch (err) { sendPluginError(res, err); }
4611
4738
  });
4612
4739
 
4740
+ // Dev-mode link of a LOCAL plugin folder — the `worca plugin link <dir>` path
4741
+ // (validate, symlink current/, import its workflow templates). Reached directly
4742
+ // via POST /api/plugins/link, and by POST /api/marketplaces when the "marketplace"
4743
+ // handed in turns out to be a single plugin folder (the natural thing to paste
4744
+ // after `Export… → Worca plugin`, issue #421).
4745
+ async function linkPluginDir(dir) {
4746
+ const abs = resolveProjectDir(dir);
4747
+ if (!abs) throw Object.assign(new Error('dir is required'), { code: 'BAD_REQUEST' });
4748
+ const v = validatePluginDir(abs);
4749
+ if (!v.ok || !v.manifest) {
4750
+ const lines = v.problems.filter((p) => p.level === 'error').map((p) => p.message);
4751
+ throw Object.assign(new Error(`cannot link ${abs}: ${lines.join('; ') || 'not a valid plugin folder'}`), { code: 'BAD_REQUEST' });
4752
+ }
4753
+ let out;
4754
+ try { out = await linkPlugin(v.manifest.name, abs); }
4755
+ catch (err) { throw Object.assign(err instanceof Error ? err : new Error(String(err)), { code: err?.code || 'BAD_REQUEST' }); }
4756
+ reloadChatWorkers(v.manifest.name);
4757
+ return out; // { ok, name, dir, workflows: { imported, skipped } }
4758
+ }
4759
+
4760
+ app.post('/api/plugins/link', async (req, res) => {
4761
+ try {
4762
+ res.json(await linkPluginDir(req.body && typeof req.body.dir === 'string' ? req.body.dir : ''));
4763
+ } catch (err) { sendPluginError(res, err); }
4764
+ });
4765
+
4613
4766
  app.post('/api/marketplaces', async (req, res) => {
4614
4767
  const url = req.body && typeof req.body.url === 'string' ? req.body.url.trim() : '';
4615
4768
  if (!url) return badRequest(res, 'url is required');
4616
4769
  try {
4617
4770
  res.json({ ok: true, marketplace: withInstalled([await addMarketplace(url)])[0] });
4618
- } catch (err) { sendPluginError(res, err); }
4771
+ } catch (err) {
4772
+ if (err && err.code === 'PLUGIN_FOLDER') {
4773
+ // Not a marketplace but a plugin folder: link it, and SAY that is what happened.
4774
+ try { return res.json({ ok: true, linked: true, plugin: await linkPluginDir(err.dir) }); }
4775
+ catch (e) { return sendPluginError(res, e); }
4776
+ }
4777
+ sendPluginError(res, err);
4778
+ }
4619
4779
  });
4620
4780
 
4621
4781
  // refresh-all (a distinct path from :id/refresh, so registration order is irrelevant).