@harness-fe/mcp-server 4.0.0-next.2 → 4.0.0-next.3
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/dist/bin.d.ts +2 -0
- package/dist/bin.js +15 -0
- package/dist/daemon.d.ts +3 -3
- package/dist/daemon.js +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +42 -16
- package/dist/mcpHttp.d.ts +2 -2
- package/dist/mcpHttp.js +8 -2
- package/package.json +5 -7
- package/src/bin.ts +19 -0
- package/src/daemon.ts +3 -3
- package/src/experimental.test.ts +2 -2
- package/src/index.ts +4 -4
- package/src/mcp.ts +44 -20
- package/src/mcpHttp.test.ts +3 -3
- package/src/mcpHttp.ts +10 -4
- package/src/mcpLayer.e2e.test.ts +2 -2
- package/src/newCapabilities.e2e.test.ts +3 -3
- package/dist/auth.d.ts +0 -53
- package/dist/auth.js +0 -212
- package/dist/bridge.d.ts +0 -323
- package/dist/bridge.js +0 -1618
- package/dist/cli.d.ts +0 -18
- package/dist/cli.js +0 -293
- package/dist/dashboardApi.d.ts +0 -40
- package/dist/dashboardApi.js +0 -142
- package/dist/dashboardSpa.d.ts +0 -18
- package/dist/dashboardSpa.js +0 -180
- package/dist/dashboardUrl.d.ts +0 -13
- package/dist/dashboardUrl.js +0 -18
- package/dist/eventsHandler.d.ts +0 -24
- package/dist/eventsHandler.js +0 -114
- package/dist/identity.d.ts +0 -90
- package/dist/identity.js +0 -123
- package/dist/openBrowser.d.ts +0 -33
- package/dist/openBrowser.js +0 -63
- package/dist/remoteBridge.d.ts +0 -61
- package/dist/remoteBridge.js +0 -307
- package/dist/replayCreate.d.ts +0 -36
- package/dist/replayCreate.js +0 -156
- package/dist/replayViewer.d.ts +0 -20
- package/dist/replayViewer.js +0 -168
- package/dist/sessionRouter.d.ts +0 -45
- package/dist/sessionRouter.js +0 -88
- package/dist/store/JsonMemoryStore.d.ts +0 -52
- package/dist/store/JsonMemoryStore.js +0 -119
- package/dist/store/JsonTaskStore.d.ts +0 -21
- package/dist/store/JsonTaskStore.js +0 -53
- package/dist/store/JsonlStore.d.ts +0 -128
- package/dist/store/JsonlStore.js +0 -1172
- package/dist/store/MemoryEventStore.d.ts +0 -47
- package/dist/store/MemoryEventStore.js +0 -111
- package/dist/store/WriteQueue.d.ts +0 -51
- package/dist/store/WriteQueue.js +0 -142
- package/dist/store/index.d.ts +0 -6
- package/dist/store/index.js +0 -5
- package/dist/store/types.d.ts +0 -427
- package/dist/store/types.js +0 -19
- package/dist/visitorTimeline.d.ts +0 -24
- package/dist/visitorTimeline.js +0 -68
- package/src/auth.test.ts +0 -90
- package/src/auth.ts +0 -248
- package/src/bridge-auth.test.ts +0 -196
- package/src/bridge.test.ts +0 -1708
- package/src/bridge.ts +0 -1854
- package/src/cli.ts +0 -338
- package/src/dashboardApi.test.ts +0 -235
- package/src/dashboardApi.ts +0 -184
- package/src/dashboardSpa.test.ts +0 -239
- package/src/dashboardSpa.ts +0 -195
- package/src/dashboardUrl.test.ts +0 -46
- package/src/dashboardUrl.ts +0 -28
- package/src/eventsHandler.test.ts +0 -247
- package/src/eventsHandler.ts +0 -136
- package/src/identity.test.ts +0 -109
- package/src/identity.ts +0 -137
- package/src/openBrowser.test.ts +0 -103
- package/src/openBrowser.ts +0 -81
- package/src/remoteBridge.test.ts +0 -119
- package/src/remoteBridge.ts +0 -404
- package/src/replay.test.ts +0 -271
- package/src/replayCreate.ts +0 -194
- package/src/replayViewer.ts +0 -173
- package/src/sessionRouter.ts +0 -119
- package/src/store/JsonMemoryStore.test.ts +0 -175
- package/src/store/JsonMemoryStore.ts +0 -128
- package/src/store/JsonTaskStore.test.ts +0 -212
- package/src/store/JsonTaskStore.ts +0 -59
- package/src/store/JsonlStore.test.ts +0 -1538
- package/src/store/JsonlStore.ts +0 -1325
- package/src/store/MemoryEventStore.test.ts +0 -119
- package/src/store/MemoryEventStore.ts +0 -151
- package/src/store/WriteQueue.ts +0 -165
- package/src/store/identityTagging.test.ts +0 -67
- package/src/store/index.ts +0 -29
- package/src/store/types.ts +0 -532
- package/src/visitorTimeline.test.ts +0 -197
- package/src/visitorTimeline.ts +0 -89
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
-
import { MemoryEventStore } from './MemoryEventStore.js';
|
|
4
|
-
|
|
5
|
-
function msg(id: number): JSONRPCMessage {
|
|
6
|
-
return { jsonrpc: '2.0', id, result: { ok: id } } as JSONRPCMessage;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
describe('MemoryEventStore', () => {
|
|
10
|
-
it('assigns monotonically ordered event ids per stream', async () => {
|
|
11
|
-
const s = new MemoryEventStore();
|
|
12
|
-
const a1 = await s.storeEvent('A', msg(1));
|
|
13
|
-
const a2 = await s.storeEvent('A', msg(2));
|
|
14
|
-
const b1 = await s.storeEvent('B', msg(1));
|
|
15
|
-
const a3 = await s.storeEvent('A', msg(3));
|
|
16
|
-
|
|
17
|
-
expect(a1 < a2).toBe(true);
|
|
18
|
-
expect(a2 < a3).toBe(true);
|
|
19
|
-
expect(b1.startsWith('B::')).toBe(true);
|
|
20
|
-
expect(a1.startsWith('A::')).toBe(true);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('replays only events strictly after lastEventId, in order', async () => {
|
|
24
|
-
const s = new MemoryEventStore();
|
|
25
|
-
const ids: string[] = [];
|
|
26
|
-
for (let i = 1; i <= 5; i++) ids.push(await s.storeEvent('A', msg(i)));
|
|
27
|
-
|
|
28
|
-
const sent: Array<{ eventId: string; message: JSONRPCMessage }> = [];
|
|
29
|
-
const sid = await s.replayEventsAfter(ids[1]!, {
|
|
30
|
-
send: async (eventId, message) => {
|
|
31
|
-
sent.push({ eventId, message });
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
expect(sid).toBe('A');
|
|
36
|
-
expect(sent.map((s) => s.eventId)).toEqual([ids[2], ids[3], ids[4]]);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('getStreamIdForEventId recovers stream from event id', async () => {
|
|
40
|
-
const s = new MemoryEventStore();
|
|
41
|
-
const id = await s.storeEvent('stream-xyz', msg(1));
|
|
42
|
-
expect(await s.getStreamIdForEventId(id)).toBe('stream-xyz');
|
|
43
|
-
expect(await s.getStreamIdForEventId('no-separator')).toBeUndefined();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('replay across unknown stream returns empty without throwing', async () => {
|
|
47
|
-
const s = new MemoryEventStore();
|
|
48
|
-
const sent: string[] = [];
|
|
49
|
-
const sid = await s.replayEventsAfter('ghost::000000000001', {
|
|
50
|
-
send: async (eventId) => {
|
|
51
|
-
sent.push(eventId);
|
|
52
|
-
},
|
|
53
|
-
});
|
|
54
|
-
expect(sid).toBe('ghost');
|
|
55
|
-
expect(sent).toEqual([]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('evicts oldest per-stream events when maxEventsPerStream is exceeded', async () => {
|
|
59
|
-
const s = new MemoryEventStore({ maxEventsPerStream: 3 });
|
|
60
|
-
const ids: string[] = [];
|
|
61
|
-
for (let i = 1; i <= 5; i++) ids.push(await s.storeEvent('A', msg(i)));
|
|
62
|
-
|
|
63
|
-
// After eviction only the last 3 remain. Replaying after the
|
|
64
|
-
// evicted first id returns just whatever is still buffered.
|
|
65
|
-
const sent: string[] = [];
|
|
66
|
-
await s.replayEventsAfter(ids[0]!, {
|
|
67
|
-
send: async (eventId) => {
|
|
68
|
-
sent.push(eventId);
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
|
-
expect(sent).toEqual([ids[2], ids[3], ids[4]]);
|
|
72
|
-
expect(s.size()).toBe(3);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('evicts events older than maxAgeMs', async () => {
|
|
76
|
-
let now = 1_000_000;
|
|
77
|
-
const s = new MemoryEventStore({ maxAgeMs: 1000, now: () => now });
|
|
78
|
-
const id1 = await s.storeEvent('A', msg(1));
|
|
79
|
-
now += 500;
|
|
80
|
-
const id2 = await s.storeEvent('A', msg(2));
|
|
81
|
-
now += 2000; // both old now from id1's perspective, id2 still within window
|
|
82
|
-
const id3 = await s.storeEvent('A', msg(3));
|
|
83
|
-
|
|
84
|
-
// id1 + id2 are older than 1000ms relative to `now`, so they are evicted.
|
|
85
|
-
const sent: string[] = [];
|
|
86
|
-
await s.replayEventsAfter(id1, {
|
|
87
|
-
send: async (eventId) => {
|
|
88
|
-
sent.push(eventId);
|
|
89
|
-
},
|
|
90
|
-
});
|
|
91
|
-
expect(sent).toEqual([id3]);
|
|
92
|
-
expect(id2 < id3).toBe(true); // counters still monotonic
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('never reuses an event id after the stream buffer empties', async () => {
|
|
96
|
-
let now = 1_000;
|
|
97
|
-
const s = new MemoryEventStore({ maxAgeMs: 100, now: () => now });
|
|
98
|
-
const id1 = await s.storeEvent('A', msg(1));
|
|
99
|
-
now += 1000; // evict id1
|
|
100
|
-
const id2 = await s.storeEvent('A', msg(2));
|
|
101
|
-
|
|
102
|
-
expect(id1).not.toBe(id2);
|
|
103
|
-
// counter monotonic — id2 must compare greater
|
|
104
|
-
expect(id1 < id2).toBe(true);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('evicts across streams when global byte cap is exceeded', async () => {
|
|
108
|
-
// Each msg is ~30 bytes JSON; cap so only ~2 events fit globally.
|
|
109
|
-
const s = new MemoryEventStore({ maxBytesTotal: 60 });
|
|
110
|
-
await s.storeEvent('A', msg(1));
|
|
111
|
-
await s.storeEvent('B', msg(2));
|
|
112
|
-
await s.storeEvent('A', msg(3));
|
|
113
|
-
await s.storeEvent('B', msg(4));
|
|
114
|
-
|
|
115
|
-
// Should have shed oldest until under cap.
|
|
116
|
-
expect(s.bytes()).toBeLessThanOrEqual(60);
|
|
117
|
-
expect(s.size()).toBeLessThanOrEqual(3);
|
|
118
|
-
});
|
|
119
|
-
});
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MemoryEventStore — in-memory bounded buffer of MCP HTTP-streaming events,
|
|
3
|
-
* implements the SDK `EventStore` interface so that
|
|
4
|
-
* `StreamableHTTPServerTransport` can resume a dropped SSE connection from
|
|
5
|
-
* a client-supplied `Last-Event-ID`.
|
|
6
|
-
*
|
|
7
|
-
* Eviction is applied on every store, in this order:
|
|
8
|
-
* 1. drop per-stream events older than `maxAgeMs`
|
|
9
|
-
* 2. drop oldest per-stream events while the stream length exceeds `maxEventsPerStream`
|
|
10
|
-
* 3. drop globally-oldest events across all streams while `totalBytes` exceeds `maxBytesTotal`
|
|
11
|
-
*
|
|
12
|
-
* Event ids are `{streamId}::{padded counter}`. The counter is never reused
|
|
13
|
-
* for a given stream so a reconnect with a stale `Last-Event-ID` can be
|
|
14
|
-
* detected (the id is just absent from the buffer).
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type {
|
|
18
|
-
EventId,
|
|
19
|
-
EventStore,
|
|
20
|
-
StreamId,
|
|
21
|
-
} from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
22
|
-
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
|
23
|
-
|
|
24
|
-
export interface MemoryEventStoreOptions {
|
|
25
|
-
/** Max events retained per stream. Default 1000. */
|
|
26
|
-
maxEventsPerStream?: number;
|
|
27
|
-
/** Max age of a retained event, in ms. Default 5 minutes. */
|
|
28
|
-
maxAgeMs?: number;
|
|
29
|
-
/** Soft cap on total buffered bytes across all streams. Default 50 MiB. */
|
|
30
|
-
maxBytesTotal?: number;
|
|
31
|
-
/** Time source — override for tests. Default `Date.now`. */
|
|
32
|
-
now?: () => number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface StoredEvent {
|
|
36
|
-
eventId: EventId;
|
|
37
|
-
message: JSONRPCMessage;
|
|
38
|
-
ts: number;
|
|
39
|
-
bytes: number;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const SEPARATOR = '::';
|
|
43
|
-
const COUNTER_PAD = 12;
|
|
44
|
-
|
|
45
|
-
export class MemoryEventStore implements EventStore {
|
|
46
|
-
private readonly maxEventsPerStream: number;
|
|
47
|
-
private readonly maxAgeMs: number;
|
|
48
|
-
private readonly maxBytesTotal: number;
|
|
49
|
-
private readonly now: () => number;
|
|
50
|
-
|
|
51
|
-
private readonly streams = new Map<StreamId, StoredEvent[]>();
|
|
52
|
-
private readonly counters = new Map<StreamId, number>();
|
|
53
|
-
private totalBytes = 0;
|
|
54
|
-
|
|
55
|
-
constructor(opts: MemoryEventStoreOptions = {}) {
|
|
56
|
-
this.maxEventsPerStream = opts.maxEventsPerStream ?? 1000;
|
|
57
|
-
this.maxAgeMs = opts.maxAgeMs ?? 5 * 60_000;
|
|
58
|
-
this.maxBytesTotal = opts.maxBytesTotal ?? 50 * 1024 * 1024;
|
|
59
|
-
this.now = opts.now ?? Date.now;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId> {
|
|
63
|
-
const counter = (this.counters.get(streamId) ?? 0) + 1;
|
|
64
|
-
this.counters.set(streamId, counter);
|
|
65
|
-
const eventId = `${streamId}${SEPARATOR}${counter.toString().padStart(COUNTER_PAD, '0')}`;
|
|
66
|
-
|
|
67
|
-
const bytes = Buffer.byteLength(JSON.stringify(message), 'utf8');
|
|
68
|
-
const stored: StoredEvent = { eventId, message, ts: this.now(), bytes };
|
|
69
|
-
|
|
70
|
-
let stream = this.streams.get(streamId);
|
|
71
|
-
if (!stream) {
|
|
72
|
-
stream = [];
|
|
73
|
-
this.streams.set(streamId, stream);
|
|
74
|
-
}
|
|
75
|
-
stream.push(stored);
|
|
76
|
-
this.totalBytes += bytes;
|
|
77
|
-
|
|
78
|
-
this.evict();
|
|
79
|
-
return eventId;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined> {
|
|
83
|
-
const idx = eventId.lastIndexOf(SEPARATOR);
|
|
84
|
-
return idx < 0 ? undefined : eventId.slice(0, idx);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async replayEventsAfter(
|
|
88
|
-
lastEventId: EventId,
|
|
89
|
-
{ send }: { send: (eventId: EventId, message: JSONRPCMessage) => Promise<void> },
|
|
90
|
-
): Promise<StreamId> {
|
|
91
|
-
const streamId = await this.getStreamIdForEventId(lastEventId);
|
|
92
|
-
if (!streamId) return '';
|
|
93
|
-
const stream = this.streams.get(streamId);
|
|
94
|
-
if (!stream) return streamId;
|
|
95
|
-
for (const ev of stream) {
|
|
96
|
-
if (ev.eventId > lastEventId) {
|
|
97
|
-
await send(ev.eventId, ev.message);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return streamId;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Test helper — total events currently buffered. */
|
|
104
|
-
size(): number {
|
|
105
|
-
let n = 0;
|
|
106
|
-
for (const stream of this.streams.values()) n += stream.length;
|
|
107
|
-
return n;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Test helper — current buffered bytes. */
|
|
111
|
-
bytes(): number {
|
|
112
|
-
return this.totalBytes;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
private evict(): void {
|
|
116
|
-
const now = this.now();
|
|
117
|
-
|
|
118
|
-
for (const [sid, stream] of this.streams) {
|
|
119
|
-
while (
|
|
120
|
-
stream.length > 0 &&
|
|
121
|
-
(stream.length > this.maxEventsPerStream || now - stream[0].ts > this.maxAgeMs)
|
|
122
|
-
) {
|
|
123
|
-
const dropped = stream.shift()!;
|
|
124
|
-
this.totalBytes -= dropped.bytes;
|
|
125
|
-
}
|
|
126
|
-
if (stream.length === 0) {
|
|
127
|
-
this.streams.delete(sid);
|
|
128
|
-
// Keep the counter — eventIds must never be reused for a stream
|
|
129
|
-
// even if its buffer is currently empty.
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (this.totalBytes <= this.maxBytesTotal) return;
|
|
134
|
-
|
|
135
|
-
while (this.totalBytes > this.maxBytesTotal) {
|
|
136
|
-
let oldestSid: StreamId | undefined;
|
|
137
|
-
let oldestTs = Infinity;
|
|
138
|
-
for (const [sid, stream] of this.streams) {
|
|
139
|
-
if (stream.length > 0 && stream[0].ts < oldestTs) {
|
|
140
|
-
oldestTs = stream[0].ts;
|
|
141
|
-
oldestSid = sid;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
if (!oldestSid) break;
|
|
145
|
-
const stream = this.streams.get(oldestSid)!;
|
|
146
|
-
const dropped = stream.shift()!;
|
|
147
|
-
this.totalBytes -= dropped.bytes;
|
|
148
|
-
if (stream.length === 0) this.streams.delete(oldestSid);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
package/src/store/WriteQueue.ts
DELETED
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WriteQueue — async batching queue for JSONL timeline writes.
|
|
3
|
-
*
|
|
4
|
-
* Key behaviors:
|
|
5
|
-
* - `seq` is assigned at enqueue time (not flush time) to preserve arrival order
|
|
6
|
-
* - `setTimeout` on first enqueue after a flush — avoids unnecessary timer overhead when idle
|
|
7
|
-
* - `flush` calls `fs.appendFile` once per file path with all buffered lines joined by `\n`
|
|
8
|
-
* - `drain` awaits the current flush and any in-flight writes before returning
|
|
9
|
-
* - On flush failure: log error, discard batch, continue — seq numbers from failed batch are not reused
|
|
10
|
-
*
|
|
11
|
-
* Requirements: 4.1, 4.2, 4.6, 4.7, 4.8
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { appendFile } from 'node:fs/promises';
|
|
15
|
-
|
|
16
|
-
/** Maximum delay (ms) before a pending flush is executed. */
|
|
17
|
-
const FLUSH_DELAY_MS = 16;
|
|
18
|
-
|
|
19
|
-
export class WriteQueue {
|
|
20
|
-
/** filePath → pending lines to write */
|
|
21
|
-
private buffers = new Map<string, string[]>();
|
|
22
|
-
|
|
23
|
-
/** Pending setTimeout handle; null when idle */
|
|
24
|
-
private timer: NodeJS.Timeout | null = null;
|
|
25
|
-
|
|
26
|
-
/** sessionId → next seq number (assigned at enqueue time) */
|
|
27
|
-
private seq = new Map<string, number>();
|
|
28
|
-
|
|
29
|
-
/** Promise for the currently in-flight flush, if any */
|
|
30
|
-
private flushPromise: Promise<void> | null = null;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Enqueue a StoreEvent line for writing.
|
|
34
|
-
*
|
|
35
|
-
* The `line` parameter should be a JSON object string WITHOUT a `seq` field.
|
|
36
|
-
* WriteQueue assigns the seq for the given sessionId, injects it into the line,
|
|
37
|
-
* and pushes the result to the buffer for `filePath`.
|
|
38
|
-
*
|
|
39
|
-
* @param filePath Absolute path to the JSONL file to append to
|
|
40
|
-
* @param sessionId Session identifier used to track the per-session seq counter
|
|
41
|
-
* @param line Pre-serialized JSON object string (without `seq` field)
|
|
42
|
-
*/
|
|
43
|
-
enqueue(filePath: string, sessionId: string, line: string): void {
|
|
44
|
-
// Assign seq at enqueue time to preserve arrival order
|
|
45
|
-
const seq = this.seq.get(sessionId) ?? 0;
|
|
46
|
-
this.seq.set(sessionId, seq + 1);
|
|
47
|
-
|
|
48
|
-
// Inject seq into the JSON line
|
|
49
|
-
// The line is a JSON object string like '{"ts":1000,"t":"log",...}'
|
|
50
|
-
// We insert seq as the first field for readability
|
|
51
|
-
const lineWithSeq = injectSeq(line, seq);
|
|
52
|
-
|
|
53
|
-
// Push to buffer for this file path
|
|
54
|
-
const buf = this.buffers.get(filePath);
|
|
55
|
-
if (buf) {
|
|
56
|
-
buf.push(lineWithSeq);
|
|
57
|
-
} else {
|
|
58
|
-
this.buffers.set(filePath, [lineWithSeq]);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Schedule a flush if not already pending
|
|
62
|
-
if (this.timer === null) {
|
|
63
|
-
this.timer = setTimeout(() => {
|
|
64
|
-
this.timer = null;
|
|
65
|
-
this.flushPromise = this.flush();
|
|
66
|
-
}, FLUSH_DELAY_MS);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Drain all buffers: one `fs.appendFile` call per file path.
|
|
72
|
-
* On error: log + discard batch (do not retry).
|
|
73
|
-
* Seq numbers from failed batches are not reused.
|
|
74
|
-
*/
|
|
75
|
-
async flush(): Promise<void> {
|
|
76
|
-
if (this.buffers.size === 0) return;
|
|
77
|
-
|
|
78
|
-
// Snapshot and clear the buffers atomically before any async work.
|
|
79
|
-
// This ensures new enqueues during the flush go into fresh buffers.
|
|
80
|
-
const snapshot = new Map(this.buffers);
|
|
81
|
-
this.buffers.clear();
|
|
82
|
-
|
|
83
|
-
const writes: Promise<void>[] = [];
|
|
84
|
-
|
|
85
|
-
for (const [filePath, lines] of snapshot) {
|
|
86
|
-
if (lines.length === 0) continue;
|
|
87
|
-
// Join all lines with newline; append a trailing newline
|
|
88
|
-
const content = lines.join('\n') + '\n';
|
|
89
|
-
writes.push(
|
|
90
|
-
appendFile(filePath, content, 'utf-8').catch((err: unknown) => {
|
|
91
|
-
console.error(
|
|
92
|
-
`[WriteQueue] flush failed for ${filePath} (${lines.length} events discarded):`,
|
|
93
|
-
err,
|
|
94
|
-
);
|
|
95
|
-
// Discard batch — do not retry, do not reuse seq numbers
|
|
96
|
-
}),
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
await Promise.all(writes);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Flush any pending timer-scheduled writes immediately, then await
|
|
105
|
-
* the current in-flight flush (if any). Used on `close()` / SIGINT
|
|
106
|
-
* to ensure all buffered events reach disk before the process exits.
|
|
107
|
-
*/
|
|
108
|
-
async drain(): Promise<void> {
|
|
109
|
-
// Cancel the pending timer so we don't double-flush
|
|
110
|
-
if (this.timer !== null) {
|
|
111
|
-
clearTimeout(this.timer);
|
|
112
|
-
this.timer = null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Capture any already in-flight flush (timer may have fired just before drain)
|
|
116
|
-
const inflight = this.flushPromise;
|
|
117
|
-
|
|
118
|
-
// Flush whatever is currently buffered (may be empty if inflight already drained it)
|
|
119
|
-
const newFlush = this.flush();
|
|
120
|
-
this.flushPromise = newFlush;
|
|
121
|
-
|
|
122
|
-
// Await both: the previously in-flight flush and the new one
|
|
123
|
-
await Promise.all([inflight, newFlush]);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Get the current seq counter for a session (for testing / inspection).
|
|
128
|
-
* Returns 0 if no events have been enqueued for this session yet.
|
|
129
|
-
*/
|
|
130
|
-
getSeq(sessionId: string): number {
|
|
131
|
-
return this.seq.get(sessionId) ?? 0;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Inject a `seq` field as the first property of a JSON object string.
|
|
139
|
-
*
|
|
140
|
-
* Input: '{"ts":1000,"t":"log"}'
|
|
141
|
-
* Output: '{"seq":0,"ts":1000,"t":"log"}'
|
|
142
|
-
*
|
|
143
|
-
* Falls back to appending seq if the line is not a valid JSON object.
|
|
144
|
-
*/
|
|
145
|
-
function injectSeq(line: string, seq: number): string {
|
|
146
|
-
const trimmed = line.trimStart();
|
|
147
|
-
if (trimmed.startsWith('{')) {
|
|
148
|
-
// Fast path: insert after the opening brace
|
|
149
|
-
const openBrace = line.indexOf('{');
|
|
150
|
-
const rest = line.slice(openBrace + 1).trimStart();
|
|
151
|
-
if (rest.startsWith('}')) {
|
|
152
|
-
// Empty object
|
|
153
|
-
return `{"seq":${seq}}`;
|
|
154
|
-
}
|
|
155
|
-
return `${line.slice(0, openBrace + 1)}"seq":${seq},${line.slice(openBrace + 1)}`;
|
|
156
|
-
}
|
|
157
|
-
// Fallback: parse and re-serialize (handles edge cases)
|
|
158
|
-
try {
|
|
159
|
-
const obj = JSON.parse(line) as Record<string, unknown>;
|
|
160
|
-
return JSON.stringify({ seq, ...obj });
|
|
161
|
-
} catch {
|
|
162
|
-
// If the line is not valid JSON, wrap it
|
|
163
|
-
return JSON.stringify({ seq, _raw: line });
|
|
164
|
-
}
|
|
165
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Caller-identity tagging (4.0 · P1): `createdBy` is write-once on project /
|
|
3
|
-
* session metadata — the first principal to create the record owns it, and
|
|
4
|
-
* later upserts (which never carry an identity in normal flow) must not
|
|
5
|
-
* silently re-attribute ownership.
|
|
6
|
-
*/
|
|
7
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
8
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
9
|
-
import { tmpdir } from 'node:os';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
-
import { randomUUID } from 'node:crypto';
|
|
12
|
-
import { JsonlStore } from './JsonlStore.js';
|
|
13
|
-
|
|
14
|
-
describe('store: createdBy tagging (P1)', () => {
|
|
15
|
-
let dir: string;
|
|
16
|
-
let store: JsonlStore;
|
|
17
|
-
|
|
18
|
-
beforeEach(() => {
|
|
19
|
-
dir = mkdtempSync(join(tmpdir(), 'harness-identity-test-'));
|
|
20
|
-
store = new JsonlStore(dir);
|
|
21
|
-
});
|
|
22
|
-
afterEach(() => {
|
|
23
|
-
store.close();
|
|
24
|
-
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('project: records createdBy on creation', () => {
|
|
28
|
-
const p = store.upsertProject('proj-a', { createdBy: 'token:abc' });
|
|
29
|
-
expect(p.createdBy).toBe('token:abc');
|
|
30
|
-
expect(store.getProject('proj-a')?.createdBy).toBe('token:abc');
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it('project: createdBy is write-once (later upsert without it is preserved)', () => {
|
|
34
|
-
store.upsertProject('proj-a', { createdBy: 'local' });
|
|
35
|
-
const after = store.upsertProject('proj-a', { displayName: 'renamed' });
|
|
36
|
-
expect(after.createdBy).toBe('local');
|
|
37
|
-
expect(after.displayName).toBe('renamed');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('project: a later upsert cannot re-attribute ownership', () => {
|
|
41
|
-
store.upsertProject('proj-a', { createdBy: 'local' });
|
|
42
|
-
const after = store.upsertProject('proj-a', { createdBy: 'token:evil' });
|
|
43
|
-
expect(after.createdBy).toBe('local');
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('project: createdBy stays undefined when never supplied (back-compat)', () => {
|
|
47
|
-
const p = store.upsertProject('proj-legacy', { displayName: 'x' });
|
|
48
|
-
expect(p.createdBy).toBeUndefined();
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('session: records and locks createdBy', () => {
|
|
52
|
-
const sid = randomUUID();
|
|
53
|
-
store.upsertSession(sid, {
|
|
54
|
-
tabId: 'tab-1',
|
|
55
|
-
startedAt: Date.now(),
|
|
56
|
-
participants: [{ projectId: 'proj-a', joinedAt: Date.now() }],
|
|
57
|
-
createdBy: 'local',
|
|
58
|
-
});
|
|
59
|
-
store.upsertSession(sid, {
|
|
60
|
-
tabId: 'tab-1',
|
|
61
|
-
startedAt: Date.now(),
|
|
62
|
-
participants: [{ projectId: 'proj-a', joinedAt: Date.now() }],
|
|
63
|
-
createdBy: 'token:other',
|
|
64
|
-
});
|
|
65
|
-
expect(store.getSession(sid)?.createdBy).toBe('local');
|
|
66
|
-
});
|
|
67
|
-
});
|
package/src/store/index.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
export { JsonlStore, sanitizeId } from './JsonlStore.js';
|
|
2
|
-
export { WriteQueue } from './WriteQueue.js';
|
|
3
|
-
export { JsonTaskStore } from './JsonTaskStore.js';
|
|
4
|
-
export { JsonMemoryStore } from './JsonMemoryStore.js';
|
|
5
|
-
export { MemoryEventStore, type MemoryEventStoreOptions } from './MemoryEventStore.js';
|
|
6
|
-
export type {
|
|
7
|
-
IStore,
|
|
8
|
-
ITaskStore,
|
|
9
|
-
IMemoryStore,
|
|
10
|
-
MemoryEntry,
|
|
11
|
-
StoreEvent,
|
|
12
|
-
EventType,
|
|
13
|
-
ProjectMeta,
|
|
14
|
-
ProjectTreeNode,
|
|
15
|
-
BuildMeta,
|
|
16
|
-
SessionMeta,
|
|
17
|
-
TabMeta,
|
|
18
|
-
SessionSummary,
|
|
19
|
-
TailOptions,
|
|
20
|
-
SearchOptions,
|
|
21
|
-
RecordingChunkSummary,
|
|
22
|
-
RecordingChunk,
|
|
23
|
-
ReplayExportMeta,
|
|
24
|
-
RetentionPolicy,
|
|
25
|
-
PurgeResult,
|
|
26
|
-
EventStore,
|
|
27
|
-
EventId,
|
|
28
|
-
StreamId,
|
|
29
|
-
} from './types.js';
|