@rynx-ai/runtime 0.1.11-beta.4 → 0.1.11-beta.41
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/claude/executor.d.ts +19 -5
- package/dist/claude/executor.js +56 -12
- package/dist/claude/models.d.ts +0 -5
- package/dist/claude/models.js +1 -7
- package/dist/claude/native-bridge.d.ts +103 -1
- package/dist/claude/native-bridge.js +445 -30
- package/dist/claude/native-hook-main.js +81 -1
- package/dist/claude/native-hooks.js +7 -0
- package/dist/claude/native-integration.d.ts +178 -26
- package/dist/claude/native-integration.js +1528 -170
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/claude/transcript-clone.d.ts +18 -0
- package/dist/claude/transcript-clone.js +497 -0
- package/dist/claude/transcript.d.ts +27 -4
- package/dist/claude/transcript.js +158 -47
- package/dist/codex-app-server/client.d.ts +10 -6
- package/dist/codex-app-server/client.js +67 -15
- package/dist/codex-app-server/forwarder.d.ts +92 -3
- package/dist/codex-app-server/forwarder.js +532 -57
- package/dist/codex-app-server/mapping.d.ts +3 -6
- package/dist/codex-app-server/mapping.js +206 -36
- package/dist/codex-app-server/mcp-startup.d.ts +13 -0
- package/dist/codex-app-server/mcp-startup.js +63 -0
- package/dist/codex-app-server/process-registry.d.ts +36 -0
- package/dist/codex-app-server/process-registry.js +320 -0
- package/dist/codex-app-server/protocol.d.ts +64 -7
- package/dist/codex-app-server/ws-channel.d.ts +7 -0
- package/dist/codex-app-server/ws-channel.js +104 -28
- package/dist/codex-home.d.ts +35 -3
- package/dist/codex-home.js +323 -18
- package/dist/codex-session-store.d.ts +23 -0
- package/dist/codex-session-store.js +21 -0
- package/dist/host.d.ts +103 -46
- package/dist/host.js +1988 -634
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/input-resources.d.ts +4 -0
- package/dist/input-resources.js +21 -5
- package/dist/models-catalog.d.ts +2 -1
- package/dist/models-catalog.js +94 -6
- package/dist/runner/child.d.ts +97 -28
- package/dist/runner/child.js +1486 -100
- package/dist/runner/manager.d.ts +110 -29
- package/dist/runner/manager.js +1481 -246
- package/dist/runner/protocol.d.ts +212 -24
- package/dist/runner/protocol.js +5 -0
- package/dist/runner/startup-policy.d.ts +7 -0
- package/dist/runner/startup-policy.js +10 -0
- package/dist/runner/transport.d.ts +18 -2
- package/dist/runner/transport.js +82 -3
- package/dist/runner-main.js +8 -3
- package/dist/terminal/claude-tui.d.ts +3 -1
- package/dist/terminal/claude-tui.js +3 -1
- package/dist/terminal/codex-tui.d.ts +4 -0
- package/dist/terminal/codex-tui.js +5 -0
- package/dist/terminal/control-parser.d.ts +39 -0
- package/dist/terminal/control-parser.js +172 -0
- package/dist/terminal/registry.d.ts +18 -15
- package/dist/terminal/registry.js +44 -23
- package/dist/terminal/spool.d.ts +47 -0
- package/dist/terminal/spool.js +231 -0
- package/dist/terminal/tmux.d.ts +126 -74
- package/dist/terminal/tmux.js +807 -211
- package/package.json +4 -4
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export declare class TmuxControlProtocolError extends Error {
|
|
2
|
+
readonly name = "TmuxControlProtocolError";
|
|
3
|
+
}
|
|
4
|
+
export interface TmuxControlParserOptions {
|
|
5
|
+
onOutput(chunk: Uint8Array): void;
|
|
6
|
+
onControlLine(line: Uint8Array): void;
|
|
7
|
+
outputChunkBytes?: number;
|
|
8
|
+
maxControlLineBytes?: number;
|
|
9
|
+
}
|
|
10
|
+
/** Incremental parser for tmux control mode.
|
|
11
|
+
*
|
|
12
|
+
* `%output` payloads are decoded directly into fixed-size output chunks. Only
|
|
13
|
+
* the possible tail of one `\ooo` escape survives between writes. Other
|
|
14
|
+
* control notifications stay line-oriented and are capped independently.
|
|
15
|
+
*/
|
|
16
|
+
export declare class TmuxControlParser {
|
|
17
|
+
private readonly onOutput;
|
|
18
|
+
private readonly onControlLine;
|
|
19
|
+
private readonly outputChunkBytes;
|
|
20
|
+
private readonly maxControlLineBytes;
|
|
21
|
+
private mode;
|
|
22
|
+
private line;
|
|
23
|
+
private pane;
|
|
24
|
+
private escape;
|
|
25
|
+
private output;
|
|
26
|
+
private outputLength;
|
|
27
|
+
private ended;
|
|
28
|
+
constructor(options: TmuxControlParserOptions);
|
|
29
|
+
get retainedBytes(): number;
|
|
30
|
+
write(input: Uint8Array): void;
|
|
31
|
+
end(): void;
|
|
32
|
+
private consume;
|
|
33
|
+
private consumeGeneric;
|
|
34
|
+
private finishControlLine;
|
|
35
|
+
private consumePayload;
|
|
36
|
+
private flushEscapeLiteral;
|
|
37
|
+
private pushOutput;
|
|
38
|
+
private flushOutput;
|
|
39
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const OUTPUT_PREFIX = Buffer.from("%output ", "ascii");
|
|
2
|
+
const DEFAULT_OUTPUT_CHUNK_BYTES = 64 * 1024;
|
|
3
|
+
const DEFAULT_MAX_CONTROL_LINE_BYTES = 16 * 1024;
|
|
4
|
+
const MAX_PANE_TOKEN_BYTES = 256;
|
|
5
|
+
export class TmuxControlProtocolError extends Error {
|
|
6
|
+
name = "TmuxControlProtocolError";
|
|
7
|
+
}
|
|
8
|
+
/** Incremental parser for tmux control mode.
|
|
9
|
+
*
|
|
10
|
+
* `%output` payloads are decoded directly into fixed-size output chunks. Only
|
|
11
|
+
* the possible tail of one `\ooo` escape survives between writes. Other
|
|
12
|
+
* control notifications stay line-oriented and are capped independently.
|
|
13
|
+
*/
|
|
14
|
+
export class TmuxControlParser {
|
|
15
|
+
onOutput;
|
|
16
|
+
onControlLine;
|
|
17
|
+
outputChunkBytes;
|
|
18
|
+
maxControlLineBytes;
|
|
19
|
+
mode = "prefix";
|
|
20
|
+
line = [];
|
|
21
|
+
pane = [];
|
|
22
|
+
escape = [];
|
|
23
|
+
output;
|
|
24
|
+
outputLength = 0;
|
|
25
|
+
ended = false;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.onOutput = options.onOutput;
|
|
28
|
+
this.onControlLine = options.onControlLine;
|
|
29
|
+
this.outputChunkBytes = positiveInteger(options.outputChunkBytes ?? DEFAULT_OUTPUT_CHUNK_BYTES, "outputChunkBytes");
|
|
30
|
+
this.maxControlLineBytes = positiveInteger(options.maxControlLineBytes ?? DEFAULT_MAX_CONTROL_LINE_BYTES, "maxControlLineBytes");
|
|
31
|
+
this.output = Buffer.allocUnsafe(this.outputChunkBytes);
|
|
32
|
+
}
|
|
33
|
+
get retainedBytes() {
|
|
34
|
+
return this.line.length + this.pane.length + this.escape.length + this.outputLength;
|
|
35
|
+
}
|
|
36
|
+
write(input) {
|
|
37
|
+
if (this.ended)
|
|
38
|
+
throw new TmuxControlProtocolError("tmux control parser has ended");
|
|
39
|
+
const bytes = Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
40
|
+
for (const byte of bytes)
|
|
41
|
+
this.consume(byte);
|
|
42
|
+
}
|
|
43
|
+
end() {
|
|
44
|
+
if (this.ended)
|
|
45
|
+
return;
|
|
46
|
+
this.ended = true;
|
|
47
|
+
if (this.mode === "payload") {
|
|
48
|
+
this.flushEscapeLiteral();
|
|
49
|
+
this.flushOutput();
|
|
50
|
+
}
|
|
51
|
+
this.line = [];
|
|
52
|
+
this.pane = [];
|
|
53
|
+
this.escape = [];
|
|
54
|
+
}
|
|
55
|
+
consume(byte) {
|
|
56
|
+
if (this.mode === "payload") {
|
|
57
|
+
this.consumePayload(byte);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (this.mode === "pane") {
|
|
61
|
+
if (byte === 0x20) {
|
|
62
|
+
if (this.pane.length === 0) {
|
|
63
|
+
this.line = [...OUTPUT_PREFIX];
|
|
64
|
+
this.consumeGeneric(byte);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
this.mode = "payload";
|
|
68
|
+
this.pane = [];
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (byte === 0x0a) {
|
|
73
|
+
this.line = [...OUTPUT_PREFIX, ...this.pane];
|
|
74
|
+
this.pane = [];
|
|
75
|
+
this.finishControlLine();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.pane.push(byte);
|
|
79
|
+
if (this.pane.length > MAX_PANE_TOKEN_BYTES) {
|
|
80
|
+
throw new TmuxControlProtocolError("tmux output pane token exceeded its limit");
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.consumeGeneric(byte);
|
|
85
|
+
}
|
|
86
|
+
consumeGeneric(byte) {
|
|
87
|
+
if (byte === 0x0a) {
|
|
88
|
+
this.finishControlLine();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.line.push(byte);
|
|
92
|
+
if (this.line.length > this.maxControlLineBytes) {
|
|
93
|
+
throw new TmuxControlProtocolError(`tmux control line exceeded ${this.maxControlLineBytes} bytes`);
|
|
94
|
+
}
|
|
95
|
+
if (this.line.length === OUTPUT_PREFIX.length) {
|
|
96
|
+
let outputPrefix = true;
|
|
97
|
+
for (let index = 0; index < OUTPUT_PREFIX.length; index += 1) {
|
|
98
|
+
if (this.line[index] !== OUTPUT_PREFIX[index]) {
|
|
99
|
+
outputPrefix = false;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (outputPrefix) {
|
|
104
|
+
this.line = [];
|
|
105
|
+
this.mode = "pane";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
finishControlLine() {
|
|
110
|
+
if (this.line.at(-1) === 0x0d)
|
|
111
|
+
this.line.pop();
|
|
112
|
+
this.onControlLine(Uint8Array.from(this.line));
|
|
113
|
+
this.line = [];
|
|
114
|
+
this.pane = [];
|
|
115
|
+
this.mode = "prefix";
|
|
116
|
+
}
|
|
117
|
+
consumePayload(byte) {
|
|
118
|
+
if (byte === 0x0a) {
|
|
119
|
+
this.flushEscapeLiteral();
|
|
120
|
+
this.flushOutput();
|
|
121
|
+
this.mode = "prefix";
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (this.escape.length > 0) {
|
|
125
|
+
this.escape.push(byte);
|
|
126
|
+
if (this.escape.length === 4) {
|
|
127
|
+
if (this.escape.slice(1).every(isOctalDigit)) {
|
|
128
|
+
this.pushOutput((this.escape[1] - 0x30) * 64 +
|
|
129
|
+
(this.escape[2] - 0x30) * 8 +
|
|
130
|
+
(this.escape[3] - 0x30));
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
for (const escapedByte of this.escape)
|
|
134
|
+
this.pushOutput(escapedByte);
|
|
135
|
+
}
|
|
136
|
+
this.escape = [];
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (byte === 0x5c) {
|
|
141
|
+
this.escape = [byte];
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.pushOutput(byte);
|
|
145
|
+
}
|
|
146
|
+
flushEscapeLiteral() {
|
|
147
|
+
for (const byte of this.escape)
|
|
148
|
+
this.pushOutput(byte);
|
|
149
|
+
this.escape = [];
|
|
150
|
+
}
|
|
151
|
+
pushOutput(byte) {
|
|
152
|
+
this.output[this.outputLength] = byte;
|
|
153
|
+
this.outputLength += 1;
|
|
154
|
+
if (this.outputLength === this.output.byteLength)
|
|
155
|
+
this.flushOutput();
|
|
156
|
+
}
|
|
157
|
+
flushOutput() {
|
|
158
|
+
if (this.outputLength === 0)
|
|
159
|
+
return;
|
|
160
|
+
this.onOutput(Uint8Array.from(this.output.subarray(0, this.outputLength)));
|
|
161
|
+
this.outputLength = 0;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function isOctalDigit(value) {
|
|
165
|
+
return value >= 0x30 && value <= 0x37;
|
|
166
|
+
}
|
|
167
|
+
function positiveInteger(value, field) {
|
|
168
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
169
|
+
throw new TypeError(`${field} must be a positive integer`);
|
|
170
|
+
}
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
@@ -2,41 +2,44 @@
|
|
|
2
2
|
* Per-runner registry of live {@link TmuxTerminal}s, keyed by terminal id.
|
|
3
3
|
*
|
|
4
4
|
* Owns terminal lifecycle. Read-only vs read-write is decided PURELY by the
|
|
5
|
-
* requested role (the caller derives it from the viewer's permission level)
|
|
6
|
-
*
|
|
7
|
-
* from the URL → `-r`). There is deliberately NO stateful "first owner wins,
|
|
5
|
+
* requested role (the caller derives it from the viewer's permission level).
|
|
6
|
+
* There is deliberately NO stateful "first owner wins,
|
|
8
7
|
* later owners downgraded" slot: that rynx-only mechanism leaked its owner slot
|
|
9
8
|
* across a detach (the release ran on the attachment's `onExit`, which the
|
|
10
9
|
* runner child's own `onExit` clobbered), so a tab switch away then back
|
|
11
10
|
* re-attached read-only and silently dropped every keystroke. Multiple
|
|
12
11
|
* read-write owners coexisting is fine — tmux merges their input.
|
|
13
12
|
*/
|
|
14
|
-
import { TmuxTerminal, type
|
|
13
|
+
import { TmuxTerminal, type PreparedTerminalAttachment, type TmuxTerminalOptions } from "./tmux.js";
|
|
15
14
|
export type TerminalRole = "owner" | "read-only";
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
export type TerminalLifecycle = "required" | "auxiliary";
|
|
16
|
+
export type TerminalLaunchOptions = Omit<TmuxTerminalOptions, "name"> & {
|
|
17
|
+
lifecycle: TerminalLifecycle;
|
|
18
|
+
};
|
|
19
|
+
export interface PrepareResult {
|
|
20
|
+
preparation: PreparedTerminalAttachment;
|
|
19
21
|
role: TerminalRole;
|
|
20
22
|
}
|
|
21
23
|
export declare class TerminalRegistry {
|
|
22
24
|
private readonly terminals;
|
|
23
25
|
get(id: string): TmuxTerminal | undefined;
|
|
26
|
+
lifecycle(id: string): TerminalLifecycle | undefined;
|
|
24
27
|
has(id: string): boolean;
|
|
25
28
|
/** Create + start a terminal under `id`, reusing a LIVE existing one. If the
|
|
26
29
|
* existing terminal's tmux session died (its TUI process exited), drop it and
|
|
27
30
|
* relaunch — so a reconnect after the pane died restarts the terminal instead
|
|
28
31
|
* of attaching to a dead session. `name` is derived from the id so the tmux
|
|
29
32
|
* socket is unique per terminal. */
|
|
30
|
-
getOrCreate(id: string, opts:
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
attach(id: string, requestedRole: TerminalRole, dims?: {
|
|
33
|
+
getOrCreate(id: string, opts: TerminalLaunchOptions): TmuxTerminal;
|
|
34
|
+
/** Move a live resource to a new logical id. Target collision is atomic. */
|
|
35
|
+
transfer(sourceId: string, targetId: string): void;
|
|
36
|
+
prepare(id: string, requestedRole: TerminalRole, dims?: {
|
|
35
37
|
cols?: number;
|
|
36
38
|
rows?: number;
|
|
37
|
-
}): Promise<
|
|
38
|
-
/** Kill a terminal's tmux server and drop it.
|
|
39
|
-
|
|
39
|
+
}): Promise<PrepareResult>;
|
|
40
|
+
/** Kill a terminal's tmux server and drop it. When `expected` is supplied,
|
|
41
|
+
* close only if that exact instance still owns the id. */
|
|
42
|
+
close(id: string, expected?: TmuxTerminal): boolean;
|
|
40
43
|
/** Kill every terminal (runner shutdown). */
|
|
41
44
|
closeAll(): void;
|
|
42
45
|
}
|
|
@@ -2,20 +2,22 @@
|
|
|
2
2
|
* Per-runner registry of live {@link TmuxTerminal}s, keyed by terminal id.
|
|
3
3
|
*
|
|
4
4
|
* Owns terminal lifecycle. Read-only vs read-write is decided PURELY by the
|
|
5
|
-
* requested role (the caller derives it from the viewer's permission level)
|
|
6
|
-
*
|
|
7
|
-
* from the URL → `-r`). There is deliberately NO stateful "first owner wins,
|
|
5
|
+
* requested role (the caller derives it from the viewer's permission level).
|
|
6
|
+
* There is deliberately NO stateful "first owner wins,
|
|
8
7
|
* later owners downgraded" slot: that rynx-only mechanism leaked its owner slot
|
|
9
8
|
* across a detach (the release ran on the attachment's `onExit`, which the
|
|
10
9
|
* runner child's own `onExit` clobbered), so a tab switch away then back
|
|
11
10
|
* re-attached read-only and silently dropped every keystroke. Multiple
|
|
12
11
|
* read-write owners coexisting is fine — tmux merges their input.
|
|
13
12
|
*/
|
|
14
|
-
import { TmuxTerminal } from "./tmux.js";
|
|
13
|
+
import { TmuxTerminal, } from "./tmux.js";
|
|
15
14
|
export class TerminalRegistry {
|
|
16
15
|
terminals = new Map();
|
|
17
16
|
get(id) {
|
|
18
|
-
return this.terminals.get(id);
|
|
17
|
+
return this.terminals.get(id)?.terminal;
|
|
18
|
+
}
|
|
19
|
+
lifecycle(id) {
|
|
20
|
+
return this.terminals.get(id)?.lifecycle;
|
|
19
21
|
}
|
|
20
22
|
has(id) {
|
|
21
23
|
return this.terminals.has(id);
|
|
@@ -27,40 +29,59 @@ export class TerminalRegistry {
|
|
|
27
29
|
* socket is unique per terminal. */
|
|
28
30
|
getOrCreate(id, opts) {
|
|
29
31
|
const existing = this.terminals.get(id);
|
|
30
|
-
if (existing && existing.isAlive())
|
|
31
|
-
|
|
32
|
+
if (existing && existing.terminal.isAlive()) {
|
|
33
|
+
if (existing.lifecycle !== opts.lifecycle) {
|
|
34
|
+
throw new Error(`terminal ${id} lifecycle does not match its live resource`);
|
|
35
|
+
}
|
|
36
|
+
return existing.terminal;
|
|
37
|
+
}
|
|
32
38
|
if (existing) {
|
|
33
39
|
// Dead pane (its TUI exited) — kill the husk + drop it before relaunching.
|
|
40
|
+
this.terminals.delete(id);
|
|
34
41
|
try {
|
|
35
|
-
existing.kill();
|
|
42
|
+
existing.terminal.kill();
|
|
36
43
|
}
|
|
37
44
|
catch {
|
|
38
45
|
// already gone — fine
|
|
39
46
|
}
|
|
40
|
-
this.terminals.delete(id);
|
|
41
47
|
}
|
|
42
|
-
const
|
|
48
|
+
const { lifecycle, ...terminalOptions } = opts;
|
|
49
|
+
const terminal = new TmuxTerminal({ ...terminalOptions, name: id });
|
|
43
50
|
terminal.start();
|
|
44
|
-
this.terminals.set(id, terminal);
|
|
51
|
+
this.terminals.set(id, { terminal, lifecycle });
|
|
45
52
|
return terminal;
|
|
46
53
|
}
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
/** Move a live resource to a new logical id. Target collision is atomic. */
|
|
55
|
+
transfer(sourceId, targetId) {
|
|
56
|
+
if (sourceId === targetId)
|
|
57
|
+
return;
|
|
58
|
+
if (this.terminals.has(targetId)) {
|
|
59
|
+
throw new Error(`terminal ${targetId} already exists`);
|
|
60
|
+
}
|
|
61
|
+
const source = this.terminals.get(sourceId);
|
|
62
|
+
if (!source)
|
|
63
|
+
throw new Error(`terminal ${sourceId} does not exist`);
|
|
64
|
+
this.terminals.delete(sourceId);
|
|
65
|
+
this.terminals.set(targetId, source);
|
|
66
|
+
}
|
|
67
|
+
async prepare(id, requestedRole, dims) {
|
|
51
68
|
const terminal = this.terminals.get(id);
|
|
52
69
|
if (!terminal)
|
|
53
70
|
throw new Error(`terminal ${id} does not exist`);
|
|
54
|
-
const
|
|
55
|
-
return {
|
|
71
|
+
const preparation = await terminal.terminal.prepare(requestedRole, dims);
|
|
72
|
+
return { preparation, role: requestedRole };
|
|
56
73
|
}
|
|
57
|
-
/** Kill a terminal's tmux server and drop it.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
/** Kill a terminal's tmux server and drop it. When `expected` is supplied,
|
|
75
|
+
* close only if that exact instance still owns the id. */
|
|
76
|
+
close(id, expected) {
|
|
77
|
+
const entry = this.terminals.get(id);
|
|
78
|
+
if (!entry || (expected && entry.terminal !== expected))
|
|
79
|
+
return false;
|
|
80
|
+
// Remove the resource before best-effort close so a failed kill cannot
|
|
81
|
+
// leave a dead registry entry that later attaches will reuse.
|
|
63
82
|
this.terminals.delete(id);
|
|
83
|
+
entry.terminal.kill();
|
|
84
|
+
return true;
|
|
64
85
|
}
|
|
65
86
|
/** Kill every terminal (runner shutdown). */
|
|
66
87
|
closeAll() {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface TerminalSpoolRead {
|
|
2
|
+
data: Uint8Array;
|
|
3
|
+
nextOffset: number;
|
|
4
|
+
done: boolean;
|
|
5
|
+
finalOffset?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare class TerminalSpoolCapacityError extends Error {
|
|
8
|
+
readonly name = "TerminalSpoolCapacityError";
|
|
9
|
+
}
|
|
10
|
+
export declare class TerminalSpoolProtocolError extends Error {
|
|
11
|
+
readonly name = "TerminalSpoolProtocolError";
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A single-reader byte log backed by disposable fixed-size files. Producers
|
|
15
|
+
* append while the consumer pulls with an exact monotonic offset. Fully
|
|
16
|
+
* consumed segments are unlinked immediately, so retained disk is bounded by
|
|
17
|
+
* viewer lag rather than the lifetime output volume.
|
|
18
|
+
*/
|
|
19
|
+
export declare class SegmentedTerminalSpool {
|
|
20
|
+
readonly directory: string;
|
|
21
|
+
readonly segmentBytes: number;
|
|
22
|
+
readonly quotaBytes: number | undefined;
|
|
23
|
+
private readonly segments;
|
|
24
|
+
private writeOffset;
|
|
25
|
+
private readOffset;
|
|
26
|
+
private ended;
|
|
27
|
+
private closed;
|
|
28
|
+
private failure;
|
|
29
|
+
private pending;
|
|
30
|
+
constructor(options?: {
|
|
31
|
+
segmentBytes?: number;
|
|
32
|
+
quotaBytes?: number;
|
|
33
|
+
directory?: string;
|
|
34
|
+
});
|
|
35
|
+
get retainedBytes(): number;
|
|
36
|
+
get finalOffset(): number | undefined;
|
|
37
|
+
append(input: Uint8Array): void;
|
|
38
|
+
end(): void;
|
|
39
|
+
fail(error: Error): void;
|
|
40
|
+
read(offset: number, maxBytes: number): Promise<TerminalSpoolRead>;
|
|
41
|
+
close(): void;
|
|
42
|
+
private assertWritable;
|
|
43
|
+
private createSegment;
|
|
44
|
+
private settlePending;
|
|
45
|
+
private readNow;
|
|
46
|
+
private discardConsumedSegments;
|
|
47
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, mkdtempSync, openSync, readSync, rmSync, unlinkSync, writeSync, } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const DEFAULT_SEGMENT_BYTES = 1024 * 1024;
|
|
5
|
+
const MAX_READ_BYTES = 1024 * 1024;
|
|
6
|
+
export class TerminalSpoolCapacityError extends Error {
|
|
7
|
+
name = "TerminalSpoolCapacityError";
|
|
8
|
+
}
|
|
9
|
+
export class TerminalSpoolProtocolError extends Error {
|
|
10
|
+
name = "TerminalSpoolProtocolError";
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A single-reader byte log backed by disposable fixed-size files. Producers
|
|
14
|
+
* append while the consumer pulls with an exact monotonic offset. Fully
|
|
15
|
+
* consumed segments are unlinked immediately, so retained disk is bounded by
|
|
16
|
+
* viewer lag rather than the lifetime output volume.
|
|
17
|
+
*/
|
|
18
|
+
export class SegmentedTerminalSpool {
|
|
19
|
+
directory;
|
|
20
|
+
segmentBytes;
|
|
21
|
+
quotaBytes;
|
|
22
|
+
segments = [];
|
|
23
|
+
writeOffset = 0;
|
|
24
|
+
readOffset = 0;
|
|
25
|
+
ended = false;
|
|
26
|
+
closed = false;
|
|
27
|
+
failure;
|
|
28
|
+
pending;
|
|
29
|
+
constructor(options = {}) {
|
|
30
|
+
this.segmentBytes = positiveInteger(options.segmentBytes ?? DEFAULT_SEGMENT_BYTES, "segmentBytes");
|
|
31
|
+
this.quotaBytes = options.quotaBytes === undefined
|
|
32
|
+
? undefined
|
|
33
|
+
: positiveInteger(options.quotaBytes, "quotaBytes");
|
|
34
|
+
const parent = options.directory ?? tmpdir();
|
|
35
|
+
mkdirSync(parent, { recursive: true });
|
|
36
|
+
this.directory = mkdtempSync(join(parent, "rynx-terminal-spool-"));
|
|
37
|
+
}
|
|
38
|
+
get retainedBytes() {
|
|
39
|
+
return this.writeOffset - this.readOffset;
|
|
40
|
+
}
|
|
41
|
+
get finalOffset() {
|
|
42
|
+
return this.ended ? this.writeOffset : undefined;
|
|
43
|
+
}
|
|
44
|
+
append(input) {
|
|
45
|
+
this.assertWritable();
|
|
46
|
+
if (input.byteLength === 0)
|
|
47
|
+
return;
|
|
48
|
+
if (this.quotaBytes !== undefined &&
|
|
49
|
+
this.retainedBytes + input.byteLength > this.quotaBytes) {
|
|
50
|
+
const error = new TerminalSpoolCapacityError(`Terminal viewer lag exceeded ${this.quotaBytes} bytes`);
|
|
51
|
+
this.fail(error);
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
const source = Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
55
|
+
let sourceOffset = 0;
|
|
56
|
+
while (sourceOffset < source.byteLength) {
|
|
57
|
+
let segment = this.segments.at(-1);
|
|
58
|
+
if (!segment || segment.sealed)
|
|
59
|
+
segment = this.createSegment();
|
|
60
|
+
const writable = Math.min(this.segmentBytes - segment.length, source.byteLength - sourceOffset);
|
|
61
|
+
writeSync(segment.fd, source, sourceOffset, writable, segment.length);
|
|
62
|
+
segment.length += writable;
|
|
63
|
+
sourceOffset += writable;
|
|
64
|
+
this.writeOffset += writable;
|
|
65
|
+
if (segment.length === this.segmentBytes) {
|
|
66
|
+
segment.sealed = true;
|
|
67
|
+
closeSync(segment.fd);
|
|
68
|
+
segment.fd = -1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
this.settlePending();
|
|
72
|
+
}
|
|
73
|
+
end() {
|
|
74
|
+
if (this.closed || this.ended)
|
|
75
|
+
return;
|
|
76
|
+
this.ended = true;
|
|
77
|
+
const segment = this.segments.at(-1);
|
|
78
|
+
if (segment && !segment.sealed) {
|
|
79
|
+
segment.sealed = true;
|
|
80
|
+
closeSync(segment.fd);
|
|
81
|
+
segment.fd = -1;
|
|
82
|
+
}
|
|
83
|
+
this.settlePending();
|
|
84
|
+
}
|
|
85
|
+
fail(error) {
|
|
86
|
+
if (this.closed || this.failure)
|
|
87
|
+
return;
|
|
88
|
+
this.failure = error;
|
|
89
|
+
this.ended = true;
|
|
90
|
+
const pending = this.pending;
|
|
91
|
+
this.pending = undefined;
|
|
92
|
+
pending?.reject(error);
|
|
93
|
+
}
|
|
94
|
+
read(offset, maxBytes) {
|
|
95
|
+
if (this.closed)
|
|
96
|
+
return Promise.reject(new TerminalSpoolProtocolError("Terminal spool is closed"));
|
|
97
|
+
if (this.failure)
|
|
98
|
+
return Promise.reject(this.failure);
|
|
99
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset !== this.readOffset) {
|
|
100
|
+
return Promise.reject(new TerminalSpoolProtocolError(`Terminal spool expected offset ${this.readOffset}, received ${String(offset)}`));
|
|
101
|
+
}
|
|
102
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_READ_BYTES) {
|
|
103
|
+
return Promise.reject(new TerminalSpoolProtocolError(`Terminal spool maxBytes must be within 1..${MAX_READ_BYTES}`));
|
|
104
|
+
}
|
|
105
|
+
if (this.pending) {
|
|
106
|
+
return Promise.reject(new TerminalSpoolProtocolError("Concurrent reads from one terminal spool are not allowed"));
|
|
107
|
+
}
|
|
108
|
+
const available = this.writeOffset - this.readOffset;
|
|
109
|
+
if (available > 0 || this.ended)
|
|
110
|
+
return Promise.resolve(this.readNow(maxBytes));
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
this.pending = { offset, maxBytes, resolve, reject };
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
close() {
|
|
116
|
+
if (this.closed)
|
|
117
|
+
return;
|
|
118
|
+
this.closed = true;
|
|
119
|
+
const error = new TerminalSpoolProtocolError("Terminal spool is closed");
|
|
120
|
+
const pending = this.pending;
|
|
121
|
+
this.pending = undefined;
|
|
122
|
+
pending?.reject(error);
|
|
123
|
+
for (const segment of this.segments) {
|
|
124
|
+
if (segment.fd !== -1) {
|
|
125
|
+
try {
|
|
126
|
+
closeSync(segment.fd);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Best-effort cleanup continues through every segment.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
this.segments.length = 0;
|
|
134
|
+
rmSync(this.directory, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
assertWritable() {
|
|
137
|
+
if (this.closed)
|
|
138
|
+
throw new TerminalSpoolProtocolError("Terminal spool is closed");
|
|
139
|
+
if (this.failure)
|
|
140
|
+
throw this.failure;
|
|
141
|
+
if (this.ended)
|
|
142
|
+
throw new TerminalSpoolProtocolError("Terminal spool has ended");
|
|
143
|
+
}
|
|
144
|
+
createSegment() {
|
|
145
|
+
const start = this.writeOffset;
|
|
146
|
+
const path = join(this.directory, `${String(start).padStart(20, "0")}.bin`);
|
|
147
|
+
const segment = {
|
|
148
|
+
path,
|
|
149
|
+
start,
|
|
150
|
+
fd: openSync(path, "wx+", 0o600),
|
|
151
|
+
length: 0,
|
|
152
|
+
sealed: false,
|
|
153
|
+
};
|
|
154
|
+
this.segments.push(segment);
|
|
155
|
+
return segment;
|
|
156
|
+
}
|
|
157
|
+
settlePending() {
|
|
158
|
+
const pending = this.pending;
|
|
159
|
+
if (!pending || (this.writeOffset === pending.offset && !this.ended))
|
|
160
|
+
return;
|
|
161
|
+
this.pending = undefined;
|
|
162
|
+
if (this.failure)
|
|
163
|
+
pending.reject(this.failure);
|
|
164
|
+
else
|
|
165
|
+
pending.resolve(this.readNow(pending.maxBytes));
|
|
166
|
+
}
|
|
167
|
+
readNow(maxBytes) {
|
|
168
|
+
if (this.failure)
|
|
169
|
+
throw this.failure;
|
|
170
|
+
const available = this.writeOffset - this.readOffset;
|
|
171
|
+
const length = Math.min(maxBytes, available);
|
|
172
|
+
const data = Buffer.allocUnsafe(length);
|
|
173
|
+
let copied = 0;
|
|
174
|
+
let cursor = this.readOffset;
|
|
175
|
+
for (const segment of this.segments) {
|
|
176
|
+
const segmentEnd = segment.start + segment.length;
|
|
177
|
+
if (cursor >= segmentEnd)
|
|
178
|
+
continue;
|
|
179
|
+
if (cursor < segment.start) {
|
|
180
|
+
throw new TerminalSpoolProtocolError("Terminal spool contains a byte gap");
|
|
181
|
+
}
|
|
182
|
+
const within = cursor - segment.start;
|
|
183
|
+
const count = Math.min(segment.length - within, length - copied);
|
|
184
|
+
if (count <= 0)
|
|
185
|
+
break;
|
|
186
|
+
const fd = segment.fd === -1 ? openSync(segment.path, "r") : segment.fd;
|
|
187
|
+
try {
|
|
188
|
+
const actual = readSync(fd, data, copied, count, within);
|
|
189
|
+
if (actual !== count)
|
|
190
|
+
throw new TerminalSpoolProtocolError("Terminal spool segment was truncated");
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
if (segment.fd === -1)
|
|
194
|
+
closeSync(fd);
|
|
195
|
+
}
|
|
196
|
+
copied += count;
|
|
197
|
+
cursor += count;
|
|
198
|
+
if (copied === length)
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
this.readOffset += copied;
|
|
202
|
+
this.discardConsumedSegments();
|
|
203
|
+
const done = this.ended && this.readOffset === this.writeOffset;
|
|
204
|
+
return {
|
|
205
|
+
data: Uint8Array.from(data.subarray(0, copied)),
|
|
206
|
+
nextOffset: this.readOffset,
|
|
207
|
+
done,
|
|
208
|
+
...(done ? { finalOffset: this.writeOffset } : {}),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
discardConsumedSegments() {
|
|
212
|
+
while (this.segments.length > 0) {
|
|
213
|
+
const segment = this.segments[0];
|
|
214
|
+
if (!segment.sealed || segment.start + segment.length > this.readOffset)
|
|
215
|
+
return;
|
|
216
|
+
this.segments.shift();
|
|
217
|
+
try {
|
|
218
|
+
unlinkSync(segment.path);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// The directory-level close remains the final cleanup fence.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function positiveInteger(value, field) {
|
|
227
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
228
|
+
throw new TypeError(`${field} must be a positive integer`);
|
|
229
|
+
}
|
|
230
|
+
return value;
|
|
231
|
+
}
|