@worca/ui 0.1.0-rc.1

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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Worca install/update logic for the UI server.
3
+ *
4
+ * Delegates to the `worca init` CLI for installation and upgrades.
5
+ * The UI only needs to check installation status and spawn the CLI.
6
+ *
7
+ * - checkWorcaInstalled(path) → check if .claude/worca/ exists in a project
8
+ * - runWorcaSetup(targetPath, opts) → spawn `worca init --upgrade` in the project
9
+ */
10
+
11
+ import { spawn } from 'node:child_process';
12
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+
15
+ /**
16
+ * Check whether worca is installed in the given project path.
17
+ */
18
+ export function checkWorcaInstalled(projectPath) {
19
+ return existsSync(join(projectPath, '.claude', 'worca'));
20
+ }
21
+
22
+ /**
23
+ * Spawn `worca init --upgrade` in the target project directory.
24
+ * Optionally passes --source if a source repo path is provided.
25
+ *
26
+ * Returns { pid } immediately. Writes progress to a status file
27
+ * at <targetPath>/.worca/setup-status.json.
28
+ *
29
+ * @param {string} targetPath - The project root directory
30
+ * @param {{ source?: string }} opts - Optional source repo path
31
+ * @returns {{ pid: number }}
32
+ */
33
+ export function runWorcaSetup(targetPath, opts = {}) {
34
+ // Ensure .worca dir exists for status file
35
+ const worcaDir = join(targetPath, '.worca');
36
+ mkdirSync(worcaDir, { recursive: true });
37
+
38
+ const statusFile = join(worcaDir, 'setup-status.json');
39
+
40
+ // Write initial status
41
+ writeFileSync(
42
+ statusFile,
43
+ `${JSON.stringify(
44
+ {
45
+ status: 'running',
46
+ started_at: new Date().toISOString(),
47
+ target: targetPath,
48
+ },
49
+ null,
50
+ 2,
51
+ )}\n`,
52
+ 'utf8',
53
+ );
54
+
55
+ const args = ['init', '--upgrade'];
56
+ if (opts.source) {
57
+ args.push('--source', opts.source);
58
+ }
59
+
60
+ const child = spawn('worca', args, {
61
+ detached: true,
62
+ stdio: 'ignore',
63
+ cwd: targetPath,
64
+ env: { ...process.env },
65
+ });
66
+
67
+ // On error, write failure status
68
+ child.on('error', (err) => {
69
+ try {
70
+ writeFileSync(
71
+ statusFile,
72
+ `${JSON.stringify(
73
+ {
74
+ status: 'error',
75
+ error: err.message || 'spawn failed',
76
+ finished_at: new Date().toISOString(),
77
+ },
78
+ null,
79
+ 2,
80
+ )}\n`,
81
+ 'utf8',
82
+ );
83
+ } catch {
84
+ /* best effort */
85
+ }
86
+ });
87
+
88
+ child.on('exit', (code) => {
89
+ const payload =
90
+ code !== 0
91
+ ? {
92
+ status: 'error',
93
+ error: `Process exited with code ${code}`,
94
+ finished_at: new Date().toISOString(),
95
+ }
96
+ : {
97
+ status: 'done',
98
+ finished_at: new Date().toISOString(),
99
+ };
100
+ try {
101
+ writeFileSync(
102
+ statusFile,
103
+ `${JSON.stringify(payload, null, 2)}\n`,
104
+ 'utf8',
105
+ );
106
+ } catch {
107
+ /* best effort */
108
+ }
109
+ });
110
+
111
+ child.unref();
112
+
113
+ return { pid: child.pid };
114
+ }
@@ -0,0 +1,62 @@
1
+ /**
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.
5
+ */
6
+
7
+ import { existsSync, watch } from 'node:fs';
8
+ import { join, resolve } from 'node:path';
9
+ import { listIssues } from './beads-reader.js';
10
+
11
+ const BEADS_DEBOUNCE_MS = 200;
12
+
13
+ /**
14
+ * @param {{ worcaDir: string, broadcaster: { broadcast: Function }, projectId?: string }} deps
15
+ */
16
+ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
17
+ const beadsDbPath = resolve(join(worcaDir, '..', '.beads', 'beads.db'));
18
+ const beadsDir = resolve(join(worcaDir, '..', '.beads'));
19
+ let beadsWatcher = null;
20
+ let BEADS_REFRESH_TIMER = null;
21
+
22
+ function scheduleBeadsRefresh() {
23
+ if (BEADS_REFRESH_TIMER) clearTimeout(BEADS_REFRESH_TIMER);
24
+ BEADS_REFRESH_TIMER = setTimeout(() => {
25
+ BEADS_REFRESH_TIMER = null;
26
+ try {
27
+ const issues = listIssues(beadsDbPath);
28
+ broadcaster.broadcast(
29
+ 'beads-update',
30
+ {
31
+ issues,
32
+ dbExists: true,
33
+ dbPath: beadsDbPath,
34
+ },
35
+ projectId,
36
+ );
37
+ } catch {
38
+ /* ignore */
39
+ }
40
+ }, BEADS_DEBOUNCE_MS);
41
+ }
42
+
43
+ if (existsSync(beadsDir)) {
44
+ try {
45
+ beadsWatcher = watch(beadsDir, (_event, filename) => {
46
+ if (filename?.startsWith('beads.db')) scheduleBeadsRefresh();
47
+ });
48
+ } catch {
49
+ /* ignore */
50
+ }
51
+ }
52
+
53
+ function getBeadsDbPath() {
54
+ return beadsDbPath;
55
+ }
56
+
57
+ function destroy() {
58
+ if (beadsWatcher) beadsWatcher.close();
59
+ }
60
+
61
+ return { getBeadsDbPath, destroy };
62
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * WebSocket broadcast utilities.
3
+ * Stateless — uses wss.clients and the subs WeakMap from client-manager.
4
+ *
5
+ * Protocol 2 clients receive an extra `project` field in broadcast messages.
6
+ * Protocol 1 clients receive messages identical to pre-multi-project behavior.
7
+ */
8
+
9
+ /**
10
+ * @param {{ wss: import('ws').WebSocketServer, getSubs: Function }} deps
11
+ */
12
+ export function createBroadcaster({ wss, getSubs }) {
13
+ /**
14
+ * Build a message envelope. For protocol 2 clients with a projectId,
15
+ * a `project` field is added to the top-level message.
16
+ */
17
+ function sendToClient(ws, baseMsg) {
18
+ const s = getSubs(ws);
19
+ if (s && s.protocolVersion >= 2 && s.projectId) {
20
+ ws.send(JSON.stringify({ ...baseMsg, project: s.projectId }));
21
+ } else {
22
+ ws.send(JSON.stringify(baseMsg));
23
+ }
24
+ }
25
+
26
+ function broadcast(type, payload, projectId) {
27
+ const base = {
28
+ id: `evt-${Date.now()}`,
29
+ ok: true,
30
+ type,
31
+ payload,
32
+ };
33
+ for (const ws of wss.clients) {
34
+ if (ws.readyState !== ws.OPEN) continue;
35
+ if (projectId) {
36
+ const s = getSubs(ws);
37
+ // Skip clients subscribed to a different project.
38
+ // Send to unscoped clients (protocol 1) and matching protocol 2 clients.
39
+ if (
40
+ s &&
41
+ s.protocolVersion >= 2 &&
42
+ s.projectId &&
43
+ s.projectId !== projectId
44
+ )
45
+ continue;
46
+ }
47
+ sendToClient(ws, base);
48
+ }
49
+ }
50
+
51
+ function broadcastToSubscribers(runId, type, payload) {
52
+ const base = {
53
+ id: `evt-${Date.now()}`,
54
+ ok: true,
55
+ type,
56
+ payload,
57
+ };
58
+ for (const ws of wss.clients) {
59
+ if (ws.readyState !== ws.OPEN) continue;
60
+ const s = getSubs(ws);
61
+ if (s && s.runId === runId) {
62
+ sendToClient(ws, base);
63
+ }
64
+ }
65
+ }
66
+
67
+ function broadcastToLogSubscribers(stage, type, payload, runId) {
68
+ const base = {
69
+ id: `evt-${Date.now()}`,
70
+ ok: true,
71
+ type,
72
+ payload,
73
+ };
74
+ for (const ws of wss.clients) {
75
+ if (ws.readyState !== ws.OPEN) continue;
76
+ const s = getSubs(ws);
77
+ if (s && (s.logStage === stage || s.logStage === '*')) {
78
+ if (runId && s.logRunId && s.logRunId !== runId) continue;
79
+ sendToClient(ws, base);
80
+ }
81
+ }
82
+ }
83
+
84
+ function broadcastPipelineEvent(runId, event) {
85
+ const base = {
86
+ id: `evt-${Date.now()}`,
87
+ ok: true,
88
+ type: 'pipeline-event',
89
+ payload: event,
90
+ };
91
+ for (const ws of wss.clients) {
92
+ if (ws.readyState !== ws.OPEN) continue;
93
+ const s = getSubs(ws);
94
+ if (s && s.eventsRunId === runId) {
95
+ sendToClient(ws, base);
96
+ }
97
+ }
98
+ }
99
+
100
+ return {
101
+ broadcast,
102
+ broadcastToSubscribers,
103
+ broadcastToLogSubscribers,
104
+ broadcastPipelineEvent,
105
+ };
106
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * WebSocket client subscription and heartbeat management.
3
+ * Owns the subs WeakMap that tracks per-client subscriptions.
4
+ * Tracks per-project client counts for activity-based tiering.
5
+ */
6
+
7
+ /**
8
+ * @param {{ wss: import('ws').WebSocketServer }} deps
9
+ */
10
+ export function createClientManager({ wss }) {
11
+ /** @type {WeakMap<import('ws').WebSocket, { runId: string | null, logStage: string | null, logRunId: string | null, eventsRunId: string | null, protocolVersion: number, projectId: string | null }>} */
12
+ const subs = new WeakMap();
13
+
14
+ /** @type {Map<string, number>} per-project connected client count */
15
+ const projectClientCounts = new Map();
16
+
17
+ /** @type {Set<(projectId: string, count: number) => void>} */
18
+ const clientCountHandlers = new Set();
19
+
20
+ function ensureSubs(ws) {
21
+ let s = subs.get(ws);
22
+ if (!s) {
23
+ s = {
24
+ runId: null,
25
+ logStage: null,
26
+ logRunId: null,
27
+ eventsRunId: null,
28
+ protocolVersion: 1,
29
+ projectId: null,
30
+ };
31
+ subs.set(ws, s);
32
+ }
33
+ return s;
34
+ }
35
+
36
+ function getSubs(ws) {
37
+ return subs.get(ws);
38
+ }
39
+
40
+ function deleteSubs(ws) {
41
+ const s = subs.get(ws);
42
+ if (s?.projectId) {
43
+ _decrementProject(s.projectId);
44
+ }
45
+ subs.delete(ws);
46
+ }
47
+
48
+ function setProtocol(ws, version, projectId) {
49
+ const s = ensureSubs(ws);
50
+ const oldProjectId = s.projectId;
51
+ s.protocolVersion = version;
52
+ s.projectId = projectId ?? null;
53
+
54
+ // Update project client counts
55
+ if (oldProjectId && oldProjectId !== projectId) {
56
+ _decrementProject(oldProjectId);
57
+ }
58
+ if (projectId && projectId !== oldProjectId) {
59
+ _incrementProject(projectId);
60
+ }
61
+ }
62
+
63
+ function _incrementProject(projectId) {
64
+ const current = projectClientCounts.get(projectId) || 0;
65
+ const newCount = current + 1;
66
+ projectClientCounts.set(projectId, newCount);
67
+ _notifyCountChange(projectId, newCount);
68
+ }
69
+
70
+ function _decrementProject(projectId) {
71
+ const current = projectClientCounts.get(projectId) || 0;
72
+ const newCount = Math.max(0, current - 1);
73
+ if (newCount === 0) {
74
+ projectClientCounts.delete(projectId);
75
+ } else {
76
+ projectClientCounts.set(projectId, newCount);
77
+ }
78
+ _notifyCountChange(projectId, newCount);
79
+ }
80
+
81
+ function _notifyCountChange(projectId, count) {
82
+ for (const fn of clientCountHandlers) {
83
+ try {
84
+ fn(projectId, count);
85
+ } catch {
86
+ /* ignore */
87
+ }
88
+ }
89
+ }
90
+
91
+ function getProjectClientCount(projectId) {
92
+ return projectClientCounts.get(projectId) || 0;
93
+ }
94
+
95
+ function onClientCountChange(handler) {
96
+ clientCountHandlers.add(handler);
97
+ return () => {
98
+ clientCountHandlers.delete(handler);
99
+ };
100
+ }
101
+
102
+ // Heartbeat — ping all clients every 30s, terminate unresponsive ones
103
+ const heartbeat = setInterval(() => {
104
+ for (const ws of wss.clients) {
105
+ if (ws.isAlive === false) {
106
+ ws.terminate();
107
+ continue;
108
+ }
109
+ ws.isAlive = false;
110
+ ws.ping();
111
+ }
112
+ }, 30000);
113
+ heartbeat.unref?.();
114
+
115
+ function destroy() {
116
+ clearInterval(heartbeat);
117
+ clientCountHandlers.clear();
118
+ }
119
+
120
+ return {
121
+ ensureSubs,
122
+ getSubs,
123
+ deleteSubs,
124
+ setProtocol,
125
+ getProjectClientCount,
126
+ onClientCountChange,
127
+ destroy,
128
+ };
129
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Pipeline event file watcher — manages events.jsonl subscriptions.
3
+ * Owns the eventWatchers map and event reading/filtering logic.
4
+ */
5
+
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { watchEvents } from './watcher.js';
9
+
10
+ /**
11
+ * Convert a glob pattern (with * and **) to a RegExp for matching event type strings.
12
+ * - `*` matches any sequence of non-dot characters
13
+ * - `**` matches any sequence of characters (including dots)
14
+ *
15
+ * @param {string} pattern
16
+ * @param {string} str
17
+ * @returns {boolean}
18
+ */
19
+ export function matchesGlob(pattern, str) {
20
+ const regexStr = pattern
21
+ .split('**')
22
+ .map((part) =>
23
+ part
24
+ .split('*')
25
+ .map((s) => s.replace(/[.+^${}()|[\]\\]/g, '\\$&'))
26
+ .join('[^.]*'),
27
+ )
28
+ .join('.*');
29
+ return new RegExp(`^${regexStr}$`).test(str);
30
+ }
31
+
32
+ /**
33
+ * @param {{
34
+ * broadcaster: { broadcastPipelineEvent: Function },
35
+ * getSubs: Function,
36
+ * wss: import('ws').WebSocketServer,
37
+ * resolveRunDirById: Function
38
+ * }} deps
39
+ */
40
+ export function createEventWatcher({
41
+ broadcaster,
42
+ getSubs,
43
+ wss,
44
+ resolveRunDirById,
45
+ }) {
46
+ /** @type {Map<string, { close: () => void }>} */
47
+ const eventWatchers = new Map();
48
+
49
+ function readEventsFromFile(
50
+ runId,
51
+ { since_event_id, event_types, limit = 100 } = {},
52
+ ) {
53
+ const eventsPath = join(resolveRunDirById(runId), 'events.jsonl');
54
+ if (!existsSync(eventsPath)) return [];
55
+ try {
56
+ const content = readFileSync(eventsPath, 'utf8');
57
+ let events = [];
58
+ for (const line of content.split('\n')) {
59
+ if (!line.trim()) continue;
60
+ try {
61
+ events.push(JSON.parse(line));
62
+ } catch {
63
+ /* skip malformed */
64
+ }
65
+ }
66
+ if (since_event_id) {
67
+ const idx = events.findIndex((e) => e.event_id === since_event_id);
68
+ if (idx >= 0) events = events.slice(idx + 1);
69
+ }
70
+ if (event_types && event_types.length > 0) {
71
+ events = events.filter((e) =>
72
+ event_types.some((p) => matchesGlob(p, e.event_type)),
73
+ );
74
+ }
75
+ return events.slice(0, limit);
76
+ } catch {
77
+ return [];
78
+ }
79
+ }
80
+
81
+ function subscribeEvents(runId) {
82
+ if (!eventWatchers.has(runId)) {
83
+ const runDir = resolveRunDirById(runId);
84
+ const w = watchEvents(runDir, (event) =>
85
+ broadcaster.broadcastPipelineEvent(runId, event),
86
+ );
87
+ eventWatchers.set(runId, w);
88
+ }
89
+ }
90
+
91
+ function maybeCloseEventWatcher(runId) {
92
+ for (const ws of wss.clients) {
93
+ const s = getSubs(ws);
94
+ if (s?.eventsRunId === runId) return; // still in use
95
+ }
96
+ const w = eventWatchers.get(runId);
97
+ if (w) {
98
+ try {
99
+ w.close();
100
+ } catch {
101
+ /* ignore */
102
+ }
103
+ eventWatchers.delete(runId);
104
+ }
105
+ }
106
+
107
+ function destroy() {
108
+ for (const w of eventWatchers.values()) {
109
+ try {
110
+ w.close();
111
+ } catch {
112
+ /* ignore */
113
+ }
114
+ }
115
+ eventWatchers.clear();
116
+ }
117
+
118
+ return {
119
+ readEventsFromFile,
120
+ subscribeEvents,
121
+ maybeCloseEventWatcher,
122
+ destroy,
123
+ };
124
+ }