@worca/ui 0.35.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 +1658 -1414
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +114 -1
- package/package.json +1 -1
- package/server/app.js +227 -0
- package/server/code-review-graph-status.js +206 -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
|
@@ -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' ||
|