@worca/app 1.2.0-rc.1 → 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';
@@ -93,6 +96,9 @@ import { listGlobalModels, addGlobalModel, updateGlobalModel } from '../src/core
93
96
  import { modelEnvRef, maskModelEnvValue, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
94
97
  import { listPluginModels, modelSecretsSchema, pluginModelSecretStatus } from '../src/core/plugin-models.mjs';
95
98
  import { testModel } from '../src/core/model-test.mjs';
99
+ import {
100
+ DEFAULT_UI_PORT, UI_HEALTH_NAME, newUiToken, writeUiInstance, removeUiInstance, uiUrl,
101
+ } from '../src/core/ui-instance.mjs';
96
102
  import { validateGuardrails } from '../src/core/guardrails.mjs';
97
103
  import {
98
104
  listBuiltinGuardrailSets, listGuardrailSets, readGuardrailSet,
@@ -104,7 +110,10 @@ import {
104
110
  } from '../src/core/workflows.mjs';
105
111
  import { registryPortsFn } from '../src/core/graph/registry-ports.mjs';
106
112
  import { sweepV1Runs, V1_RUN_RETIRED } from '../src/core/db.mjs';
107
- 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';
108
117
  import { loadAgentRegistry } from '../src/core/agent-registry.mjs';
109
118
  import {
110
119
  listLocalBranches, currentBranch, isValidSourceRef, sweepRunRoots, sweepLegacyWorktreesAll,
@@ -123,7 +132,7 @@ import { createAgentGen } from '../src/core/agent-gen.mjs';
123
132
  import { listAgents, readAgent, createAgent, updateAgent, deleteAgent, AGENT_KEY_RE } from '../src/core/agent-store.mjs';
124
133
  import {
125
134
  listInstalledPlugins, installPlugin, updatePlugin, uninstallPlugin,
126
- setPluginEnabled, doctorPlugin,
135
+ setPluginEnabled, doctorPlugin, linkPlugin,
127
136
  listOrphanPluginData, purgePluginData,
128
137
  } from '../src/core/plugin-store.mjs';
129
138
  import { fetchCandidate } from '../src/core/plugin-repo.mjs';
@@ -146,7 +155,7 @@ import { createNotifier } from '../src/core/chat/notifier.mjs';
146
155
  import { TokenBucket } from '../src/core/chat/rate-limiter.mjs';
147
156
  import { renderTest } from '../src/core/chat/renderers.mjs';
148
157
  import { readPluginsLock, pluginCurrentDir } from '../src/core/plugins-lock.mjs';
149
- 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';
150
159
  import { listTaskSources, retryWriteback } from '../src/core/sources.mjs';
151
160
  import { callSource, PluginOpError } from '../src/core/plugin-shim.mjs';
152
161
  import { HLJS_GRAMMAR_IDS } from './public/hljs-loader.mjs';
@@ -174,6 +183,7 @@ const PUBLIC_DIR = path.join(__dirname, 'public');
174
183
  const AGENTS_DIR = path.join(PROJECT_ROOT, 'agents');
175
184
  const SKILLS_DIR = path.join(PROJECT_ROOT, 'skills');
176
185
  const require = createRequire(import.meta.url);
186
+ const PKG_VERSION = require('../package.json').version;
177
187
  const HLJS_LANGUAGE_FILE_RE = /^[a-z0-9][a-z0-9-]{0,63}\.min\.js$/;
178
188
  // Primaries plus the sub-language grammars their instances register
179
189
  // (hljs-loader.mjs); a shipped but unmapped grammar stays a plain 404.
@@ -212,7 +222,7 @@ const ASK_VENDOR_ASSETS = {
212
222
  dompurify: resolveEsmAsset('dompurify'),
213
223
  };
214
224
 
215
- const PORT = Number(process.env.PORT) || 4317;
225
+ const PORT = Number(process.env.PORT) || DEFAULT_UI_PORT;
216
226
  // Bind to loopback by default (S1). Power users who knowingly want LAN exposure
217
227
  // can set WORCA_HOST=0.0.0.0, but the localhost-only Host/Origin guard still
218
228
  // applies unless they also front it with auth.
@@ -275,6 +285,11 @@ const MAX_BUFFER = 5000;
275
285
  const app = express();
276
286
  const server = http.createServer(app);
277
287
  const wss = new WebSocketServer({ server, path: '/ws' });
288
+ // ws re-emits the http server's 'error' on the WebSocketServer. With no listener
289
+ // here, an EADDRINUSE on listen() became an unhandled 'error' event and a full
290
+ // stack trace; the http server's own handler (isMain below) is the one that
291
+ // reports it, so this side of the pair only has to not throw.
292
+ wss.on('error', () => {});
278
293
 
279
294
  /** All currently connected sockets. */
280
295
  const sockets = new Set();
@@ -2469,9 +2484,11 @@ app.delete('/api/projects', async (req, res) => {
2469
2484
  // user's machine); when it reports `unsupported` the UI falls back to an
2470
2485
  // in-app modal fed by GET /api/fs/dirs. Localhost-only like every route here
2471
2486
  // (global isLocalRequest middleware).
2472
- app.post('/api/fs/pick-folder', async (_req, res) => {
2487
+ app.post('/api/fs/pick-folder', async (req, res) => {
2473
2488
  try {
2474
- 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 }));
2475
2492
  } catch (err) {
2476
2493
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
2477
2494
  }
@@ -2779,6 +2796,52 @@ const settingsState = () => ({
2779
2796
  askMaxBudgetUsd: askMaxBudgetUsd(),
2780
2797
  debugSpawnEnabled: storedDebugSpawnEnabled(), // what is STORED (the checkbox)
2781
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(),
2802
+ });
2803
+
2804
+ // ---------------------------------------------------------------------------
2805
+ // Instance lifecycle (`worca ui status|stop|restart`, src/core/ui-instance.mjs)
2806
+ // ---------------------------------------------------------------------------
2807
+ // `uiControl` is set by the boot block below when the server owns a port. Under
2808
+ // test (app imported, no bind) it stays empty: /api/health still answers, and
2809
+ // /api/shutdown refuses with 503 rather than exiting the test runner.
2810
+ const uiControl = { token: null, onShutdown: null, startedAt: null };
2811
+ const startedAtIso = () => uiControl.startedAt || null;
2812
+
2813
+ app.get('/api/health', (req, res) => {
2814
+ const addr = req.socket && req.socket.localPort;
2815
+ res.json({
2816
+ name: UI_HEALTH_NAME,
2817
+ version: PKG_VERSION,
2818
+ pid: process.pid,
2819
+ host: HOST,
2820
+ port: addr || PORT,
2821
+ startedAt: startedAtIso(),
2822
+ });
2823
+ });
2824
+
2825
+ /** Constant-time bearer check; `expected` is the boot-time token from ui.json. */
2826
+ function bearerMatches(header, expected) {
2827
+ if (!expected || typeof header !== 'string') return false;
2828
+ const m = /^Bearer\s+(\S+)$/i.exec(header.trim());
2829
+ if (!m) return false;
2830
+ const a = Buffer.from(m[1]);
2831
+ const b = Buffer.from(expected);
2832
+ return a.length === b.length && timingSafeEqual(a, b);
2833
+ }
2834
+
2835
+ app.post('/api/shutdown', (req, res) => {
2836
+ if (!uiControl.token || typeof uiControl.onShutdown !== 'function') {
2837
+ return res.status(503).json({ error: 'shutdown is only available on a server started with `worca ui`' });
2838
+ }
2839
+ if (!bearerMatches(req.headers.authorization, uiControl.token)) {
2840
+ return res.status(401).json({ error: 'shutdown requires the bearer token from the instance file' });
2841
+ }
2842
+ res.status(202).json({ ok: true, pid: process.pid });
2843
+ // Answer first, exit on the next tick so the 202 actually leaves the socket.
2844
+ setImmediate(() => uiControl.onShutdown('request'));
2782
2845
  });
2783
2846
 
2784
2847
  app.get('/api/settings', (_req, res) => {
@@ -2795,6 +2858,12 @@ app.post('/api/settings', async (req, res) => {
2795
2858
  const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
2796
2859
  const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
2797
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;
2798
2867
  // Normalize the budget keys first, then validate them as a SET before ANY write.
2799
2868
  // Each setter persists on its own, so a two-key POST whose second key is invalid
2800
2869
  // used to answer 400 with the first key already on disk, no budget-changed
@@ -2816,6 +2885,13 @@ app.post('/api/settings', async (req, res) => {
2816
2885
  assertCostLimitInputs(budget);
2817
2886
  assertAskLimitInputs(ask);
2818
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);
2819
2895
  // Root first: it is the one key whose setter can still fail AFTER the asserts
2820
2896
  // above (an unusable path), so every other key's write must come after it or
2821
2897
  // a mixed POST would answer 400 with those keys already applied on disk.
@@ -2834,10 +2910,12 @@ app.post('/api/settings', async (req, res) => {
2834
2910
  if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
2835
2911
  if (has('askMaxBudgetUsd')) await setAskMaxBudgetUsd(ask.askMaxBudgetUsd);
2836
2912
  if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
2913
+ if (hasTitleModelKey) await setTitleModel(titleModelInput);
2914
+ if (hasHideBuiltinKey) await setHideBuiltinModels(body.hideBuiltinModels);
2837
2915
  if (hasBudgetKey) emitChanged('budget-changed');
2838
2916
  // Other open tabs repaint their Settings cards (a stale tab could otherwise
2839
2917
  // "save" its old checkbox state over this one with no feedback to either).
2840
- if (hasAskKey || hasDebugSpawnKey) emitChanged('settings-changed');
2918
+ if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey) emitChanged('settings-changed');
2841
2919
  res.json({ ...settingsState(), chat: chatPrefs() });
2842
2920
  } catch (err) {
2843
2921
  // The setters throw only on an unusable path -> client error (400).
@@ -3058,7 +3136,10 @@ const pluginModelsPayload = () => {
3058
3136
  };
3059
3137
 
3060
3138
  app.get('/api/models', (req, res) => {
3061
- 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
+ });
3062
3143
  });
3063
3144
 
3064
3145
  app.post('/api/models', async (req, res) => {
@@ -3271,7 +3352,10 @@ app.post('/api/models/:id/test', async (req, res) => {
3271
3352
  const lc = id.toLowerCase();
3272
3353
  const global = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
3273
3354
  const plugin = global ? null : listPluginModels().find((m) => m.id.toLowerCase() === lc);
3274
- 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)}` });
3275
3359
  if (plugin && plugin.secrets.length) {
3276
3360
  // Don't burn a spawn guaranteed to fail — resolveModelEnv drops unset secrets.
3277
3361
  const unset = pluginModelSecretStatus(plugin.plugin)
@@ -3295,25 +3379,35 @@ app.post('/api/models/:id/test', async (req, res) => {
3295
3379
  // /api/projects + /api/config delegation pattern: thin handlers, validation and
3296
3380
  // atomic persistence owned by src/core/workflows.mjs + workflow-validator.mjs.
3297
3381
  // ---------------------------------------------------------------------------
3298
- // Validate one node-defaults block against the project-less catalog. Returns an
3299
- // error message, or '' when the block is acceptable. Mirrors setStep's rules so a
3300
- // workflow default can never name something a per-project override could not.
3301
- function nodeDefaultsError(raw, models, where) {
3302
- if (raw == null) return '';
3303
- if (typeof raw !== 'object' || Array.isArray(raw)) return `defaults for ${where} must be an object`;
3304
- const model = typeof raw.model === 'string' ? raw.model.trim() : '';
3305
- const effort = typeof raw.effort === 'string' ? raw.effort.trim() : '';
3306
- const entry = model ? models.find((m) => m.id === model) : null;
3307
- if (model && !entry) return `unknown model "${model}"`;
3308
- // subagentModel is a fixed alias enum, NOT a catalog id: validated via the
3309
- // shared helper so a typo is a 400 with the same message every writer uses.
3310
- const subIssue = subagentModelIssue(raw.subagentModel);
3311
- if (subIssue) return subIssue;
3312
- if (!effort) return '';
3313
- if (!EFFORTS.includes(effort)) return `unknown effort "${effort}"`;
3314
- if (!entry) return 'select a model before choosing an effort';
3315
- if (!entry.efforts.includes(effort)) return `model "${model}" does not support effort "${effort}"`;
3316
- 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 });
3317
3411
  }
3318
3412
 
3319
3413
  app.get('/api/workflows', async (req, res) => {
@@ -3342,63 +3436,61 @@ app.get('/api/workflows/:id', async (req, res) => {
3342
3436
  if (err && (err.code === 'NOT_FOUND' || err.code === 'ARCHIVED')) {
3343
3437
  return res.status(404).json({ error: err.message });
3344
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 });
3345
3442
  res.status(500).json({ error: err && err.message ? err.message : String(err) });
3346
3443
  }
3347
3444
  });
3348
3445
 
3349
3446
  app.post('/api/workflows', async (req, res) => {
3350
- const body = req.body || {};
3351
3447
  // The v1 pipeline format is RETIRED: only graphs are accepted (spec §10.2).
3352
3448
  // The whole v1 arm (its steps-borne node defaults, validateWorkflow and
3353
3449
  // writeWorkflow) died with it — nothing reaches the v1 store through the API.
3354
- if (body.version !== 2) {
3355
- return badRequest(res, 'v1 pipeline templates are no longer accepted — save a graph (version 2)');
3356
- }
3357
3450
  // ── v2 graph save ──────────────────────────────────────────────────────────
3358
- // The 422 body is the SHARED validator's issue list, by construction: the
3359
- // composer renders exactly what it would have computed locally, so the server
3360
- // and the client can never disagree about why a graph is illegal.
3361
- const graph = {
3362
- id: typeof body.id === 'string' ? body.id : undefined,
3363
- name: typeof body.name === 'string' ? body.name.trim() : '',
3364
- domain: typeof body.domain === 'string' ? body.domain : undefined,
3365
- nodes: Array.isArray(body.nodes) ? body.nodes : [],
3366
- wires: Array.isArray(body.wires) ? body.wires : [],
3367
- ...(body.canvas && typeof body.canvas === 'object' ? { canvas: body.canvas } : {}),
3368
- };
3369
- if (!graph.name) return badRequest(res, 'name is required');
3370
- try {
3371
- // Catalog validation FIRST: a v2 node's `config` IS its defaults block (§4),
3372
- // so a value the per-project override could not name must not ride in
3373
- // through a template save. nodeDefaultsError checks the tunables (model +
3374
- // effort against the catalog, subagentModel against the alias enum), so
3375
- // only AGENT_TUNABLES are handed to it — topology keys (awaitAll, arity,
3376
- // planStoreSeed) never are.
3377
- const models = await listModels('');
3378
- for (const n of graph.nodes) {
3379
- if (!n || n.kind !== 'agent' || !n.config || typeof n.config !== 'object') continue;
3380
- const picked = Object.fromEntries(
3381
- AGENT_TUNABLES.filter((k) => k in n.config).map((k) => [k, n.config[k]]));
3382
- const bad = nodeDefaultsError(picked, models, `node "${n.id}"`);
3383
- if (bad) return badRequest(res, bad);
3384
- }
3385
- const portsFn = registryPortsFn(loadAgentRegistry(AGENTS_DIR));
3386
- const { errors, warnings } = validateGraph({ ...graph, version: 2 }, portsFn);
3387
- if (errors.length) return res.status(422).json({ error: 'invalid graph', errors, warnings });
3388
- // rejectCollision (MAJ-5): the body carried no id, so wf_<slug(name)> is a
3389
- // GUESS — it must never silently replace a pipeline the user can see.
3390
- 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 });
3391
3458
  return res.status(201).json({ workflow, warnings });
3392
3459
  } catch (err) {
3393
- // C-3: a name that slugs onto the reserved wf_default is a caller error, not
3394
- // a server fault — 422, the same code the validator's refusal uses. MAJ-5: a
3395
- // minted id already in use is a 409 carrying that id, so the dialog can offer
3396
- // rename/overwrite. Both bodies carry NO issues/errors array on purpose:
3397
- // app.js's saveWorkflow maps `error` straight into the save dialog's message
3398
- // line, verbatim.
3399
- if (err && err.code === 'RESERVED_NAME') return res.status(422).json({ error: err.message });
3400
- if (err && err.code === 'ID_TAKEN') return res.status(409).json({ error: err.message, id: err.id });
3401
- 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);
3402
3494
  }
3403
3495
  });
3404
3496
 
@@ -3446,6 +3538,72 @@ app.delete('/api/workflows/:id', async (req, res) => {
3446
3538
  }
3447
3539
  });
3448
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
+
3449
3607
  // ---------------------------------------------------------------------------
3450
3608
  // Guardrail sets (global store, table guardrail_sets). The built-ins
3451
3609
  // Permissive / Normal / Strict are VIRTUAL (GUARDRAIL_PRESETS) — the server
@@ -4558,12 +4716,45 @@ app.get('/api/marketplaces', (req, res) => {
4558
4716
  } catch (err) { sendPluginError(res, err); }
4559
4717
  });
4560
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
+
4561
4745
  app.post('/api/marketplaces', async (req, res) => {
4562
4746
  const url = req.body && typeof req.body.url === 'string' ? req.body.url.trim() : '';
4563
4747
  if (!url) return badRequest(res, 'url is required');
4564
4748
  try {
4565
4749
  res.json({ ok: true, marketplace: withInstalled([await addMarketplace(url)])[0] });
4566
- } 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
+ }
4567
4758
  });
4568
4759
 
4569
4760
  // refresh-all (a distinct path from :id/refresh, so registration order is irrelevant).
@@ -5364,30 +5555,53 @@ if (isMain) {
5364
5555
  console.error(`[worca-ui] boot maintenance failed: ${err && err.message ? err.message : err}`);
5365
5556
  });
5366
5557
 
5558
+ // A port that is already taken is an EXPECTED state (the UI is usually already
5559
+ // up), not a crash: one line, no stack, exit 1. `worca ui` probes the port
5560
+ // before spawning this process and prints the friendlier "already running"
5561
+ // block itself; this branch is for `node ui/server.mjs` run by hand or a race.
5367
5562
  server.on('error', (err) => {
5563
+ if (err && err.code === 'EADDRINUSE') {
5564
+ console.error(`[worca-ui] port ${PORT} is already in use — is the UI already running?`);
5565
+ console.error(`[worca-ui] check with \`worca ui status\`, restart with \`worca ui restart\`, or pick a port: \`worca ui --port <n>\``);
5566
+ process.exit(1);
5567
+ }
5368
5568
  console.error(`[worca-ui] server error: ${err && err.message ? err.message : err}`);
5369
5569
  });
5370
5570
 
5571
+ // Channel workers must die with the server (design §9: persistent-process
5572
+ // hygiene). Graceful shutdown frame -> 5s grace -> SIGKILL, then exit. The
5573
+ // same path serves POST /api/shutdown (`worca ui stop`), which exits 0.
5574
+ let shuttingDown = false;
5575
+ let wroteInstanceFile = false;
5576
+ const exitCodeFor = (signal) => (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 0);
5577
+ const shutdown = (signal) => {
5578
+ if (shuttingDown) return;
5579
+ shuttingDown = true;
5580
+ channelHost.stop().finally(() => process.exit(exitCodeFor(signal)));
5581
+ };
5582
+ process.on('SIGINT', () => shutdown('SIGINT'));
5583
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
5584
+ // 'exit' handlers must be synchronous; removeUiInstance is. `ifPid` keeps an
5585
+ // old server exiting late from deleting the file a newer one just wrote.
5586
+ process.on('exit', () => { if (wroteInstanceFile) removeUiInstance({ ifPid: process.pid }); });
5587
+
5371
5588
  server.listen(PORT, HOST, () => {
5372
- const shown = HOST === '127.0.0.1' || HOST === '::1' ? 'localhost' : HOST;
5373
- const url = `http://${shown}:${PORT}`;
5589
+ const port = server.address().port;
5590
+ const url = uiUrl({ host: HOST, port });
5374
5591
  console.log(`[worca-ui] listening on ${url} (bound to ${HOST})`);
5375
- console.log(`[worca-ui] WebSocket on ws://${shown}:${PORT}/ws`);
5592
+ uiControl.token = newUiToken();
5593
+ uiControl.onShutdown = shutdown;
5594
+ uiControl.startedAt = new Date().toISOString();
5595
+ writeUiInstance({
5596
+ pid: process.pid, host: HOST, port, token: uiControl.token,
5597
+ version: PKG_VERSION, startedAt: uiControl.startedAt,
5598
+ }).then(() => { wroteInstanceFile = true; }, (err) => {
5599
+ console.error(`[worca-ui] could not write the instance file (\`worca ui stop\` will fall back to a signal): ${err && err.message ? err.message : err}`);
5600
+ });
5376
5601
  try { channelHost.start(); } catch (err) {
5377
5602
  console.error(`[worca-ui] chat channel host failed to start: ${err && err.message ? err.message : err}`);
5378
5603
  }
5379
5604
  });
5380
-
5381
- // Channel workers must die with the server (design §9: persistent-process
5382
- // hygiene). Graceful shutdown frame -> 5s grace -> SIGKILL, then exit.
5383
- let shuttingDown = false;
5384
- const shutdownChat = (signal) => {
5385
- if (shuttingDown) return;
5386
- shuttingDown = true;
5387
- channelHost.stop().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
5388
- };
5389
- process.on('SIGINT', () => shutdownChat('SIGINT'));
5390
- process.on('SIGTERM', () => shutdownChat('SIGTERM'));
5391
5605
  }
5392
5606
 
5393
5607
  export { app, server, runs };
@@ -5396,4 +5610,5 @@ export const _testing = {
5396
5610
  chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
5397
5611
  chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
5398
5612
  emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
5613
+ uiControl, bearerMatches,
5399
5614
  };