@dreb/dashboard 2.40.2 → 2.42.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.
- package/README.md +85 -12
- package/dist/server/event-hub.d.ts +70 -14
- package/dist/server/event-hub.d.ts.map +1 -1
- package/dist/server/event-hub.js +170 -43
- package/dist/server/event-hub.js.map +1 -1
- package/dist/server/files.d.ts +3 -1
- package/dist/server/files.d.ts.map +1 -1
- package/dist/server/files.js +6 -1
- package/dist/server/files.js.map +1 -1
- package/dist/server/runtime-pool.d.ts +39 -0
- package/dist/server/runtime-pool.d.ts.map +1 -1
- package/dist/server/runtime-pool.js +78 -0
- package/dist/server/runtime-pool.js.map +1 -1
- package/dist/server/server.d.ts +6 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +281 -24
- package/dist/server/server.js.map +1 -1
- package/dist/shared/protocol.d.ts +78 -0
- package/dist/shared/protocol.d.ts.map +1 -1
- package/dist/shared/protocol.js +2 -0
- package/dist/shared/protocol.js.map +1 -1
- package/dist/static/assets/index-C36pWEQW.css +1 -0
- package/dist/static/assets/index-CBUAw_qM.js +77 -0
- package/dist/static/index.html +2 -2
- package/dist/static/sw.js +1 -1
- package/package.json +1 -1
- package/dist/static/assets/index-Bi9TBYaH.css +0 -1
- package/dist/static/assets/index-DIfuz8nQ.js +0 -77
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ Open `http://127.0.0.1:5343`.
|
|
|
41
41
|
- **Session view** — full chat parity: markdown streaming transcript, tool
|
|
42
42
|
cards, thinking blocks, compaction summaries, per-message copy, tasks panel,
|
|
43
43
|
suggest-next chip, slash-command autocomplete, image attach/paste,
|
|
44
|
-
queued-message restore, footer-parity info bar (branch, tokens, cost, ctx%,
|
|
44
|
+
queued-message restore, persistent session-header live indicator, footer-parity info bar (branch, tokens, cost, ctx%,
|
|
45
45
|
median tok/s), stats/loaded-context/fork modals, steer/follow-up composer
|
|
46
46
|
modes, ■ abort, model/thinking switchers, extension-UI modals, export HTML,
|
|
47
47
|
and live auto-naming.
|
|
@@ -50,14 +50,88 @@ Open `http://127.0.0.1:5343`.
|
|
|
50
50
|
view survives browser reloads. No composer: the parent session controls the
|
|
51
51
|
agent.
|
|
52
52
|
- **Files** — host-wide browse with places shortcuts, upload (collision
|
|
53
|
-
prompts before overwrite), download, new-folder, "new session here"
|
|
53
|
+
prompts before overwrite), download, new-folder, "new session here", and the
|
|
54
|
+
effective global nested-context trust for the viewed canonical folder. Trust
|
|
55
|
+
the folder and descendants, or untrust the actual granting root (including
|
|
56
|
+
its inherited descendants).
|
|
54
57
|
- **Settings** — persistent defaults (provider-grouped model dropdown,
|
|
55
|
-
thinking, queue modes, image handling, skill commands,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
thinking, queue modes, image handling, skill commands, transport,
|
|
59
|
+
hide-thinking, compaction/retry), per-agent model fallback editor, and the
|
|
60
|
+
global-only nested-context policy: an auditable trusted-roots list with
|
|
61
|
+
revoke and simple add-by-path controls, plus a prominent expert trust-all
|
|
62
|
+
warning. The Files view remains the primary trust-grant flow. Most defaults
|
|
63
|
+
seed new sessions; opening Settings flushes pending writes and reloads durable
|
|
64
|
+
global + project settings so external edits appear, while read/parse/write
|
|
65
|
+
failures are shown instead of stale values. Trust changes are observed by
|
|
66
|
+
active processes for future lazy loads and cannot retract already injected
|
|
67
|
+
context. Also includes dashboard-local preferences (thinking expansion and
|
|
68
|
+
notification permission), current pairing code, and paired-devices management.
|
|
59
69
|
- **Pairing** — remote first-login rotating-code flow.
|
|
60
70
|
|
|
71
|
+
## Live connection and recovery
|
|
72
|
+
|
|
73
|
+
The accessible text indicator in the top bar and persistent session header reports
|
|
74
|
+
the SSE connection as **connecting**, **connected**, **retrying**, **resyncing**,
|
|
75
|
+
**disconnected**, or **auth failed** (with retry delay where applicable); color
|
|
76
|
+
is not its only cue. The session-header indicator remains visible when session
|
|
77
|
+
details or composer controls are collapsed.
|
|
78
|
+
The server replays reducer-relevant projected envelopes from history bounded by
|
|
79
|
+
both count and bytes, with a separate byte cap for each replay. A server restart,
|
|
80
|
+
sequence gap, history eviction, or over-budget replay sends only that reconnect
|
|
81
|
+
a resync barrier at the current cursor, not a partial replay; an individually
|
|
82
|
+
oversized event sends a global barrier because every browser missed it.
|
|
83
|
+
|
|
84
|
+
Recovery fetches an authoritative snapshot whose HTTP `barrierSeq` was captured
|
|
85
|
+
synchronously at the RPC snapshot marker, discards queued envelopes through that
|
|
86
|
+
sequence, then replays only later envelopes. A viewed subagent has an additional
|
|
87
|
+
disk-read boundary so intervening relays are not lost. This restores transcripts,
|
|
88
|
+
background-agent state, and the atomically replaced task list after a hard refresh
|
|
89
|
+
or gap without interrupting healthy browsers. Backpressure disconnects a slow
|
|
90
|
+
client and uses the same recovery path; a foreground 60-second liveness watchdog
|
|
91
|
+
does likewise for a stalled stream. Named, unnumbered heartbeats arrive every 25
|
|
92
|
+
seconds.
|
|
93
|
+
|
|
94
|
+
Retries use client-owned capped exponential backoff (maximum 30 seconds) with
|
|
95
|
+
±25% jitter. The attempt count resets only after 60 seconds of healthy
|
|
96
|
+
liveness, not on socket open. Returning to a visible tab always validates auth;
|
|
97
|
+
validation is aborted after 10 seconds so a black-holed request cannot stall
|
|
98
|
+
recovery. A 401/403 becomes **auth failed**, while timeouts and other failures
|
|
99
|
+
recover normally.
|
|
100
|
+
Optional correlated diagnostics are dashboard-authenticated, metadata-only,
|
|
101
|
+
4 KiB-capped, and rate-limited (one summary per connection every 30 seconds);
|
|
102
|
+
they never include prompts, cookies, SSE payloads, or tool data.
|
|
103
|
+
|
|
104
|
+
See the full [dashboard recovery contract](../coding-agent/docs/dashboard.md#live-connection-and-recovery) and [RPC snapshot ordering](../coding-agent/docs/rpc.md#get_dashboard_snapshot).
|
|
105
|
+
|
|
106
|
+
## Nested context trust
|
|
107
|
+
|
|
108
|
+
The Files trust controls apply only to **lazy nested/out-of-cwd** context
|
|
109
|
+
loading. They do not control dreb's separate initial upward scan for
|
|
110
|
+
`AGENTS.md`/`CLAUDE.md` from a session's launch cwd.
|
|
111
|
+
|
|
112
|
+
Lazy loading is off by default. The Files view is the primary grant flow:
|
|
113
|
+
trust the viewed folder and descendants, or untrust its actual granting root.
|
|
114
|
+
Settings also lists every configured root for audit and revoke, and offers a
|
|
115
|
+
simple add-by-path control. Trusting through either screen writes an explicit,
|
|
116
|
+
global-only `context.trustedFolders` root in
|
|
117
|
+
`~/.dreb/agent/settings.json`; that root covers itself and descendants after
|
|
118
|
+
canonical native-`realpath` resolution. A symlink that lexically appears below
|
|
119
|
+
a root but resolves outside it is not trusted. Project `.dreb/settings.json`
|
|
120
|
+
cannot enable, disable, or extend nested-context trust; only global settings
|
|
121
|
+
and the dashboard Files/Settings controls can, so a cloned repository cannot
|
|
122
|
+
grant itself trust.
|
|
123
|
+
|
|
124
|
+
The Files view reports the actual state returned by RPC: `untrusted`,
|
|
125
|
+
`trusted-root` (including an inherited canonical granting root), or
|
|
126
|
+
`unrestricted`. Its **untrust** action removes the granting root, not merely
|
|
127
|
+
the selected descendant. `context.autoLoadNested: true` is a global-only expert
|
|
128
|
+
trust-all override; it allows any resolvable directory and can inject
|
|
129
|
+
prompt-injection content from untrusted repositories, so folder controls cannot
|
|
130
|
+
narrow it. Main agents and subagents share this policy. Active processes see
|
|
131
|
+
trust changes before future lazy loads, but already injected context cannot be
|
|
132
|
+
removed. Permitted lazy context is secret-scrubbed, appended after extension
|
|
133
|
+
`tool_result` transforms, and deduplicated per session.
|
|
134
|
+
|
|
61
135
|
## Security model — exactly two modes
|
|
62
136
|
|
|
63
137
|
**Local (default).** The server binds the loopback interface only. LAN packets
|
|
@@ -134,12 +208,11 @@ Browser (SolidJS, hash-routed SPA)
|
|
|
134
208
|
⇄ RpcClient pool — one `dreb --mode rpc` child per live session
|
|
135
209
|
```
|
|
136
210
|
|
|
137
|
-
- Events stream over
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
runtime publishes a synthetic `runtime_removed` event so clients
|
|
141
|
-
session's state.
|
|
142
|
-
write buffer are disconnected and rely on reconnect catch-up.
|
|
211
|
+
- Events stream over one SSE connection carrying `{seq, key, event}` envelopes.
|
|
212
|
+
Count/byte-bounded projected replay and an explicitly captured snapshot cursor
|
|
213
|
+
provide recovery; see [Live connection and recovery](#live-connection-and-recovery).
|
|
214
|
+
Deleting a runtime publishes a synthetic `runtime_removed` event so clients
|
|
215
|
+
evict that session's state.
|
|
143
216
|
- Background subagent transcripts arrive over the same pipe via the
|
|
144
217
|
`background_agent_event` relay (see `docs/rpc.md` in
|
|
145
218
|
`@dreb/coding-agent`) — no session-file tailing.
|
|
@@ -1,31 +1,87 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SSE event hub —
|
|
3
|
-
*
|
|
2
|
+
* SSE event hub — projects runtime events for the dashboard, keeps a
|
|
3
|
+
* byte-bounded replay history, and fans serialized frames out to browsers.
|
|
4
4
|
*/
|
|
5
5
|
import type { EventEnvelope } from "../shared/protocol.js";
|
|
6
|
+
export type SseWriteKind = "live" | "replay" | "resync";
|
|
7
|
+
/** Metadata deliberately excludes the serialized event payload. */
|
|
8
|
+
export interface SseWriteMetadata {
|
|
9
|
+
kind: SseWriteKind;
|
|
10
|
+
seq: number;
|
|
11
|
+
type: string;
|
|
12
|
+
frameBytes: number;
|
|
13
|
+
/** Safe synthesized-barrier classification, never runtime payload data. */
|
|
14
|
+
reason?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ReplayDiagnostic {
|
|
17
|
+
kind: "replay" | "resync";
|
|
18
|
+
count: number;
|
|
19
|
+
bytes: number;
|
|
20
|
+
fromSeq?: number;
|
|
21
|
+
toSeq?: number;
|
|
22
|
+
reason?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Return false when this connection can no longer accept frames. */
|
|
6
25
|
export interface SseClient {
|
|
7
|
-
write(chunk: string):
|
|
26
|
+
write(chunk: string, metadata?: SseWriteMetadata): boolean | undefined;
|
|
8
27
|
}
|
|
28
|
+
export interface EventHubOptions {
|
|
29
|
+
/** Maximum number of retained frames. */
|
|
30
|
+
bufferSize?: number;
|
|
31
|
+
/** Maximum encoded bytes retained for reconnect replay. */
|
|
32
|
+
bufferBytes?: number;
|
|
33
|
+
/** Maximum encoded bytes written during a single replay. */
|
|
34
|
+
replayBytes?: number;
|
|
35
|
+
/** Largest projected event frame that may be delivered directly. */
|
|
36
|
+
eventBytes?: number;
|
|
37
|
+
}
|
|
38
|
+
export declare const DEFAULT_BUFFER_SIZE = 2000;
|
|
39
|
+
export declare const DEFAULT_BUFFER_BYTES: number;
|
|
40
|
+
/** Kept below the response destruction ceiling in server.ts. */
|
|
41
|
+
export declare const DEFAULT_REPLAY_BYTES: number;
|
|
42
|
+
export declare const DEFAULT_EVENT_BYTES: number;
|
|
43
|
+
/**
|
|
44
|
+
* Dashboard-only transport projection. Each removed field is cumulative data
|
|
45
|
+
* that the dashboard reducer does not read. Unknown event types are returned
|
|
46
|
+
* exactly as received so extensions and future runtimes remain forward-safe.
|
|
47
|
+
*/
|
|
48
|
+
export declare function projectDashboardEvent(event: Record<string, unknown>): Record<string, unknown>;
|
|
9
49
|
export declare class EventHub {
|
|
10
|
-
private readonly bufferSize;
|
|
11
50
|
private seq;
|
|
51
|
+
private bufferedBytes;
|
|
12
52
|
private readonly buffer;
|
|
13
53
|
private readonly clients;
|
|
14
|
-
|
|
54
|
+
private readonly options;
|
|
55
|
+
constructor(options?: number | EventHubOptions);
|
|
15
56
|
/** Publish an event from a runtime; assigns a sequence number and fans out. */
|
|
16
|
-
publish(key: string,
|
|
57
|
+
publish(key: string, rawEvent: Record<string, unknown>): EventEnvelope;
|
|
17
58
|
/**
|
|
18
|
-
* Attach a client.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* When the requested id has already been evicted from the buffer, or belongs
|
|
22
|
-
* to an older server instance whose sequence is no longer present, a
|
|
23
|
-
* `dashboard_resync` event is sent first — the client must refetch state
|
|
24
|
-
* because the gap cannot be replayed.
|
|
59
|
+
* Attach a client. Replays only a complete, bounded range. A gap or replay
|
|
60
|
+
* over budget receives a recovery frame only on this connection; it never
|
|
61
|
+
* consumes a global sequence or disturbs healthy clients' ordered stream.
|
|
25
62
|
*/
|
|
26
|
-
attach(client: SseClient, lastEventId?: number): () => void;
|
|
63
|
+
attach(client: SseClient, lastEventId?: number, onReplay?: (diagnostic: ReplayDiagnostic) => void): () => void;
|
|
27
64
|
get clientCount(): number;
|
|
65
|
+
get historyBytes(): number;
|
|
66
|
+
get historyCount(): number;
|
|
67
|
+
/** Current sequence, captured without emitting, retaining, or fanning out. */
|
|
68
|
+
get currentSequence(): number;
|
|
69
|
+
private serialize;
|
|
70
|
+
private retain;
|
|
71
|
+
private replayAfter;
|
|
72
|
+
private resyncReason;
|
|
73
|
+
/** Explicit oversized events are globally unrecoverable, so retain and fan out their barrier. */
|
|
74
|
+
private publishResync;
|
|
75
|
+
/**
|
|
76
|
+
* A stale reconnect needs the current ordering cursor, not a new global
|
|
77
|
+
* event. With no events yet, establish sequence 1 so it has a usable cursor.
|
|
78
|
+
*/
|
|
79
|
+
private targetedResync;
|
|
80
|
+
private fanout;
|
|
81
|
+
private write;
|
|
28
82
|
}
|
|
29
83
|
/** Format an envelope as an SSE frame with the sequence number as event id. */
|
|
30
84
|
export declare function formatSseFrame(envelope: EventEnvelope): string;
|
|
85
|
+
/** Observable liveness signal; intentionally unnumbered and never buffered. */
|
|
86
|
+
export declare function formatHeartbeatFrame(): string;
|
|
31
87
|
//# sourceMappingURL=event-hub.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"event-hub.d.ts","sourceRoot":"","sources":["../../src/server/event-hub.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,MAAM,WAAW,SAAS;IACzB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAID,qBAAa,QAAQ;IAKR,OAAO,CAAC,QAAQ,CAAC,UAAU;IAJvC,OAAO,CAAC,GAAG,CAAK;IAChB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAuB;IAC9C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAEhD,YAA6B,UAAU,SAAsB,EAAI;IAEjE,+EAA+E;IAC/E,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,aAAa,CAiBlE;IAED;;;;;;;;OAQG;IACH,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,IAAI,CAsB1D;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;CACD;AAED,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,QAAQ,EAAE,aAAa,GAAG,MAAM,CAE9D","sourcesContent":["/**\n * SSE event hub — fans runtime events out to connected browsers with a bounded\n * ring buffer per hub for Last-Event-ID catch-up after reconnects.\n */\n\nimport type { EventEnvelope } from \"../shared/protocol.js\";\n\nexport interface SseClient {\n\twrite(chunk: string): void;\n}\n\nconst DEFAULT_BUFFER_SIZE = 2000;\n\nexport class EventHub {\n\tprivate seq = 0;\n\tprivate readonly buffer: EventEnvelope[] = [];\n\tprivate readonly clients = new Set<SseClient>();\n\n\tconstructor(private readonly bufferSize = DEFAULT_BUFFER_SIZE) {}\n\n\t/** Publish an event from a runtime; assigns a sequence number and fans out. */\n\tpublish(key: string, event: Record<string, unknown>): EventEnvelope {\n\t\tthis.seq += 1;\n\t\tconst envelope: EventEnvelope = { seq: this.seq, key, event };\n\t\tthis.buffer.push(envelope);\n\t\tif (this.buffer.length > this.bufferSize) {\n\t\t\tthis.buffer.splice(0, this.buffer.length - this.bufferSize);\n\t\t}\n\t\tconst frame = formatSseFrame(envelope);\n\t\tfor (const client of this.clients) {\n\t\t\ttry {\n\t\t\t\tclient.write(frame);\n\t\t\t} catch {\n\t\t\t\t// Dead connections are removed via their close handlers; a write\n\t\t\t\t// failure here must not break the loop for other clients.\n\t\t\t}\n\t\t}\n\t\treturn envelope;\n\t}\n\n\t/**\n\t * Attach a client. When `lastEventId` is provided, buffered events after it\n\t * are replayed first. Returns a detach function.\n\t *\n\t * When the requested id has already been evicted from the buffer, or belongs\n\t * to an older server instance whose sequence is no longer present, a\n\t * `dashboard_resync` event is sent first — the client must refetch state\n\t * because the gap cannot be replayed.\n\t */\n\tattach(client: SseClient, lastEventId?: number): () => void {\n\t\tif (lastEventId !== undefined) {\n\t\t\tconst oldest = this.buffer[0]?.seq;\n\t\t\tconst newest = this.buffer[this.buffer.length - 1]?.seq;\n\t\t\tif (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest) {\n\t\t\t\tthis.seq += 1;\n\t\t\t\tclient.write(\n\t\t\t\t\tformatSseFrame({\n\t\t\t\t\t\tseq: this.seq,\n\t\t\t\t\t\tkey: \"\",\n\t\t\t\t\t\tevent: { type: \"dashboard_resync\", reason: oldest === undefined ? \"empty_buffer\" : \"buffer_gap\" },\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\tfor (const envelope of this.buffer) {\n\t\t\t\tif (envelope.seq > lastEventId) client.write(formatSseFrame(envelope));\n\t\t\t}\n\t\t}\n\t\tthis.clients.add(client);\n\t\treturn () => {\n\t\t\tthis.clients.delete(client);\n\t\t};\n\t}\n\n\tget clientCount(): number {\n\t\treturn this.clients.size;\n\t}\n}\n\n/** Format an envelope as an SSE frame with the sequence number as event id. */\nexport function formatSseFrame(envelope: EventEnvelope): string {\n\treturn `id: ${envelope.seq}\\ndata: ${JSON.stringify(envelope)}\\n\\n`;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"event-hub.d.ts","sourceRoot":"","sources":["../../src/server/event-hub.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * SSE event hub — projects runtime events for the dashboard, keeps a\n * byte-bounded replay history, and fans serialized frames out to browsers.\n */\n\nimport type { EventEnvelope } from \"../shared/protocol.js\";\n\nexport type SseWriteKind = \"live\" | \"replay\" | \"resync\";\n\n/** Metadata deliberately excludes the serialized event payload. */\nexport interface SseWriteMetadata {\n\tkind: SseWriteKind;\n\tseq: number;\n\ttype: string;\n\tframeBytes: number;\n\t/** Safe synthesized-barrier classification, never runtime payload data. */\n\treason?: string;\n}\n\nexport interface ReplayDiagnostic {\n\tkind: \"replay\" | \"resync\";\n\tcount: number;\n\tbytes: number;\n\tfromSeq?: number;\n\ttoSeq?: number;\n\treason?: string;\n}\n\n/** Return false when this connection can no longer accept frames. */\nexport interface SseClient {\n\twrite(chunk: string, metadata?: SseWriteMetadata): boolean | undefined;\n}\n\nexport interface EventHubOptions {\n\t/** Maximum number of retained frames. */\n\tbufferSize?: number;\n\t/** Maximum encoded bytes retained for reconnect replay. */\n\tbufferBytes?: number;\n\t/** Maximum encoded bytes written during a single replay. */\n\treplayBytes?: number;\n\t/** Largest projected event frame that may be delivered directly. */\n\teventBytes?: number;\n}\n\nexport const DEFAULT_BUFFER_SIZE = 2000;\nexport const DEFAULT_BUFFER_BYTES = 8 * 1024 * 1024;\n/** Kept below the response destruction ceiling in server.ts. */\nexport const DEFAULT_REPLAY_BYTES = 3 * 1024 * 1024;\nexport const DEFAULT_EVENT_BYTES = 1024 * 1024;\n\ninterface SerializedEnvelope {\n\tenvelope: EventEnvelope;\n\tframe: string;\n\tbytes: number;\n\t/** Present only for barriers synthesized by this hub, never runtime data. */\n\tresyncReason?: string;\n}\n\nfunction omit<T extends Record<string, unknown>>(event: T, ...keys: string[]): Record<string, unknown> {\n\tconst copy = { ...event };\n\tfor (const key of keys) delete copy[key];\n\treturn copy;\n}\n\n/**\n * Dashboard-only transport projection. Each removed field is cumulative data\n * that the dashboard reducer does not read. Unknown event types are returned\n * exactly as received so extensions and future runtimes remain forward-safe.\n */\nexport function projectDashboardEvent(event: Record<string, unknown>): Record<string, unknown> {\n\tswitch (event.type) {\n\t\tcase \"agent_end\":\n\t\t\treturn omit(event, \"messages\");\n\t\tcase \"turn_end\":\n\t\t\treturn omit(event, \"message\", \"toolResults\");\n\t\tcase \"message_update\":\n\t\t\treturn omit(event, \"message\");\n\t\tcase \"tool_execution_update\":\n\t\t\treturn omit(event, \"args\");\n\t\tcase \"stream_retry\":\n\t\t\treturn omit(event, \"discardedPartial\");\n\t\tcase \"length_retry\":\n\t\t\treturn omit(event, \"discardedPartial\");\n\t\tcase \"background_agent_event\": {\n\t\t\tconst child = event.event;\n\t\t\treturn child && typeof child === \"object\" && !Array.isArray(child)\n\t\t\t\t? { ...event, event: projectDashboardEvent(child as Record<string, unknown>) }\n\t\t\t\t: event;\n\t\t}\n\t\tdefault:\n\t\t\treturn event;\n\t}\n}\n\nexport class EventHub {\n\tprivate seq = 0;\n\tprivate bufferedBytes = 0;\n\tprivate readonly buffer: SerializedEnvelope[] = [];\n\tprivate readonly clients = new Set<SseClient>();\n\tprivate readonly options: Required<EventHubOptions>;\n\n\tconstructor(options: number | EventHubOptions = {}) {\n\t\tthis.options = {\n\t\t\tbufferSize: typeof options === \"number\" ? options : (options.bufferSize ?? DEFAULT_BUFFER_SIZE),\n\t\t\tbufferBytes:\n\t\t\t\ttypeof options === \"number\" ? DEFAULT_BUFFER_BYTES : (options.bufferBytes ?? DEFAULT_BUFFER_BYTES),\n\t\t\treplayBytes:\n\t\t\t\ttypeof options === \"number\" ? DEFAULT_REPLAY_BYTES : (options.replayBytes ?? DEFAULT_REPLAY_BYTES),\n\t\t\teventBytes: typeof options === \"number\" ? DEFAULT_EVENT_BYTES : (options.eventBytes ?? DEFAULT_EVENT_BYTES),\n\t\t};\n\t}\n\n\t/** Publish an event from a runtime; assigns a sequence number and fans out. */\n\tpublish(key: string, rawEvent: Record<string, unknown>): EventEnvelope {\n\t\tconst event = projectDashboardEvent(rawEvent);\n\t\tconst serialized = this.serialize(this.seq + 1, key, event);\n\t\tif (serialized.bytes > this.options.eventBytes) {\n\t\t\treturn this.publishResync(\"oversized_event\").envelope;\n\t\t}\n\t\tthis.seq += 1;\n\t\tthis.retain(serialized);\n\t\tthis.fanout(serialized, \"live\");\n\t\treturn serialized.envelope;\n\t}\n\n\t/**\n\t * Attach a client. Replays only a complete, bounded range. A gap or replay\n\t * over budget receives a recovery frame only on this connection; it never\n\t * consumes a global sequence or disturbs healthy clients' ordered stream.\n\t */\n\tattach(client: SseClient, lastEventId?: number, onReplay?: (diagnostic: ReplayDiagnostic) => void): () => void {\n\t\tif (lastEventId !== undefined) {\n\t\t\tconst replay = this.replayAfter(lastEventId);\n\t\t\tif (!replay) {\n\t\t\t\tconst reason = this.resyncReason(lastEventId);\n\t\t\t\tconst barrier = this.targetedResync(reason);\n\t\t\t\tonReplay?.({ kind: \"resync\", count: 1, bytes: barrier.bytes, toSeq: barrier.envelope.seq, reason });\n\t\t\t\tif (!this.write(client, barrier, \"resync\")) return () => {};\n\t\t\t} else {\n\t\t\t\tconst bytes = replay.reduce((total, item) => total + item.bytes, 0);\n\t\t\t\tonReplay?.({\n\t\t\t\t\tkind: \"replay\",\n\t\t\t\t\tcount: replay.length,\n\t\t\t\t\tbytes,\n\t\t\t\t\tfromSeq: replay[0]?.envelope.seq,\n\t\t\t\t\ttoSeq: replay[replay.length - 1]?.envelope.seq,\n\t\t\t\t});\n\t\t\t\tfor (const serialized of replay) {\n\t\t\t\t\tif (!this.write(client, serialized, \"replay\")) return () => {};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tthis.clients.add(client);\n\t\treturn () => {\n\t\t\tthis.clients.delete(client);\n\t\t};\n\t}\n\n\tget clientCount(): number {\n\t\treturn this.clients.size;\n\t}\n\n\tget historyBytes(): number {\n\t\treturn this.bufferedBytes;\n\t}\n\n\tget historyCount(): number {\n\t\treturn this.buffer.length;\n\t}\n\n\t/** Current sequence, captured without emitting, retaining, or fanning out. */\n\tget currentSequence(): number {\n\t\treturn this.seq;\n\t}\n\n\tprivate serialize(seq: number, key: string, event: Record<string, unknown>): SerializedEnvelope {\n\t\tconst envelope: EventEnvelope = { seq, key, event };\n\t\tconst frame = formatSseFrame(envelope);\n\t\treturn { envelope, frame, bytes: Buffer.byteLength(frame) };\n\t}\n\n\tprivate retain(serialized: SerializedEnvelope): void {\n\t\tthis.buffer.push(serialized);\n\t\tthis.bufferedBytes += serialized.bytes;\n\t\twhile (this.buffer.length > this.options.bufferSize || this.bufferedBytes > this.options.bufferBytes) {\n\t\t\tconst evicted = this.buffer.shift();\n\t\t\tif (!evicted) break;\n\t\t\tthis.bufferedBytes -= evicted.bytes;\n\t\t}\n\t}\n\n\tprivate replayAfter(lastEventId: number): SerializedEnvelope[] | undefined {\n\t\tconst oldest = this.buffer[0]?.envelope.seq;\n\t\tconst newest = this.buffer[this.buffer.length - 1]?.envelope.seq;\n\t\tif (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest)\n\t\t\treturn undefined;\n\t\tconst replay = this.buffer.filter((item) => item.envelope.seq > lastEventId);\n\t\treturn replay.reduce((bytes, item) => bytes + item.bytes, 0) <= this.options.replayBytes ? replay : undefined;\n\t}\n\n\tprivate resyncReason(lastEventId: number): string {\n\t\tconst oldest = this.buffer[0]?.envelope.seq;\n\t\tconst newest = this.buffer[this.buffer.length - 1]?.envelope.seq;\n\t\tif (oldest === undefined) return \"empty_buffer\";\n\t\tif (lastEventId < oldest - 1 || lastEventId > newest!) return \"buffer_gap\";\n\t\treturn \"replay_over_budget\";\n\t}\n\n\t/** Explicit oversized events are globally unrecoverable, so retain and fan out their barrier. */\n\tprivate publishResync(reason: string): SerializedEnvelope {\n\t\tthis.seq += 1;\n\t\tconst barrier = this.serialize(this.seq, \"\", { type: \"dashboard_resync\", reason });\n\t\tbarrier.resyncReason = reason;\n\t\tthis.retain(barrier);\n\t\tthis.fanout(barrier, \"resync\");\n\t\treturn barrier;\n\t}\n\n\t/**\n\t * A stale reconnect needs the current ordering cursor, not a new global\n\t * event. With no events yet, establish sequence 1 so it has a usable cursor.\n\t */\n\tprivate targetedResync(reason: string): SerializedEnvelope {\n\t\tif (this.seq === 0) this.seq = 1;\n\t\tconst barrier = this.serialize(this.seq, \"\", { type: \"dashboard_resync\", reason });\n\t\tbarrier.resyncReason = reason;\n\t\treturn barrier;\n\t}\n\n\tprivate fanout(serialized: SerializedEnvelope, kind: SseWriteKind): void {\n\t\tfor (const client of this.clients) {\n\t\t\tif (!this.write(client, serialized, kind)) this.clients.delete(client);\n\t\t}\n\t}\n\n\tprivate write(client: SseClient, serialized: SerializedEnvelope, kind: SseWriteKind): boolean {\n\t\ttry {\n\t\t\treturn (\n\t\t\t\tclient.write(serialized.frame, {\n\t\t\t\t\tkind,\n\t\t\t\t\tseq: serialized.envelope.seq,\n\t\t\t\t\ttype: String(serialized.envelope.event.type ?? \"unknown\"),\n\t\t\t\t\tframeBytes: serialized.bytes,\n\t\t\t\t\t...(serialized.resyncReason ? { reason: serialized.resyncReason } : {}),\n\t\t\t\t}) !== false\n\t\t\t);\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n}\n\n/** Format an envelope as an SSE frame with the sequence number as event id. */\nexport function formatSseFrame(envelope: EventEnvelope): string {\n\treturn `id: ${envelope.seq}\\ndata: ${JSON.stringify(envelope)}\\n\\n`;\n}\n\n/** Observable liveness signal; intentionally unnumbered and never buffered. */\nexport function formatHeartbeatFrame(): string {\n\treturn \"event: heartbeat\\ndata: {}\\n\\n\";\n}\n"]}
|
package/dist/server/event-hub.js
CHANGED
|
@@ -1,60 +1,101 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SSE event hub —
|
|
3
|
-
*
|
|
2
|
+
* SSE event hub — projects runtime events for the dashboard, keeps a
|
|
3
|
+
* byte-bounded replay history, and fans serialized frames out to browsers.
|
|
4
4
|
*/
|
|
5
|
-
const DEFAULT_BUFFER_SIZE = 2000;
|
|
5
|
+
export const DEFAULT_BUFFER_SIZE = 2000;
|
|
6
|
+
export const DEFAULT_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
/** Kept below the response destruction ceiling in server.ts. */
|
|
8
|
+
export const DEFAULT_REPLAY_BYTES = 3 * 1024 * 1024;
|
|
9
|
+
export const DEFAULT_EVENT_BYTES = 1024 * 1024;
|
|
10
|
+
function omit(event, ...keys) {
|
|
11
|
+
const copy = { ...event };
|
|
12
|
+
for (const key of keys)
|
|
13
|
+
delete copy[key];
|
|
14
|
+
return copy;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Dashboard-only transport projection. Each removed field is cumulative data
|
|
18
|
+
* that the dashboard reducer does not read. Unknown event types are returned
|
|
19
|
+
* exactly as received so extensions and future runtimes remain forward-safe.
|
|
20
|
+
*/
|
|
21
|
+
export function projectDashboardEvent(event) {
|
|
22
|
+
switch (event.type) {
|
|
23
|
+
case "agent_end":
|
|
24
|
+
return omit(event, "messages");
|
|
25
|
+
case "turn_end":
|
|
26
|
+
return omit(event, "message", "toolResults");
|
|
27
|
+
case "message_update":
|
|
28
|
+
return omit(event, "message");
|
|
29
|
+
case "tool_execution_update":
|
|
30
|
+
return omit(event, "args");
|
|
31
|
+
case "stream_retry":
|
|
32
|
+
return omit(event, "discardedPartial");
|
|
33
|
+
case "length_retry":
|
|
34
|
+
return omit(event, "discardedPartial");
|
|
35
|
+
case "background_agent_event": {
|
|
36
|
+
const child = event.event;
|
|
37
|
+
return child && typeof child === "object" && !Array.isArray(child)
|
|
38
|
+
? { ...event, event: projectDashboardEvent(child) }
|
|
39
|
+
: event;
|
|
40
|
+
}
|
|
41
|
+
default:
|
|
42
|
+
return event;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
6
45
|
export class EventHub {
|
|
7
|
-
bufferSize;
|
|
8
46
|
seq = 0;
|
|
47
|
+
bufferedBytes = 0;
|
|
9
48
|
buffer = [];
|
|
10
49
|
clients = new Set();
|
|
11
|
-
|
|
12
|
-
|
|
50
|
+
options;
|
|
51
|
+
constructor(options = {}) {
|
|
52
|
+
this.options = {
|
|
53
|
+
bufferSize: typeof options === "number" ? options : (options.bufferSize ?? DEFAULT_BUFFER_SIZE),
|
|
54
|
+
bufferBytes: typeof options === "number" ? DEFAULT_BUFFER_BYTES : (options.bufferBytes ?? DEFAULT_BUFFER_BYTES),
|
|
55
|
+
replayBytes: typeof options === "number" ? DEFAULT_REPLAY_BYTES : (options.replayBytes ?? DEFAULT_REPLAY_BYTES),
|
|
56
|
+
eventBytes: typeof options === "number" ? DEFAULT_EVENT_BYTES : (options.eventBytes ?? DEFAULT_EVENT_BYTES),
|
|
57
|
+
};
|
|
13
58
|
}
|
|
14
59
|
/** Publish an event from a runtime; assigns a sequence number and fans out. */
|
|
15
|
-
publish(key,
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
this.
|
|
19
|
-
|
|
20
|
-
this.buffer.splice(0, this.buffer.length - this.bufferSize);
|
|
21
|
-
}
|
|
22
|
-
const frame = formatSseFrame(envelope);
|
|
23
|
-
for (const client of this.clients) {
|
|
24
|
-
try {
|
|
25
|
-
client.write(frame);
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
// Dead connections are removed via their close handlers; a write
|
|
29
|
-
// failure here must not break the loop for other clients.
|
|
30
|
-
}
|
|
60
|
+
publish(key, rawEvent) {
|
|
61
|
+
const event = projectDashboardEvent(rawEvent);
|
|
62
|
+
const serialized = this.serialize(this.seq + 1, key, event);
|
|
63
|
+
if (serialized.bytes > this.options.eventBytes) {
|
|
64
|
+
return this.publishResync("oversized_event").envelope;
|
|
31
65
|
}
|
|
32
|
-
|
|
66
|
+
this.seq += 1;
|
|
67
|
+
this.retain(serialized);
|
|
68
|
+
this.fanout(serialized, "live");
|
|
69
|
+
return serialized.envelope;
|
|
33
70
|
}
|
|
34
71
|
/**
|
|
35
|
-
* Attach a client.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* When the requested id has already been evicted from the buffer, or belongs
|
|
39
|
-
* to an older server instance whose sequence is no longer present, a
|
|
40
|
-
* `dashboard_resync` event is sent first — the client must refetch state
|
|
41
|
-
* because the gap cannot be replayed.
|
|
72
|
+
* Attach a client. Replays only a complete, bounded range. A gap or replay
|
|
73
|
+
* over budget receives a recovery frame only on this connection; it never
|
|
74
|
+
* consumes a global sequence or disturbs healthy clients' ordered stream.
|
|
42
75
|
*/
|
|
43
|
-
attach(client, lastEventId) {
|
|
76
|
+
attach(client, lastEventId, onReplay) {
|
|
44
77
|
if (lastEventId !== undefined) {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
this.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
event: { type: "dashboard_resync", reason: oldest === undefined ? "empty_buffer" : "buffer_gap" },
|
|
53
|
-
}));
|
|
78
|
+
const replay = this.replayAfter(lastEventId);
|
|
79
|
+
if (!replay) {
|
|
80
|
+
const reason = this.resyncReason(lastEventId);
|
|
81
|
+
const barrier = this.targetedResync(reason);
|
|
82
|
+
onReplay?.({ kind: "resync", count: 1, bytes: barrier.bytes, toSeq: barrier.envelope.seq, reason });
|
|
83
|
+
if (!this.write(client, barrier, "resync"))
|
|
84
|
+
return () => { };
|
|
54
85
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
86
|
+
else {
|
|
87
|
+
const bytes = replay.reduce((total, item) => total + item.bytes, 0);
|
|
88
|
+
onReplay?.({
|
|
89
|
+
kind: "replay",
|
|
90
|
+
count: replay.length,
|
|
91
|
+
bytes,
|
|
92
|
+
fromSeq: replay[0]?.envelope.seq,
|
|
93
|
+
toSeq: replay[replay.length - 1]?.envelope.seq,
|
|
94
|
+
});
|
|
95
|
+
for (const serialized of replay) {
|
|
96
|
+
if (!this.write(client, serialized, "replay"))
|
|
97
|
+
return () => { };
|
|
98
|
+
}
|
|
58
99
|
}
|
|
59
100
|
}
|
|
60
101
|
this.clients.add(client);
|
|
@@ -65,9 +106,95 @@ export class EventHub {
|
|
|
65
106
|
get clientCount() {
|
|
66
107
|
return this.clients.size;
|
|
67
108
|
}
|
|
109
|
+
get historyBytes() {
|
|
110
|
+
return this.bufferedBytes;
|
|
111
|
+
}
|
|
112
|
+
get historyCount() {
|
|
113
|
+
return this.buffer.length;
|
|
114
|
+
}
|
|
115
|
+
/** Current sequence, captured without emitting, retaining, or fanning out. */
|
|
116
|
+
get currentSequence() {
|
|
117
|
+
return this.seq;
|
|
118
|
+
}
|
|
119
|
+
serialize(seq, key, event) {
|
|
120
|
+
const envelope = { seq, key, event };
|
|
121
|
+
const frame = formatSseFrame(envelope);
|
|
122
|
+
return { envelope, frame, bytes: Buffer.byteLength(frame) };
|
|
123
|
+
}
|
|
124
|
+
retain(serialized) {
|
|
125
|
+
this.buffer.push(serialized);
|
|
126
|
+
this.bufferedBytes += serialized.bytes;
|
|
127
|
+
while (this.buffer.length > this.options.bufferSize || this.bufferedBytes > this.options.bufferBytes) {
|
|
128
|
+
const evicted = this.buffer.shift();
|
|
129
|
+
if (!evicted)
|
|
130
|
+
break;
|
|
131
|
+
this.bufferedBytes -= evicted.bytes;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
replayAfter(lastEventId) {
|
|
135
|
+
const oldest = this.buffer[0]?.envelope.seq;
|
|
136
|
+
const newest = this.buffer[this.buffer.length - 1]?.envelope.seq;
|
|
137
|
+
if (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest)
|
|
138
|
+
return undefined;
|
|
139
|
+
const replay = this.buffer.filter((item) => item.envelope.seq > lastEventId);
|
|
140
|
+
return replay.reduce((bytes, item) => bytes + item.bytes, 0) <= this.options.replayBytes ? replay : undefined;
|
|
141
|
+
}
|
|
142
|
+
resyncReason(lastEventId) {
|
|
143
|
+
const oldest = this.buffer[0]?.envelope.seq;
|
|
144
|
+
const newest = this.buffer[this.buffer.length - 1]?.envelope.seq;
|
|
145
|
+
if (oldest === undefined)
|
|
146
|
+
return "empty_buffer";
|
|
147
|
+
if (lastEventId < oldest - 1 || lastEventId > newest)
|
|
148
|
+
return "buffer_gap";
|
|
149
|
+
return "replay_over_budget";
|
|
150
|
+
}
|
|
151
|
+
/** Explicit oversized events are globally unrecoverable, so retain and fan out their barrier. */
|
|
152
|
+
publishResync(reason) {
|
|
153
|
+
this.seq += 1;
|
|
154
|
+
const barrier = this.serialize(this.seq, "", { type: "dashboard_resync", reason });
|
|
155
|
+
barrier.resyncReason = reason;
|
|
156
|
+
this.retain(barrier);
|
|
157
|
+
this.fanout(barrier, "resync");
|
|
158
|
+
return barrier;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* A stale reconnect needs the current ordering cursor, not a new global
|
|
162
|
+
* event. With no events yet, establish sequence 1 so it has a usable cursor.
|
|
163
|
+
*/
|
|
164
|
+
targetedResync(reason) {
|
|
165
|
+
if (this.seq === 0)
|
|
166
|
+
this.seq = 1;
|
|
167
|
+
const barrier = this.serialize(this.seq, "", { type: "dashboard_resync", reason });
|
|
168
|
+
barrier.resyncReason = reason;
|
|
169
|
+
return barrier;
|
|
170
|
+
}
|
|
171
|
+
fanout(serialized, kind) {
|
|
172
|
+
for (const client of this.clients) {
|
|
173
|
+
if (!this.write(client, serialized, kind))
|
|
174
|
+
this.clients.delete(client);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
write(client, serialized, kind) {
|
|
178
|
+
try {
|
|
179
|
+
return (client.write(serialized.frame, {
|
|
180
|
+
kind,
|
|
181
|
+
seq: serialized.envelope.seq,
|
|
182
|
+
type: String(serialized.envelope.event.type ?? "unknown"),
|
|
183
|
+
frameBytes: serialized.bytes,
|
|
184
|
+
...(serialized.resyncReason ? { reason: serialized.resyncReason } : {}),
|
|
185
|
+
}) !== false);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
68
191
|
}
|
|
69
192
|
/** Format an envelope as an SSE frame with the sequence number as event id. */
|
|
70
193
|
export function formatSseFrame(envelope) {
|
|
71
194
|
return `id: ${envelope.seq}\ndata: ${JSON.stringify(envelope)}\n\n`;
|
|
72
195
|
}
|
|
196
|
+
/** Observable liveness signal; intentionally unnumbered and never buffered. */
|
|
197
|
+
export function formatHeartbeatFrame() {
|
|
198
|
+
return "event: heartbeat\ndata: {}\n\n";
|
|
199
|
+
}
|
|
73
200
|
//# sourceMappingURL=event-hub.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"event-hub.js","sourceRoot":"","sources":["../../src/server/event-hub.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * SSE event hub — fans runtime events out to connected browsers with a bounded\n * ring buffer per hub for Last-Event-ID catch-up after reconnects.\n */\n\nimport type { EventEnvelope } from \"../shared/protocol.js\";\n\nexport interface SseClient {\n\twrite(chunk: string): void;\n}\n\nconst DEFAULT_BUFFER_SIZE = 2000;\n\nexport class EventHub {\n\tprivate seq = 0;\n\tprivate readonly buffer: EventEnvelope[] = [];\n\tprivate readonly clients = new Set<SseClient>();\n\n\tconstructor(private readonly bufferSize = DEFAULT_BUFFER_SIZE) {}\n\n\t/** Publish an event from a runtime; assigns a sequence number and fans out. */\n\tpublish(key: string, event: Record<string, unknown>): EventEnvelope {\n\t\tthis.seq += 1;\n\t\tconst envelope: EventEnvelope = { seq: this.seq, key, event };\n\t\tthis.buffer.push(envelope);\n\t\tif (this.buffer.length > this.bufferSize) {\n\t\t\tthis.buffer.splice(0, this.buffer.length - this.bufferSize);\n\t\t}\n\t\tconst frame = formatSseFrame(envelope);\n\t\tfor (const client of this.clients) {\n\t\t\ttry {\n\t\t\t\tclient.write(frame);\n\t\t\t} catch {\n\t\t\t\t// Dead connections are removed via their close handlers; a write\n\t\t\t\t// failure here must not break the loop for other clients.\n\t\t\t}\n\t\t}\n\t\treturn envelope;\n\t}\n\n\t/**\n\t * Attach a client. When `lastEventId` is provided, buffered events after it\n\t * are replayed first. Returns a detach function.\n\t *\n\t * When the requested id has already been evicted from the buffer, or belongs\n\t * to an older server instance whose sequence is no longer present, a\n\t * `dashboard_resync` event is sent first — the client must refetch state\n\t * because the gap cannot be replayed.\n\t */\n\tattach(client: SseClient, lastEventId?: number): () => void {\n\t\tif (lastEventId !== undefined) {\n\t\t\tconst oldest = this.buffer[0]?.seq;\n\t\t\tconst newest = this.buffer[this.buffer.length - 1]?.seq;\n\t\t\tif (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest) {\n\t\t\t\tthis.seq += 1;\n\t\t\t\tclient.write(\n\t\t\t\t\tformatSseFrame({\n\t\t\t\t\t\tseq: this.seq,\n\t\t\t\t\t\tkey: \"\",\n\t\t\t\t\t\tevent: { type: \"dashboard_resync\", reason: oldest === undefined ? \"empty_buffer\" : \"buffer_gap\" },\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\t\t\tfor (const envelope of this.buffer) {\n\t\t\t\tif (envelope.seq > lastEventId) client.write(formatSseFrame(envelope));\n\t\t\t}\n\t\t}\n\t\tthis.clients.add(client);\n\t\treturn () => {\n\t\t\tthis.clients.delete(client);\n\t\t};\n\t}\n\n\tget clientCount(): number {\n\t\treturn this.clients.size;\n\t}\n}\n\n/** Format an envelope as an SSE frame with the sequence number as event id. */\nexport function formatSseFrame(envelope: EventEnvelope): string {\n\treturn `id: ${envelope.seq}\\ndata: ${JSON.stringify(envelope)}\\n\\n`;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"event-hub.js","sourceRoot":"","sources":["../../src/server/event-hub.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * SSE event hub — projects runtime events for the dashboard, keeps a\n * byte-bounded replay history, and fans serialized frames out to browsers.\n */\n\nimport type { EventEnvelope } from \"../shared/protocol.js\";\n\nexport type SseWriteKind = \"live\" | \"replay\" | \"resync\";\n\n/** Metadata deliberately excludes the serialized event payload. */\nexport interface SseWriteMetadata {\n\tkind: SseWriteKind;\n\tseq: number;\n\ttype: string;\n\tframeBytes: number;\n\t/** Safe synthesized-barrier classification, never runtime payload data. */\n\treason?: string;\n}\n\nexport interface ReplayDiagnostic {\n\tkind: \"replay\" | \"resync\";\n\tcount: number;\n\tbytes: number;\n\tfromSeq?: number;\n\ttoSeq?: number;\n\treason?: string;\n}\n\n/** Return false when this connection can no longer accept frames. */\nexport interface SseClient {\n\twrite(chunk: string, metadata?: SseWriteMetadata): boolean | undefined;\n}\n\nexport interface EventHubOptions {\n\t/** Maximum number of retained frames. */\n\tbufferSize?: number;\n\t/** Maximum encoded bytes retained for reconnect replay. */\n\tbufferBytes?: number;\n\t/** Maximum encoded bytes written during a single replay. */\n\treplayBytes?: number;\n\t/** Largest projected event frame that may be delivered directly. */\n\teventBytes?: number;\n}\n\nexport const DEFAULT_BUFFER_SIZE = 2000;\nexport const DEFAULT_BUFFER_BYTES = 8 * 1024 * 1024;\n/** Kept below the response destruction ceiling in server.ts. */\nexport const DEFAULT_REPLAY_BYTES = 3 * 1024 * 1024;\nexport const DEFAULT_EVENT_BYTES = 1024 * 1024;\n\ninterface SerializedEnvelope {\n\tenvelope: EventEnvelope;\n\tframe: string;\n\tbytes: number;\n\t/** Present only for barriers synthesized by this hub, never runtime data. */\n\tresyncReason?: string;\n}\n\nfunction omit<T extends Record<string, unknown>>(event: T, ...keys: string[]): Record<string, unknown> {\n\tconst copy = { ...event };\n\tfor (const key of keys) delete copy[key];\n\treturn copy;\n}\n\n/**\n * Dashboard-only transport projection. Each removed field is cumulative data\n * that the dashboard reducer does not read. Unknown event types are returned\n * exactly as received so extensions and future runtimes remain forward-safe.\n */\nexport function projectDashboardEvent(event: Record<string, unknown>): Record<string, unknown> {\n\tswitch (event.type) {\n\t\tcase \"agent_end\":\n\t\t\treturn omit(event, \"messages\");\n\t\tcase \"turn_end\":\n\t\t\treturn omit(event, \"message\", \"toolResults\");\n\t\tcase \"message_update\":\n\t\t\treturn omit(event, \"message\");\n\t\tcase \"tool_execution_update\":\n\t\t\treturn omit(event, \"args\");\n\t\tcase \"stream_retry\":\n\t\t\treturn omit(event, \"discardedPartial\");\n\t\tcase \"length_retry\":\n\t\t\treturn omit(event, \"discardedPartial\");\n\t\tcase \"background_agent_event\": {\n\t\t\tconst child = event.event;\n\t\t\treturn child && typeof child === \"object\" && !Array.isArray(child)\n\t\t\t\t? { ...event, event: projectDashboardEvent(child as Record<string, unknown>) }\n\t\t\t\t: event;\n\t\t}\n\t\tdefault:\n\t\t\treturn event;\n\t}\n}\n\nexport class EventHub {\n\tprivate seq = 0;\n\tprivate bufferedBytes = 0;\n\tprivate readonly buffer: SerializedEnvelope[] = [];\n\tprivate readonly clients = new Set<SseClient>();\n\tprivate readonly options: Required<EventHubOptions>;\n\n\tconstructor(options: number | EventHubOptions = {}) {\n\t\tthis.options = {\n\t\t\tbufferSize: typeof options === \"number\" ? options : (options.bufferSize ?? DEFAULT_BUFFER_SIZE),\n\t\t\tbufferBytes:\n\t\t\t\ttypeof options === \"number\" ? DEFAULT_BUFFER_BYTES : (options.bufferBytes ?? DEFAULT_BUFFER_BYTES),\n\t\t\treplayBytes:\n\t\t\t\ttypeof options === \"number\" ? DEFAULT_REPLAY_BYTES : (options.replayBytes ?? DEFAULT_REPLAY_BYTES),\n\t\t\teventBytes: typeof options === \"number\" ? DEFAULT_EVENT_BYTES : (options.eventBytes ?? DEFAULT_EVENT_BYTES),\n\t\t};\n\t}\n\n\t/** Publish an event from a runtime; assigns a sequence number and fans out. */\n\tpublish(key: string, rawEvent: Record<string, unknown>): EventEnvelope {\n\t\tconst event = projectDashboardEvent(rawEvent);\n\t\tconst serialized = this.serialize(this.seq + 1, key, event);\n\t\tif (serialized.bytes > this.options.eventBytes) {\n\t\t\treturn this.publishResync(\"oversized_event\").envelope;\n\t\t}\n\t\tthis.seq += 1;\n\t\tthis.retain(serialized);\n\t\tthis.fanout(serialized, \"live\");\n\t\treturn serialized.envelope;\n\t}\n\n\t/**\n\t * Attach a client. Replays only a complete, bounded range. A gap or replay\n\t * over budget receives a recovery frame only on this connection; it never\n\t * consumes a global sequence or disturbs healthy clients' ordered stream.\n\t */\n\tattach(client: SseClient, lastEventId?: number, onReplay?: (diagnostic: ReplayDiagnostic) => void): () => void {\n\t\tif (lastEventId !== undefined) {\n\t\t\tconst replay = this.replayAfter(lastEventId);\n\t\t\tif (!replay) {\n\t\t\t\tconst reason = this.resyncReason(lastEventId);\n\t\t\t\tconst barrier = this.targetedResync(reason);\n\t\t\t\tonReplay?.({ kind: \"resync\", count: 1, bytes: barrier.bytes, toSeq: barrier.envelope.seq, reason });\n\t\t\t\tif (!this.write(client, barrier, \"resync\")) return () => {};\n\t\t\t} else {\n\t\t\t\tconst bytes = replay.reduce((total, item) => total + item.bytes, 0);\n\t\t\t\tonReplay?.({\n\t\t\t\t\tkind: \"replay\",\n\t\t\t\t\tcount: replay.length,\n\t\t\t\t\tbytes,\n\t\t\t\t\tfromSeq: replay[0]?.envelope.seq,\n\t\t\t\t\ttoSeq: replay[replay.length - 1]?.envelope.seq,\n\t\t\t\t});\n\t\t\t\tfor (const serialized of replay) {\n\t\t\t\t\tif (!this.write(client, serialized, \"replay\")) return () => {};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tthis.clients.add(client);\n\t\treturn () => {\n\t\t\tthis.clients.delete(client);\n\t\t};\n\t}\n\n\tget clientCount(): number {\n\t\treturn this.clients.size;\n\t}\n\n\tget historyBytes(): number {\n\t\treturn this.bufferedBytes;\n\t}\n\n\tget historyCount(): number {\n\t\treturn this.buffer.length;\n\t}\n\n\t/** Current sequence, captured without emitting, retaining, or fanning out. */\n\tget currentSequence(): number {\n\t\treturn this.seq;\n\t}\n\n\tprivate serialize(seq: number, key: string, event: Record<string, unknown>): SerializedEnvelope {\n\t\tconst envelope: EventEnvelope = { seq, key, event };\n\t\tconst frame = formatSseFrame(envelope);\n\t\treturn { envelope, frame, bytes: Buffer.byteLength(frame) };\n\t}\n\n\tprivate retain(serialized: SerializedEnvelope): void {\n\t\tthis.buffer.push(serialized);\n\t\tthis.bufferedBytes += serialized.bytes;\n\t\twhile (this.buffer.length > this.options.bufferSize || this.bufferedBytes > this.options.bufferBytes) {\n\t\t\tconst evicted = this.buffer.shift();\n\t\t\tif (!evicted) break;\n\t\t\tthis.bufferedBytes -= evicted.bytes;\n\t\t}\n\t}\n\n\tprivate replayAfter(lastEventId: number): SerializedEnvelope[] | undefined {\n\t\tconst oldest = this.buffer[0]?.envelope.seq;\n\t\tconst newest = this.buffer[this.buffer.length - 1]?.envelope.seq;\n\t\tif (oldest === undefined || newest === undefined || lastEventId < oldest - 1 || lastEventId > newest)\n\t\t\treturn undefined;\n\t\tconst replay = this.buffer.filter((item) => item.envelope.seq > lastEventId);\n\t\treturn replay.reduce((bytes, item) => bytes + item.bytes, 0) <= this.options.replayBytes ? replay : undefined;\n\t}\n\n\tprivate resyncReason(lastEventId: number): string {\n\t\tconst oldest = this.buffer[0]?.envelope.seq;\n\t\tconst newest = this.buffer[this.buffer.length - 1]?.envelope.seq;\n\t\tif (oldest === undefined) return \"empty_buffer\";\n\t\tif (lastEventId < oldest - 1 || lastEventId > newest!) return \"buffer_gap\";\n\t\treturn \"replay_over_budget\";\n\t}\n\n\t/** Explicit oversized events are globally unrecoverable, so retain and fan out their barrier. */\n\tprivate publishResync(reason: string): SerializedEnvelope {\n\t\tthis.seq += 1;\n\t\tconst barrier = this.serialize(this.seq, \"\", { type: \"dashboard_resync\", reason });\n\t\tbarrier.resyncReason = reason;\n\t\tthis.retain(barrier);\n\t\tthis.fanout(barrier, \"resync\");\n\t\treturn barrier;\n\t}\n\n\t/**\n\t * A stale reconnect needs the current ordering cursor, not a new global\n\t * event. With no events yet, establish sequence 1 so it has a usable cursor.\n\t */\n\tprivate targetedResync(reason: string): SerializedEnvelope {\n\t\tif (this.seq === 0) this.seq = 1;\n\t\tconst barrier = this.serialize(this.seq, \"\", { type: \"dashboard_resync\", reason });\n\t\tbarrier.resyncReason = reason;\n\t\treturn barrier;\n\t}\n\n\tprivate fanout(serialized: SerializedEnvelope, kind: SseWriteKind): void {\n\t\tfor (const client of this.clients) {\n\t\t\tif (!this.write(client, serialized, kind)) this.clients.delete(client);\n\t\t}\n\t}\n\n\tprivate write(client: SseClient, serialized: SerializedEnvelope, kind: SseWriteKind): boolean {\n\t\ttry {\n\t\t\treturn (\n\t\t\t\tclient.write(serialized.frame, {\n\t\t\t\t\tkind,\n\t\t\t\t\tseq: serialized.envelope.seq,\n\t\t\t\t\ttype: String(serialized.envelope.event.type ?? \"unknown\"),\n\t\t\t\t\tframeBytes: serialized.bytes,\n\t\t\t\t\t...(serialized.resyncReason ? { reason: serialized.resyncReason } : {}),\n\t\t\t\t}) !== false\n\t\t\t);\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n}\n\n/** Format an envelope as an SSE frame with the sequence number as event id. */\nexport function formatSseFrame(envelope: EventEnvelope): string {\n\treturn `id: ${envelope.seq}\\ndata: ${JSON.stringify(envelope)}\\n\\n`;\n}\n\n/** Observable liveness signal; intentionally unnumbered and never buffered. */\nexport function formatHeartbeatFrame(): string {\n\treturn \"event: heartbeat\\ndata: {}\\n\\n\";\n}\n"]}
|
package/dist/server/files.d.ts
CHANGED
|
@@ -22,7 +22,9 @@ export declare function canonicalizePath(raw: string, opts: {
|
|
|
22
22
|
export declare class FileApi {
|
|
23
23
|
private readonly log;
|
|
24
24
|
constructor(log: FileOpLogger);
|
|
25
|
-
|
|
25
|
+
/** Resolve an existing directory for listing and context-trust RPC operations. */
|
|
26
|
+
resolveDirectory(rawPath: string): Promise<string>;
|
|
27
|
+
list(rawPath: string): Promise<Omit<DirListingDto, "contextTrust">>;
|
|
26
28
|
/** Resolve a download target; returns the canonical path (must be a file). */
|
|
27
29
|
resolveDownload(rawPath: string): Promise<{
|
|
28
30
|
path: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../src/server/files.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,uBAAuB,CAAC;AAEzE,MAAM,MAAM,YAAY,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;AAQtF;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAwCjG;AAED,qBAAa,OAAO;IACP,OAAO,CAAC,QAAQ,CAAC,GAAG;IAAhC,YAA6B,GAAG,EAAE,YAAY,EAAI;
|
|
1
|
+
{"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../src/server/files.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,KAAK,EAAE,aAAa,EAAgB,MAAM,uBAAuB,CAAC;AAEzE,MAAM,MAAM,YAAY,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;AAQtF;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAwCjG;AAED,qBAAa,OAAO;IACP,OAAO,CAAC,QAAQ,CAAC,GAAG;IAAhC,YAA6B,GAAG,EAAE,YAAY,EAAI;IAElD,kFAAkF;IAC5E,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAOvD;IAEK,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,aAAa,EAAE,cAAc,CAAC,CAAC,CA2BxE;IAED,8EAA8E;IACxE,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAQ9E;IAED;;;OAGG;IACG,aAAa,CAClB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,OAAO,GAChB,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,QAAQ,CAAC;QAAC,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;QAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,CAAC,CAkDxG;IAEK,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAOzD;CACD;AAED,kDAAkD;AAClD,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAS7G","sourcesContent":["/**\n * Host-wide file API — browse, download, upload, mkdir.\n *\n * The dashboard is a trusted-operator surface: a paired device\n * already equals terminal access, so there is no project jail. Path handling\n * still canonicalizes and rejects confusion tricks so the *API* cannot be\n * abused: percent-decode checks, null-byte rejection, symlink resolution to a\n * real absolute path, and server-side logging of every operation.\n */\n\nimport { randomBytes } from \"node:crypto\";\nimport { link, mkdir, open, readdir, realpath, rename, stat, unlink } from \"node:fs/promises\";\nimport { dirname, isAbsolute, join, normalize, resolve, sep } from \"node:path\";\nimport type { Writable } from \"node:stream\";\nimport type { DirListingDto, FileEntryDto } from \"../shared/protocol.js\";\n\nexport type FileOpLogger = (operation: string, path: string, detail?: string) => void;\n\nfunction assertValidChildName(name: string, label: string): void {\n\tif (!name || name.includes(\"/\") || name.includes(\"\\\\\") || name.includes(\"\\0\") || name === \".\" || name === \"..\") {\n\t\tthrow Object.assign(new Error(`Invalid ${label}: ${name}`), { status: 400 });\n\t}\n}\n\n/**\n * Canonicalize a client-supplied path. Throws (status 400) on anything\n * suspicious rather than guessing. Returns the resolved absolute path with\n * symlinks in the *parent* chain resolved (the leaf may not exist yet for\n * writes, so its parent is what gets realpath'd).\n */\nexport async function canonicalizePath(raw: string, opts: { mustExist: boolean }): Promise<string> {\n\tif (typeof raw !== \"string\" || raw.length === 0) {\n\t\tthrow Object.assign(new Error(\"Path is required\"), { status: 400 });\n\t}\n\tif (raw.includes(\"\\0\")) {\n\t\tthrow Object.assign(new Error(\"Path contains a null byte\"), { status: 400 });\n\t}\n\t// Reject lingering percent-encodings that survived normal URL decoding —\n\t// double-encoding is a classic canonicalization-confusion vector.\n\tif (/%2e|%2f|%5c|%00/i.test(raw)) {\n\t\tthrow Object.assign(new Error(\"Path contains percent-encoded traversal sequences\"), { status: 400 });\n\t}\n\tif (!isAbsolute(raw)) {\n\t\tthrow Object.assign(new Error(\"Path must be absolute\"), { status: 400 });\n\t}\n\tconst normalized = normalize(raw);\n\n\tif (opts.mustExist) {\n\t\ttry {\n\t\t\treturn await realpath(normalized);\n\t\t} catch (err) {\n\t\t\tthrow Object.assign(new Error(`Path does not exist or is unreadable: ${normalized}`), {\n\t\t\t\tstatus: 404,\n\t\t\t\tcause: err,\n\t\t\t});\n\t\t}\n\t}\n\t// For creation targets: resolve the parent, keep the leaf name literal.\n\tconst parent = dirname(normalized);\n\tlet realParent: string;\n\ttry {\n\t\trealParent = await realpath(parent);\n\t} catch (err) {\n\t\tthrow Object.assign(new Error(`Parent directory does not exist: ${parent}`), { status: 404, cause: err });\n\t}\n\tconst leaf = normalized.slice(parent === sep ? 1 : parent.length + 1);\n\tif (!leaf || leaf.includes(sep) || leaf === \".\" || leaf === \"..\") {\n\t\tthrow Object.assign(new Error(`Invalid file name: ${leaf || \"(empty)\"}`), { status: 400 });\n\t}\n\treturn join(realParent, leaf);\n}\n\nexport class FileApi {\n\tconstructor(private readonly log: FileOpLogger) {}\n\n\t/** Resolve an existing directory for listing and context-trust RPC operations. */\n\tasync resolveDirectory(rawPath: string): Promise<string> {\n\t\tconst path = await canonicalizePath(rawPath, { mustExist: true });\n\t\tconst info = await stat(path);\n\t\tif (!info.isDirectory()) {\n\t\t\tthrow Object.assign(new Error(`Not a directory: ${path}`), { status: 400 });\n\t\t}\n\t\treturn path;\n\t}\n\n\tasync list(rawPath: string): Promise<Omit<DirListingDto, \"contextTrust\">> {\n\t\tconst path = await this.resolveDirectory(rawPath);\n\t\tconst names = await readdir(path, { withFileTypes: true });\n\t\tconst entries: FileEntryDto[] = [];\n\t\tfor (const dirent of names) {\n\t\t\tlet type: FileEntryDto[\"type\"] = \"other\";\n\t\t\tif (dirent.isDirectory()) type = \"dir\";\n\t\t\telse if (dirent.isFile()) type = \"file\";\n\t\t\telse if (dirent.isSymbolicLink()) type = \"symlink\";\n\t\t\tlet size = 0;\n\t\t\tlet modified = \"\";\n\t\t\ttry {\n\t\t\t\tconst s = await stat(join(path, dirent.name));\n\t\t\t\tsize = s.size;\n\t\t\t\tmodified = s.mtime.toISOString();\n\t\t\t\tif (type === \"symlink\") type = s.isDirectory() ? \"dir\" : \"file\";\n\t\t\t} catch {\n\t\t\t\t// Broken symlink or permission issue — keep the entry, mark it \"other\".\n\t\t\t\ttype = \"other\";\n\t\t\t}\n\t\t\tentries.push({ name: dirent.name, type, size, modified });\n\t\t}\n\t\tentries.sort((a, b) =>\n\t\t\t(a.type === \"dir\") === (b.type === \"dir\") ? a.name.localeCompare(b.name) : a.type === \"dir\" ? -1 : 1,\n\t\t);\n\t\tthis.log(\"list\", path);\n\t\treturn { path, entries };\n\t}\n\n\t/** Resolve a download target; returns the canonical path (must be a file). */\n\tasync resolveDownload(rawPath: string): Promise<{ path: string; size: number }> {\n\t\tconst path = await canonicalizePath(rawPath, { mustExist: true });\n\t\tconst info = await stat(path);\n\t\tif (!info.isFile()) {\n\t\t\tthrow Object.assign(new Error(`Not a file: ${path}`), { status: 400 });\n\t\t}\n\t\tthis.log(\"download\", path, `${info.size} bytes`);\n\t\treturn { path, size: info.size };\n\t}\n\n\t/**\n\t * Upload into a directory. Refuses to overwrite unless `overwrite` is set —\n\t * the collision surfaces as 409 so the client can prompt.\n\t */\n\tasync prepareUpload(\n\t\trawDir: string,\n\t\tfileName: string,\n\t\toverwrite: boolean,\n\t): Promise<{ path: string; stream: Writable; commit: () => Promise<void>; cleanup: () => Promise<void> }> {\n\t\tconst dir = await canonicalizePath(rawDir, { mustExist: true });\n\t\tconst dirInfo = await stat(dir);\n\t\tif (!dirInfo.isDirectory()) {\n\t\t\tthrow Object.assign(new Error(`Upload target is not a directory: ${dir}`), { status: 400 });\n\t\t}\n\t\tassertValidChildName(fileName, \"upload file name\");\n\t\tconst path = join(dir, fileName);\n\t\tlet exists = false;\n\t\ttry {\n\t\t\tawait stat(path);\n\t\t\texists = true;\n\t\t} catch {\n\t\t\texists = false;\n\t\t}\n\t\tif (exists && !overwrite) {\n\t\t\tthrow Object.assign(new Error(`File exists: ${path}`), { status: 409 });\n\t\t}\n\n\t\tconst tempPath = join(dir, `.dreb-upload-${process.pid}-${Date.now()}-${randomBytes(6).toString(\"hex\")}.tmp`);\n\t\tconst file = await open(tempPath, \"wx\");\n\t\tconst stream = file.createWriteStream({ autoClose: true });\n\t\tlet committed = false;\n\t\tconst cleanup = async () => {\n\t\t\tif (committed) return;\n\t\t\tawait unlink(tempPath).catch(() => {});\n\t\t};\n\t\tconst commit = async () => {\n\t\t\ttry {\n\t\t\t\tif (overwrite) {\n\t\t\t\t\tawait rename(tempPath, path);\n\t\t\t\t} else {\n\t\t\t\t\t// Atomic no-overwrite publish: hard-link into the final name and fail\n\t\t\t\t\t// with EEXIST if another writer won the race after the early stat().\n\t\t\t\t\tawait link(tempPath, path).catch((err: NodeJS.ErrnoException) => {\n\t\t\t\t\t\tif (err.code === \"EEXIST\") {\n\t\t\t\t\t\t\tthrow Object.assign(new Error(`File exists: ${path}`), { status: 409, cause: err });\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthrow err;\n\t\t\t\t\t});\n\t\t\t\t\tawait unlink(tempPath);\n\t\t\t\t}\n\t\t\t\tcommitted = true;\n\t\t\t\tthis.log(\"upload\", path, exists ? \"overwrite\" : \"create\");\n\t\t\t} catch (err) {\n\t\t\t\tawait cleanup();\n\t\t\t\tthrow err;\n\t\t\t}\n\t\t};\n\t\treturn { path, stream, commit, cleanup };\n\t}\n\n\tasync mkdir(rawDir: string, name: string): Promise<string> {\n\t\tassertValidChildName(name, \"folder name\");\n\t\tconst parent = await canonicalizePath(rawDir, { mustExist: true });\n\t\tconst path = join(parent, name);\n\t\tawait mkdir(path);\n\t\tthis.log(\"mkdir\", path);\n\t\treturn path;\n\t}\n}\n\n/** Places shown as shortcuts in the files tab. */\nexport function defaultPlaces(homeDir: string, projectRoots: string[]): Array<{ label: string; path: string }> {\n\tconst places = [\n\t\t{ label: \"home\", path: homeDir },\n\t\t{ label: \"/tmp\", path: resolve(\"/tmp\") },\n\t];\n\tfor (const root of projectRoots) {\n\t\tplaces.push({ label: root.split(sep).filter(Boolean).pop() ?? root, path: root });\n\t}\n\treturn places;\n}\n"]}
|
package/dist/server/files.js
CHANGED
|
@@ -68,12 +68,17 @@ export class FileApi {
|
|
|
68
68
|
constructor(log) {
|
|
69
69
|
this.log = log;
|
|
70
70
|
}
|
|
71
|
-
|
|
71
|
+
/** Resolve an existing directory for listing and context-trust RPC operations. */
|
|
72
|
+
async resolveDirectory(rawPath) {
|
|
72
73
|
const path = await canonicalizePath(rawPath, { mustExist: true });
|
|
73
74
|
const info = await stat(path);
|
|
74
75
|
if (!info.isDirectory()) {
|
|
75
76
|
throw Object.assign(new Error(`Not a directory: ${path}`), { status: 400 });
|
|
76
77
|
}
|
|
78
|
+
return path;
|
|
79
|
+
}
|
|
80
|
+
async list(rawPath) {
|
|
81
|
+
const path = await this.resolveDirectory(rawPath);
|
|
77
82
|
const names = await readdir(path, { withFileTypes: true });
|
|
78
83
|
const entries = [];
|
|
79
84
|
for (const dirent of names) {
|