@worca/ui 0.9.0-rc.3 → 0.9.0-rc.5

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.
@@ -19,7 +19,9 @@ import {
19
19
  import { homedir } from 'node:os';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { Router } from 'express';
22
+ import { actionAllowed } from '../app/utils/state-actions.js';
22
23
  import { dbExists, getIssue, listIssues } from './beads-reader.js';
24
+ import { dispatchExternal } from './dispatch-external.js';
23
25
  import { ensureWebhookForUi } from './ensure-webhook.js';
24
26
  import { readPreferences } from './preferences.js';
25
27
  import { ProcessManager } from './process-manager.js';
@@ -130,7 +132,13 @@ export function projectResolver({ prefsDir, projectRoot }) {
130
132
  settingsPath:
131
133
  project.settingsPath || join(project.path, '.claude', 'settings.json'),
132
134
  projectRoot: projRoot,
133
- pm: new ProcessManager({ worcaDir, projectRoot: projRoot }),
135
+ pm: new ProcessManager({
136
+ worcaDir,
137
+ projectRoot: projRoot,
138
+ settingsPath:
139
+ project.settingsPath ||
140
+ join(project.path, '.claude', 'settings.json'),
141
+ }),
134
142
  };
135
143
  next();
136
144
  };
@@ -738,21 +746,6 @@ export function createProjectScopedRoutes({
738
746
  }
739
747
  });
740
748
 
741
- // DELETE /api/projects/:projectId/runs/:id — stop a running pipeline
742
- router.delete('/runs/:id', requireWorcaDir, (req, res) => {
743
- try {
744
- const result = req.project.pm.stopPipeline(req.params.id);
745
- const { broadcast } = req.app.locals;
746
- if (broadcast) broadcast('run-stopped', { pid: result.pid });
747
- res.json({ ok: true, stopped: true, pid: result.pid });
748
- } catch (err) {
749
- if (err.code === 'not_running') {
750
- return res.status(404).json({ ok: false, error: err.message });
751
- }
752
- res.status(500).json({ ok: false, error: err.message });
753
- }
754
- });
755
-
756
749
  // POST /api/projects/:projectId/runs/:id/pause
757
750
  router.post('/runs/:id/pause', requireWorcaDir, (req, res) => {
758
751
  const runId = req.params.id;
@@ -808,13 +801,13 @@ export function createProjectScopedRoutes({
808
801
  }
809
802
  });
810
803
 
811
- // POST /api/projects/:projectId/runs/:id/stop — control.json + SIGTERM
812
- router.post('/runs/:id/stop', requireWorcaDir, (req, res) => {
804
+ // POST /api/projects/:projectId/runs/:id/stop — control.json + SIGTERM + webhook
805
+ router.post('/runs/:id/stop', requireWorcaDir, async (req, res) => {
813
806
  const runId = req.params.id;
814
807
  if (!validateRunId(runId)) {
815
808
  return res.status(400).json({ ok: false, error: 'Invalid runId' });
816
809
  }
817
- const { worcaDir } = req.project;
810
+ const { worcaDir, settingsPath } = req.project;
818
811
  try {
819
812
  const controlDir = join(worcaDir, 'runs', runId);
820
813
  mkdirSync(controlDir, { recursive: true });
@@ -834,14 +827,15 @@ export function createProjectScopedRoutes({
834
827
  } catch {
835
828
  /* non-fatal — SIGTERM follows */
836
829
  }
830
+ let forced = false;
837
831
  try {
838
- const result = req.project.pm.stopPipeline(runId);
839
- const { broadcast } = req.app.locals;
840
- if (broadcast) broadcast('run-stopped', { runId, pid: result.pid });
841
- res.json({ ok: true, stopped: true, runId, pid: result.pid });
832
+ const result = await req.project.pm.stopPipelineSync(runId, {
833
+ timeoutMs: 5000,
834
+ });
835
+ forced = !!result.forced;
842
836
  } catch (err) {
843
837
  if (err.code === 'not_running') {
844
- const statusPath = findRunStatusPath(req.project.worcaDir, runId);
838
+ const statusPath = findRunStatusPath(worcaDir, runId);
845
839
  if (statusPath) {
846
840
  try {
847
841
  const st = JSON.parse(readFileSync(statusPath, 'utf8'));
@@ -849,17 +843,11 @@ export function createProjectScopedRoutes({
849
843
  st.pipeline_status === 'paused' ||
850
844
  st.pipeline_status === 'running'
851
845
  ) {
852
- st.pipeline_status = 'cancelled';
853
- st.stop_reason = 'force_cancelled';
854
- st.completed_at = new Date().toISOString();
855
- writeFileSync(
856
- statusPath,
857
- `${JSON.stringify(st, null, 2)}\n`,
858
- 'utf8',
859
- );
860
- const { broadcast } = req.app.locals;
861
- if (broadcast) broadcast('run-stopped', { runId, pid: null });
862
- return res.json({ ok: true, stopped: true, runId, pid: null });
846
+ return res.status(409).json({
847
+ ok: false,
848
+ code: 'no_running_process',
849
+ suggested_action: 'cancel',
850
+ });
863
851
  }
864
852
  } catch {
865
853
  /* fall through to 404 */
@@ -867,17 +855,61 @@ export function createProjectScopedRoutes({
867
855
  }
868
856
  return res.status(404).json({ ok: false, error: err.message });
869
857
  }
870
- res.status(500).json({ ok: false, error: err.message });
858
+ // For other errors, continue process may have exited
859
+ }
860
+
861
+ const statusPath = findRunStatusPath(worcaDir, runId);
862
+ const { broadcast, scheduleRefresh } = req.app.locals;
863
+ if (broadcast) broadcast('run-stopped', { runId });
864
+ if (scheduleRefresh) scheduleRefresh(req.project?.name);
865
+ res.json({ ok: true, stopped: true, runId });
866
+
867
+ // If Python exited cleanly (not forced), its finally/atexit already
868
+ // dispatched the webhook. Only dispatch from Node when SIGKILL was needed.
869
+ if (forced && statusPath) {
870
+ let st;
871
+ try {
872
+ st = JSON.parse(readFileSync(statusPath, 'utf8'));
873
+ } catch {
874
+ return;
875
+ }
876
+ const terminalStatus = st.pipeline_status;
877
+ if (terminalStatus === 'interrupted' || terminalStatus === 'failed') {
878
+ const eventType =
879
+ terminalStatus === 'interrupted'
880
+ ? 'pipeline.run.interrupted'
881
+ : 'pipeline.run.failed';
882
+ const startedAt = st.started_at;
883
+ const elapsedMs = startedAt
884
+ ? Date.now() - new Date(startedAt).getTime()
885
+ : 0;
886
+ dispatchExternal({
887
+ runDir: dirname(statusPath),
888
+ settingsPath,
889
+ eventType,
890
+ payload: {
891
+ interrupted_stage: st.stage || st.current_stage || 'unknown',
892
+ elapsed_ms: elapsedMs,
893
+ source: 'user_stop',
894
+ },
895
+ }).then((result) => {
896
+ if (!result.ok) {
897
+ console.error(
898
+ `[stop] dispatchExternal failed for run ${runId}: ${result.reason}${result.stderr ? ` — ${result.stderr}` : ''}`,
899
+ );
900
+ }
901
+ });
902
+ }
871
903
  }
872
904
  });
873
905
 
874
- // POST /api/projects/:projectId/runs/:id/cancel — force-cancel a stale run
875
- router.post('/runs/:id/cancel', requireWorcaDir, (req, res) => {
906
+ // POST /api/projects/:projectId/runs/:id/cancel — force-cancel a run
907
+ router.post('/runs/:id/cancel', requireWorcaDir, async (req, res) => {
876
908
  const runId = req.params.id;
877
909
  if (!validateRunId(runId)) {
878
910
  return res.status(400).json({ ok: false, error: 'Invalid runId' });
879
911
  }
880
- const { worcaDir } = req.project;
912
+ const { worcaDir, settingsPath } = req.project;
881
913
  const statusPath = findRunStatusPath(worcaDir, runId);
882
914
  if (!statusPath) {
883
915
  return res
@@ -885,20 +917,69 @@ export function createProjectScopedRoutes({
885
917
  .json({ ok: false, error: `Run "${runId}" not found` });
886
918
  }
887
919
  try {
888
- const st = JSON.parse(readFileSync(statusPath, 'utf8'));
920
+ let st = JSON.parse(readFileSync(statusPath, 'utf8'));
889
921
  if (
890
922
  st.pipeline_status === 'completed' ||
891
923
  st.pipeline_status === 'cancelled'
892
924
  ) {
893
925
  return res.json({ ok: true, already: st.pipeline_status });
894
926
  }
927
+ if (!actionAllowed('cancel', st.pipeline_status)) {
928
+ return res.status(409).json({ ok: false, code: 'action_not_allowed' });
929
+ }
930
+
931
+ const wasRunning = st.pipeline_status === 'running';
932
+ if (wasRunning) {
933
+ try {
934
+ await req.project.pm.stopPipelineSync(runId, { timeoutMs: 5000 });
935
+ } catch {
936
+ // Expected: process may already be dead (not_running). Cancel proceeds to write cancelled status regardless.
937
+ }
938
+ // Re-read: Python's signal/atexit handler may have updated status.json
939
+ try {
940
+ st = JSON.parse(readFileSync(statusPath, 'utf8'));
941
+ } catch {
942
+ /* use pre-stop snapshot */
943
+ }
944
+ }
945
+
946
+ // Python's SIGTERM handler may have already emitted pipeline.run.interrupted.
947
+ // Only emit pipeline.run.cancelled if Python didn't already emit a terminal event.
948
+ const pythonEmittedTerminal =
949
+ wasRunning && st.pipeline_status === 'interrupted';
950
+
895
951
  st.pipeline_status = 'cancelled';
896
952
  st.stop_reason = 'force_cancelled';
897
953
  st.completed_at = new Date().toISOString();
898
954
  writeFileSync(statusPath, `${JSON.stringify(st, null, 2)}\n`, 'utf8');
899
- const { broadcast } = req.app.locals;
900
- if (broadcast) broadcast('run-stopped', { runId, pid: null });
955
+
956
+ const { broadcast, scheduleRefresh } = req.app.locals;
957
+ if (broadcast) broadcast('run-cancelled', { runId });
958
+ if (scheduleRefresh) scheduleRefresh(req.project?.name);
901
959
  res.json({ ok: true, cancelled: true, runId });
960
+
961
+ if (!pythonEmittedTerminal) {
962
+ const startedAt = st.started_at;
963
+ const elapsedMs = startedAt
964
+ ? Date.now() - new Date(startedAt).getTime()
965
+ : 0;
966
+ dispatchExternal({
967
+ runDir: dirname(statusPath),
968
+ settingsPath,
969
+ eventType: 'pipeline.run.cancelled',
970
+ payload: {
971
+ cancelled_stage: st.stage || st.current_stage || 'unknown',
972
+ elapsed_ms: elapsedMs,
973
+ source: 'user_cancel',
974
+ },
975
+ }).then((result) => {
976
+ if (!result.ok) {
977
+ console.error(
978
+ `[cancel] dispatchExternal failed for run ${runId}: ${result.reason}${result.stderr ? ` — ${result.stderr}` : ''}`,
979
+ );
980
+ }
981
+ });
982
+ }
902
983
  } catch (err) {
903
984
  res.status(500).json({ ok: false, error: err.message });
904
985
  }
@@ -1004,6 +1085,49 @@ export function createProjectScopedRoutes({
1004
1085
  }
1005
1086
  });
1006
1087
 
1088
+ // POST /api/projects/:projectId/runs/:id/delete — permanently remove a run
1089
+ router.post('/runs/:id/delete', requireWorcaDir, (req, res) => {
1090
+ const runId = req.params.id;
1091
+ if (!validateRunId(runId)) {
1092
+ return res.status(400).json({ ok: false, error: 'Invalid runId' });
1093
+ }
1094
+ const { worcaDir } = req.project;
1095
+ const statusPath = findRunStatusPath(worcaDir, runId);
1096
+ if (!statusPath) {
1097
+ return res
1098
+ .status(404)
1099
+ .json({ ok: false, error: `Run "${runId}" not found` });
1100
+ }
1101
+ try {
1102
+ const st = JSON.parse(readFileSync(statusPath, 'utf8'));
1103
+ if (!actionAllowed('delete', st.pipeline_status)) {
1104
+ return res.status(409).json({
1105
+ ok: false,
1106
+ code: 'action_not_allowed',
1107
+ error: `Cannot delete a run with status "${st.pipeline_status}" — stop or cancel it first`,
1108
+ });
1109
+ }
1110
+ } catch (err) {
1111
+ return res
1112
+ .status(500)
1113
+ .json({ ok: false, error: `Failed to read status: ${err.message}` });
1114
+ }
1115
+ try {
1116
+ req.project.pm.deleteRun(runId);
1117
+ const { broadcast } = req.app.locals;
1118
+ if (broadcast) broadcast('run-deleted', { runId });
1119
+ res.json({ ok: true, deleted: true, runId });
1120
+ } catch (err) {
1121
+ if (err.code === 'still_running') {
1122
+ return res.status(409).json({ ok: false, error: err.message });
1123
+ }
1124
+ if (err.code === 'not_found') {
1125
+ return res.status(404).json({ ok: false, error: err.message });
1126
+ }
1127
+ res.status(500).json({ ok: false, error: err.message });
1128
+ }
1129
+ });
1130
+
1007
1131
  // POST /api/projects/:projectId/runs/:id/stages/:stage/restart
1008
1132
  router.post(
1009
1133
  '/runs/:id/stages/:stage/restart',
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * Beads database watcher — monitors .beads/beads.db for changes.
3
- * Watches the directory (not just the file) because SQLite WAL mode
4
- * writes to beads.db-wal first.
3
+ * Uses both fs.watch (directory events) and fs.watchFile (stat-based polling)
4
+ * because fs.watch on macOS misses SQLite WAL writes done via mmap.
5
5
  */
6
6
 
7
- import { existsSync, watch } from 'node:fs';
7
+ import { existsSync, unwatchFile, watch, watchFile } from 'node:fs';
8
8
  import { join, resolve } from 'node:path';
9
9
  import { listIssues } from './beads-reader.js';
10
10
 
11
11
  const BEADS_DEBOUNCE_MS = 500;
12
+ const BEADS_POLL_MS = 2000;
12
13
 
13
14
  /**
14
15
  * @param {{ worcaDir: string, broadcaster: { broadcast: Function }, projectId?: string }} deps
@@ -16,7 +17,8 @@ const BEADS_DEBOUNCE_MS = 500;
16
17
  export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
17
18
  const beadsDbPath = resolve(join(worcaDir, '..', '.beads', 'beads.db'));
18
19
  const beadsDir = resolve(join(worcaDir, '..', '.beads'));
19
- let beadsWatcher = null;
20
+ const beadsWalPath = `${beadsDbPath}-wal`;
21
+ let fsWatcher = null;
20
22
  let BEADS_REFRESH_TIMER = null;
21
23
 
22
24
  function scheduleBeadsRefresh() {
@@ -41,13 +43,22 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
41
43
  }
42
44
 
43
45
  if (existsSync(beadsDir)) {
46
+ // fs.watch for directory-level events (checkpoint writes to main db)
44
47
  try {
45
- beadsWatcher = watch(beadsDir, (_event, filename) => {
48
+ fsWatcher = watch(beadsDir, (_event, filename) => {
46
49
  if (filename?.startsWith('beads.db')) scheduleBeadsRefresh();
47
50
  });
48
51
  } catch {
49
52
  /* ignore */
50
53
  }
54
+
55
+ // fs.watchFile (stat-based polling) for WAL — fs.watch misses mmap writes
56
+ // on macOS. watchFile tolerates a missing file; it starts firing once created.
57
+ watchFile(beadsWalPath, { interval: BEADS_POLL_MS }, (curr, prev) => {
58
+ if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) {
59
+ scheduleBeadsRefresh();
60
+ }
61
+ });
51
62
  }
52
63
 
53
64
  function getBeadsDbPath() {
@@ -55,7 +66,12 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
55
66
  }
56
67
 
57
68
  function destroy() {
58
- if (beadsWatcher) beadsWatcher.close();
69
+ if (fsWatcher) fsWatcher.close();
70
+ try {
71
+ unwatchFile(beadsWalPath);
72
+ } catch {
73
+ /* */
74
+ }
59
75
  }
60
76
 
61
77
  return { getBeadsDbPath, destroy };
@@ -505,7 +505,7 @@ export function createMessageRouter({
505
505
  }
506
506
  if (!alive || checks >= maxChecks) {
507
507
  clearInterval(pollInterval);
508
- reconcileStatus(proj.worcaDir);
508
+ reconcileStatus(proj.worcaDir, proj.settingsPath);
509
509
  proj.wset.statusWatcher?.scheduleRefresh();
510
510
  }
511
511
  }, 500);