@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/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
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,
@@ -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 — per-run plan markdown
788
+ // GET /api/projects/:projectId/runs/:runId/plan-iterationslist 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
- // Returns the markdown content of the run's plan file. Two sources, in
789
- // priority order:
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. May point at
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. status.json doesn't always carry the path so we
795
- // reconstruct it from the worktree.
818
+ // own PLAN stage.
796
819
  //
797
- // Returns 404 when neither path exists / is readable. Replies in
798
- // text/markdown so the client just streams it into the dialog body.
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 { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
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' ||