@tangle-network/agent-provider-tangle 0.9.0 → 0.10.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 +89 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/tangle-capabilities.d.ts +22 -1
- package/dist/tangle-capabilities.js +120 -4
- package/dist/tangle-create-options.js +3 -10
- package/dist/tangle-deployment-capabilities.d.ts +7 -0
- package/dist/tangle-deployment-capabilities.js +3 -0
- package/dist/tangle-environment-session.d.ts +9 -1
- package/dist/tangle-environment-session.js +36 -11
- package/dist/tangle-environment.d.ts +7 -1
- package/dist/tangle-environment.js +74 -3
- package/dist/tangle-events.d.ts +32 -2
- package/dist/tangle-events.js +138 -40
- package/dist/tangle-failure-reason.d.ts +13 -0
- package/dist/tangle-failure-reason.js +46 -0
- package/dist/tangle-interaction-response.d.ts +26 -0
- package/dist/tangle-interaction-response.js +169 -0
- package/dist/tangle-observation.d.ts +57 -0
- package/dist/tangle-observation.js +525 -0
- package/dist/tangle-provider.js +3 -1
- package/dist/tangle-resources.d.ts +22 -0
- package/dist/tangle-resources.js +74 -0
- package/dist/tangle-terminal-frames.d.ts +44 -0
- package/dist/tangle-terminal-frames.js +137 -0
- package/dist/tangle-terminal.d.ts +12 -0
- package/dist/tangle-terminal.js +439 -0
- package/dist/tangle-types.d.ts +181 -1
- package/dist/tangle-usage-log.d.ts +22 -0
- package/dist/tangle-usage-log.js +22 -0
- package/package.json +19 -5
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { MAX_ARRAY_LENGTH, MAX_STRING_LENGTH } from "./tangle-contract-safety.js";
|
|
2
|
+
/** Frames retained for replay. An older cursor is refused, never skipped. */
|
|
3
|
+
const MAX_RETAINED_FRAMES = MAX_ARRAY_LENGTH;
|
|
4
|
+
/**
|
|
5
|
+
* Ordered terminal frames with a replay cursor.
|
|
6
|
+
*
|
|
7
|
+
* Every frame takes a monotonic ordinal, and an `output` frame also takes a
|
|
8
|
+
* monotonic `seq` that a consumer replays from. `since` is EXCLUSIVE: it names
|
|
9
|
+
* the last sequence the consumer processed, so a reconnect resumes with
|
|
10
|
+
* neither loss nor duplication. A cursor whose successor frames were evicted is
|
|
11
|
+
* refused, because silently resuming after the gap would drop terminal output
|
|
12
|
+
* the consumer believes it received.
|
|
13
|
+
*
|
|
14
|
+
* The buffer is bounded, so the accepted cursors move as frames are evicted.
|
|
15
|
+
* {@link cursors} states the window they moved to: `earliest` is the oldest
|
|
16
|
+
* cursor `read` accepts and delivers every retained frame from, and `latest` is
|
|
17
|
+
* the newest output frame. A read with no cursor starts at `earliest`, so a
|
|
18
|
+
* consumer that holds none is never locked out. A consumer that names an
|
|
19
|
+
* evicted cursor is refused and told which cursor to resume from, because it
|
|
20
|
+
* believes it received the frames the gap would silently drop.
|
|
21
|
+
*/
|
|
22
|
+
export class TerminalFrameLog {
|
|
23
|
+
entries = [];
|
|
24
|
+
lastOrdinal = 0;
|
|
25
|
+
outputSeq = 0;
|
|
26
|
+
evictedOrdinal = 0;
|
|
27
|
+
evictedOutputSeq = 0;
|
|
28
|
+
ended = false;
|
|
29
|
+
waiters = [];
|
|
30
|
+
append(event) {
|
|
31
|
+
this.lastOrdinal += 1;
|
|
32
|
+
this.entries.push({ ordinal: this.lastOrdinal, event });
|
|
33
|
+
while (this.entries.length > MAX_RETAINED_FRAMES) {
|
|
34
|
+
const dropped = this.entries.shift();
|
|
35
|
+
if (dropped === undefined)
|
|
36
|
+
continue;
|
|
37
|
+
this.evictedOrdinal = dropped.ordinal;
|
|
38
|
+
if (dropped.event.type === "output")
|
|
39
|
+
this.evictedOutputSeq = dropped.event.seq;
|
|
40
|
+
}
|
|
41
|
+
this.wake();
|
|
42
|
+
}
|
|
43
|
+
/** Append decoded PTY text, split so no frame exceeds the contract bound. */
|
|
44
|
+
appendOutput(text) {
|
|
45
|
+
for (let offset = 0; offset < text.length; offset += MAX_STRING_LENGTH) {
|
|
46
|
+
this.outputSeq += 1;
|
|
47
|
+
this.append({
|
|
48
|
+
type: "output",
|
|
49
|
+
seq: this.outputSeq,
|
|
50
|
+
data: text.slice(offset, offset + MAX_STRING_LENGTH),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** No more frames will arrive; readers drain what is retained and return. */
|
|
55
|
+
end() {
|
|
56
|
+
this.ended = true;
|
|
57
|
+
this.wake();
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The cursors this buffer can serve. Reading from `earliest` yields every
|
|
61
|
+
* retained frame and loses no output, because eviction drops output frames in
|
|
62
|
+
* sequence order and `earliest` names the newest one that was dropped.
|
|
63
|
+
*/
|
|
64
|
+
get cursors() {
|
|
65
|
+
return { earliest: this.evictedOutputSeq, latest: this.outputSeq };
|
|
66
|
+
}
|
|
67
|
+
async *read(since, signal) {
|
|
68
|
+
signal?.throwIfAborted();
|
|
69
|
+
// An absent cursor resolves to the oldest retained frame here, where the
|
|
70
|
+
// read begins, so frames evicted between the call and the first iteration
|
|
71
|
+
// cannot lock the consumer out of a window it never named.
|
|
72
|
+
let cursor = this.ordinalForCursor(since ?? this.evictedOutputSeq);
|
|
73
|
+
while (true) {
|
|
74
|
+
signal?.throwIfAborted();
|
|
75
|
+
if (this.evictedOrdinal > cursor) {
|
|
76
|
+
throw new Error("Tangle terminal frames were evicted before this consumer read them");
|
|
77
|
+
}
|
|
78
|
+
const pending = this.entries.filter((entry) => entry.ordinal > cursor);
|
|
79
|
+
if (pending.length > 0) {
|
|
80
|
+
for (const entry of pending) {
|
|
81
|
+
cursor = entry.ordinal;
|
|
82
|
+
yield entry.event;
|
|
83
|
+
signal?.throwIfAborted();
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (this.ended)
|
|
88
|
+
return;
|
|
89
|
+
await this.waitForFrames(signal);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
ordinalForCursor(since) {
|
|
93
|
+
if (!Number.isSafeInteger(since) || since < 0) {
|
|
94
|
+
throw new Error("Tangle terminal replay cursor must be a non-negative safe integer");
|
|
95
|
+
}
|
|
96
|
+
if (since > this.outputSeq) {
|
|
97
|
+
throw new Error("Tangle terminal replay cursor is ahead of the retained frames");
|
|
98
|
+
}
|
|
99
|
+
// The oldest accepted cursor names the newest evicted output frame, so a
|
|
100
|
+
// consumer holding it has processed every frame this buffer dropped and
|
|
101
|
+
// receives every frame it still holds.
|
|
102
|
+
if (since === this.evictedOutputSeq)
|
|
103
|
+
return this.evictedOrdinal;
|
|
104
|
+
const found = this.entries.find((entry) => entry.event.type === "output" && entry.event.seq === since);
|
|
105
|
+
if (found === undefined) {
|
|
106
|
+
// Name the live cursor in the refusal, so a consumer that read no window
|
|
107
|
+
// still learns where it can resume instead of retrying the dropped one.
|
|
108
|
+
throw new Error(`Tangle terminal replay cursor is older than the retained frame buffer; resume from cursor ${this.evictedOutputSeq}`);
|
|
109
|
+
}
|
|
110
|
+
return found.ordinal;
|
|
111
|
+
}
|
|
112
|
+
waitForFrames(signal) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
let onAbort;
|
|
115
|
+
const wake = () => {
|
|
116
|
+
if (onAbort !== undefined)
|
|
117
|
+
signal?.removeEventListener("abort", onAbort);
|
|
118
|
+
resolve();
|
|
119
|
+
};
|
|
120
|
+
this.waiters.push(wake);
|
|
121
|
+
if (signal !== undefined) {
|
|
122
|
+
onAbort = () => {
|
|
123
|
+
reject(signal.reason instanceof Error
|
|
124
|
+
? signal.reason
|
|
125
|
+
: new DOMException("The operation was aborted", "AbortError"));
|
|
126
|
+
};
|
|
127
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
wake() {
|
|
132
|
+
const waiters = this.waiters;
|
|
133
|
+
this.waiters = [];
|
|
134
|
+
for (const waiter of waiters)
|
|
135
|
+
waiter();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AgentTerminalSession, TerminalAttachRequest, TerminalAttachResult } from "@tangle-network/agent-interface";
|
|
2
|
+
import type { SandboxInstanceLike } from "./tangle-types.js";
|
|
3
|
+
/** Terminal handles this environment holds, keyed by terminal session id. */
|
|
4
|
+
export interface TangleTerminalRegistry {
|
|
5
|
+
attach(request: TerminalAttachRequest, options?: {
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
}): Promise<TerminalAttachResult>;
|
|
8
|
+
get(terminalSessionId: string): AgentTerminalSession;
|
|
9
|
+
}
|
|
10
|
+
/** True when this sandbox backs the interactive terminal surface. */
|
|
11
|
+
export declare function sandboxBacksInteractiveTerminal(box: SandboxInstanceLike): boolean;
|
|
12
|
+
export declare function createTangleTerminalRegistry(box: SandboxInstanceLike): TangleTerminalRegistry;
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { TerminalAttachRequestSchema, TerminalInputSchema, TerminalResizeSchema, TerminalSessionRefSchema, terminalSessionUsable, } from "@tangle-network/agent-interface";
|
|
3
|
+
import { TerminalFrameLog } from "./tangle-terminal-frames.js";
|
|
4
|
+
import { awaitWithSignal, MAX_STRING_LENGTH } from "./tangle-contract-safety.js";
|
|
5
|
+
import { transportFailureReason } from "./tangle-failure-reason.js";
|
|
6
|
+
import { assertOptionKeys } from "./tangle-environment-validation.js";
|
|
7
|
+
const MAX_TERMINAL_DIMENSION = 10_000;
|
|
8
|
+
/** True when this sandbox backs the interactive terminal surface. */
|
|
9
|
+
export function sandboxBacksInteractiveTerminal(box) {
|
|
10
|
+
// Attach opens the PTY socket; `get` supplies the shell, working directory,
|
|
11
|
+
// and geometry a terminal reference must state. Neither alone is enough.
|
|
12
|
+
// The member is an accessor on the SDK class, so a client that cannot build
|
|
13
|
+
// the manager fails the check instead of failing the capability read.
|
|
14
|
+
try {
|
|
15
|
+
const terminals = box.terminals;
|
|
16
|
+
return (typeof terminals?.attach === "function" && typeof terminals?.get === "function");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function createTangleTerminalRegistry(box) {
|
|
23
|
+
const attached = new Map();
|
|
24
|
+
return {
|
|
25
|
+
async attach(request, options) {
|
|
26
|
+
assertOptionKeys(options, ["signal"], "Tangle terminal attach");
|
|
27
|
+
const exactRequest = TerminalAttachRequestSchema.parse(request);
|
|
28
|
+
options?.signal?.throwIfAborted();
|
|
29
|
+
const terminals = box.terminals;
|
|
30
|
+
if (terminals === undefined ||
|
|
31
|
+
typeof terminals.attach !== "function" ||
|
|
32
|
+
typeof terminals.get !== "function") {
|
|
33
|
+
return {
|
|
34
|
+
status: "unavailable",
|
|
35
|
+
reason: "the Sandbox client exposes no interactive terminal transport",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (exactRequest.mode === "logical") {
|
|
39
|
+
// Sandbox resumes a terminal by reattaching its PTY and replaying the
|
|
40
|
+
// retained screen. It exposes no way to read that history without
|
|
41
|
+
// attaching, so a logical resume is refused rather than served by an
|
|
42
|
+
// attach the caller did not ask for.
|
|
43
|
+
return {
|
|
44
|
+
status: "unavailable",
|
|
45
|
+
reason: 'the Tangle sandbox transport has no logical terminal resume; attach with mode "attach"',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const connectionId = exactRequest.connectionId ?? exactRequest.terminalSessionId ?? randomUUID();
|
|
49
|
+
const log = new TerminalFrameLog();
|
|
50
|
+
const activity = { localMs: Date.now() };
|
|
51
|
+
const exit = {
|
|
52
|
+
seen: false,
|
|
53
|
+
};
|
|
54
|
+
const decoder = new TextDecoder();
|
|
55
|
+
let ready;
|
|
56
|
+
let stream;
|
|
57
|
+
try {
|
|
58
|
+
stream = await awaitWithSignal(terminals.attach(connectionId, {
|
|
59
|
+
...(exactRequest.cols === undefined ? {} : { cols: exactRequest.cols }),
|
|
60
|
+
...(exactRequest.rows === undefined ? {} : { rows: exactRequest.rows }),
|
|
61
|
+
...(exactRequest.command === undefined
|
|
62
|
+
? {}
|
|
63
|
+
: { command: exactRequest.command }),
|
|
64
|
+
...(exactRequest.cwd === undefined ? {} : { cwd: exactRequest.cwd }),
|
|
65
|
+
handlers: {
|
|
66
|
+
onReady: (info) => {
|
|
67
|
+
ready = info;
|
|
68
|
+
log.append({
|
|
69
|
+
type: "ready",
|
|
70
|
+
...(exactRequest.cols === undefined ? {} : { cols: exactRequest.cols }),
|
|
71
|
+
...(exactRequest.rows === undefined ? {} : { rows: exactRequest.rows }),
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
onData: (data) => {
|
|
75
|
+
activity.localMs = Date.now();
|
|
76
|
+
log.appendOutput(decoder.decode(data, { stream: true }));
|
|
77
|
+
},
|
|
78
|
+
onExit: (info) => {
|
|
79
|
+
exit.seen = true;
|
|
80
|
+
exit.exitCode = info.exitCode;
|
|
81
|
+
exit.exitSignal = info.exitSignal;
|
|
82
|
+
log.append({
|
|
83
|
+
type: "exit",
|
|
84
|
+
...(Number.isSafeInteger(info.exitCode)
|
|
85
|
+
? { exitCode: info.exitCode }
|
|
86
|
+
: {}),
|
|
87
|
+
...(typeof info.exitSignal === "string" && info.exitSignal.length > 0
|
|
88
|
+
? { exitSignal: info.exitSignal }
|
|
89
|
+
: {}),
|
|
90
|
+
});
|
|
91
|
+
log.end();
|
|
92
|
+
},
|
|
93
|
+
onError: (error) => {
|
|
94
|
+
// A frame belongs to the PTY stream, which carries whatever the
|
|
95
|
+
// terminal itself produced, so the socket error is carried as
|
|
96
|
+
// the terminal reports it and only its length is bounded.
|
|
97
|
+
log.append({ type: "error", message: socketErrorFrame(error) });
|
|
98
|
+
},
|
|
99
|
+
onClose: () => {
|
|
100
|
+
log.end();
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
}), options?.signal);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
options?.signal?.throwIfAborted();
|
|
107
|
+
return {
|
|
108
|
+
status: "unknown",
|
|
109
|
+
message: transportFailureReason("terminal attach", error),
|
|
110
|
+
retryable: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
options?.signal?.throwIfAborted();
|
|
114
|
+
let acknowledgement;
|
|
115
|
+
try {
|
|
116
|
+
// `ready` is an accessor on the SDK stream that throws until the
|
|
117
|
+
// runtime's acknowledgement arrives. Read outside a guard it would
|
|
118
|
+
// replace this attach's result with a raw transport error and abandon
|
|
119
|
+
// the socket the attach opened.
|
|
120
|
+
acknowledgement = ready ?? stream.ready;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
await closeQuietly(stream);
|
|
124
|
+
return {
|
|
125
|
+
status: "unknown",
|
|
126
|
+
message: transportFailureReason("terminal acknowledgement", error),
|
|
127
|
+
retryable: true,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (acknowledgement === undefined ||
|
|
131
|
+
typeof acknowledgement.sessionId !== "string" ||
|
|
132
|
+
acknowledgement.sessionId.length === 0) {
|
|
133
|
+
await closeQuietly(stream);
|
|
134
|
+
return {
|
|
135
|
+
status: "unknown",
|
|
136
|
+
message: "the Tangle terminal transport attached without a session id",
|
|
137
|
+
retryable: false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (exactRequest.terminalSessionId !== undefined &&
|
|
141
|
+
acknowledgement.sessionId !== exactRequest.terminalSessionId) {
|
|
142
|
+
await closeQuietly(stream);
|
|
143
|
+
return {
|
|
144
|
+
status: "unknown",
|
|
145
|
+
message: "the Tangle terminal transport attached a different terminal session",
|
|
146
|
+
retryable: false,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (!Number.isSafeInteger(acknowledgement.detachTimeoutMs) ||
|
|
150
|
+
acknowledgement.detachTimeoutMs <= 0) {
|
|
151
|
+
// The detach window is the only bound on how long the runtime keeps
|
|
152
|
+
// this PTY, so without it the reference cannot state an expiry and
|
|
153
|
+
// every later call would be unbounded.
|
|
154
|
+
await closeQuietly(stream);
|
|
155
|
+
return {
|
|
156
|
+
status: "unknown",
|
|
157
|
+
message: "the Tangle terminal transport reported no detach window",
|
|
158
|
+
retryable: false,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
let info;
|
|
162
|
+
try {
|
|
163
|
+
info = await awaitWithSignal(terminals.get(acknowledgement.sessionId), options?.signal);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
options?.signal?.throwIfAborted();
|
|
167
|
+
await closeQuietly(stream);
|
|
168
|
+
return {
|
|
169
|
+
status: "unknown",
|
|
170
|
+
message: transportFailureReason("terminal metadata read", error),
|
|
171
|
+
retryable: true,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (info === null || info === undefined) {
|
|
175
|
+
await closeQuietly(stream);
|
|
176
|
+
return {
|
|
177
|
+
status: "unknown",
|
|
178
|
+
message: "the Tangle runtime reported no metadata for the attached terminal",
|
|
179
|
+
retryable: true,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const state = terminalStateFromInfo(info, acknowledgement, exactRequest.parentExecutionId, activity.localMs);
|
|
183
|
+
if (typeof state === "string") {
|
|
184
|
+
await closeQuietly(stream);
|
|
185
|
+
return { status: "unknown", message: state, retryable: false };
|
|
186
|
+
}
|
|
187
|
+
const previous = attached.get(state.terminalSessionId);
|
|
188
|
+
state.attachCount =
|
|
189
|
+
previous === undefined ? 1 : previous.session.ref.attachCount + 1;
|
|
190
|
+
// A handle drops only the entry it still owns, matched by the socket it
|
|
191
|
+
// was built on. A later attach replaces that entry with its own socket,
|
|
192
|
+
// so a stale handle's detach cannot evict the terminal now held.
|
|
193
|
+
const release = () => {
|
|
194
|
+
if (attached.get(state.terminalSessionId)?.stream === stream) {
|
|
195
|
+
attached.delete(state.terminalSessionId);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const session = createTangleTerminalSession(stream, log, state, activity, exit, release);
|
|
199
|
+
attached.set(state.terminalSessionId, { session, stream });
|
|
200
|
+
// The registry holds one socket per terminal. The socket this attach
|
|
201
|
+
// replaces stays open on the runtime until its own close, so it is
|
|
202
|
+
// closed here rather than abandoned with the handle that owned it.
|
|
203
|
+
if (previous !== undefined)
|
|
204
|
+
await closeQuietly(previous.stream);
|
|
205
|
+
return {
|
|
206
|
+
status: acknowledgement.restored === true ? "reattached" : "attached",
|
|
207
|
+
mode: "attach",
|
|
208
|
+
ref: session.ref,
|
|
209
|
+
attachCount: state.attachCount,
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
get(terminalSessionId) {
|
|
213
|
+
const held = attached.get(terminalSessionId);
|
|
214
|
+
if (held === undefined) {
|
|
215
|
+
throw new Error("Tangle terminal is not attached through this environment");
|
|
216
|
+
}
|
|
217
|
+
return held.session;
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Build the terminal facts from what the runtime reported. Every field a
|
|
223
|
+
* terminal reference must state is required here: a runtime that omits one
|
|
224
|
+
* cannot be described, so the attach fails closed rather than inventing a
|
|
225
|
+
* shell, a directory, or a geometry the PTY does not have.
|
|
226
|
+
*/
|
|
227
|
+
function terminalStateFromInfo(info, ready, parentExecutionId, localLastActivityMs) {
|
|
228
|
+
const name = boundedLabel(info.name);
|
|
229
|
+
if (name === undefined)
|
|
230
|
+
return "the Tangle runtime reported a terminal without a name";
|
|
231
|
+
const shell = boundedLabel(info.shell);
|
|
232
|
+
if (shell === undefined)
|
|
233
|
+
return "the Tangle runtime reported a terminal without a shell";
|
|
234
|
+
const cwd = boundedLabel(info.cwd);
|
|
235
|
+
if (cwd === undefined) {
|
|
236
|
+
return "the Tangle runtime reported a terminal without a working directory";
|
|
237
|
+
}
|
|
238
|
+
const cols = terminalDimension(info.cols);
|
|
239
|
+
const rows = terminalDimension(info.rows);
|
|
240
|
+
if (cols === undefined || rows === undefined) {
|
|
241
|
+
return "the Tangle runtime reported a terminal without a valid geometry";
|
|
242
|
+
}
|
|
243
|
+
const createdAt = isoTimestamp(info.createdAt);
|
|
244
|
+
const lastActivityMs = epochMs(info.lastActivityAt);
|
|
245
|
+
if (createdAt === undefined || lastActivityMs === undefined) {
|
|
246
|
+
return "the Tangle runtime reported a terminal without valid timestamps";
|
|
247
|
+
}
|
|
248
|
+
if (typeof info.isRunning !== "boolean") {
|
|
249
|
+
return "the Tangle runtime reported a terminal without a running state";
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
terminalSessionId: ready.sessionId,
|
|
253
|
+
parentExecutionId,
|
|
254
|
+
connectionId: ready.connectionId,
|
|
255
|
+
name,
|
|
256
|
+
shell,
|
|
257
|
+
...(boundedLabel(info.command) === undefined
|
|
258
|
+
? {}
|
|
259
|
+
: { command: boundedLabel(info.command) }),
|
|
260
|
+
cwd,
|
|
261
|
+
cols,
|
|
262
|
+
rows,
|
|
263
|
+
createdAt,
|
|
264
|
+
runtimeLastActivityMs: lastActivityMs,
|
|
265
|
+
localLastActivityMs,
|
|
266
|
+
detachTimeoutMs: ready.detachTimeoutMs,
|
|
267
|
+
isRunning: info.isRunning,
|
|
268
|
+
...(Number.isSafeInteger(info.exitCode) ? { exitCode: info.exitCode } : {}),
|
|
269
|
+
...(boundedLabel(info.exitSignal) === undefined
|
|
270
|
+
? {}
|
|
271
|
+
: { exitSignal: boundedLabel(info.exitSignal) }),
|
|
272
|
+
attachCount: 1,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function createTangleTerminalSession(stream, log, state, activity, exit, release) {
|
|
276
|
+
// A reference describes what this handle can do with its own socket. The
|
|
277
|
+
// runtime keeps a detached PTY alive, but a closed socket carries no input
|
|
278
|
+
// and no output, so a detached or closed handle reports the terminal as not
|
|
279
|
+
// running and every operation that needs a live socket is denied. The handle
|
|
280
|
+
// releases its registry entry at that same instant, before the socket close
|
|
281
|
+
// it cannot un-commit, so a close that fails mid-flight leaks no entry.
|
|
282
|
+
let detached = false;
|
|
283
|
+
const socketOpen = () => detached === false && stream.isOpen !== false;
|
|
284
|
+
const currentRef = () => {
|
|
285
|
+
const lastActivityMs = Math.max(state.runtimeLastActivityMs, activity.localMs);
|
|
286
|
+
return TerminalSessionRefSchema.parse({
|
|
287
|
+
terminalSessionId: state.terminalSessionId,
|
|
288
|
+
parentExecutionId: state.parentExecutionId,
|
|
289
|
+
name: state.name,
|
|
290
|
+
shell: state.shell,
|
|
291
|
+
...(state.command === undefined ? {} : { command: state.command }),
|
|
292
|
+
cwd: state.cwd,
|
|
293
|
+
cols: state.cols,
|
|
294
|
+
rows: state.rows,
|
|
295
|
+
connectionId: state.connectionId,
|
|
296
|
+
createdAt: state.createdAt,
|
|
297
|
+
lastActivityAt: new Date(lastActivityMs).toISOString(),
|
|
298
|
+
// The runtime keeps a detached PTY for its detach window and no longer,
|
|
299
|
+
// so the reference expires one window after the last activity it can
|
|
300
|
+
// prove. An attached socket keeps renewing that instant, and a reference
|
|
301
|
+
// read after the window denies use instead of guessing.
|
|
302
|
+
expiresAt: new Date(lastActivityMs + state.detachTimeoutMs).toISOString(),
|
|
303
|
+
isRunning: state.isRunning && !exit.seen && socketOpen(),
|
|
304
|
+
...(exit.seen && Number.isSafeInteger(exit.exitCode)
|
|
305
|
+
? { exitCode: exit.exitCode }
|
|
306
|
+
: state.exitCode === undefined
|
|
307
|
+
? {}
|
|
308
|
+
: { exitCode: state.exitCode }),
|
|
309
|
+
...(exit.seen && typeof exit.exitSignal === "string" && exit.exitSignal.length > 0
|
|
310
|
+
? { exitSignal: exit.exitSignal }
|
|
311
|
+
: state.exitSignal === undefined
|
|
312
|
+
? {}
|
|
313
|
+
: { exitSignal: state.exitSignal }),
|
|
314
|
+
attachCount: state.attachCount,
|
|
315
|
+
});
|
|
316
|
+
};
|
|
317
|
+
const assertUsable = (operation) => {
|
|
318
|
+
if (!terminalSessionUsable(currentRef(), new Date().toISOString())) {
|
|
319
|
+
throw new Error(`Tangle terminal ${operation} requires a live, unexpired terminal`);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
return {
|
|
323
|
+
get ref() {
|
|
324
|
+
return currentRef();
|
|
325
|
+
},
|
|
326
|
+
get cursors() {
|
|
327
|
+
return log.cursors;
|
|
328
|
+
},
|
|
329
|
+
async input(input, options) {
|
|
330
|
+
assertOptionKeys(options, ["signal"], "Tangle terminal input");
|
|
331
|
+
const exactInput = TerminalInputSchema.parse(input);
|
|
332
|
+
options?.signal?.throwIfAborted();
|
|
333
|
+
assertUsable("input");
|
|
334
|
+
stream.write(exactInput.data);
|
|
335
|
+
activity.localMs = Date.now();
|
|
336
|
+
},
|
|
337
|
+
async resize(resize, options) {
|
|
338
|
+
assertOptionKeys(options, ["signal"], "Tangle terminal resize");
|
|
339
|
+
const exactResize = TerminalResizeSchema.parse(resize);
|
|
340
|
+
options?.signal?.throwIfAborted();
|
|
341
|
+
assertUsable("resize");
|
|
342
|
+
stream.resize(exactResize.cols, exactResize.rows);
|
|
343
|
+
state.cols = exactResize.cols;
|
|
344
|
+
state.rows = exactResize.rows;
|
|
345
|
+
activity.localMs = Date.now();
|
|
346
|
+
// The runtime does not echo a geometry change, so the ordered frame log
|
|
347
|
+
// records it here; a consumer replaying frames sees the resize in order
|
|
348
|
+
// with the output it applies to.
|
|
349
|
+
log.append({ type: "resize", cols: exactResize.cols, rows: exactResize.rows });
|
|
350
|
+
},
|
|
351
|
+
async detach(options) {
|
|
352
|
+
assertOptionKeys(options, ["signal"], "Tangle terminal detach");
|
|
353
|
+
options?.signal?.throwIfAborted();
|
|
354
|
+
detached = true;
|
|
355
|
+
release();
|
|
356
|
+
await awaitWithSignal(stream.close(), options?.signal);
|
|
357
|
+
state.attachCount = Math.max(0, state.attachCount - 1);
|
|
358
|
+
return {
|
|
359
|
+
status: "detached",
|
|
360
|
+
terminalSessionId: state.terminalSessionId,
|
|
361
|
+
connectionId: state.connectionId,
|
|
362
|
+
};
|
|
363
|
+
},
|
|
364
|
+
async close(options) {
|
|
365
|
+
assertOptionKeys(options, ["signal"], "Tangle terminal close");
|
|
366
|
+
options?.signal?.throwIfAborted();
|
|
367
|
+
detached = true;
|
|
368
|
+
release();
|
|
369
|
+
await awaitWithSignal(stream.close(), options?.signal);
|
|
370
|
+
state.attachCount = Math.max(0, state.attachCount - 1);
|
|
371
|
+
if (exit.seen) {
|
|
372
|
+
state.isRunning = false;
|
|
373
|
+
return {
|
|
374
|
+
status: "closed",
|
|
375
|
+
terminalSessionId: state.terminalSessionId,
|
|
376
|
+
...(Number.isSafeInteger(exit.exitCode) ? { exitCode: exit.exitCode } : {}),
|
|
377
|
+
...(typeof exit.exitSignal === "string" && exit.exitSignal.length > 0
|
|
378
|
+
? { exitSignal: exit.exitSignal }
|
|
379
|
+
: {}),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
// Sandbox exposes no terminal delete, so a socket close only detaches:
|
|
383
|
+
// the PTY survives until its detach window expires. Reporting `closed`
|
|
384
|
+
// would claim a termination this adapter cannot prove.
|
|
385
|
+
return {
|
|
386
|
+
status: "unknown",
|
|
387
|
+
terminalSessionId: state.terminalSessionId,
|
|
388
|
+
message: "the Sandbox client exposes no terminal delete; the runtime keeps this PTY until its detach window expires",
|
|
389
|
+
retryable: false,
|
|
390
|
+
};
|
|
391
|
+
},
|
|
392
|
+
events(options) {
|
|
393
|
+
assertOptionKeys(options, ["since", "signal"], "Tangle terminal events");
|
|
394
|
+
// The absent cursor stays absent: the frame log resolves it to the oldest
|
|
395
|
+
// frame it still retains. Naming 0 here would hand a fresh consumer the
|
|
396
|
+
// one cursor the log refuses once the buffer has evicted an output frame.
|
|
397
|
+
return log.read(options?.since, options?.signal);
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
async function closeQuietly(stream) {
|
|
402
|
+
try {
|
|
403
|
+
await stream.close();
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
// The attach already failed, and the socket is being abandoned. The
|
|
407
|
+
// caller's failure is reported from the attach result it receives.
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function boundedLabel(value) {
|
|
411
|
+
return typeof value === "string" && value.length > 0 && value.length <= 512
|
|
412
|
+
? value
|
|
413
|
+
: undefined;
|
|
414
|
+
}
|
|
415
|
+
function terminalDimension(value) {
|
|
416
|
+
return typeof value === "number" &&
|
|
417
|
+
Number.isSafeInteger(value) &&
|
|
418
|
+
value > 0 &&
|
|
419
|
+
value <= MAX_TERMINAL_DIMENSION
|
|
420
|
+
? value
|
|
421
|
+
: undefined;
|
|
422
|
+
}
|
|
423
|
+
function epochMs(value) {
|
|
424
|
+
if (typeof value !== "string")
|
|
425
|
+
return undefined;
|
|
426
|
+
const time = Date.parse(value);
|
|
427
|
+
return Number.isFinite(time) ? time : undefined;
|
|
428
|
+
}
|
|
429
|
+
function isoTimestamp(value) {
|
|
430
|
+
const time = epochMs(value);
|
|
431
|
+
return time === undefined ? undefined : new Date(time).toISOString();
|
|
432
|
+
}
|
|
433
|
+
function socketErrorFrame(error) {
|
|
434
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
435
|
+
const trimmed = message.trim();
|
|
436
|
+
return trimmed.length === 0
|
|
437
|
+
? "the Tangle terminal transport failed without a message"
|
|
438
|
+
: trimmed.slice(0, MAX_STRING_LENGTH);
|
|
439
|
+
}
|