-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathruntime.ts
More file actions
140 lines (120 loc) · 6.41 KB
/
Copy pathruntime.ts
File metadata and controls
140 lines (120 loc) · 6.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
import type { ExecutionTerminalService, TerminalAuthority, TerminalPeer, ExecutorCallOptions } from '@garcon/server-agent-interface';
import type { TerminalCreateRequest, TerminalStreamClientMessage } from '../../../common/terminal.js';
import { parseTerminalReference } from '../../../common/terminal-identity.js';
import { TerminalError } from '../../../common/terminal-error.js';
import { TerminalManager, type TerminalManagerOptions } from './terminal-manager.js';
interface Subscription {
authority: TerminalAuthority;
peer: TerminalPeer;
timer: ReturnType<typeof setTimeout> | null;
}
/** Owns PTYs independently of replaceable controller/provider sessions. */
export class TerminalRuntime {
readonly id: string;
readonly #managers = new Map<string, TerminalManager>();
#stopped = false;
constructor(private readonly options: TerminalManagerOptions) {
this.id = options.terminalRuntimeId ?? crypto.randomUUID();
}
service(executorId: string): TerminalService {
if (this.#stopped) throw new TerminalError('terminal-unavailable', 'Terminal executor is stopping.', 503);
let manager = this.#managers.get(executorId);
if (!manager) {
manager = new TerminalManager({ ...this.options, executorId, terminalRuntimeId: this.id });
this.#managers.set(executorId, manager);
}
return new TerminalService(manager);
}
shutdown(): void {
this.#stopped = true;
for (const manager of this.#managers.values()) manager.shutdown();
this.#managers.clear();
}
}
export class TerminalService implements ExecutionTerminalService {
readonly #subscriptions = new Map<TerminalPeer, Subscription>();
#disposed = false;
#epoch = crypto.randomUUID();
constructor(readonly manager: TerminalManager) {}
async list(authority: TerminalAuthority, options?: ExecutorCallOptions) {
this.#check(authority, options);
return { success: true as const, terminalRuntimeId: this.manager.terminalRuntimeId, attachmentEpoch: this.#epoch, terminals: this.manager.list(authority) };
}
async create(authority: TerminalAuthority, request: TerminalCreateRequest, options?: ExecutorCallOptions) {
this.#check(authority, options);
if (request.executorId !== undefined && request.executorId !== this.manager.executorId) throw new TerminalError('terminal-validation', 'Terminal executor mismatch.');
if (!request.expectedTerminalRuntimeId) throw new TerminalError('terminal-validation', 'List terminals before creating a shell.');
return this.manager.create(authority, request);
}
async rename(authority: TerminalAuthority, terminalId: string, title: string | null, options?: ExecutorCallOptions) {
this.#check(authority, options); this.#reference(terminalId);
return this.manager.rename(authority, terminalId, title);
}
async terminate(authority: TerminalAuthority, terminalId: string, requestId: string, options?: ExecutorCallOptions) {
this.#check(authority, options); this.#reference(terminalId);
return this.manager.terminate(authority, terminalId, requestId);
}
async attach(authority: TerminalAuthority, peer: TerminalPeer, request: Extract<TerminalStreamClientMessage, { type: 'terminal-attach' }>) {
this.#check(authority); this.#reference(request.terminalId);
if (request.attachmentEpoch !== this.#epoch) throw new TerminalError('terminal-not-attached', 'Refresh terminal attachments after reconnecting.', 409);
let subscription = this.#subscriptions.get(peer);
if (!subscription) {
const current: Subscription = { authority, timer: null, peer: {
connectionId: peer.connectionId, ownedTerminalIds: peer.ownedTerminalIds,
sendTerminalMessage: (message) => {
if (this.#subscriptions.get(peer) !== current) return;
if (authority.expiresAtMs !== null && authority.expiresAtMs <= Date.now()) { this.detachPeer(authority, peer); return; }
peer.sendTerminalMessage(message);
},
} };
subscription = current;
this.#subscriptions.set(peer, current);
const expire = () => {
if (authority.expiresAtMs === null) return;
const delay = authority.expiresAtMs - Date.now();
if (delay <= 0) { this.detachPeer(authority, peer); return; }
current.timer = setTimeout(expire, Math.min(delay, 2_147_000_000));
current.timer.unref();
};
expire();
}
this.manager.attach(authority, subscription.peer, request);
}
async input(authority: TerminalAuthority, peer: TerminalPeer, terminalId: string, data: string) {
this.#check(authority); this.#reference(terminalId);
this.manager.input(authority, this.#peer(peer), terminalId, data);
}
async resize(authority: TerminalAuthority, peer: TerminalPeer, terminalId: string, cols: number, rows: number) {
this.#check(authority); this.#reference(terminalId);
this.manager.resize(authority, this.#peer(peer), terminalId, cols, rows);
}
detachPeer(authority: TerminalAuthority, peer: TerminalPeer): void {
const subscription = this.#subscriptions.get(peer);
if (!subscription) return;
this.#subscriptions.delete(peer);
if (subscription.timer) clearTimeout(subscription.timer);
this.manager.detachPeer(authority, subscription.peer);
}
disconnect(): void {
this.#epoch = crypto.randomUUID();
for (const [peer, subscription] of this.#subscriptions) this.detachPeer(subscription.authority, peer);
}
dispose(): void { this.#disposed = true; this.disconnect(); }
#peer(peer: TerminalPeer): TerminalPeer {
const subscription = this.#subscriptions.get(peer);
if (!subscription) throw new TerminalError('terminal-not-attached', 'Terminal attachment expired.', 409);
return subscription.peer;
}
#check(authority: TerminalAuthority, options?: ExecutorCallOptions): void {
options?.signal?.throwIfAborted();
if (this.#disposed) throw new TerminalError('terminal-unavailable', 'Terminal connection retired.', 503);
if (!authority.key || (authority.expiresAtMs !== null && authority.expiresAtMs <= Date.now())) {
throw new TerminalError('terminal-auth-expired', 'Terminal authorization expired.', 401);
}
}
#reference(id: string): void {
const ref = parseTerminalReference(id);
if (!ref || ref.executorId !== this.manager.executorId) throw new TerminalError('terminal-validation', 'Invalid terminal target.');
if (ref.terminalRuntimeId !== this.manager.terminalRuntimeId) throw new TerminalError('terminal-runtime-changed', 'Terminal executor restarted.', 409);
}
}