@worca/ui 0.36.0 → 0.37.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/app/main.bundle.js +1460 -1356
- package/app/main.bundle.js.map +3 -3
- package/app/styles.css +37 -1
- package/package.json +1 -1
- package/server/app.js +79 -0
- package/server/integrations/index.js +163 -0
- package/server/project-routes.js +63 -13
- package/server/run-dir-resolver.js +37 -1
- package/server/settings-validator.js +22 -0
package/app/styles.css
CHANGED
|
@@ -2202,7 +2202,6 @@ sl-details.dispatch-section-details::part(content) {
|
|
|
2202
2202
|
|
|
2203
2203
|
.pricing-model-name {
|
|
2204
2204
|
font-weight: 500;
|
|
2205
|
-
text-transform: uppercase;
|
|
2206
2205
|
}
|
|
2207
2206
|
|
|
2208
2207
|
.pricing-table sl-input {
|
|
@@ -2226,6 +2225,28 @@ sl-input.pricing-input::part(input) {
|
|
|
2226
2225
|
margin-bottom: 8px;
|
|
2227
2226
|
}
|
|
2228
2227
|
|
|
2228
|
+
/* Inline explainer that distinguishes alt-endpoint overrides from default
|
|
2229
|
+
Anthropic runs (where Claude CLI's total_cost_usd remains authoritative). */
|
|
2230
|
+
.pricing-source-note {
|
|
2231
|
+
display: block;
|
|
2232
|
+
margin: 12px 0;
|
|
2233
|
+
font-size: 13px;
|
|
2234
|
+
line-height: 1.5;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
.pricing-source-note strong {
|
|
2238
|
+
display: block;
|
|
2239
|
+
margin-bottom: 4px;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
.pricing-source-note code {
|
|
2243
|
+
font-family: var(--sl-font-mono, ui-monospace, SFMono-Regular, monospace);
|
|
2244
|
+
font-size: 0.92em;
|
|
2245
|
+
padding: 1px 4px;
|
|
2246
|
+
background: var(--sl-color-neutral-100, rgba(127, 127, 127, 0.12));
|
|
2247
|
+
border-radius: 3px;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2229
2250
|
/* Settings toast overlay */
|
|
2230
2251
|
.settings-toast {
|
|
2231
2252
|
position: fixed;
|
|
@@ -5308,6 +5329,10 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
5308
5329
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
5309
5330
|
}
|
|
5310
5331
|
|
|
5332
|
+
.model-card .settings-card-title {
|
|
5333
|
+
text-transform: none;
|
|
5334
|
+
}
|
|
5335
|
+
|
|
5311
5336
|
.model-card.is-dirty {
|
|
5312
5337
|
border-color: var(--status-running, #3b82f6);
|
|
5313
5338
|
box-shadow: 0 0 0 1px var(--status-running, #3b82f6);
|
|
@@ -6217,6 +6242,17 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
6217
6242
|
color: var(--fg-muted);
|
|
6218
6243
|
}
|
|
6219
6244
|
|
|
6245
|
+
/* W-061: plan revision selector inside the plan dialog */
|
|
6246
|
+
.plan-iter-selector {
|
|
6247
|
+
display: flex;
|
|
6248
|
+
flex-wrap: wrap;
|
|
6249
|
+
align-items: center;
|
|
6250
|
+
gap: 6px;
|
|
6251
|
+
margin: 0 0 12px;
|
|
6252
|
+
padding-bottom: 12px;
|
|
6253
|
+
border-bottom: 1px solid var(--border, var(--bg-secondary));
|
|
6254
|
+
}
|
|
6255
|
+
|
|
6220
6256
|
/* --- sl-dialog: wider markdown-body dialogs (plan, guide) --- */
|
|
6221
6257
|
|
|
6222
6258
|
sl-dialog.markdown-dialog::part(panel) {
|
package/package.json
CHANGED
package/server/app.js
CHANGED
|
@@ -102,6 +102,23 @@ export function buildWorkspaceArgs(workspace_root, workspace_id, manifest) {
|
|
|
102
102
|
return args;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Returns true when `host` resolves to a loopback bind — undefined/null are
|
|
107
|
+
* treated as loopback because the production default in
|
|
108
|
+
* `worca-ui/server/index.js` is `127.0.0.1`. Used by the outbound-send route
|
|
109
|
+
* to refuse exposing user-addressable chat from a non-loopback bind.
|
|
110
|
+
*
|
|
111
|
+
* @param {string|undefined|null} host
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
export function isLoopbackHost(host) {
|
|
115
|
+
if (host === undefined || host === null || host === '') return true;
|
|
116
|
+
if (host === 'localhost' || host === '::1') return true;
|
|
117
|
+
if (host === '::ffff:127.0.0.1') return true;
|
|
118
|
+
if (host.startsWith('127.')) return true;
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
105
122
|
export function createApp(options = {}) {
|
|
106
123
|
const app = express();
|
|
107
124
|
const appDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'app');
|
|
@@ -865,6 +882,68 @@ export function createApp(options = {}) {
|
|
|
865
882
|
res.json(integrations.status());
|
|
866
883
|
});
|
|
867
884
|
|
|
885
|
+
// POST /api/integrations/send — fan out a NormalizedMessage to one or
|
|
886
|
+
// more chat platforms through the same allowlist + rate-limiter pipeline
|
|
887
|
+
// the worca event fan-out uses. Drives the worca-notify skill.
|
|
888
|
+
//
|
|
889
|
+
// Body shape:
|
|
890
|
+
// {
|
|
891
|
+
// platforms?: string[], // omit → all enabled chat adapters
|
|
892
|
+
// message: NormalizedMessage, // { title, body: MessageSegment[], severity }
|
|
893
|
+
// chat_id?: string // override the configured chat_id
|
|
894
|
+
// }
|
|
895
|
+
//
|
|
896
|
+
// Returns 200 with `{ results: [{ platform, ok, error? }] }` for both
|
|
897
|
+
// total-success and partial-success cases; 4xx only for malformed input.
|
|
898
|
+
//
|
|
899
|
+
// Auth model: the endpoint is INTENTIONALLY restricted to loopback binds
|
|
900
|
+
// (127.0.0.0/8, ::1, localhost). The CSRF middleware above lets through
|
|
901
|
+
// requests with no Origin header (so webhooks work); without this guard,
|
|
902
|
+
// a UI server started with HOST=0.0.0.0 or --host <public-ip> would expose
|
|
903
|
+
// unauthenticated send-to-user's-chat to anything that can reach the port.
|
|
904
|
+
// 503 (subsystem disabled) and 403 (non-loopback bind) are terminal —
|
|
905
|
+
// retrying won't help.
|
|
906
|
+
app.post('/api/integrations/send', async (req, res) => {
|
|
907
|
+
if (!isLoopbackHost(serverHost)) {
|
|
908
|
+
return res.status(403).json({
|
|
909
|
+
error:
|
|
910
|
+
'send endpoint is restricted to loopback binds; ' +
|
|
911
|
+
'restart the UI server on 127.0.0.1 / ::1 / localhost ' +
|
|
912
|
+
'to send notifications',
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
const integrations = app.locals.integrations;
|
|
916
|
+
if (!integrations) {
|
|
917
|
+
return res
|
|
918
|
+
.status(503)
|
|
919
|
+
.json({ error: 'integrations subsystem not initialized' });
|
|
920
|
+
}
|
|
921
|
+
if (integrations.status?.().enabled === false) {
|
|
922
|
+
return res
|
|
923
|
+
.status(503)
|
|
924
|
+
.json({ error: 'integrations subsystem disabled in config' });
|
|
925
|
+
}
|
|
926
|
+
const { platforms, message, chat_id: chatIdOverride } = req.body ?? {};
|
|
927
|
+
if (!message || typeof message !== 'object') {
|
|
928
|
+
return res
|
|
929
|
+
.status(400)
|
|
930
|
+
.json({ error: 'message is required and must be an object' });
|
|
931
|
+
}
|
|
932
|
+
if (platforms !== undefined && !Array.isArray(platforms)) {
|
|
933
|
+
return res.status(400).json({ error: 'platforms must be an array' });
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
const out = await integrations.sendOutbound({
|
|
937
|
+
platforms,
|
|
938
|
+
message,
|
|
939
|
+
chatIdOverride,
|
|
940
|
+
});
|
|
941
|
+
res.json(out);
|
|
942
|
+
} catch (err) {
|
|
943
|
+
res.status(400).json({ error: String(err?.message ?? err) });
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
|
|
868
947
|
// GET /api/integrations/config — return saved config (secrets redacted)
|
|
869
948
|
app.get('/api/integrations/config', (_req, res) => {
|
|
870
949
|
const configPath = join(prefsDir, 'integrations', 'config.json');
|
|
@@ -31,10 +31,61 @@ const NO_OP_STUB = {
|
|
|
31
31
|
status() {
|
|
32
32
|
return { enabled: false };
|
|
33
33
|
},
|
|
34
|
+
enabledPlatforms() {
|
|
35
|
+
return [];
|
|
36
|
+
},
|
|
37
|
+
async sendOutbound() {
|
|
38
|
+
return {
|
|
39
|
+
results: [
|
|
40
|
+
{
|
|
41
|
+
platform: '(none)',
|
|
42
|
+
ok: false,
|
|
43
|
+
error: 'integrations subsystem disabled',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
},
|
|
34
48
|
strictInboxVerification: false,
|
|
35
49
|
secrets: [],
|
|
36
50
|
};
|
|
37
51
|
|
|
52
|
+
// Chat-capable adapter names. `webhook_out` is excluded — it's an
|
|
53
|
+
// event-fan-out destination, not a chat platform users can address by name.
|
|
54
|
+
const CHAT_PLATFORMS = ['telegram', 'discord', 'slack'];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Strip credentials from adapter error messages before surfacing them
|
|
58
|
+
* through sendOutbound results. Adapters' underlying `fetch` failures
|
|
59
|
+
* frequently embed the full request URL (including bot tokens for
|
|
60
|
+
* Telegram and webhook secrets for Slack/Discord) in the error text;
|
|
61
|
+
* one redaction pattern per known shape.
|
|
62
|
+
*
|
|
63
|
+
* Patterns covered:
|
|
64
|
+
* - Telegram bot URL token: `bot<digits>:<token>`
|
|
65
|
+
* - Slack incoming-webhook URL: `hooks.slack.com/services/T…/B…/<24>`
|
|
66
|
+
* - Discord webhook URL: `discord(app)?.com/api/webhooks/<id>/<token>`
|
|
67
|
+
* - Slack OAuth tokens: `xox[abprs]-<token>`
|
|
68
|
+
*
|
|
69
|
+
* Exported for tests; the per-platform error path in sendOutbound is the
|
|
70
|
+
* sole production caller today.
|
|
71
|
+
*
|
|
72
|
+
* @param {unknown} input
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
export function redactSecrets(input) {
|
|
76
|
+
return String(input)
|
|
77
|
+
.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot<redacted>')
|
|
78
|
+
.replace(
|
|
79
|
+
/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/g,
|
|
80
|
+
'hooks.slack.com/services/<redacted>',
|
|
81
|
+
)
|
|
82
|
+
.replace(
|
|
83
|
+
/discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/g,
|
|
84
|
+
'discord.com/api/webhooks/<redacted>',
|
|
85
|
+
)
|
|
86
|
+
.replace(/xox[abprs]-[A-Za-z0-9-]+/g, 'xox<redacted>');
|
|
87
|
+
}
|
|
88
|
+
|
|
38
89
|
/**
|
|
39
90
|
* @param {{
|
|
40
91
|
* port: number,
|
|
@@ -195,6 +246,116 @@ export function createIntegrations({
|
|
|
195
246
|
}
|
|
196
247
|
}
|
|
197
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Names of chat-capable adapters currently booted (enabled in config and
|
|
251
|
+
* successfully constructed). Drives the worca-notify skill's default
|
|
252
|
+
* fan-out target list. Excludes webhook_out (not a chat platform).
|
|
253
|
+
* @returns {string[]}
|
|
254
|
+
*/
|
|
255
|
+
function enabledPlatforms() {
|
|
256
|
+
return [...adapterMap.keys()].filter((n) => CHAT_PLATFORMS.includes(n));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Send a NormalizedMessage to one or more chat platforms through the same
|
|
261
|
+
* allowlist + rate-limiter pipeline as pipeline-event fan-out. Used by the
|
|
262
|
+
* POST /api/integrations/send route (which the worca-notify skill calls).
|
|
263
|
+
*
|
|
264
|
+
* Per-platform failures are returned as individual result entries, never
|
|
265
|
+
* thrown — the caller decides whether a partial success is acceptable. The
|
|
266
|
+
* overall promise rejects only for caller-error inputs (invalid message).
|
|
267
|
+
*
|
|
268
|
+
* @param {{
|
|
269
|
+
* platforms?: string[],
|
|
270
|
+
* message: import('./adapter.js').NormalizedMessage,
|
|
271
|
+
* chatIdOverride?: string,
|
|
272
|
+
* }} opts
|
|
273
|
+
* @returns {Promise<{results: Array<{platform: string, ok: boolean, error?: string}>}>}
|
|
274
|
+
*/
|
|
275
|
+
async function sendOutbound({ platforms, message, chatIdOverride }) {
|
|
276
|
+
if (!message || typeof message !== 'object') {
|
|
277
|
+
throw new Error('message must be an object');
|
|
278
|
+
}
|
|
279
|
+
if (!Array.isArray(message.body)) {
|
|
280
|
+
throw new Error('message.body must be an array of segments');
|
|
281
|
+
}
|
|
282
|
+
if (
|
|
283
|
+
chatIdOverride !== undefined &&
|
|
284
|
+
chatIdOverride !== null &&
|
|
285
|
+
typeof chatIdOverride !== 'string' &&
|
|
286
|
+
typeof chatIdOverride !== 'number'
|
|
287
|
+
) {
|
|
288
|
+
throw new Error('chat_id must be a string or number');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const targets =
|
|
292
|
+
Array.isArray(platforms) && platforms.length > 0
|
|
293
|
+
? platforms
|
|
294
|
+
: enabledPlatforms();
|
|
295
|
+
|
|
296
|
+
const results = [];
|
|
297
|
+
for (const name of targets) {
|
|
298
|
+
const entry = adapterMap.get(name);
|
|
299
|
+
if (!entry) {
|
|
300
|
+
results.push({
|
|
301
|
+
platform: name,
|
|
302
|
+
ok: false,
|
|
303
|
+
error: CHAT_PLATFORMS.includes(name)
|
|
304
|
+
? 'platform not enabled or not configured'
|
|
305
|
+
: 'unknown platform',
|
|
306
|
+
});
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const chatId =
|
|
311
|
+
chatIdOverride !== undefined && chatIdOverride !== null
|
|
312
|
+
? String(chatIdOverride)
|
|
313
|
+
: String(
|
|
314
|
+
entry.adapterCfg.chat_id ?? entry.adapterCfg.channel_id ?? '',
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
if (!chatId) {
|
|
318
|
+
results.push({
|
|
319
|
+
platform: name,
|
|
320
|
+
ok: false,
|
|
321
|
+
error: 'no chat_id configured for this platform',
|
|
322
|
+
});
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!allowlist.isAllowed({ platform: name, chatId })) {
|
|
327
|
+
results.push({
|
|
328
|
+
platform: name,
|
|
329
|
+
ok: false,
|
|
330
|
+
error: 'chat_id not in allowlist',
|
|
331
|
+
});
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const rl = rateLimiters.get(name);
|
|
337
|
+
const sendFn = (m) => entry.adapter.send(chatId, m);
|
|
338
|
+
if (rl) {
|
|
339
|
+
await rl.send(message, sendFn);
|
|
340
|
+
} else {
|
|
341
|
+
await sendFn(message);
|
|
342
|
+
}
|
|
343
|
+
results.push({ platform: name, ok: true });
|
|
344
|
+
} catch (err) {
|
|
345
|
+
// Strip credentials from the error before surfacing — adapters'
|
|
346
|
+
// fetch failures can echo full request URLs that embed bot tokens
|
|
347
|
+
// (Telegram), webhook secrets (Slack/Discord), or OAuth tokens.
|
|
348
|
+
results.push({
|
|
349
|
+
platform: name,
|
|
350
|
+
ok: false,
|
|
351
|
+
error: redactSecrets(err?.message ?? err),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return { results };
|
|
357
|
+
}
|
|
358
|
+
|
|
198
359
|
function onEvent(stored) {
|
|
199
360
|
const rawBody = stored[RAW_BODY];
|
|
200
361
|
const sigHeader = stored.headers?.['x-worca-signature'];
|
|
@@ -276,6 +437,8 @@ export function createIntegrations({
|
|
|
276
437
|
status,
|
|
277
438
|
reloadAdapter,
|
|
278
439
|
removeAdapter,
|
|
440
|
+
enabledPlatforms,
|
|
441
|
+
sendOutbound,
|
|
279
442
|
/** @internal — used by detect endpoint to pause/resume adapter */
|
|
280
443
|
_getAdapter: (name) => adapterMap.get(name) ?? null,
|
|
281
444
|
strictInboxVerification: cfg.strict_inbox_verification ?? false,
|
package/server/project-routes.js
CHANGED
|
@@ -76,7 +76,9 @@ function validateRunId(runId) {
|
|
|
76
76
|
// worktree runs registered in <worcaDir>/multi/pipelines.d/.
|
|
77
77
|
import {
|
|
78
78
|
findRunStatusPath,
|
|
79
|
+
listPlanIterations,
|
|
79
80
|
readPipelineOverlay,
|
|
81
|
+
resolveRunDir,
|
|
80
82
|
updatePipelineStatus,
|
|
81
83
|
} from './run-dir-resolver.js';
|
|
82
84
|
export { findRunStatusPath };
|
|
@@ -783,25 +785,77 @@ export function createProjectScopedRoutes({
|
|
|
783
785
|
}
|
|
784
786
|
});
|
|
785
787
|
|
|
786
|
-
// GET /api/projects/:projectId/runs/:runId/plan —
|
|
788
|
+
// GET /api/projects/:projectId/runs/:runId/plan-iterations — list the
|
|
789
|
+
// append-only numbered plan revisions (W-061). plan-001 is the original
|
|
790
|
+
// input; each plan_review revision appends the next number. Returns
|
|
791
|
+
// `{ ok, iterations: [{n, file}], latest }` (empty list for pre-W-061 runs).
|
|
792
|
+
router.get('/runs/:runId/plan-iterations', requireWorcaDir, (req, res) => {
|
|
793
|
+
const { runId } = req.params;
|
|
794
|
+
if (!validateRunId(runId)) {
|
|
795
|
+
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
796
|
+
}
|
|
797
|
+
const { worcaDir } = req.project;
|
|
798
|
+
const runDir = resolveRunDir(worcaDir, runId);
|
|
799
|
+
const iterations = listPlanIterations(runDir);
|
|
800
|
+
const latest = iterations.length
|
|
801
|
+
? iterations[iterations.length - 1].n
|
|
802
|
+
: null;
|
|
803
|
+
return res.json({
|
|
804
|
+
ok: true,
|
|
805
|
+
iterations: iterations.map((it) => ({ n: it.n, file: it.file })),
|
|
806
|
+
latest,
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
// GET /api/projects/:projectId/runs/:runId/plan — plan markdown.
|
|
787
811
|
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
812
|
+
// W-061: prefers the run dir's numbered plan iterations (plan-NNN.md) and
|
|
813
|
+
// serves the latest by default, or a specific one via `?iteration=N`. Falls
|
|
814
|
+
// back, for pre-W-061 runs, to:
|
|
790
815
|
// 1. stages.plan.plan_file — set by workspace children that received a
|
|
791
|
-
// pre-built per-repo plan from the workspace planner.
|
|
792
|
-
// an absolute path under the workspace run dir.
|
|
816
|
+
// pre-built per-repo plan from the workspace planner.
|
|
793
817
|
// 2. <worktree>/MASTER_PLAN.md — generated by a standalone pipeline's
|
|
794
|
-
// own PLAN stage.
|
|
795
|
-
// reconstruct it from the worktree.
|
|
818
|
+
// own PLAN stage.
|
|
796
819
|
//
|
|
797
|
-
// Returns 404 when
|
|
798
|
-
//
|
|
820
|
+
// Returns 404 when nothing is readable. Replies in text/markdown so the
|
|
821
|
+
// client just streams it into the dialog body.
|
|
799
822
|
router.get('/runs/:runId/plan', requireWorcaDir, (req, res) => {
|
|
800
823
|
const { runId } = req.params;
|
|
801
824
|
if (!validateRunId(runId)) {
|
|
802
825
|
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
803
826
|
}
|
|
804
827
|
const { worcaDir } = req.project;
|
|
828
|
+
|
|
829
|
+
// W-061: numbered plan iterations are the authoritative source.
|
|
830
|
+
const runDir = resolveRunDir(worcaDir, runId);
|
|
831
|
+
const iterations = listPlanIterations(runDir);
|
|
832
|
+
if (iterations.length > 0) {
|
|
833
|
+
let chosen;
|
|
834
|
+
const wantRaw = req.query.iteration;
|
|
835
|
+
if (wantRaw !== undefined) {
|
|
836
|
+
const want = Number.parseInt(wantRaw, 10);
|
|
837
|
+
chosen = iterations.find((it) => it.n === want);
|
|
838
|
+
if (!chosen) {
|
|
839
|
+
return res
|
|
840
|
+
.status(404)
|
|
841
|
+
.json({ ok: false, error: `Plan iteration ${wantRaw} not found` });
|
|
842
|
+
}
|
|
843
|
+
} else {
|
|
844
|
+
chosen = iterations[iterations.length - 1]; // latest = current plan
|
|
845
|
+
}
|
|
846
|
+
try {
|
|
847
|
+
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
|
|
848
|
+
res.send(readFileSync(chosen.path, 'utf8'));
|
|
849
|
+
return;
|
|
850
|
+
} catch (err) {
|
|
851
|
+
return res.status(500).json({
|
|
852
|
+
ok: false,
|
|
853
|
+
error: `Failed to read plan file: ${err.message}`,
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Legacy fallback (pre-W-061 runs with no numbered plan files).
|
|
805
859
|
const statusPath = findRunStatusPath(worcaDir, runId);
|
|
806
860
|
if (!statusPath) {
|
|
807
861
|
return res
|
|
@@ -817,10 +871,6 @@ export function createProjectScopedRoutes({
|
|
|
817
871
|
.json({ ok: false, error: `Failed to read status: ${err.message}` });
|
|
818
872
|
}
|
|
819
873
|
const planFile = status?.stages?.plan?.plan_file ?? null;
|
|
820
|
-
// `status.worktree` is a boolean flag (true when the run is hosted in a
|
|
821
|
-
// worktree), not a path. The actual worktree path lives in the
|
|
822
|
-
// pipelines.d registry entry. Walk it via the overlay so we can offer
|
|
823
|
-
// the MASTER_PLAN.md fallback even when stage.plan_file isn't set.
|
|
824
874
|
const overlay = readPipelineOverlay(worcaDir, runId);
|
|
825
875
|
const worktreePath =
|
|
826
876
|
typeof overlay?.worktree_path === 'string' ? overlay.worktree_path : null;
|
|
@@ -13,9 +13,45 @@
|
|
|
13
13
|
* 3. `<worcaDir>/multi/pipelines.d/<runId>.json` → `<worktree_path>/.worca/runs/<runId>/`
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
existsSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
renameSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from 'node:fs';
|
|
17
23
|
import { join } from 'node:path';
|
|
18
24
|
|
|
25
|
+
// W-061: plans are stored in the run dir as append-only numbered files
|
|
26
|
+
// (plan-001.md, plan-002.md, …). plan-001 is the original input — the ingested
|
|
27
|
+
// copy for a provided plan, or the Planner's first draft. Each plan_review
|
|
28
|
+
// revision appends the next number; the highest number is the current plan.
|
|
29
|
+
const PLAN_ITERATION_RE = /^plan-(\d{3})\.md$/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* List the numbered plan iterations in a run dir, ascending by number.
|
|
33
|
+
* @param {string|null} runDir - absolute path to the run dir
|
|
34
|
+
* @returns {Array<{n:number,file:string,path:string}>} ascending by n; [] if none
|
|
35
|
+
*/
|
|
36
|
+
export function listPlanIterations(runDir) {
|
|
37
|
+
if (!runDir || !existsSync(runDir)) return [];
|
|
38
|
+
let files;
|
|
39
|
+
try {
|
|
40
|
+
files = readdirSync(runDir);
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
return files
|
|
45
|
+
.map((f) => {
|
|
46
|
+
const m = f.match(PLAN_ITERATION_RE);
|
|
47
|
+
return m
|
|
48
|
+
? { n: Number.parseInt(m[1], 10), file: f, path: join(runDir, f) }
|
|
49
|
+
: null;
|
|
50
|
+
})
|
|
51
|
+
.filter((it) => it !== null)
|
|
52
|
+
.sort((a, b) => a.n - b.n);
|
|
53
|
+
}
|
|
54
|
+
|
|
19
55
|
/**
|
|
20
56
|
* Resolve a runId to its on-disk run directory.
|
|
21
57
|
* @param {string} worcaDir - the parent project's .worca directory
|
|
@@ -25,6 +25,8 @@ const VALID_EFFORT_RUNGS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
|
25
25
|
const VALID_AUTO_MODES = ['disabled', 'reactive', 'adaptive'];
|
|
26
26
|
const VALID_EFFORT_KEYS = ['auto_mode', 'auto_cap'];
|
|
27
27
|
const VALID_MILESTONES = ['plan_approval', 'pr_approval', 'deploy_approval'];
|
|
28
|
+
const VALID_PLAN_REVIEW_MODES = ['review', 'review_and_edit'];
|
|
29
|
+
const VALID_PLAN_REVIEW_ENFORCE = ['auto', 'review', 'review_and_edit'];
|
|
28
30
|
const VALID_GUARDS = [
|
|
29
31
|
'block_rm_rf',
|
|
30
32
|
'block_env_write',
|
|
@@ -176,6 +178,16 @@ export function validateSettingsPayload(body, options = {}) {
|
|
|
176
178
|
if (cfg.agent !== undefined && !VALID_AGENTS.includes(cfg.agent)) {
|
|
177
179
|
details.push(`Invalid agent "${cfg.agent}" for stage "${name}"`);
|
|
178
180
|
}
|
|
181
|
+
if (name === 'plan_review' && cfg.mode !== undefined) {
|
|
182
|
+
if (
|
|
183
|
+
typeof cfg.mode !== 'string' ||
|
|
184
|
+
!VALID_PLAN_REVIEW_MODES.includes(cfg.mode)
|
|
185
|
+
) {
|
|
186
|
+
details.push(
|
|
187
|
+
`stages.plan_review.mode must be one of: ${VALID_PLAN_REVIEW_MODES.join(', ')}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
179
191
|
}
|
|
180
192
|
}
|
|
181
193
|
}
|
|
@@ -407,6 +419,16 @@ export function validateSettingsPayload(body, options = {}) {
|
|
|
407
419
|
details.push('worca.governance must be an object');
|
|
408
420
|
} else {
|
|
409
421
|
const g = w.governance;
|
|
422
|
+
if (g.plan_review_enforce !== undefined) {
|
|
423
|
+
if (
|
|
424
|
+
typeof g.plan_review_enforce !== 'string' ||
|
|
425
|
+
!VALID_PLAN_REVIEW_ENFORCE.includes(g.plan_review_enforce)
|
|
426
|
+
) {
|
|
427
|
+
details.push(
|
|
428
|
+
`governance.plan_review_enforce must be one of: ${VALID_PLAN_REVIEW_ENFORCE.join(', ')}`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
410
432
|
if (g.guards !== undefined) {
|
|
411
433
|
if (
|
|
412
434
|
typeof g.guards !== 'object' ||
|