@worca/ui 0.9.0 → 0.10.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.
Files changed (33) hide show
  1. package/app/main.bundle.js +900 -803
  2. package/app/main.bundle.js.map +4 -4
  3. package/app/styles.css +210 -8
  4. package/app/utils/state-actions.js +55 -0
  5. package/package.json +6 -4
  6. package/server/app.js +291 -6
  7. package/server/beads-reader.js +1 -1
  8. package/server/dispatch-external.js +106 -0
  9. package/server/ensure-webhook.js +66 -0
  10. package/server/index.js +22 -0
  11. package/server/integrations/adapter.js +91 -0
  12. package/server/integrations/adapters/discord.js +109 -0
  13. package/server/integrations/adapters/slack.js +106 -0
  14. package/server/integrations/adapters/telegram.js +231 -0
  15. package/server/integrations/adapters/webhook_out.js +253 -0
  16. package/server/integrations/allowlist.js +19 -0
  17. package/server/integrations/chat_context.js +68 -0
  18. package/server/integrations/commands/control.js +120 -0
  19. package/server/integrations/commands/global.js +239 -0
  20. package/server/integrations/commands/parser.js +29 -0
  21. package/server/integrations/commands/project.js +394 -0
  22. package/server/integrations/config-loader.js +40 -0
  23. package/server/integrations/index.js +390 -0
  24. package/server/integrations/markdown.js +220 -0
  25. package/server/integrations/rate_limiter.js +131 -0
  26. package/server/integrations/renderers.js +191 -0
  27. package/server/integrations/rest_client.js +17 -0
  28. package/server/integrations/verify.js +23 -0
  29. package/server/process-manager.js +212 -14
  30. package/server/project-routes.js +210 -44
  31. package/server/settings-validator.js +250 -0
  32. package/server/ws-beads-watcher.js +22 -6
  33. package/server/ws-message-router.js +1 -1
@@ -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({ 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
+ }),
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({ prefsDir, projectRoot }) {
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({ prefsDir = null } = {}) {
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.stopPipeline(runId);
808
- const { broadcast } = req.app.locals;
809
- if (broadcast) broadcast('run-stopped', { runId, pid: result.pid });
810
- 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;
811
836
  } catch (err) {
812
837
  if (err.code === 'not_running') {
813
- const statusPath = findRunStatusPath(req.project.worcaDir, runId);
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
- st.pipeline_status = 'cancelled';
822
- st.stop_reason = 'force_cancelled';
823
- st.completed_at = new Date().toISOString();
824
- writeFileSync(
825
- statusPath,
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
- 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
+ }
840
903
  }
841
904
  });
842
905
 
843
- // POST /api/projects/:projectId/runs/:id/cancel — force-cancel a stale run
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
- const st = JSON.parse(readFileSync(statusPath, 'utf8'));
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
- const { broadcast } = req.app.locals;
869
- 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);
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
- * 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 };