@worca/ui 0.15.2 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -422,10 +422,49 @@ export class ProcessManager {
422
422
  if (opts.resume) {
423
423
  args.push('--resume');
424
424
  if (opts.runId) {
425
- const statusDir = resumeCtx
426
- ? resumeCtx.runDir
427
- : join(this.worcaDir, 'runs', opts.runId);
425
+ // The runner derives worca_dir from os.path.dirname(status_path) and
426
+ // builds the per-run dir as <worca_dir>/runs/<run_id>/. We must pass
427
+ // the worca root, not the per-run dir — passing <worca>/runs/<id>
428
+ // would make the runner compute a nested <worca>/runs/<id>/runs/<id>/
429
+ // and write status updates there while the UI keeps reading the
430
+ // original. _find_active_runs(worca_root) then locates the run.
431
+ const statusDir = resumeCtx ? resumeCtx.worcaDir : this.worcaDir;
428
432
  args.push('--status-dir', statusDir);
433
+
434
+ // Worktree runs: registry lives in the parent project's .worca, not
435
+ // the worktree's. run_worktree.py passes --registry-base on initial
436
+ // launch; resume must do the same so update_pipeline() lands on the
437
+ // right registry entry. Without this, the runner's terminal /
438
+ // resume-flip-to-running registry updates silently no-op against a
439
+ // non-existent <worktree>/.worca/multi/pipelines.d/<id>.json.
440
+ if (resumeCtx && resumeCtx.worcaDir !== this.worcaDir) {
441
+ args.push('--registry-base', this.worcaDir);
442
+ }
443
+
444
+ // _find_active_runs filters out runs whose pipeline_status is in
445
+ // {completed, interrupted}. To resume an interrupted/failed run, flip
446
+ // the top-level status to "resuming" so the runner can pick it up;
447
+ // it'll transition to "running" once it's processing.
448
+ const statusPath = resumeCtx
449
+ ? join(resumeCtx.runDir, 'status.json')
450
+ : join(this.worcaDir, 'runs', opts.runId, 'status.json');
451
+ try {
452
+ const s = JSON.parse(readFileSync(statusPath, 'utf8'));
453
+ if (
454
+ s.pipeline_status === 'interrupted' ||
455
+ s.pipeline_status === 'failed'
456
+ ) {
457
+ s.pipeline_status = 'resuming';
458
+ delete s.stop_reason;
459
+ writeFileSync(
460
+ statusPath,
461
+ `${JSON.stringify(s, null, 2)}\n`,
462
+ 'utf8',
463
+ );
464
+ }
465
+ } catch {
466
+ /* non-fatal — runner will surface a clearer error if the file is missing */
467
+ }
429
468
  }
430
469
  } else if (opts.sourceType !== undefined) {
431
470
  // New format: separate source and prompt args
@@ -71,7 +71,11 @@ function validateRunId(runId) {
71
71
  // Re-exported from run-dir-resolver so callers (including older tests) can
72
72
  // continue importing from project-routes. The implementation now overlays
73
73
  // worktree runs registered in <worcaDir>/multi/pipelines.d/.
74
- import { findRunStatusPath, readPipelineOverlay } from './run-dir-resolver.js';
74
+ import {
75
+ findRunStatusPath,
76
+ readPipelineOverlay,
77
+ updatePipelineStatus,
78
+ } from './run-dir-resolver.js';
75
79
  export { findRunStatusPath };
76
80
 
77
81
  /** Validate a branch name — alphanumeric, dots, hyphens, underscores, slashes */
@@ -935,7 +939,14 @@ export function createProjectScopedRoutes({
935
939
  }
936
940
  const { worcaDir, settingsPath } = req.project;
937
941
  try {
938
- const controlDir = join(worcaDir, 'runs', runId);
942
+ // Worktree runs read control.json from <worktree>/.worca/runs/<id>/.
943
+ // Writing it to the parent project's worcaDir leaves the runner
944
+ // unaware of the stop request — SIGTERM still works, but we lose
945
+ // graceful-shutdown semantics.
946
+ const overlay = readPipelineOverlay(worcaDir, runId);
947
+ const controlDir = overlay?.worktree_path
948
+ ? join(overlay.worktree_path, '.worca', 'runs', runId)
949
+ : join(worcaDir, 'runs', runId);
939
950
  mkdirSync(controlDir, { recursive: true });
940
951
  writeFileSync(
941
952
  join(controlDir, 'control.json'),
@@ -1079,6 +1090,11 @@ export function createProjectScopedRoutes({
1079
1090
  st.completed_at = new Date().toISOString();
1080
1091
  writeFileSync(statusPath, `${JSON.stringify(st, null, 2)}\n`, 'utf8');
1081
1092
 
1093
+ // Mirror into the multi-pipeline registry so global-mode views don't
1094
+ // keep reporting the run as "running". Best-effort — the registry entry
1095
+ // only exists for worktree runs.
1096
+ updatePipelineStatus(worcaDir, runId, 'cancelled');
1097
+
1082
1098
  const { broadcast, scheduleRefresh } = req.app.locals;
1083
1099
  if (broadcast) broadcast('run-cancelled', { runId });
1084
1100
  if (scheduleRefresh) scheduleRefresh(req.project?.name);
@@ -1141,7 +1157,12 @@ export function createProjectScopedRoutes({
1141
1157
  pipeline_status: st.pipeline_status,
1142
1158
  });
1143
1159
  }
1144
- const controlDir = join(worcaDir, 'runs', runId);
1160
+ // Worktree runs read control.json from <worktree>/.worca/runs/<id>/;
1161
+ // writing to the parent's worcaDir is invisible to the runner.
1162
+ const overlay = readPipelineOverlay(worcaDir, runId);
1163
+ const controlDir = overlay?.worktree_path
1164
+ ? join(overlay.worktree_path, '.worca', 'runs', runId)
1165
+ : join(worcaDir, 'runs', runId);
1145
1166
  mkdirSync(controlDir, { recursive: true });
1146
1167
  writeFileSync(
1147
1168
  join(controlDir, 'control.json'),
@@ -13,7 +13,7 @@
13
13
  * 3. `<worcaDir>/multi/pipelines.d/<runId>.json` → `<worktree_path>/.worca/runs/<runId>/`
14
14
  */
15
15
 
16
- import { existsSync, readFileSync } from 'node:fs';
16
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
18
 
19
19
  /**
@@ -77,3 +77,37 @@ export function readPipelineOverlay(worcaDir, runId) {
77
77
  return null;
78
78
  }
79
79
  }
80
+
81
+ /**
82
+ * Update the multi-pipeline registry entry's status field.
83
+ *
84
+ * Mirrors src/worca/orchestrator/registry.py:update_pipeline() — terminal
85
+ * status (cancelled/completed/failed/interrupted) only. The Python runner
86
+ * updates the registry on its own terminal paths; this helper exists for
87
+ * callers (the UI cancel route) that write status.json directly without
88
+ * going through the runner.
89
+ *
90
+ * Returns true when the registry entry existed and was updated, false when
91
+ * there's no registry entry for this runId (e.g. local in-place run).
92
+ *
93
+ * @param {string} worcaDir - the parent project's .worca directory
94
+ * @param {string} runId
95
+ * @param {string} status - new status value (e.g. 'cancelled')
96
+ */
97
+ export function updatePipelineStatus(worcaDir, runId, status) {
98
+ const regPath = join(worcaDir, 'multi', 'pipelines.d', `${runId}.json`);
99
+ if (!existsSync(regPath)) return false;
100
+ try {
101
+ const data = JSON.parse(readFileSync(regPath, 'utf8'));
102
+ data.status = status;
103
+ data.updated_at = new Date().toISOString();
104
+ // Match Python's atomic write: write to .tmp + rename. Avoids partial
105
+ // writes if the process crashes mid-write.
106
+ const tmpPath = `${regPath}.tmp`;
107
+ writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
108
+ renameSync(tmpPath, regPath);
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }