@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.
- package/app/index.html +23 -0
- package/app/main.bundle.js +5738 -0
- package/app/main.bundle.js.map +7 -0
- package/app/styles.css +3897 -0
- package/app/vendor/shoelace-dark.css +483 -0
- package/app/vendor/shoelace-light.css +484 -0
- package/app/vendor/xterm.css +285 -0
- package/bin/worca-ui.js +540 -0
- package/package.json +71 -0
- package/scripts/build-frontend.js +49 -0
- package/server/app.js +421 -0
- package/server/beads-reader.js +199 -0
- package/server/index.js +131 -0
- package/server/log-tailer.js +156 -0
- package/server/multi-watcher.js +237 -0
- package/server/preferences.js +17 -0
- package/server/process-manager.js +546 -0
- package/server/project-registry.js +145 -0
- package/server/project-routes.js +1265 -0
- package/server/settings-merge.js +83 -0
- package/server/settings-reader.js +23 -0
- package/server/settings-validator.js +506 -0
- package/server/watcher-set.js +286 -0
- package/server/watcher.js +357 -0
- package/server/webhook-inbox.js +59 -0
- package/server/worca-setup.js +114 -0
- package/server/ws-beads-watcher.js +62 -0
- package/server/ws-broadcaster.js +106 -0
- package/server/ws-client-manager.js +129 -0
- package/server/ws-event-watcher.js +124 -0
- package/server/ws-log-watcher.js +299 -0
- package/server/ws-message-router.js +870 -0
- package/server/ws-modular.js +309 -0
- package/server/ws-status-watcher.js +259 -0
- package/server/ws.js +5 -0
|
@@ -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
|
+
}
|