@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/README.md +21 -1
- package/package.json +1 -1
- package/src/cli/worca-cc.mjs +183 -8
- package/src/core/ask/models.mjs +7 -1
- package/src/core/ask/turn.mjs +5 -0
- package/src/core/config.mjs +33 -4
- package/src/core/folder-dialog.mjs +21 -12
- package/src/core/marketplaces.mjs +13 -0
- package/src/core/model-env.mjs +41 -0
- package/src/core/model-test.mjs +2 -1
- package/src/core/plugin-manifest.mjs +45 -8
- package/src/core/run-harness.mjs +7 -0
- package/src/core/settings.mjs +61 -0
- package/src/core/title.mjs +72 -9
- package/src/core/ui-instance.mjs +31 -4
- package/src/core/workflow-export.mjs +1169 -0
- package/src/core/workflow-share.mjs +191 -0
- package/src/core/workflows.mjs +35 -4
- package/src/shared/graph/validate.mjs +5 -3
- package/ui/public/app.js +675 -23
- package/ui/public/ask-panel.mjs +242 -8
- 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 +151 -5
- package/ui/public/models-view.mjs +34 -3
- package/ui/public/style.css +117 -8
- package/ui/server.mjs +237 -77
package/ui/server.mjs
CHANGED
|
@@ -42,7 +42,10 @@ import {
|
|
|
42
42
|
askMaxTurns, askMaxBudgetUsd, setAskMaxTurns, setAskMaxBudgetUsd, assertAskLimitInputs,
|
|
43
43
|
chatPrefs, setChatPrefs,
|
|
44
44
|
debugSpawnEnabled as storedDebugSpawnEnabled, effectiveDebugSpawn, setDebugSpawnEnabled, assertDebugSpawnInput, SETTINGS_POST_KEYS,
|
|
45
|
+
titleModel as storedTitleModel, setTitleModel, assertTitleModelInput,
|
|
46
|
+
hideBuiltinModels, setHideBuiltinModels, assertHideBuiltinModelsInput,
|
|
45
47
|
} from '../src/core/settings.mjs';
|
|
48
|
+
import { describeTitleModel } from '../src/core/title.mjs';
|
|
46
49
|
import {
|
|
47
50
|
ASK_ID_RE, createThread as askCreateThread, getThread as askGetThread,
|
|
48
51
|
listThreads as askListThreads, updateThread as askUpdateThread,
|
|
@@ -85,7 +88,7 @@ import { pickFolderNative } from '../src/core/folder-dialog.mjs';
|
|
|
85
88
|
import { listFolders } from '../src/core/fs-browse.mjs';
|
|
86
89
|
import {
|
|
87
90
|
readConfig, setStep, addCustomModel, removeCustomModel, listModels,
|
|
88
|
-
PREDEFINED_MODELS, agentSteps, EFFORTS,
|
|
91
|
+
PREDEFINED_MODELS, agentSteps, EFFORTS, catalogHasModel,
|
|
89
92
|
readRunConfig, setNodeModel, setFeedbackCycles, setWireCycles, setActiveWorkflow, resetWorkflowConfig,
|
|
90
93
|
globalModelRefs, removeGlobalModelAndRefs, promoteCustomModel, costUnreliableModelIds,
|
|
91
94
|
} from '../src/core/config.mjs';
|
|
@@ -107,7 +110,10 @@ import {
|
|
|
107
110
|
} from '../src/core/workflows.mjs';
|
|
108
111
|
import { registryPortsFn } from '../src/core/graph/registry-ports.mjs';
|
|
109
112
|
import { sweepV1Runs, V1_RUN_RETIRED } from '../src/core/db.mjs';
|
|
110
|
-
import {
|
|
113
|
+
import { exportWorkflow, exportWorkflowPlugin, ON_CONFLICT_MODES, RESOLUTION_CHOICES } from '../src/core/workflow-export.mjs';
|
|
114
|
+
import {
|
|
115
|
+
saveGraphWorkflow, importGraphWorkflow, exportGraphJson, workflowFileSlug, nodeDefaultsError,
|
|
116
|
+
} from '../src/core/workflow-share.mjs';
|
|
111
117
|
import { loadAgentRegistry } from '../src/core/agent-registry.mjs';
|
|
112
118
|
import {
|
|
113
119
|
listLocalBranches, currentBranch, isValidSourceRef, sweepRunRoots, sweepLegacyWorktreesAll,
|
|
@@ -126,7 +132,7 @@ import { createAgentGen } from '../src/core/agent-gen.mjs';
|
|
|
126
132
|
import { listAgents, readAgent, createAgent, updateAgent, deleteAgent, AGENT_KEY_RE } from '../src/core/agent-store.mjs';
|
|
127
133
|
import {
|
|
128
134
|
listInstalledPlugins, installPlugin, updatePlugin, uninstallPlugin,
|
|
129
|
-
setPluginEnabled, doctorPlugin,
|
|
135
|
+
setPluginEnabled, doctorPlugin, linkPlugin,
|
|
130
136
|
listOrphanPluginData, purgePluginData,
|
|
131
137
|
} from '../src/core/plugin-store.mjs';
|
|
132
138
|
import { fetchCandidate } from '../src/core/plugin-repo.mjs';
|
|
@@ -149,7 +155,7 @@ import { createNotifier } from '../src/core/chat/notifier.mjs';
|
|
|
149
155
|
import { TokenBucket } from '../src/core/chat/rate-limiter.mjs';
|
|
150
156
|
import { renderTest } from '../src/core/chat/renderers.mjs';
|
|
151
157
|
import { readPluginsLock, pluginCurrentDir } from '../src/core/plugins-lock.mjs';
|
|
152
|
-
import { normalizeManifest, PLUGIN_NAME_RE as MANIFEST_PLUGIN_NAME_RE } from '../src/core/plugin-manifest.mjs';
|
|
158
|
+
import { normalizeManifest, validatePluginDir, PLUGIN_NAME_RE as MANIFEST_PLUGIN_NAME_RE } from '../src/core/plugin-manifest.mjs';
|
|
153
159
|
import { listTaskSources, retryWriteback } from '../src/core/sources.mjs';
|
|
154
160
|
import { callSource, PluginOpError } from '../src/core/plugin-shim.mjs';
|
|
155
161
|
import { HLJS_GRAMMAR_IDS } from './public/hljs-loader.mjs';
|
|
@@ -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
|
|
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 (
|
|
2504
|
+
app.post('/api/fs/pick-folder', async (req, res) => {
|
|
2482
2505
|
try {
|
|
2483
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
//
|
|
3351
|
-
//
|
|
3352
|
-
//
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
const
|
|
3357
|
-
const
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
//
|
|
3361
|
-
//
|
|
3362
|
-
|
|
3363
|
-
if (
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
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
|
-
//
|
|
3411
|
-
//
|
|
3412
|
-
// and
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
nodes: Array.isArray(body.nodes) ? body.nodes : [],
|
|
3418
|
-
wires: Array.isArray(body.wires) ? body.wires : [],
|
|
3419
|
-
...(body.canvas && typeof body.canvas === 'object' ? { canvas: body.canvas } : {}),
|
|
3420
|
-
};
|
|
3421
|
-
if (!graph.name) return badRequest(res, 'name is required');
|
|
3422
|
-
try {
|
|
3423
|
-
// Catalog validation FIRST: a v2 node's `config` IS its defaults block (§4),
|
|
3424
|
-
// so a value the per-project override could not name must not ride in
|
|
3425
|
-
// through a template save. nodeDefaultsError checks the tunables (model +
|
|
3426
|
-
// effort against the catalog, subagentModel against the alias enum), so
|
|
3427
|
-
// only AGENT_TUNABLES are handed to it — topology keys (awaitAll, arity,
|
|
3428
|
-
// planStoreSeed) never are.
|
|
3429
|
-
const models = await listModels('');
|
|
3430
|
-
for (const n of graph.nodes) {
|
|
3431
|
-
if (!n || n.kind !== 'agent' || !n.config || typeof n.config !== 'object') continue;
|
|
3432
|
-
const picked = Object.fromEntries(
|
|
3433
|
-
AGENT_TUNABLES.filter((k) => k in n.config).map((k) => [k, n.config[k]]));
|
|
3434
|
-
const bad = nodeDefaultsError(picked, models, `node "${n.id}"`);
|
|
3435
|
-
if (bad) return badRequest(res, bad);
|
|
3436
|
-
}
|
|
3437
|
-
const portsFn = registryPortsFn(loadAgentRegistry(AGENTS_DIR));
|
|
3438
|
-
const { errors, warnings } = validateGraph({ ...graph, version: 2 }, portsFn);
|
|
3439
|
-
if (errors.length) return res.status(422).json({ error: 'invalid graph', errors, warnings });
|
|
3440
|
-
// rejectCollision (MAJ-5): the body carried no id, so wf_<slug(name)> is a
|
|
3441
|
-
// GUESS — it must never silently replace a pipeline the user can see.
|
|
3442
|
-
const workflow = await writeGraphWorkflow(graph, { rejectCollision: true });
|
|
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
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
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) {
|
|
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).
|