@worca/ui 0.16.0 → 0.18.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
@@ -3415,6 +3415,27 @@ sl-details.learnings-panel::part(content) {
3415
3415
  margin: 8px 0 4px;
3416
3416
  }
3417
3417
 
3418
+ .pr-verification-banner {
3419
+ margin: 8px 0 4px;
3420
+ }
3421
+
3422
+ .pr-verified-row {
3423
+ display: flex;
3424
+ align-items: center;
3425
+ gap: 8px;
3426
+ margin-top: 6px;
3427
+ font-size: 13px;
3428
+ }
3429
+
3430
+ .pr-verified-row .meta-label {
3431
+ color: var(--muted);
3432
+ }
3433
+
3434
+ .pr-verified-badge {
3435
+ display: inline-flex;
3436
+ align-items: center;
3437
+ }
3438
+
3418
3439
  .classification-strip {
3419
3440
  margin-top: 10px;
3420
3441
  padding: 8px 10px;
package/bin/worca-ui.js CHANGED
@@ -113,6 +113,23 @@ export function parseArgs(argv) {
113
113
  return args;
114
114
  }
115
115
 
116
+ /** Build the argv array for spawning server/index.js. Exported for testing. */
117
+ export function buildSpawnArgs({
118
+ serverScript,
119
+ port,
120
+ host,
121
+ isGlobal,
122
+ projectPath,
123
+ }) {
124
+ const args = [serverScript, '--port', String(port), '--host', host];
125
+ if (isGlobal) {
126
+ args.push('--global');
127
+ } else if (projectPath) {
128
+ args.push('--project', projectPath);
129
+ }
130
+ return args;
131
+ }
132
+
116
133
  /** Resolve log file path based on mode (mirrors PID file location). */
117
134
  function resolveLogPath(isGlobal) {
118
135
  if (isGlobal) {
@@ -242,7 +259,7 @@ function describePortOccupant(port) {
242
259
  return null;
243
260
  }
244
261
 
245
- async function start({ port, host, open, global: isGlobal }) {
262
+ async function start({ port, host, open, global: isGlobal, projectPath }) {
246
263
  const { pidDir, pidFile } = resolvePidPaths(isGlobal);
247
264
 
248
265
  const existing = readPidFile(pidFile);
@@ -281,16 +298,13 @@ async function start({ port, host, open, global: isGlobal }) {
281
298
  }
282
299
  }
283
300
 
284
- const spawnArgs = [
285
- SERVER_SCRIPT,
286
- '--port',
287
- String(availablePort),
288
- '--host',
301
+ const spawnArgs = buildSpawnArgs({
302
+ serverScript: SERVER_SCRIPT,
303
+ port: availablePort,
289
304
  host,
290
- ];
291
- if (isGlobal) {
292
- spawnArgs.push('--global');
293
- }
305
+ isGlobal,
306
+ projectPath,
307
+ });
294
308
 
295
309
  // Capture child stdout+stderr to a log file so startup crashes are visible.
296
310
  // Without this, errors thrown during module load (missing files, bad imports,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -50,6 +50,7 @@
50
50
  "prepublishOnly": "npm run build && npm test",
51
51
  "test": "vitest run",
52
52
  "test:watch": "vitest",
53
+ "test:coverage": "vitest run --coverage",
53
54
  "test:browser": "playwright test",
54
55
  "test:browser:ui": "playwright test --ui",
55
56
  "lint": "biome check",
@@ -70,6 +71,7 @@
70
71
  "devDependencies": {
71
72
  "@biomejs/biome": "^2.2.4",
72
73
  "@playwright/test": "^1.58.2",
74
+ "@vitest/coverage-v8": "^4.0.15",
73
75
  "esbuild": "^0.27.1",
74
76
  "jsdom": "^29.0.1",
75
77
  "vitest": "^4.0.15"
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Parse server startup arguments from an argv array.
3
+ * Returns { port, host, isGlobal, projectPath }.
4
+ * Pass defaults to seed port/host from env vars before argv overrides them.
5
+ */
6
+ export function parseServerArgv(argv, defaults = {}) {
7
+ let port = defaults.port ?? 3400;
8
+ let host = defaults.host ?? '127.0.0.1';
9
+ let isGlobal = true;
10
+ let projectPath = null;
11
+
12
+ for (let i = 0; i < argv.length; i++) {
13
+ if (argv[i] === '--port' && argv[i + 1]) {
14
+ port = parseInt(argv[++i], 10);
15
+ } else if (argv[i] === '--host' && argv[i + 1]) {
16
+ host = argv[++i];
17
+ } else if (argv[i] === '--global') {
18
+ isGlobal = true;
19
+ } else if (argv[i] === '--project') {
20
+ isGlobal = false;
21
+ if (argv[i + 1] && !argv[i + 1].startsWith('-')) {
22
+ projectPath = argv[++i];
23
+ }
24
+ }
25
+ }
26
+
27
+ return { port, host, isGlobal, projectPath };
28
+ }
package/server/index.js CHANGED
@@ -4,21 +4,20 @@ import { createServer } from 'node:http';
4
4
  import { homedir, platform } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { createApp } from './app.js';
7
+ import { parseServerArgv } from './argv-parser.js';
7
8
  import { createIntegrations } from './integrations/index.js';
8
9
  import { attachWsServer } from './ws.js';
9
10
 
10
- // Parse argv
11
- let port = parseInt(process.env.PORT, 10) || 3400;
12
- let host = process.env.HOST || '127.0.0.1';
13
- let isGlobal = true;
14
- for (let i = 0; i < process.argv.length; i++) {
15
- if (process.argv[i] === '--port' && process.argv[i + 1])
16
- port = parseInt(process.argv[++i], 10);
17
- if (process.argv[i] === '--host' && process.argv[i + 1])
18
- host = process.argv[++i];
19
- if (process.argv[i] === '--global') isGlobal = true;
20
- if (process.argv[i] === '--project') isGlobal = false;
21
- }
11
+ // Parse argv (env vars provide defaults; argv flags take precedence)
12
+ const {
13
+ port,
14
+ host,
15
+ isGlobal,
16
+ projectPath: explicitProjectPath,
17
+ } = parseServerArgv(process.argv, {
18
+ port: parseInt(process.env.PORT, 10) || 3400,
19
+ host: process.env.HOST || '127.0.0.1',
20
+ });
22
21
 
23
22
  // Resolve project root: walk up from cwd until we find .claude/settings.json
24
23
  import { existsSync } from 'node:fs';
@@ -44,8 +43,8 @@ if (isGlobal) {
44
43
  worcaDir = null;
45
44
  settingsPath = null;
46
45
  } else {
47
- // Per-project mode: resolve from cwd
48
- projectRoot = findProjectRoot(process.cwd());
46
+ // Per-project mode: use explicit path when provided, otherwise walk up from cwd
47
+ projectRoot = explicitProjectPath ?? findProjectRoot(process.cwd());
49
48
  worcaDir = join(projectRoot, '.worca');
50
49
  settingsPath = join(projectRoot, '.claude', 'settings.json');
51
50
  }
@@ -201,12 +201,48 @@ export class ProcessManager {
201
201
  }
202
202
  }
203
203
 
204
+ // Also scan pipelines.d/ for worktree PIDs (worktree runs never appear in runs/)
205
+ const pipelinesDir = join(this.worcaDir, 'multi', 'pipelines.d');
206
+ if (existsSync(pipelinesDir)) {
207
+ try {
208
+ for (const entry of readdirSync(pipelinesDir)) {
209
+ if (!entry.endsWith('.json')) continue;
210
+ const runId = entry.slice(0, -5);
211
+ try {
212
+ const reg = JSON.parse(
213
+ readFileSync(join(pipelinesDir, entry), 'utf8'),
214
+ );
215
+ if (reg.worktree_path) {
216
+ const wtPidPath = join(
217
+ reg.worktree_path,
218
+ '.worca',
219
+ 'runs',
220
+ runId,
221
+ 'pipeline.pid',
222
+ );
223
+ if (existsSync(wtPidPath)) runIds.add(runId);
224
+ }
225
+ } catch {
226
+ /* ignore malformed registry entry */
227
+ }
228
+ }
229
+ } catch {
230
+ /* ignore */
231
+ }
232
+ }
233
+
204
234
  for (const runId of runIds) {
205
235
  // Check if this run's process is alive
206
236
  const alive = this.getRunningPid(runId);
207
237
  if (alive) continue;
208
238
 
209
- const statusPath = join(this.worcaDir, 'runs', runId, 'status.json');
239
+ // Route all paths through resolveRunContext so worktree runs use
240
+ // their worktree dir rather than the project-root runs/ dir.
241
+ const ctx = this.resolveRunContext(runId);
242
+ if (!ctx) continue;
243
+ const { runDir } = ctx;
244
+
245
+ const statusPath = join(runDir, 'status.json');
210
246
  if (!existsSync(statusPath)) continue;
211
247
 
212
248
  let status;
@@ -236,7 +272,7 @@ export class ProcessManager {
236
272
 
237
273
  // Append synthetic terminal event if none exists yet.
238
274
  // Use pipeline.run.interrupted for signal-killed runs, pipeline.run.failed otherwise.
239
- const eventsPath = join(this.worcaDir, 'runs', runId, 'events.jsonl');
275
+ const eventsPath = join(runDir, 'events.jsonl');
240
276
  let hasTerminalEvent = false;
241
277
  if (existsSync(eventsPath)) {
242
278
  try {
@@ -269,7 +305,7 @@ export class ProcessManager {
269
305
  if (this.settingsPath) {
270
306
  dispatches.push(
271
307
  dispatchExternal({
272
- runDir: join(this.worcaDir, 'runs', runId),
308
+ runDir,
273
309
  settingsPath: this.settingsPath,
274
310
  eventType,
275
311
  payload,
@@ -744,7 +780,9 @@ export class ProcessManager {
744
780
  * @param {string} runId
745
781
  */
746
782
  _killAgentSubprocess(runId) {
747
- const pidPath = join(this.worcaDir, 'runs', runId, 'agent.pid');
783
+ const ctx = this.resolveRunContext(runId);
784
+ const runDir = ctx ? ctx.runDir : join(this.worcaDir, 'runs', runId);
785
+ const pidPath = join(runDir, 'agent.pid');
748
786
  if (!existsSync(pidPath)) return;
749
787
  try {
750
788
  const agentPid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
@@ -158,6 +158,7 @@ export function createMessageRouter({
158
158
  }
159
159
  const agentName = run.stages?.[stage]?.agent || stage;
160
160
  const effectiveRunId = run.run_id || runId;
161
+ const effectiveWorcaDir = run.worktree_worca_dir || proj.worcaDir;
161
162
 
162
163
  const iterations = run.stages?.[stage]?.iterations || [];
163
164
  const iterationPrompts = iterations.map((iter, idx) => {
@@ -189,7 +190,7 @@ export function createMessageRouter({
189
190
  const iterNum = iter.number ?? 0;
190
191
  const resolvedCandidates = [
191
192
  join(
192
- proj.worcaDir,
193
+ effectiveWorcaDir,
193
194
  'runs',
194
195
  effectiveRunId,
195
196
  'agents',
@@ -197,7 +198,7 @@ export function createMessageRouter({
197
198
  `${stage}-${agentName}-iter-${iterNum}.md`,
198
199
  ),
199
200
  join(
200
- proj.worcaDir,
201
+ effectiveWorcaDir,
201
202
  'results',
202
203
  effectiveRunId,
203
204
  'agents',
@@ -206,7 +207,7 @@ export function createMessageRouter({
206
207
  ),
207
208
  // Fallback for early W-037 runs without stage prefix
208
209
  join(
209
- proj.worcaDir,
210
+ effectiveWorcaDir,
210
211
  'runs',
211
212
  effectiveRunId,
212
213
  'agents',
@@ -214,7 +215,7 @@ export function createMessageRouter({
214
215
  `${agentName}-iter-${iterNum}.md`,
215
216
  ),
216
217
  join(
217
- proj.worcaDir,
218
+ effectiveWorcaDir,
218
219
  'results',
219
220
  effectiveRunId,
220
221
  'agents',
@@ -241,14 +242,14 @@ export function createMessageRouter({
241
242
  if (!resolvedPrompt) {
242
243
  const templateCandidates = [
243
244
  join(
244
- proj.worcaDir,
245
+ effectiveWorcaDir,
245
246
  'runs',
246
247
  effectiveRunId,
247
248
  'agents',
248
249
  `${agentName}.md`,
249
250
  ),
250
251
  join(
251
- proj.worcaDir,
252
+ effectiveWorcaDir,
252
253
  'results',
253
254
  effectiveRunId,
254
255
  'agents',
@@ -37,9 +37,11 @@ const TERMINAL_STATUSES = new Set([
37
37
  * @returns {string}
38
38
  */
39
39
  export function resolveLatestRunDir(worcaDir) {
40
+ // Collect (runId → runDir) for all live runs from local runs/ and worktree pipelines.d/
41
+ const liveRuns = new Map();
42
+
40
43
  const runsDir = join(worcaDir, 'runs');
41
44
  if (existsSync(runsDir)) {
42
- let latest = null;
43
45
  try {
44
46
  for (const entry of readdirSync(runsDir, { withFileTypes: true })) {
45
47
  if (!entry.isDirectory()) continue;
@@ -49,7 +51,36 @@ export function resolveLatestRunDir(worcaDir) {
49
51
  const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
50
52
  if (!Number.isNaN(pid) && pid > 0) {
51
53
  process.kill(pid, 0); // throws if dead
52
- if (!latest || entry.name > latest) latest = entry.name;
54
+ liveRuns.set(entry.name, join(runsDir, entry.name));
55
+ }
56
+ } catch {
57
+ /* dead process or invalid PID */
58
+ }
59
+ }
60
+ } catch {
61
+ /* ignore */
62
+ }
63
+ }
64
+
65
+ // Also scan pipelines.d/ for worktree PIDs (worktree runs never appear in runs/)
66
+ const pipelinesDir = join(worcaDir, 'multi', 'pipelines.d');
67
+ if (existsSync(pipelinesDir)) {
68
+ try {
69
+ for (const entry of readdirSync(pipelinesDir)) {
70
+ if (!entry.endsWith('.json')) continue;
71
+ const runId = entry.slice(0, -5);
72
+ try {
73
+ const reg = JSON.parse(
74
+ readFileSync(join(pipelinesDir, entry), 'utf8'),
75
+ );
76
+ if (!reg.worktree_path) continue;
77
+ const wtRunDir = join(reg.worktree_path, '.worca', 'runs', runId);
78
+ const pidPath = join(wtRunDir, 'pipeline.pid');
79
+ if (!existsSync(pidPath)) continue;
80
+ const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
81
+ if (!Number.isNaN(pid) && pid > 0) {
82
+ process.kill(pid, 0); // throws if dead
83
+ liveRuns.set(runId, wtRunDir);
53
84
  }
54
85
  } catch {
55
86
  /* dead process or invalid PID */
@@ -58,9 +89,16 @@ export function resolveLatestRunDir(worcaDir) {
58
89
  } catch {
59
90
  /* ignore */
60
91
  }
61
- if (latest) return join(runsDir, latest);
62
92
  }
63
- return worcaDir; // legacy fallback
93
+
94
+ if (liveRuns.size === 0) return worcaDir; // legacy fallback
95
+
96
+ // Return the runDir of the latest (alphabetically largest) live run ID
97
+ let latestId = null;
98
+ for (const id of liveRuns.keys()) {
99
+ if (!latestId || id > latestId) latestId = id;
100
+ }
101
+ return liveRuns.get(latestId);
64
102
  }
65
103
 
66
104
  /**