@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/README.md +36 -2
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +358 -19
- 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 +262 -0
- 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 +305 -90
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 {
|
|
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) ||
|
|
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 (
|
|
2487
|
+
app.post('/api/fs/pick-folder', async (req, res) => {
|
|
2473
2488
|
try {
|
|
2474
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
//
|
|
3299
|
-
//
|
|
3300
|
-
//
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
const
|
|
3305
|
-
const
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
//
|
|
3309
|
-
//
|
|
3310
|
-
|
|
3311
|
-
if (
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
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
|
-
//
|
|
3359
|
-
//
|
|
3360
|
-
// and
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
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
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
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) {
|
|
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
|
|
5373
|
-
const url =
|
|
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
|
-
|
|
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
|
};
|