@harness-fe/mcp-server 3.0.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/dist/auth.d.ts +53 -0
  4. package/dist/auth.js +212 -0
  5. package/dist/bridge.d.ts +302 -0
  6. package/dist/bridge.js +1580 -0
  7. package/dist/cli.d.ts +18 -0
  8. package/dist/cli.js +277 -0
  9. package/dist/daemon.d.ts +98 -0
  10. package/dist/daemon.js +80 -0
  11. package/dist/dashboardApi.d.ts +40 -0
  12. package/dist/dashboardApi.js +142 -0
  13. package/dist/dashboardSpa.d.ts +18 -0
  14. package/dist/dashboardSpa.js +180 -0
  15. package/dist/dashboardUrl.d.ts +13 -0
  16. package/dist/dashboardUrl.js +18 -0
  17. package/dist/eventsHandler.d.ts +24 -0
  18. package/dist/eventsHandler.js +114 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +6 -0
  21. package/dist/mcp.d.ts +15 -0
  22. package/dist/mcp.js +923 -0
  23. package/dist/mcpHttp.d.ts +39 -0
  24. package/dist/mcpHttp.js +49 -0
  25. package/dist/openBrowser.d.ts +33 -0
  26. package/dist/openBrowser.js +63 -0
  27. package/dist/remoteBridge.d.ts +61 -0
  28. package/dist/remoteBridge.js +307 -0
  29. package/dist/replayCreate.d.ts +36 -0
  30. package/dist/replayCreate.js +156 -0
  31. package/dist/replayViewer.d.ts +20 -0
  32. package/dist/replayViewer.js +168 -0
  33. package/dist/sessionRouter.d.ts +42 -0
  34. package/dist/sessionRouter.js +88 -0
  35. package/dist/store/JsonMemoryStore.d.ts +52 -0
  36. package/dist/store/JsonMemoryStore.js +119 -0
  37. package/dist/store/JsonTaskStore.d.ts +21 -0
  38. package/dist/store/JsonTaskStore.js +53 -0
  39. package/dist/store/JsonlStore.d.ts +128 -0
  40. package/dist/store/JsonlStore.js +1168 -0
  41. package/dist/store/MemoryEventStore.d.ts +47 -0
  42. package/dist/store/MemoryEventStore.js +111 -0
  43. package/dist/store/WriteQueue.d.ts +51 -0
  44. package/dist/store/WriteQueue.js +142 -0
  45. package/dist/store/index.d.ts +6 -0
  46. package/dist/store/index.js +5 -0
  47. package/dist/store/types.d.ts +416 -0
  48. package/dist/store/types.js +19 -0
  49. package/package.json +63 -0
  50. package/src/auth.test.ts +90 -0
  51. package/src/auth.ts +248 -0
  52. package/src/bridge-auth.test.ts +196 -0
  53. package/src/bridge.test.ts +1708 -0
  54. package/src/bridge.ts +1804 -0
  55. package/src/cli.ts +315 -0
  56. package/src/daemon.test.ts +123 -0
  57. package/src/daemon.ts +161 -0
  58. package/src/dashboardApi.test.ts +235 -0
  59. package/src/dashboardApi.ts +184 -0
  60. package/src/dashboardSpa.test.ts +239 -0
  61. package/src/dashboardSpa.ts +195 -0
  62. package/src/dashboardUrl.test.ts +46 -0
  63. package/src/dashboardUrl.ts +28 -0
  64. package/src/eventsHandler.test.ts +247 -0
  65. package/src/eventsHandler.ts +136 -0
  66. package/src/index.ts +26 -0
  67. package/src/mcp.ts +1407 -0
  68. package/src/mcpHttp.test.ts +101 -0
  69. package/src/mcpHttp.ts +88 -0
  70. package/src/openBrowser.test.ts +103 -0
  71. package/src/openBrowser.ts +81 -0
  72. package/src/remoteBridge.test.ts +119 -0
  73. package/src/remoteBridge.ts +404 -0
  74. package/src/replay.test.ts +271 -0
  75. package/src/replayCreate.ts +194 -0
  76. package/src/replayViewer.ts +173 -0
  77. package/src/sessionRouter.ts +116 -0
  78. package/src/store/JsonMemoryStore.test.ts +175 -0
  79. package/src/store/JsonMemoryStore.ts +128 -0
  80. package/src/store/JsonTaskStore.test.ts +212 -0
  81. package/src/store/JsonTaskStore.ts +59 -0
  82. package/src/store/JsonlStore.test.ts +1538 -0
  83. package/src/store/JsonlStore.ts +1321 -0
  84. package/src/store/MemoryEventStore.test.ts +119 -0
  85. package/src/store/MemoryEventStore.ts +151 -0
  86. package/src/store/WriteQueue.ts +165 -0
  87. package/src/store/index.ts +29 -0
  88. package/src/store/types.ts +517 -0
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Shared replay-export logic used by both the leader's MCP tool handler and
3
+ * the leader's mcp.call dispatcher (for follower proxy calls).
4
+ *
5
+ * Takes a time window (or a center timestamp), pulls the overlapping rrweb
6
+ * chunks for a single tab, concatenates the events, persists them as an
7
+ * export, and returns the metadata + viewerUrl.
8
+ */
9
+ /** rrweb event types: 2 = FullSnapshot baseline. Replay needs at least one. */
10
+ function isFullSnapshotEvent(ev) {
11
+ return (typeof ev === 'object' &&
12
+ ev !== null &&
13
+ ev.type === 2);
14
+ }
15
+ export function createReplayExport(store, baseUrl, input) {
16
+ const { sessionId, tabId, ts, windowMs, since, until, label } = input;
17
+ const session = store.getSession(sessionId);
18
+ if (!session) {
19
+ return { error: 'session not found', sessionId, since: since ?? 0, until: until ?? 0 };
20
+ }
21
+ let resolvedSince;
22
+ let resolvedUntil;
23
+ if (typeof since === 'number' && typeof until === 'number') {
24
+ if (until <= since) {
25
+ return { error: 'until must be greater than since', sessionId, since, until };
26
+ }
27
+ resolvedSince = since;
28
+ resolvedUntil = until;
29
+ }
30
+ else if (typeof ts === 'number') {
31
+ const radius = windowMs ?? 15_000;
32
+ resolvedSince = ts - radius;
33
+ resolvedUntil = ts + radius;
34
+ }
35
+ else {
36
+ return {
37
+ error: 'must provide either ts (with optional windowMs) or both since and until',
38
+ sessionId,
39
+ since: 0,
40
+ until: 0,
41
+ };
42
+ }
43
+ const chunks = store.sliceRecordings(sessionId, resolvedSince, resolvedUntil);
44
+ if (chunks.length === 0) {
45
+ return {
46
+ error: 'no rrweb chunks found in window',
47
+ sessionId,
48
+ tabId,
49
+ since: resolvedSince,
50
+ until: resolvedUntil,
51
+ };
52
+ }
53
+ let scopedTabId = tabId;
54
+ if (!scopedTabId) {
55
+ const byTab = new Map();
56
+ for (const c of chunks)
57
+ byTab.set(c.tabId, (byTab.get(c.tabId) ?? 0) + c.eventCount);
58
+ let best = '';
59
+ let bestEvents = -1;
60
+ for (const [t, count] of byTab) {
61
+ if (count > bestEvents) {
62
+ best = t;
63
+ bestEvents = count;
64
+ }
65
+ }
66
+ scopedTabId = best;
67
+ }
68
+ const tabChunks = chunks.filter((c) => c.tabId === scopedTabId);
69
+ const events = [];
70
+ let startTs = Infinity;
71
+ let endTs = -Infinity;
72
+ for (const c of tabChunks) {
73
+ for (const ev of c.events)
74
+ events.push(ev);
75
+ if (c.startTs < startTs)
76
+ startTs = c.startTs;
77
+ if (c.endTs > endTs)
78
+ endTs = c.endTs;
79
+ }
80
+ // rrweb replay requires a baseline pair — type:4 (Meta) + type:2
81
+ // (FullSnapshot) — before any type:3 (IncrementalSnapshot) is meaningful.
82
+ // If the window only contains incremental mutations (e.g. user picked a
83
+ // narrow window long after the page loaded, or the very first chunk was
84
+ // lost during a daemon restart), look back across earlier chunks for the
85
+ // most recent baseline and prepend it. Replay will then start from that
86
+ // earlier DOM state and roll mutations forward into the window.
87
+ if (!events.some(isFullSnapshotEvent) && resolvedSince > 0) {
88
+ const priorChunks = store
89
+ .sliceRecordings(sessionId, 0, resolvedSince - 1)
90
+ .filter((c) => c.tabId === scopedTabId)
91
+ .sort((a, b) => a.startTs - b.startTs);
92
+ // Walk backwards from the chunk closest to window start; the first
93
+ // chunk that has a FullSnapshot becomes our baseline.
94
+ for (let i = priorChunks.length - 1; i >= 0; i--) {
95
+ const baseline = priorChunks[i];
96
+ if (!baseline)
97
+ continue;
98
+ if (baseline.events.some(isFullSnapshotEvent)) {
99
+ // Prepend baseline events (full chunk — preserves Meta + FS
100
+ // ordering rrweb emitted them in). startTs widens to baseline.
101
+ events.unshift(...baseline.events);
102
+ if (baseline.startTs < startTs)
103
+ startTs = baseline.startTs;
104
+ break;
105
+ }
106
+ }
107
+ }
108
+ if (events.length < 2) {
109
+ return {
110
+ error: 'window contains fewer than 2 rrweb events — not enough to replay',
111
+ sessionId,
112
+ tabId: scopedTabId,
113
+ since: resolvedSince,
114
+ until: resolvedUntil,
115
+ eventCount: events.length,
116
+ };
117
+ }
118
+ if (!events.some(isFullSnapshotEvent)) {
119
+ return {
120
+ error: 'window contains no rrweb FullSnapshot (type:2) baseline, and no earlier baseline could be found — replay would be blank',
121
+ sessionId,
122
+ tabId: scopedTabId,
123
+ since: resolvedSince,
124
+ until: resolvedUntil,
125
+ eventCount: events.length,
126
+ };
127
+ }
128
+ const meta = store.writeExport({
129
+ sessionId,
130
+ tabId: scopedTabId,
131
+ since: resolvedSince,
132
+ until: resolvedUntil,
133
+ label,
134
+ events,
135
+ startTs,
136
+ endTs,
137
+ chunkCount: tabChunks.length,
138
+ });
139
+ const viewerUrl = baseUrl ? `${baseUrl}/replay/${meta.exportId}` : undefined;
140
+ return {
141
+ exportId: meta.exportId,
142
+ viewerUrl,
143
+ sessionId,
144
+ tabId: scopedTabId,
145
+ since: resolvedSince,
146
+ until: resolvedUntil,
147
+ startTs,
148
+ endTs,
149
+ durationMs: endTs - startTs,
150
+ eventCount: meta.eventCount,
151
+ chunkCount: meta.chunkCount,
152
+ bytes: meta.bytes,
153
+ createdAt: meta.createdAt,
154
+ label,
155
+ };
156
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Replay viewer — HTTP routes for serving exported rrweb recordings.
3
+ *
4
+ * Routes (all served on the same port as the WS bridge):
5
+ * GET /replay/:id HTML viewer page (rrweb-player UI)
6
+ * GET /replay/:id.json Raw events array for the player to fetch
7
+ * GET /replay/static/player.js Bundled rrweb-player (UMD)
8
+ * GET /replay/static/player.css Bundled rrweb-player styles
9
+ *
10
+ * The HTML page loads the static assets relatively and then calls
11
+ * /replay/:id.json to hydrate the player. This keeps the viewer self-contained
12
+ * and offline-capable — no CDN dependencies.
13
+ */
14
+ import type { IncomingMessage, ServerResponse } from 'node:http';
15
+ import type { IStore } from './store/index.js';
16
+ /**
17
+ * Build a handler that dispatches /replay/* requests. Returns undefined for
18
+ * non-replay paths so the caller can chain or 404.
19
+ */
20
+ export declare function createReplayHandler(store: IStore): (req: IncomingMessage, res: ServerResponse) => boolean;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Replay viewer — HTTP routes for serving exported rrweb recordings.
3
+ *
4
+ * Routes (all served on the same port as the WS bridge):
5
+ * GET /replay/:id HTML viewer page (rrweb-player UI)
6
+ * GET /replay/:id.json Raw events array for the player to fetch
7
+ * GET /replay/static/player.js Bundled rrweb-player (UMD)
8
+ * GET /replay/static/player.css Bundled rrweb-player styles
9
+ *
10
+ * The HTML page loads the static assets relatively and then calls
11
+ * /replay/:id.json to hydrate the player. This keeps the viewer self-contained
12
+ * and offline-capable — no CDN dependencies.
13
+ */
14
+ import { readFileSync, existsSync } from 'node:fs';
15
+ import { createRequire } from 'node:module';
16
+ import { dirname, join } from 'node:path';
17
+ const require = createRequire(import.meta.url);
18
+ /** Locate the bundled rrweb-player dist directory. */
19
+ function resolvePlayerDist() {
20
+ // rrweb-player exposes package.json; use it to find the dist folder.
21
+ const pkgPath = require.resolve('rrweb-player/package.json');
22
+ return join(dirname(pkgPath), 'dist');
23
+ }
24
+ const PLAYER_DIST = (() => {
25
+ try {
26
+ return resolvePlayerDist();
27
+ }
28
+ catch {
29
+ return '';
30
+ }
31
+ })();
32
+ const VIEWER_HTML = (exportId, meta) => `<!doctype html>
33
+ <html lang="en">
34
+ <head>
35
+ <meta charset="utf-8" />
36
+ <title>Harness replay · ${escapeHtml(exportId)}</title>
37
+ <link rel="stylesheet" href="/replay/static/player.css" />
38
+ <style>
39
+ html, body { margin: 0; padding: 0; background: #1a1a1a; color: #eee; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
40
+ .meta { padding: 12px 16px; font-size: 13px; color: #aaa; border-bottom: 1px solid #333; }
41
+ .meta code { color: #6cf; }
42
+ .stage { padding: 16px; display: flex; justify-content: center; }
43
+ .error { padding: 24px; color: #f66; }
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <div class="meta">
48
+ Replay <code>${escapeHtml(exportId)}</code> · session <code>${escapeHtml(meta.sessionId)}</code>${meta.tabId ? ` · tab <code>${escapeHtml(meta.tabId)}</code>` : ''} · ${meta.eventCount} events · ${Math.round(meta.durationMs / 100) / 10}s
49
+ </div>
50
+ <div class="stage" id="stage"></div>
51
+ <script src="/replay/static/player.js"></script>
52
+ <script>
53
+ (async function() {
54
+ const stage = document.getElementById('stage');
55
+ try {
56
+ const resp = await fetch(${JSON.stringify(`/replay/${exportId}.json`)});
57
+ if (!resp.ok) throw new Error('failed to fetch events: ' + resp.status);
58
+ const events = await resp.json();
59
+ if (!Array.isArray(events) || events.length < 2) {
60
+ throw new Error('export has fewer than 2 events — cannot play back');
61
+ }
62
+ // rrwebPlayer is the UMD global.
63
+ new rrwebPlayer({
64
+ target: stage,
65
+ props: {
66
+ events,
67
+ autoPlay: true,
68
+ showController: true,
69
+ width: Math.min(1280, window.innerWidth - 64),
70
+ height: Math.min(720, window.innerHeight - 120),
71
+ },
72
+ });
73
+ } catch (err) {
74
+ stage.innerHTML = '<div class="error">Replay failed: ' + (err && err.message ? String(err.message).replace(/[<>&]/g, '') : 'unknown error') + '</div>';
75
+ console.error('[harness replay]', err);
76
+ }
77
+ })();
78
+ </script>
79
+ </body>
80
+ </html>`;
81
+ function escapeHtml(s) {
82
+ return s.replace(/[&<>"']/g, (ch) => {
83
+ switch (ch) {
84
+ case '&': return '&amp;';
85
+ case '<': return '&lt;';
86
+ case '>': return '&gt;';
87
+ case '"': return '&quot;';
88
+ default: return '&#39;';
89
+ }
90
+ });
91
+ }
92
+ function send(res, status, contentType, body) {
93
+ res.statusCode = status;
94
+ res.setHeader('content-type', contentType);
95
+ res.setHeader('cache-control', 'no-store');
96
+ res.end(body);
97
+ }
98
+ /**
99
+ * Build a handler that dispatches /replay/* requests. Returns undefined for
100
+ * non-replay paths so the caller can chain or 404.
101
+ */
102
+ export function createReplayHandler(store) {
103
+ return (req, res) => {
104
+ if (!req.url)
105
+ return false;
106
+ const url = new URL(req.url, 'http://localhost');
107
+ const path = url.pathname;
108
+ if (!path.startsWith('/replay/'))
109
+ return false;
110
+ // Static assets
111
+ if (path === '/replay/static/player.js') {
112
+ if (!PLAYER_DIST)
113
+ return reply500(res, 'rrweb-player not installed');
114
+ const file = join(PLAYER_DIST, 'index.js');
115
+ if (!existsSync(file))
116
+ return reply500(res, 'rrweb-player bundle missing');
117
+ send(res, 200, 'application/javascript; charset=utf-8', readFileSync(file));
118
+ return true;
119
+ }
120
+ if (path === '/replay/static/player.css') {
121
+ if (!PLAYER_DIST)
122
+ return reply500(res, 'rrweb-player not installed');
123
+ const file = join(PLAYER_DIST, 'style.css');
124
+ if (!existsSync(file))
125
+ return reply500(res, 'rrweb-player styles missing');
126
+ send(res, 200, 'text/css; charset=utf-8', readFileSync(file));
127
+ return true;
128
+ }
129
+ // /replay/:id or /replay/:id.json
130
+ const tail = path.slice('/replay/'.length);
131
+ if (!tail || tail.includes('/')) {
132
+ send(res, 404, 'text/plain; charset=utf-8', 'Not Found');
133
+ return true;
134
+ }
135
+ const isJson = tail.endsWith('.json');
136
+ const exportId = isJson ? tail.slice(0, -'.json'.length) : tail;
137
+ if (!/^[A-Za-z0-9_-]+$/.test(exportId)) {
138
+ send(res, 400, 'text/plain; charset=utf-8', 'Invalid export id');
139
+ return true;
140
+ }
141
+ const meta = store.getExport(exportId);
142
+ if (!meta) {
143
+ send(res, 404, 'text/plain; charset=utf-8', `Unknown export: ${exportId}`);
144
+ return true;
145
+ }
146
+ if (isJson) {
147
+ const events = store.readExportEvents(exportId);
148
+ if (!events) {
149
+ send(res, 404, 'application/json; charset=utf-8', '{"error":"export events missing"}');
150
+ return true;
151
+ }
152
+ send(res, 200, 'application/json; charset=utf-8', JSON.stringify(events));
153
+ return true;
154
+ }
155
+ const html = VIEWER_HTML(exportId, {
156
+ sessionId: meta.sessionId,
157
+ tabId: meta.tabId,
158
+ durationMs: Math.max(0, meta.endTs - meta.startTs),
159
+ eventCount: meta.eventCount,
160
+ });
161
+ send(res, 200, 'text/html; charset=utf-8', html);
162
+ return true;
163
+ };
164
+ function reply500(res, msg) {
165
+ send(res, 500, 'text/plain; charset=utf-8', msg);
166
+ return true;
167
+ }
168
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * SessionRouter — registry of connected peers (vite-plugin + runtime-client).
3
+ *
4
+ * - Project: 1 vite-plugin per project (uniqued by projectId)
5
+ * - Tab : N runtime-clients per project (each browser tab is one)
6
+ *
7
+ * Active tab heuristic: most recently active (last command or last event).
8
+ * Caller can override via explicit tabId on every command.
9
+ */
10
+ import type { PeerRole, TabInfo } from '@harness-fe/protocol';
11
+ export interface PeerSession {
12
+ role: PeerRole;
13
+ projectId: string;
14
+ tabId?: string;
15
+ /** Runtime-client only: identifies the page load (sessionId) this connection belongs to. */
16
+ sessionId?: string;
17
+ /** Runtime-client only: stable per-browser visitor identifier. */
18
+ visitorId?: string;
19
+ /** App-supplied user identifier propagated from HarnessScript userId prop. */
20
+ userId?: string;
21
+ /** Opaque identifier for the underlying connection. */
22
+ connectionId: string;
23
+ lastActive: number;
24
+ page?: {
25
+ url?: string;
26
+ title?: string;
27
+ userAgent?: string;
28
+ };
29
+ }
30
+ export declare class SessionRouter {
31
+ private peers;
32
+ private mostRecentTabId?;
33
+ register(session: Omit<PeerSession, 'lastActive'>): PeerSession;
34
+ unregister(connectionId: string): void;
35
+ touch(connectionId: string): void;
36
+ getByConnectionId(connectionId: string): PeerSession | undefined;
37
+ findVitePlugin(projectId?: string): PeerSession | undefined;
38
+ findTab(tabId?: string): PeerSession | undefined;
39
+ private findFallbackTab;
40
+ listTabs(): TabInfo[];
41
+ listProjects(): string[];
42
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * SessionRouter — registry of connected peers (vite-plugin + runtime-client).
3
+ *
4
+ * - Project: 1 vite-plugin per project (uniqued by projectId)
5
+ * - Tab : N runtime-clients per project (each browser tab is one)
6
+ *
7
+ * Active tab heuristic: most recently active (last command or last event).
8
+ * Caller can override via explicit tabId on every command.
9
+ */
10
+ export class SessionRouter {
11
+ peers = new Map(); // key = connectionId
12
+ mostRecentTabId;
13
+ register(session) {
14
+ const stored = { ...session, lastActive: Date.now() };
15
+ this.peers.set(session.connectionId, stored);
16
+ if (session.role === 'runtime-client' && session.tabId) {
17
+ this.mostRecentTabId = session.tabId;
18
+ }
19
+ return stored;
20
+ }
21
+ unregister(connectionId) {
22
+ const peer = this.peers.get(connectionId);
23
+ this.peers.delete(connectionId);
24
+ if (peer?.tabId && this.mostRecentTabId === peer.tabId) {
25
+ this.mostRecentTabId = this.findFallbackTab()?.tabId;
26
+ }
27
+ }
28
+ touch(connectionId) {
29
+ const peer = this.peers.get(connectionId);
30
+ if (!peer)
31
+ return;
32
+ peer.lastActive = Date.now();
33
+ if (peer.role === 'runtime-client' && peer.tabId) {
34
+ this.mostRecentTabId = peer.tabId;
35
+ }
36
+ }
37
+ getByConnectionId(connectionId) {
38
+ return this.peers.get(connectionId);
39
+ }
40
+ findVitePlugin(projectId) {
41
+ const candidates = [...this.peers.values()].filter((p) => p.role === 'vite-plugin' || p.role === 'webpack-plugin');
42
+ if (!candidates.length)
43
+ return undefined;
44
+ if (projectId)
45
+ return candidates.find((c) => c.projectId === projectId);
46
+ // No projectId filter — return the most recent.
47
+ return candidates.sort((a, b) => b.lastActive - a.lastActive)[0];
48
+ }
49
+ findTab(tabId) {
50
+ if (tabId) {
51
+ for (const p of this.peers.values()) {
52
+ if (p.role === 'runtime-client' && p.tabId === tabId)
53
+ return p;
54
+ }
55
+ return undefined;
56
+ }
57
+ if (this.mostRecentTabId) {
58
+ return this.findTab(this.mostRecentTabId);
59
+ }
60
+ return this.findFallbackTab();
61
+ }
62
+ findFallbackTab() {
63
+ const tabs = [...this.peers.values()]
64
+ .filter((p) => p.role === 'runtime-client' && p.tabId)
65
+ .sort((a, b) => b.lastActive - a.lastActive);
66
+ return tabs[0];
67
+ }
68
+ listTabs() {
69
+ return [...this.peers.values()]
70
+ .filter((p) => p.role === 'runtime-client' && !!p.tabId)
71
+ .map((p) => ({
72
+ tabId: p.tabId,
73
+ projectId: p.projectId,
74
+ url: p.page?.url,
75
+ title: p.page?.title,
76
+ userAgent: p.page?.userAgent,
77
+ connectedAt: p.lastActive,
78
+ }));
79
+ }
80
+ listProjects() {
81
+ const ids = new Set();
82
+ for (const p of this.peers.values()) {
83
+ if (p.role === 'vite-plugin' || p.role === 'webpack-plugin')
84
+ ids.add(p.projectId);
85
+ }
86
+ return [...ids];
87
+ }
88
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * JsonMemoryStore — JSON-based persistence for agent memory (key-value store per project).
3
+ *
4
+ * File layout:
5
+ * {dataDir}/{projectId}/memory.json
6
+ *
7
+ * File format:
8
+ * {
9
+ * "key1": { "key": "key1", "value": "...", "updatedAt": 1700000000000 },
10
+ * "key2": { "key": "key2", "value": "...", "updatedAt": 1700000001000 }
11
+ * }
12
+ *
13
+ * All mutations use atomic write-then-rename for durability.
14
+ */
15
+ import type { IMemoryStore, MemoryEntry } from './types.js';
16
+ export declare class JsonMemoryStore implements IMemoryStore {
17
+ private readonly dataDir;
18
+ constructor(dataDir: string);
19
+ private memoryPath;
20
+ /**
21
+ * Read and parse memory.json for a project.
22
+ * Returns a null-prototype object on missing or corrupt file (never throws).
23
+ * Using Object.create(null) prevents prototype pollution from keys like __proto__.
24
+ */
25
+ private load;
26
+ /**
27
+ * Atomically write memory data to disk using tmp + rename strategy.
28
+ * Logs error on failure without throwing.
29
+ */
30
+ private save;
31
+ /**
32
+ * Get a memory entry by key.
33
+ * Returns undefined if the key does not exist or memory.json is missing.
34
+ */
35
+ get(projectId: string, key: string): MemoryEntry | undefined;
36
+ /**
37
+ * Write or update a memory entry.
38
+ * Sets updatedAt to the current Unix ms timestamp.
39
+ * Returns the new/updated MemoryEntry.
40
+ */
41
+ set(projectId: string, key: string, value: string): MemoryEntry;
42
+ /**
43
+ * Delete a memory entry by key.
44
+ * Returns true if the key existed and was removed, false otherwise.
45
+ */
46
+ delete(projectId: string, key: string): boolean;
47
+ /**
48
+ * List all memory entries for a project, sorted by updatedAt descending.
49
+ * Returns an empty array if memory.json does not exist.
50
+ */
51
+ list(projectId: string): MemoryEntry[];
52
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * JsonMemoryStore — JSON-based persistence for agent memory (key-value store per project).
3
+ *
4
+ * File layout:
5
+ * {dataDir}/{projectId}/memory.json
6
+ *
7
+ * File format:
8
+ * {
9
+ * "key1": { "key": "key1", "value": "...", "updatedAt": 1700000000000 },
10
+ * "key2": { "key": "key2", "value": "...", "updatedAt": 1700000001000 }
11
+ * }
12
+ *
13
+ * All mutations use atomic write-then-rename for durability.
14
+ */
15
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { sanitizeId } from './JsonlStore.js';
18
+ export class JsonMemoryStore {
19
+ dataDir;
20
+ constructor(dataDir) {
21
+ this.dataDir = dataDir;
22
+ }
23
+ // ── Path helpers ──────────────────────────────────────────────────────
24
+ memoryPath(projectId) {
25
+ return join(this.dataDir, sanitizeId(projectId), 'memory.json');
26
+ }
27
+ // ── Private I/O ───────────────────────────────────────────────────────
28
+ /**
29
+ * Read and parse memory.json for a project.
30
+ * Returns a null-prototype object on missing or corrupt file (never throws).
31
+ * Using Object.create(null) prevents prototype pollution from keys like __proto__.
32
+ */
33
+ load(projectId) {
34
+ const path = this.memoryPath(projectId);
35
+ try {
36
+ const raw = readFileSync(path, 'utf-8');
37
+ const parsed = JSON.parse(raw);
38
+ // Copy into a null-prototype object to prevent prototype pollution
39
+ const safe = Object.create(null);
40
+ for (const key of Object.keys(parsed)) {
41
+ safe[key] = parsed[key];
42
+ }
43
+ return safe;
44
+ }
45
+ catch {
46
+ return Object.create(null);
47
+ }
48
+ }
49
+ /**
50
+ * Atomically write memory data to disk using tmp + rename strategy.
51
+ * Logs error on failure without throwing.
52
+ */
53
+ save(projectId, data) {
54
+ const path = this.memoryPath(projectId);
55
+ const tmpPath = `${path}.tmp`;
56
+ // Ensure the project directory exists
57
+ const dir = join(this.dataDir, sanitizeId(projectId));
58
+ try {
59
+ mkdirSync(dir, { recursive: true });
60
+ }
61
+ catch (err) {
62
+ console.error(`[JsonMemoryStore] failed to create directory ${dir}:`, err);
63
+ return;
64
+ }
65
+ try {
66
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
67
+ renameSync(tmpPath, path);
68
+ }
69
+ catch (err) {
70
+ console.error(`[JsonMemoryStore] failed to write ${path}:`, err);
71
+ }
72
+ }
73
+ // ── IMemoryStore implementation ───────────────────────────────────────
74
+ /**
75
+ * Get a memory entry by key.
76
+ * Returns undefined if the key does not exist or memory.json is missing.
77
+ */
78
+ get(projectId, key) {
79
+ const data = this.load(projectId);
80
+ return data[key];
81
+ }
82
+ /**
83
+ * Write or update a memory entry.
84
+ * Sets updatedAt to the current Unix ms timestamp.
85
+ * Returns the new/updated MemoryEntry.
86
+ */
87
+ set(projectId, key, value) {
88
+ const data = this.load(projectId);
89
+ const entry = {
90
+ key,
91
+ value,
92
+ updatedAt: Date.now(),
93
+ };
94
+ data[key] = entry;
95
+ this.save(projectId, data);
96
+ return entry;
97
+ }
98
+ /**
99
+ * Delete a memory entry by key.
100
+ * Returns true if the key existed and was removed, false otherwise.
101
+ */
102
+ delete(projectId, key) {
103
+ const data = this.load(projectId);
104
+ if (!(key in data)) {
105
+ return false;
106
+ }
107
+ delete data[key];
108
+ this.save(projectId, data);
109
+ return true;
110
+ }
111
+ /**
112
+ * List all memory entries for a project, sorted by updatedAt descending.
113
+ * Returns an empty array if memory.json does not exist.
114
+ */
115
+ list(projectId) {
116
+ const data = this.load(projectId);
117
+ return Object.values(data).sort((a, b) => b.updatedAt - a.updatedAt);
118
+ }
119
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * JsonTaskStore — JSON-based persistence for annotation tasks.
3
+ *
4
+ * File format: {dataDir}/{sanitizeId(projectId)}/tasks.json
5
+ * ```json
6
+ * { "version": 1, "tasks": Task[] }
7
+ * ```
8
+ *
9
+ * Writes are atomic: write to a .tmp file then rename to the final path.
10
+ * On read failure (missing or corrupt file), returns an empty array.
11
+ * On write failure, logs the error without throwing.
12
+ */
13
+ import type { Task } from '@harness-fe/protocol';
14
+ import type { ITaskStore } from './types.js';
15
+ export declare class JsonTaskStore implements ITaskStore {
16
+ private readonly dataDir;
17
+ constructor(dataDir: string);
18
+ private tasksPath;
19
+ loadTasks(projectId: string): Task[];
20
+ saveTasks(projectId: string, tasks: Task[]): void;
21
+ }