@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.
- package/app/main.bundle.js +733 -720
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +5 -4
- package/app/utils/state-actions.js +55 -0
- package/package.json +2 -1
- package/server/app.js +6 -0
- package/server/beads-reader.js +1 -1
- package/server/dispatch-external.js +106 -0
- package/server/process-manager.js +174 -35
- package/server/project-routes.js +166 -42
- package/server/ws-beads-watcher.js +22 -6
- package/server/ws-message-router.js +1 -1
package/server/project-routes.js
CHANGED
|
@@ -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({
|
|
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.
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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(
|
|
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
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
900
|
-
|
|
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
|
-
*
|
|
4
|
-
* writes
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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);
|