@worca/ui 0.9.0 → 0.11.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/main.bundle.js +895 -813
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +216 -9
- package/app/utils/state-actions.js +55 -0
- package/package.json +6 -4
- package/server/app.js +291 -6
- package/server/beads-reader.js +1 -1
- package/server/dispatch-external.js +106 -0
- package/server/ensure-webhook.js +66 -0
- package/server/index.js +22 -0
- package/server/integrations/adapter.js +91 -0
- package/server/integrations/adapters/discord.js +109 -0
- package/server/integrations/adapters/slack.js +106 -0
- package/server/integrations/adapters/telegram.js +231 -0
- package/server/integrations/adapters/webhook_out.js +253 -0
- package/server/integrations/allowlist.js +19 -0
- package/server/integrations/chat_context.js +68 -0
- package/server/integrations/commands/control.js +120 -0
- package/server/integrations/commands/global.js +239 -0
- package/server/integrations/commands/parser.js +29 -0
- package/server/integrations/commands/project.js +394 -0
- package/server/integrations/config-loader.js +40 -0
- package/server/integrations/index.js +390 -0
- package/server/integrations/markdown.js +220 -0
- package/server/integrations/rate_limiter.js +131 -0
- package/server/integrations/renderers.js +191 -0
- package/server/integrations/rest_client.js +17 -0
- package/server/integrations/verify.js +23 -0
- package/server/process-manager.js +217 -14
- package/server/project-routes.js +210 -44
- package/server/settings-validator.js +250 -0
- 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,10 @@ 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';
|
|
25
|
+
import { ensureWebhookForUi } from './ensure-webhook.js';
|
|
23
26
|
import { readPreferences } from './preferences.js';
|
|
24
27
|
import { ProcessManager } from './process-manager.js';
|
|
25
28
|
import {
|
|
@@ -129,7 +132,13 @@ export function projectResolver({ prefsDir, projectRoot }) {
|
|
|
129
132
|
settingsPath:
|
|
130
133
|
project.settingsPath || join(project.path, '.claude', 'settings.json'),
|
|
131
134
|
projectRoot: projRoot,
|
|
132
|
-
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
|
+
}),
|
|
133
142
|
};
|
|
134
143
|
next();
|
|
135
144
|
};
|
|
@@ -138,7 +147,12 @@ export function projectResolver({ prefsDir, projectRoot }) {
|
|
|
138
147
|
/**
|
|
139
148
|
* Router for project CRUD: GET/POST/DELETE /api/projects[/:id]
|
|
140
149
|
*/
|
|
141
|
-
export function createProjectRoutes({
|
|
150
|
+
export function createProjectRoutes({
|
|
151
|
+
prefsDir,
|
|
152
|
+
projectRoot,
|
|
153
|
+
serverHost,
|
|
154
|
+
serverPort,
|
|
155
|
+
}) {
|
|
142
156
|
const router = Router();
|
|
143
157
|
|
|
144
158
|
// GET /api/projects — list all projects (or synthesized default)
|
|
@@ -169,6 +183,17 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
|
|
|
169
183
|
}
|
|
170
184
|
try {
|
|
171
185
|
writeProject(prefsDir, entry);
|
|
186
|
+
// Auto-configure webhook so pipeline events reach this UI server
|
|
187
|
+
if (serverHost && serverPort) {
|
|
188
|
+
try {
|
|
189
|
+
ensureWebhookForUi(entry.path, {
|
|
190
|
+
host: serverHost,
|
|
191
|
+
port: serverPort,
|
|
192
|
+
});
|
|
193
|
+
} catch {
|
|
194
|
+
/* best-effort — don't fail project creation */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
172
197
|
res.status(201).json({ ok: true, project: entry });
|
|
173
198
|
} catch (err) {
|
|
174
199
|
res.status(400).json({ ok: false, error: err.message });
|
|
@@ -267,6 +292,16 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
|
|
|
267
292
|
for (const entry of batch) {
|
|
268
293
|
writeProject(prefsDir, entry);
|
|
269
294
|
written.push(entry.name);
|
|
295
|
+
if (serverHost && serverPort) {
|
|
296
|
+
try {
|
|
297
|
+
ensureWebhookForUi(entry.path, {
|
|
298
|
+
host: serverHost,
|
|
299
|
+
port: serverPort,
|
|
300
|
+
});
|
|
301
|
+
} catch {
|
|
302
|
+
/* best-effort */
|
|
303
|
+
}
|
|
304
|
+
}
|
|
270
305
|
}
|
|
271
306
|
res.status(201).json({ ok: true, projects: batch });
|
|
272
307
|
} catch (err) {
|
|
@@ -290,7 +325,11 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
|
|
|
290
325
|
* @param {{ prefsDir?: string|null }} [options] — prefsDir enables active
|
|
291
326
|
* worca-cc version lookup for /worca-status' `outdated` flag.
|
|
292
327
|
*/
|
|
293
|
-
export function createProjectScopedRoutes({
|
|
328
|
+
export function createProjectScopedRoutes({
|
|
329
|
+
prefsDir = null,
|
|
330
|
+
serverHost,
|
|
331
|
+
serverPort,
|
|
332
|
+
} = {}) {
|
|
294
333
|
const router = Router({ mergeParams: true });
|
|
295
334
|
const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
|
|
296
335
|
|
|
@@ -707,21 +746,6 @@ export function createProjectScopedRoutes({ prefsDir = null } = {}) {
|
|
|
707
746
|
}
|
|
708
747
|
});
|
|
709
748
|
|
|
710
|
-
// DELETE /api/projects/:projectId/runs/:id — stop a running pipeline
|
|
711
|
-
router.delete('/runs/:id', requireWorcaDir, (req, res) => {
|
|
712
|
-
try {
|
|
713
|
-
const result = req.project.pm.stopPipeline(req.params.id);
|
|
714
|
-
const { broadcast } = req.app.locals;
|
|
715
|
-
if (broadcast) broadcast('run-stopped', { pid: result.pid });
|
|
716
|
-
res.json({ ok: true, stopped: true, pid: result.pid });
|
|
717
|
-
} catch (err) {
|
|
718
|
-
if (err.code === 'not_running') {
|
|
719
|
-
return res.status(404).json({ ok: false, error: err.message });
|
|
720
|
-
}
|
|
721
|
-
res.status(500).json({ ok: false, error: err.message });
|
|
722
|
-
}
|
|
723
|
-
});
|
|
724
|
-
|
|
725
749
|
// POST /api/projects/:projectId/runs/:id/pause
|
|
726
750
|
router.post('/runs/:id/pause', requireWorcaDir, (req, res) => {
|
|
727
751
|
const runId = req.params.id;
|
|
@@ -777,13 +801,13 @@ export function createProjectScopedRoutes({ prefsDir = null } = {}) {
|
|
|
777
801
|
}
|
|
778
802
|
});
|
|
779
803
|
|
|
780
|
-
// POST /api/projects/:projectId/runs/:id/stop — control.json + SIGTERM
|
|
781
|
-
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) => {
|
|
782
806
|
const runId = req.params.id;
|
|
783
807
|
if (!validateRunId(runId)) {
|
|
784
808
|
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
785
809
|
}
|
|
786
|
-
const { worcaDir } = req.project;
|
|
810
|
+
const { worcaDir, settingsPath } = req.project;
|
|
787
811
|
try {
|
|
788
812
|
const controlDir = join(worcaDir, 'runs', runId);
|
|
789
813
|
mkdirSync(controlDir, { recursive: true });
|
|
@@ -803,14 +827,15 @@ export function createProjectScopedRoutes({ prefsDir = null } = {}) {
|
|
|
803
827
|
} catch {
|
|
804
828
|
/* non-fatal — SIGTERM follows */
|
|
805
829
|
}
|
|
830
|
+
let forced = false;
|
|
806
831
|
try {
|
|
807
|
-
const result = req.project.pm.
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
832
|
+
const result = await req.project.pm.stopPipelineSync(runId, {
|
|
833
|
+
timeoutMs: 5000,
|
|
834
|
+
});
|
|
835
|
+
forced = !!result.forced;
|
|
811
836
|
} catch (err) {
|
|
812
837
|
if (err.code === 'not_running') {
|
|
813
|
-
const statusPath = findRunStatusPath(
|
|
838
|
+
const statusPath = findRunStatusPath(worcaDir, runId);
|
|
814
839
|
if (statusPath) {
|
|
815
840
|
try {
|
|
816
841
|
const st = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
@@ -818,17 +843,11 @@ export function createProjectScopedRoutes({ prefsDir = null } = {}) {
|
|
|
818
843
|
st.pipeline_status === 'paused' ||
|
|
819
844
|
st.pipeline_status === 'running'
|
|
820
845
|
) {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
`${JSON.stringify(st, null, 2)}\n`,
|
|
827
|
-
'utf8',
|
|
828
|
-
);
|
|
829
|
-
const { broadcast } = req.app.locals;
|
|
830
|
-
if (broadcast) broadcast('run-stopped', { runId, pid: null });
|
|
831
|
-
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
|
+
});
|
|
832
851
|
}
|
|
833
852
|
} catch {
|
|
834
853
|
/* fall through to 404 */
|
|
@@ -836,17 +855,61 @@ export function createProjectScopedRoutes({ prefsDir = null } = {}) {
|
|
|
836
855
|
}
|
|
837
856
|
return res.status(404).json({ ok: false, error: err.message });
|
|
838
857
|
}
|
|
839
|
-
|
|
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
|
+
}
|
|
840
903
|
}
|
|
841
904
|
});
|
|
842
905
|
|
|
843
|
-
// POST /api/projects/:projectId/runs/:id/cancel — force-cancel a
|
|
844
|
-
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) => {
|
|
845
908
|
const runId = req.params.id;
|
|
846
909
|
if (!validateRunId(runId)) {
|
|
847
910
|
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
848
911
|
}
|
|
849
|
-
const { worcaDir } = req.project;
|
|
912
|
+
const { worcaDir, settingsPath } = req.project;
|
|
850
913
|
const statusPath = findRunStatusPath(worcaDir, runId);
|
|
851
914
|
if (!statusPath) {
|
|
852
915
|
return res
|
|
@@ -854,20 +917,69 @@ export function createProjectScopedRoutes({ prefsDir = null } = {}) {
|
|
|
854
917
|
.json({ ok: false, error: `Run "${runId}" not found` });
|
|
855
918
|
}
|
|
856
919
|
try {
|
|
857
|
-
|
|
920
|
+
let st = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
858
921
|
if (
|
|
859
922
|
st.pipeline_status === 'completed' ||
|
|
860
923
|
st.pipeline_status === 'cancelled'
|
|
861
924
|
) {
|
|
862
925
|
return res.json({ ok: true, already: st.pipeline_status });
|
|
863
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
|
+
|
|
864
951
|
st.pipeline_status = 'cancelled';
|
|
865
952
|
st.stop_reason = 'force_cancelled';
|
|
866
953
|
st.completed_at = new Date().toISOString();
|
|
867
954
|
writeFileSync(statusPath, `${JSON.stringify(st, null, 2)}\n`, 'utf8');
|
|
868
|
-
|
|
869
|
-
|
|
955
|
+
|
|
956
|
+
const { broadcast, scheduleRefresh } = req.app.locals;
|
|
957
|
+
if (broadcast) broadcast('run-cancelled', { runId });
|
|
958
|
+
if (scheduleRefresh) scheduleRefresh(req.project?.name);
|
|
870
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
|
+
}
|
|
871
983
|
} catch (err) {
|
|
872
984
|
res.status(500).json({ ok: false, error: err.message });
|
|
873
985
|
}
|
|
@@ -973,6 +1085,49 @@ export function createProjectScopedRoutes({ prefsDir = null } = {}) {
|
|
|
973
1085
|
}
|
|
974
1086
|
});
|
|
975
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
|
+
|
|
976
1131
|
// POST /api/projects/:projectId/runs/:id/stages/:stage/restart
|
|
977
1132
|
router.post(
|
|
978
1133
|
'/runs/:id/stages/:stage/restart',
|
|
@@ -1365,6 +1520,17 @@ export function createProjectScopedRoutes({ prefsDir = null } = {}) {
|
|
|
1365
1520
|
|
|
1366
1521
|
try {
|
|
1367
1522
|
const { pid } = runWorcaSetup(projectRoot, { source });
|
|
1523
|
+
// Auto-configure webhook so pipeline events reach this UI server
|
|
1524
|
+
if (serverHost && serverPort) {
|
|
1525
|
+
try {
|
|
1526
|
+
ensureWebhookForUi(projectRoot, {
|
|
1527
|
+
host: serverHost,
|
|
1528
|
+
port: serverPort,
|
|
1529
|
+
});
|
|
1530
|
+
} catch {
|
|
1531
|
+
/* best-effort */
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1368
1534
|
res.json({ ok: true, pid });
|
|
1369
1535
|
} catch (err) {
|
|
1370
1536
|
res.status(500).json({ ok: false, error: err.message });
|
|
@@ -532,3 +532,253 @@ export function validateSettingsPayload(body) {
|
|
|
532
532
|
|
|
533
533
|
return details.length ? { valid: false, details } : { valid: true };
|
|
534
534
|
}
|
|
535
|
+
|
|
536
|
+
const VALID_WEBHOOK_OUT_FORMATS = [
|
|
537
|
+
'generic-json',
|
|
538
|
+
'slack-compatible',
|
|
539
|
+
'discord-compatible',
|
|
540
|
+
'teams-card',
|
|
541
|
+
'ntfy',
|
|
542
|
+
'plain-text',
|
|
543
|
+
];
|
|
544
|
+
|
|
545
|
+
export function validateIntegrationsConfig(cfg) {
|
|
546
|
+
const details = [];
|
|
547
|
+
|
|
548
|
+
if (cfg.schema_version === undefined || cfg.schema_version !== 1) {
|
|
549
|
+
details.push('schema_version must be present and equal to 1');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (cfg.enabled !== undefined && typeof cfg.enabled !== 'boolean') {
|
|
553
|
+
details.push('enabled must be a boolean');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (cfg.webhook_secret_env !== undefined) {
|
|
557
|
+
if (
|
|
558
|
+
typeof cfg.webhook_secret_env !== 'string' ||
|
|
559
|
+
cfg.webhook_secret_env.length === 0
|
|
560
|
+
) {
|
|
561
|
+
details.push('webhook_secret_env must be a non-empty string');
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (cfg.webhook_secrets_env !== undefined) {
|
|
566
|
+
if (
|
|
567
|
+
typeof cfg.webhook_secrets_env !== 'string' ||
|
|
568
|
+
cfg.webhook_secrets_env.length === 0
|
|
569
|
+
) {
|
|
570
|
+
details.push('webhook_secrets_env must be a non-empty string');
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (
|
|
575
|
+
cfg.strict_inbox_verification !== undefined &&
|
|
576
|
+
typeof cfg.strict_inbox_verification !== 'boolean'
|
|
577
|
+
) {
|
|
578
|
+
details.push('strict_inbox_verification must be a boolean');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// telegram
|
|
582
|
+
if (cfg.telegram !== undefined) {
|
|
583
|
+
if (
|
|
584
|
+
typeof cfg.telegram !== 'object' ||
|
|
585
|
+
cfg.telegram === null ||
|
|
586
|
+
Array.isArray(cfg.telegram)
|
|
587
|
+
) {
|
|
588
|
+
details.push('telegram must be an object');
|
|
589
|
+
} else {
|
|
590
|
+
const tg = cfg.telegram;
|
|
591
|
+
if (tg.enabled !== undefined && typeof tg.enabled !== 'boolean') {
|
|
592
|
+
details.push('telegram.enabled must be a boolean');
|
|
593
|
+
}
|
|
594
|
+
const hasTgToken =
|
|
595
|
+
(typeof tg.bot_token === 'string' && tg.bot_token.length > 0) ||
|
|
596
|
+
(typeof tg.bot_token_env === 'string' && tg.bot_token_env.length > 0);
|
|
597
|
+
if (!hasTgToken) {
|
|
598
|
+
details.push(
|
|
599
|
+
'telegram requires bot_token or bot_token_env (non-empty string)',
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
if (
|
|
603
|
+
tg.chat_id === undefined ||
|
|
604
|
+
(typeof tg.chat_id !== 'string' && typeof tg.chat_id !== 'number')
|
|
605
|
+
) {
|
|
606
|
+
details.push('telegram.chat_id must be a string or number');
|
|
607
|
+
}
|
|
608
|
+
if (tg.events === undefined || !Array.isArray(tg.events)) {
|
|
609
|
+
details.push('telegram.events must be an array');
|
|
610
|
+
} else {
|
|
611
|
+
for (let i = 0; i < tg.events.length; i++) {
|
|
612
|
+
if (typeof tg.events[i] !== 'string' || tg.events[i].length === 0) {
|
|
613
|
+
details.push(`telegram.events[${i}] must be a non-empty string`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (tg.rate_limit_per_min !== undefined) {
|
|
618
|
+
if (
|
|
619
|
+
!Number.isInteger(tg.rate_limit_per_min) ||
|
|
620
|
+
tg.rate_limit_per_min < 1
|
|
621
|
+
) {
|
|
622
|
+
details.push(
|
|
623
|
+
'telegram.rate_limit_per_min must be a positive integer',
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// discord
|
|
631
|
+
if (cfg.discord !== undefined) {
|
|
632
|
+
if (
|
|
633
|
+
typeof cfg.discord !== 'object' ||
|
|
634
|
+
cfg.discord === null ||
|
|
635
|
+
Array.isArray(cfg.discord)
|
|
636
|
+
) {
|
|
637
|
+
details.push('discord must be an object');
|
|
638
|
+
} else {
|
|
639
|
+
const dc = cfg.discord;
|
|
640
|
+
if (dc.enabled !== undefined && typeof dc.enabled !== 'boolean') {
|
|
641
|
+
details.push('discord.enabled must be a boolean');
|
|
642
|
+
}
|
|
643
|
+
const hasDcToken =
|
|
644
|
+
(typeof dc.bot_token === 'string' && dc.bot_token.length > 0) ||
|
|
645
|
+
(typeof dc.bot_token_env === 'string' && dc.bot_token_env.length > 0);
|
|
646
|
+
if (!hasDcToken) {
|
|
647
|
+
details.push(
|
|
648
|
+
'discord requires bot_token or bot_token_env (non-empty string)',
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
if (
|
|
652
|
+
dc.channel_id === undefined ||
|
|
653
|
+
typeof dc.channel_id !== 'string' ||
|
|
654
|
+
dc.channel_id.length === 0
|
|
655
|
+
) {
|
|
656
|
+
details.push('discord.channel_id must be a non-empty string');
|
|
657
|
+
}
|
|
658
|
+
if (dc.events !== undefined && !Array.isArray(dc.events)) {
|
|
659
|
+
details.push('discord.events must be an array');
|
|
660
|
+
} else if (Array.isArray(dc.events)) {
|
|
661
|
+
for (let i = 0; i < dc.events.length; i++) {
|
|
662
|
+
if (typeof dc.events[i] !== 'string' || dc.events[i].length === 0) {
|
|
663
|
+
details.push(`discord.events[${i}] must be a non-empty string`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// slack
|
|
671
|
+
if (cfg.slack !== undefined) {
|
|
672
|
+
if (
|
|
673
|
+
typeof cfg.slack !== 'object' ||
|
|
674
|
+
cfg.slack === null ||
|
|
675
|
+
Array.isArray(cfg.slack)
|
|
676
|
+
) {
|
|
677
|
+
details.push('slack must be an object');
|
|
678
|
+
} else {
|
|
679
|
+
const sl = cfg.slack;
|
|
680
|
+
if (sl.enabled !== undefined && typeof sl.enabled !== 'boolean') {
|
|
681
|
+
details.push('slack.enabled must be a boolean');
|
|
682
|
+
}
|
|
683
|
+
const hasSlUrl =
|
|
684
|
+
(typeof sl.webhook_url === 'string' && sl.webhook_url.length > 0) ||
|
|
685
|
+
(typeof sl.webhook_url_env === 'string' &&
|
|
686
|
+
sl.webhook_url_env.length > 0);
|
|
687
|
+
if (!hasSlUrl) {
|
|
688
|
+
details.push(
|
|
689
|
+
'slack requires webhook_url or webhook_url_env (non-empty string)',
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
if (sl.events !== undefined && !Array.isArray(sl.events)) {
|
|
693
|
+
details.push('slack.events must be an array');
|
|
694
|
+
} else if (Array.isArray(sl.events)) {
|
|
695
|
+
for (let i = 0; i < sl.events.length; i++) {
|
|
696
|
+
if (typeof sl.events[i] !== 'string' || sl.events[i].length === 0) {
|
|
697
|
+
details.push(`slack.events[${i}] must be a non-empty string`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// webhook_out
|
|
705
|
+
if (cfg.webhook_out !== undefined) {
|
|
706
|
+
if (
|
|
707
|
+
typeof cfg.webhook_out !== 'object' ||
|
|
708
|
+
cfg.webhook_out === null ||
|
|
709
|
+
Array.isArray(cfg.webhook_out)
|
|
710
|
+
) {
|
|
711
|
+
details.push('webhook_out must be an object');
|
|
712
|
+
} else {
|
|
713
|
+
const wo = cfg.webhook_out;
|
|
714
|
+
if (wo.enabled !== undefined && typeof wo.enabled !== 'boolean') {
|
|
715
|
+
details.push('webhook_out.enabled must be a boolean');
|
|
716
|
+
}
|
|
717
|
+
if (wo.endpoints !== undefined) {
|
|
718
|
+
if (!Array.isArray(wo.endpoints)) {
|
|
719
|
+
details.push('webhook_out.endpoints must be an array');
|
|
720
|
+
} else {
|
|
721
|
+
for (let i = 0; i < wo.endpoints.length; i++) {
|
|
722
|
+
const ep = wo.endpoints[i];
|
|
723
|
+
const pfx = `webhook_out.endpoints[${i}]`;
|
|
724
|
+
if (typeof ep !== 'object' || ep === null || Array.isArray(ep)) {
|
|
725
|
+
details.push(`${pfx} must be an object`);
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (
|
|
729
|
+
ep.url === undefined ||
|
|
730
|
+
typeof ep.url !== 'string' ||
|
|
731
|
+
ep.url.trim().length === 0
|
|
732
|
+
) {
|
|
733
|
+
details.push(`${pfx}.url must be a non-empty string`);
|
|
734
|
+
} else {
|
|
735
|
+
try {
|
|
736
|
+
const parsed = new URL(ep.url);
|
|
737
|
+
if (
|
|
738
|
+
parsed.protocol !== 'http:' &&
|
|
739
|
+
parsed.protocol !== 'https:'
|
|
740
|
+
) {
|
|
741
|
+
details.push(`${pfx}.url must use http or https protocol`);
|
|
742
|
+
}
|
|
743
|
+
} catch {
|
|
744
|
+
details.push(`${pfx}.url is not a valid URL`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (ep.format !== undefined) {
|
|
748
|
+
if (!VALID_WEBHOOK_OUT_FORMATS.includes(ep.format)) {
|
|
749
|
+
details.push(
|
|
750
|
+
`${pfx}.format must be one of: ${VALID_WEBHOOK_OUT_FORMATS.join(', ')}`,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (ep.headers !== undefined) {
|
|
755
|
+
if (
|
|
756
|
+
typeof ep.headers !== 'object' ||
|
|
757
|
+
ep.headers === null ||
|
|
758
|
+
Array.isArray(ep.headers)
|
|
759
|
+
) {
|
|
760
|
+
details.push(`${pfx}.headers must be an object`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (ep.events !== undefined && !Array.isArray(ep.events)) {
|
|
764
|
+
details.push(`${pfx}.events must be an array`);
|
|
765
|
+
} else if (Array.isArray(ep.events)) {
|
|
766
|
+
for (let j = 0; j < ep.events.length; j++) {
|
|
767
|
+
if (
|
|
768
|
+
typeof ep.events[j] !== 'string' ||
|
|
769
|
+
ep.events[j].length === 0
|
|
770
|
+
) {
|
|
771
|
+
details.push(
|
|
772
|
+
`${pfx}.events[${j}] must be a non-empty string`,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return details.length ? { valid: false, details } : { valid: true };
|
|
784
|
+
}
|
|
@@ -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 };
|