@myagentroam/marctl 0.10.1 → 0.10.2
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/package.json +1 -1
- package/src/client.mjs +8 -0
- package/src/direct-process.mjs +351 -0
- package/src/direct.mjs +23 -17
- package/src/terminal.mjs +4 -2
package/package.json
CHANGED
package/src/client.mjs
CHANGED
|
@@ -86,6 +86,14 @@ export class MarctlClient {
|
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
async openDirectTerminal(options) {
|
|
90
|
+
const { openDirectTerminalProcess } = await import('./direct-process.mjs');
|
|
91
|
+
return openDirectTerminalProcess({
|
|
92
|
+
...options,
|
|
93
|
+
connection: { serverUrl: this.#serverUrl, key: this.#key }
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
89
97
|
async createAttachTicket({ nodeId, terminalId, afterSequence }) {
|
|
90
98
|
validateNodeId(nodeId, this.#translate);
|
|
91
99
|
validateTerminalId(terminalId, this.#translate);
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { DirectFallbackError, DirectUnsafeError } from './direct.mjs';
|
|
5
|
+
|
|
6
|
+
const CLEANUP_TIMEOUT_MS = 1000;
|
|
7
|
+
const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
const ipcBytes = new WeakMap();
|
|
9
|
+
|
|
10
|
+
function sendIpc(target, message, onError = () => {}, onSent = () => {}) {
|
|
11
|
+
const bytes = Buffer.byteLength(JSON.stringify(message));
|
|
12
|
+
const pending = ipcBytes.get(target) ?? 0;
|
|
13
|
+
if (!target.connected || pending + bytes > MAX_BUFFER_BYTES) {
|
|
14
|
+
onError(new Error('TERMINAL_CONNECTION_LOST'));
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
ipcBytes.set(target, pending + bytes);
|
|
18
|
+
try {
|
|
19
|
+
target.send(message, (error) => {
|
|
20
|
+
ipcBytes.set(target, (ipcBytes.get(target) ?? 0) - bytes);
|
|
21
|
+
if (error) onError(error);
|
|
22
|
+
else onSent();
|
|
23
|
+
});
|
|
24
|
+
return true;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
ipcBytes.set(target, (ipcBytes.get(target) ?? 0) - bytes);
|
|
27
|
+
onError(error);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function openDirectTerminalProcess({
|
|
33
|
+
connection,
|
|
34
|
+
nodeId,
|
|
35
|
+
terminalId,
|
|
36
|
+
manifest,
|
|
37
|
+
afterSequence = 0,
|
|
38
|
+
timeoutMs = 15000,
|
|
39
|
+
heartbeatMs = 10000,
|
|
40
|
+
onPrepared,
|
|
41
|
+
onIceConnected,
|
|
42
|
+
onTransportReady,
|
|
43
|
+
signal,
|
|
44
|
+
forkImpl = fork
|
|
45
|
+
}) {
|
|
46
|
+
if (signal?.aborted) return Promise.reject(new DirectFallbackError('DIRECT_RACE_CANCELLED'));
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
let child;
|
|
49
|
+
try {
|
|
50
|
+
child = forkImpl(new URL('./direct-process.mjs', import.meta.url), [], {
|
|
51
|
+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
52
|
+
execArgv: []
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
reject(new DirectFallbackError('DIRECT_UNAVAILABLE'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let settled = false;
|
|
59
|
+
let opening = false;
|
|
60
|
+
let stopping = false;
|
|
61
|
+
let exited = false;
|
|
62
|
+
let cleanupTimer;
|
|
63
|
+
const stop = () => {
|
|
64
|
+
if (stopping) return;
|
|
65
|
+
stopping = true;
|
|
66
|
+
clearTimeout(setupTimer);
|
|
67
|
+
signal?.removeEventListener('abort', onAbort);
|
|
68
|
+
if (exited) return;
|
|
69
|
+
sendIpc(child, { type: 'abort' });
|
|
70
|
+
// Native cleanup may block. Only this connection's process is terminated.
|
|
71
|
+
cleanupTimer = setTimeout(() => child.kill('SIGKILL'), CLEANUP_TIMEOUT_MS);
|
|
72
|
+
};
|
|
73
|
+
const transport = new IsolatedDirectTransport(child, stop);
|
|
74
|
+
const fail = (error) => {
|
|
75
|
+
if (settled) {
|
|
76
|
+
transport.fail(error);
|
|
77
|
+
} else {
|
|
78
|
+
settled = true;
|
|
79
|
+
reject(error);
|
|
80
|
+
stop();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const setupError = () =>
|
|
84
|
+
opening
|
|
85
|
+
? new DirectUnsafeError('DIRECT_OPEN_CANCEL_UNCONFIRMED')
|
|
86
|
+
: new DirectFallbackError('DIRECT_UNAVAILABLE');
|
|
87
|
+
const setupTimer = setTimeout(() => fail(setupError()), timeoutMs);
|
|
88
|
+
const onAbort = () =>
|
|
89
|
+
fail(
|
|
90
|
+
opening
|
|
91
|
+
? new DirectUnsafeError('DIRECT_OPEN_CANCEL_UNCONFIRMED')
|
|
92
|
+
: new DirectFallbackError('DIRECT_RACE_CANCELLED')
|
|
93
|
+
);
|
|
94
|
+
child.on('message', (message) => {
|
|
95
|
+
if (message.type === 'callback') {
|
|
96
|
+
const callback = { prepared: onPrepared, ice: onIceConnected, transport: onTransportReady }[
|
|
97
|
+
message.event
|
|
98
|
+
];
|
|
99
|
+
if (message.event === 'opening') opening = true;
|
|
100
|
+
Promise.resolve()
|
|
101
|
+
.then(() => {
|
|
102
|
+
if (stopping) throw new DirectFallbackError('DIRECT_RACE_CANCELLED');
|
|
103
|
+
return callback?.();
|
|
104
|
+
})
|
|
105
|
+
.then(
|
|
106
|
+
() =>
|
|
107
|
+
sendIpc(child, { type: 'callback.result', id: message.id }, () => fail(setupError())),
|
|
108
|
+
() => sendIpc(child, { type: 'callback.result', id: message.id, failed: true })
|
|
109
|
+
);
|
|
110
|
+
} else if (message.type === 'attached' && !settled) {
|
|
111
|
+
settled = true;
|
|
112
|
+
clearTimeout(setupTimer);
|
|
113
|
+
resolve(transport);
|
|
114
|
+
} else if (message.type === 'failure') {
|
|
115
|
+
const error = settled
|
|
116
|
+
? new Error(message.code ?? 'TERMINAL_CONNECTION_LOST')
|
|
117
|
+
: message.name === 'DirectUnsafeError' ||
|
|
118
|
+
(opening && message.name !== 'DirectFallbackError')
|
|
119
|
+
? new DirectUnsafeError(message.code)
|
|
120
|
+
: new DirectFallbackError(message.code);
|
|
121
|
+
fail(error);
|
|
122
|
+
} else if (message.type === 'frame') {
|
|
123
|
+
transport.frame(message.frame, message.isBinary);
|
|
124
|
+
} else if (message.type === 'send.result') {
|
|
125
|
+
transport.written(message.id, message.code);
|
|
126
|
+
} else if (message.type === 'finished') {
|
|
127
|
+
clearTimeout(cleanupTimer);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
child.on('error', () => fail(setupError()));
|
|
131
|
+
child.on('disconnect', () => {
|
|
132
|
+
if (!stopping) fail(setupError());
|
|
133
|
+
});
|
|
134
|
+
child.on('exit', () => {
|
|
135
|
+
exited = true;
|
|
136
|
+
clearTimeout(cleanupTimer);
|
|
137
|
+
clearTimeout(setupTimer);
|
|
138
|
+
signal?.removeEventListener('abort', onAbort);
|
|
139
|
+
if (!stopping) fail(setupError());
|
|
140
|
+
});
|
|
141
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
142
|
+
sendIpc(
|
|
143
|
+
child,
|
|
144
|
+
{
|
|
145
|
+
type: 'start',
|
|
146
|
+
options: { connection, nodeId, terminalId, manifest, afterSequence, timeoutMs, heartbeatMs }
|
|
147
|
+
},
|
|
148
|
+
() => fail(setupError())
|
|
149
|
+
);
|
|
150
|
+
if (signal?.aborted) onAbort();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
class IsolatedDirectTransport extends EventEmitter {
|
|
155
|
+
#child;
|
|
156
|
+
#stop;
|
|
157
|
+
#active = false;
|
|
158
|
+
#closed = false;
|
|
159
|
+
#events = [];
|
|
160
|
+
#bytes = 0;
|
|
161
|
+
#writes = new Map();
|
|
162
|
+
#writeBytes = 0;
|
|
163
|
+
#nextId = 0;
|
|
164
|
+
|
|
165
|
+
constructor(child, stop) {
|
|
166
|
+
super();
|
|
167
|
+
this.#child = child;
|
|
168
|
+
this.#stop = stop;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
activate() {
|
|
172
|
+
if (this.#active) return;
|
|
173
|
+
this.#active = true;
|
|
174
|
+
const events = this.#events;
|
|
175
|
+
this.#events = [];
|
|
176
|
+
this.#bytes = 0;
|
|
177
|
+
for (const [type, value, isBinary] of events) this.#emit(type, value, isBinary);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
frame(frame, isBinary = false) {
|
|
181
|
+
if (this.#closed) return;
|
|
182
|
+
if (!this.#active) {
|
|
183
|
+
this.#bytes += Buffer.byteLength(frame);
|
|
184
|
+
if (this.#bytes > MAX_BUFFER_BYTES)
|
|
185
|
+
return this.fail(new Error('TERMINAL_DIRECT_BUFFER_EXCEEDED'));
|
|
186
|
+
}
|
|
187
|
+
this.#emit('message', frame, isBinary);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
send(frame) {
|
|
191
|
+
if (this.#closed) return Promise.reject(new Error('TERMINAL_CONNECTION_LOST'));
|
|
192
|
+
const value = typeof frame === 'string' ? frame : JSON.stringify(frame);
|
|
193
|
+
const bytes = Buffer.byteLength(value);
|
|
194
|
+
if (this.#writeBytes + bytes > MAX_BUFFER_BYTES)
|
|
195
|
+
return Promise.reject(new Error('TERMINAL_DIRECT_BUFFER_EXCEEDED'));
|
|
196
|
+
const id = ++this.#nextId;
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
this.#writeBytes += bytes;
|
|
199
|
+
this.#writes.set(id, { resolve, reject, bytes });
|
|
200
|
+
sendIpc(this.#child, { type: 'send', id, frame: value }, () =>
|
|
201
|
+
this.fail(new Error('TERMINAL_CONNECTION_LOST'))
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
written(id, code) {
|
|
207
|
+
const write = this.#writes.get(id);
|
|
208
|
+
if (!write) return;
|
|
209
|
+
this.#writes.delete(id);
|
|
210
|
+
this.#writeBytes -= write.bytes;
|
|
211
|
+
if (code) write.reject(new Error(code));
|
|
212
|
+
else write.resolve();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
fail(error) {
|
|
216
|
+
if (this.#closed) return;
|
|
217
|
+
this.#emit(
|
|
218
|
+
'error',
|
|
219
|
+
new Error(
|
|
220
|
+
error instanceof DirectFallbackError || error instanceof DirectUnsafeError
|
|
221
|
+
? 'TERMINAL_CONNECTION_LOST'
|
|
222
|
+
: error.message
|
|
223
|
+
)
|
|
224
|
+
);
|
|
225
|
+
this.close();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
close() {
|
|
229
|
+
if (this.#closed) return;
|
|
230
|
+
this.#closed = true;
|
|
231
|
+
for (const { reject } of this.#writes.values()) reject(new Error('TERMINAL_CONNECTION_LOST'));
|
|
232
|
+
this.#writes.clear();
|
|
233
|
+
this.#writeBytes = 0;
|
|
234
|
+
this.#stop();
|
|
235
|
+
this.#emit('close');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#emit(type, value, isBinary = false) {
|
|
239
|
+
if (!this.#active) this.#events.push([type, value, isBinary]);
|
|
240
|
+
else if (type !== 'error' || this.listenerCount('error') > 0) this.emit(type, value, isBinary);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function runChild() {
|
|
245
|
+
const controller = new AbortController();
|
|
246
|
+
const callbacks = new Map();
|
|
247
|
+
let nextId = 0;
|
|
248
|
+
let transport;
|
|
249
|
+
let native;
|
|
250
|
+
let cleaning = false;
|
|
251
|
+
const cleanup = () => {
|
|
252
|
+
if (cleaning) return;
|
|
253
|
+
cleaning = true;
|
|
254
|
+
setImmediate(() => {
|
|
255
|
+
try {
|
|
256
|
+
native?.cleanup();
|
|
257
|
+
} catch {
|
|
258
|
+
/* Process exit also releases native resources. */
|
|
259
|
+
}
|
|
260
|
+
sendIpc(
|
|
261
|
+
process,
|
|
262
|
+
{ type: 'finished' },
|
|
263
|
+
() => process.exit(0),
|
|
264
|
+
() => process.exit(0)
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
const callback = (event) =>
|
|
269
|
+
new Promise((resolve, reject) => {
|
|
270
|
+
const id = ++nextId;
|
|
271
|
+
callbacks.set(id, { resolve, reject });
|
|
272
|
+
sendIpc(process, { type: 'callback', id, event }, reject);
|
|
273
|
+
});
|
|
274
|
+
const abort = () => {
|
|
275
|
+
controller.abort();
|
|
276
|
+
for (const { reject } of callbacks.values())
|
|
277
|
+
reject(new DirectFallbackError('DIRECT_RACE_CANCELLED'));
|
|
278
|
+
callbacks.clear();
|
|
279
|
+
if (transport) transport.close();
|
|
280
|
+
};
|
|
281
|
+
process.on('disconnect', () => {
|
|
282
|
+
abort();
|
|
283
|
+
cleanup();
|
|
284
|
+
});
|
|
285
|
+
process.on('message', async (message) => {
|
|
286
|
+
if (message.type === 'callback.result') {
|
|
287
|
+
const pending = callbacks.get(message.id);
|
|
288
|
+
callbacks.delete(message.id);
|
|
289
|
+
if (message.failed) pending?.reject(new DirectFallbackError('DIRECT_RACE_CANCELLED'));
|
|
290
|
+
else pending?.resolve();
|
|
291
|
+
} else if (message.type === 'abort') {
|
|
292
|
+
abort();
|
|
293
|
+
} else if (message.type === 'send') {
|
|
294
|
+
try {
|
|
295
|
+
await transport.send(message.frame);
|
|
296
|
+
sendIpc(process, { type: 'send.result', id: message.id });
|
|
297
|
+
} catch {
|
|
298
|
+
sendIpc(process, { type: 'send.result', id: message.id, code: 'TERMINAL_INPUT_FAILED' });
|
|
299
|
+
}
|
|
300
|
+
} else if (message.type === 'start') {
|
|
301
|
+
try {
|
|
302
|
+
const { MarctlClient } = await import('./client.mjs');
|
|
303
|
+
const { openDirectTerminal } = await import('./direct.mjs');
|
|
304
|
+
const { default: WebSocketClass } = await import('ws');
|
|
305
|
+
native = await import('node-datachannel');
|
|
306
|
+
const { connection, ...options } = message.options;
|
|
307
|
+
let iceSelection;
|
|
308
|
+
transport = await openDirectTerminal({
|
|
309
|
+
...options,
|
|
310
|
+
client: new MarctlClient(connection),
|
|
311
|
+
WebSocketClass,
|
|
312
|
+
peerConnectionFactory: (name, config) => new native.PeerConnection(name, config),
|
|
313
|
+
cleanupNative: cleanup,
|
|
314
|
+
signal: controller.signal,
|
|
315
|
+
onPrepared: () => callback('prepared'),
|
|
316
|
+
onIceConnected: () => {
|
|
317
|
+
iceSelection = callback('ice');
|
|
318
|
+
iceSelection.catch(() => controller.abort());
|
|
319
|
+
},
|
|
320
|
+
onTransportReady: async () => {
|
|
321
|
+
await iceSelection;
|
|
322
|
+
await callback('transport');
|
|
323
|
+
},
|
|
324
|
+
beforeOpen: () => callback('opening')
|
|
325
|
+
});
|
|
326
|
+
sendIpc(process, { type: 'attached' });
|
|
327
|
+
transport.on('message', (frame, isBinary) =>
|
|
328
|
+
sendIpc(process, { type: 'frame', frame: String(frame), isBinary }, abort)
|
|
329
|
+
);
|
|
330
|
+
transport.on('error', (error) => {
|
|
331
|
+
sendIpc(process, { type: 'failure', code: error.message });
|
|
332
|
+
abort();
|
|
333
|
+
});
|
|
334
|
+
transport.on('close', () => {
|
|
335
|
+
sendIpc(process, { type: 'failure', code: 'TERMINAL_DIRECT_CLOSED' });
|
|
336
|
+
cleanup();
|
|
337
|
+
});
|
|
338
|
+
transport.activate();
|
|
339
|
+
} catch (error) {
|
|
340
|
+
sendIpc(process, {
|
|
341
|
+
type: 'failure',
|
|
342
|
+
name: error instanceof DirectUnsafeError ? 'DirectUnsafeError' : 'DirectFallbackError',
|
|
343
|
+
code: error.code ?? 'DIRECT_UNAVAILABLE'
|
|
344
|
+
});
|
|
345
|
+
cleanup();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (process.send && process.argv[1] === fileURLToPath(import.meta.url)) runChild();
|
package/src/direct.mjs
CHANGED
|
@@ -22,21 +22,26 @@ export class DirectUnsafeError extends Error {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export async function openDirectTerminal({
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
25
|
+
export async function openDirectTerminal(options) {
|
|
26
|
+
const {
|
|
27
|
+
client,
|
|
28
|
+
WebSocketClass,
|
|
29
|
+
nodeId,
|
|
30
|
+
terminalId,
|
|
31
|
+
afterSequence = 0,
|
|
32
|
+
timeoutMs = DIRECT_SETUP_TIMEOUT_MS,
|
|
33
|
+
heartbeatMs = DIRECT_HEARTBEAT_MS,
|
|
34
|
+
peerConnectionFactory,
|
|
35
|
+
onPrepared,
|
|
36
|
+
onIceConnected,
|
|
37
|
+
onTransportReady,
|
|
38
|
+
beforeOpen,
|
|
39
|
+
cleanupNative: cleanupNativeImpl,
|
|
40
|
+
signal
|
|
41
|
+
} = options;
|
|
42
|
+
let { manifest } = options;
|
|
43
|
+
if (peerConnectionFactory === undefined && typeof client.openDirectTerminal === 'function')
|
|
44
|
+
return client.openDirectTerminal(options);
|
|
40
45
|
if (manifest === undefined && typeof client.getTerminalManifest !== 'function')
|
|
41
46
|
throw new DirectFallbackError('DIRECT_MANIFEST_UNAVAILABLE');
|
|
42
47
|
|
|
@@ -50,7 +55,7 @@ export async function openDirectTerminal({
|
|
|
50
55
|
let unsafeFallback = false;
|
|
51
56
|
let transportControlCleanup;
|
|
52
57
|
let transportNegotiationActive = true;
|
|
53
|
-
let cleanupNative;
|
|
58
|
+
let cleanupNative = cleanupNativeImpl;
|
|
54
59
|
try {
|
|
55
60
|
manifest ??= await client.getTerminalManifest(nodeId);
|
|
56
61
|
if (
|
|
@@ -82,7 +87,7 @@ export async function openDirectTerminal({
|
|
|
82
87
|
|
|
83
88
|
const native =
|
|
84
89
|
peerConnectionFactory === undefined ? await import('node-datachannel') : undefined;
|
|
85
|
-
cleanupNative
|
|
90
|
+
cleanupNative ??= native?.cleanup;
|
|
86
91
|
const createPeer =
|
|
87
92
|
peerConnectionFactory ?? ((name, options) => new native.PeerConnection(name, options));
|
|
88
93
|
peer = createPeer(`marctl-${transportSessionId}`, { iceServers: [] });
|
|
@@ -252,6 +257,7 @@ export async function openDirectTerminal({
|
|
|
252
257
|
heartbeatMs,
|
|
253
258
|
cleanupNative
|
|
254
259
|
});
|
|
260
|
+
if (beforeOpen) await beforeOpen();
|
|
255
261
|
await withTimeout(
|
|
256
262
|
transport.waitForAttachment(terminalPrepared.grant),
|
|
257
263
|
timeoutMs,
|
package/src/terminal.mjs
CHANGED
|
@@ -583,7 +583,9 @@ function runAttachedTerminal({
|
|
|
583
583
|
const onResize = () => {
|
|
584
584
|
try {
|
|
585
585
|
const { rows, cols } = getDimensions(stdout);
|
|
586
|
-
send({ type: 'resize', terminalId, rows, cols })
|
|
586
|
+
Promise.resolve(send({ type: 'resize', terminalId, rows, cols })).catch(() =>
|
|
587
|
+
fail('errors.terminalResizeFailed')
|
|
588
|
+
);
|
|
587
589
|
} catch {
|
|
588
590
|
fail('errors.terminalResizeFailed');
|
|
589
591
|
}
|
|
@@ -686,7 +688,7 @@ function rawToString(raw) {
|
|
|
686
688
|
function sendCloseFrame(socket, terminalId) {
|
|
687
689
|
if (!terminalId || !isSocketOpen(socket)) return;
|
|
688
690
|
try {
|
|
689
|
-
socket.send(JSON.stringify({ type: 'close', terminalId }));
|
|
691
|
+
Promise.resolve(socket.send(JSON.stringify({ type: 'close', terminalId }))).catch(() => {});
|
|
690
692
|
} catch {
|
|
691
693
|
// The DELETE request below remains the authoritative cleanup path.
|
|
692
694
|
}
|