@vibedgc/sdk 0.6.4
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/LICENSE +201 -0
- package/README.md +53 -0
- package/dist/audit.d.ts +16 -0
- package/dist/audit.js +179 -0
- package/dist/changes.d.ts +26 -0
- package/dist/changes.js +377 -0
- package/dist/client.d.ts +53 -0
- package/dist/client.js +472 -0
- package/dist/errors.d.ts +53 -0
- package/dist/errors.js +70 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/dist/mcp-bridge.mjs +36 -0
- package/dist/policy.d.ts +128 -0
- package/dist/policy.js +795 -0
- package/dist/runtime.d.ts +31 -0
- package/dist/runtime.js +148 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +124 -0
- package/dist/session.d.ts +264 -0
- package/dist/session.js +1739 -0
- package/dist/state.d.ts +37 -0
- package/dist/state.js +218 -0
- package/dist/tools.d.ts +56 -0
- package/dist/tools.js +339 -0
- package/dist/transport.d.ts +72 -0
- package/dist/transport.js +495 -0
- package/dist/types.d.ts +362 -0
- package/dist/types.js +3 -0
- package/dist/usage.d.ts +50 -0
- package/dist/usage.js +149 -0
- package/package.json +40 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type Env } from "./types.ts";
|
|
2
|
+
export type Frame = Record<string, unknown>;
|
|
3
|
+
/** Protocol v14 event types (dgc/editor_protocol.py EVENT_FIELDS). Others are skipped. */
|
|
4
|
+
export declare const KNOWN_EVENTS: ReadonlySet<string>;
|
|
5
|
+
/** A timer that works for any length (Node timers overflow past ~24.8 days) or none (`null`). */
|
|
6
|
+
export declare function longTimer(ms: number | null, fire: () => void): () => void;
|
|
7
|
+
export declare class Transport {
|
|
8
|
+
readonly argv: readonly string[];
|
|
9
|
+
private readonly cwd;
|
|
10
|
+
private readonly env;
|
|
11
|
+
private child;
|
|
12
|
+
private buf;
|
|
13
|
+
private events;
|
|
14
|
+
private readers;
|
|
15
|
+
private requests;
|
|
16
|
+
private awaited;
|
|
17
|
+
private abandoned;
|
|
18
|
+
private lastSeq;
|
|
19
|
+
private readyFrame;
|
|
20
|
+
private readyWaiter;
|
|
21
|
+
private dead;
|
|
22
|
+
private closing;
|
|
23
|
+
private exited;
|
|
24
|
+
private stderr;
|
|
25
|
+
private readonly ignored;
|
|
26
|
+
constructor(argv: readonly string[], cwd: string, env: Env);
|
|
27
|
+
/** The runtime's process id, once started. */
|
|
28
|
+
get pid(): number | undefined;
|
|
29
|
+
/** The runtime's exit code, or null while it runs. */
|
|
30
|
+
get exitCode(): number | null;
|
|
31
|
+
/** The ready handshake, once received. */
|
|
32
|
+
get ready(): Frame | null;
|
|
33
|
+
/** The last lines of the runtime's stderr (bounded). */
|
|
34
|
+
get stderrTail(): string;
|
|
35
|
+
get closed(): boolean;
|
|
36
|
+
/** Event types skipped because this SDK does not know them yet, with counts. */
|
|
37
|
+
get ignoredEventTypes(): Record<string, number>;
|
|
38
|
+
/** Launch the runtime and wait for its `ready` handshake (protocol checked). */
|
|
39
|
+
start(timeoutMs: number): Promise<Frame>;
|
|
40
|
+
private onData;
|
|
41
|
+
private accept;
|
|
42
|
+
private takeable;
|
|
43
|
+
private dispatch;
|
|
44
|
+
/** Write one command. Throws DGCRuntimeError when the backend is gone. */
|
|
45
|
+
send(command: Frame): void;
|
|
46
|
+
/** Put an event back at the front of the stream. */
|
|
47
|
+
unread(frame: Frame): void;
|
|
48
|
+
/**
|
|
49
|
+
* The oldest event (matching `predicate`) nobody's request is waiting for. `timeoutMs` null
|
|
50
|
+
* waits without limit. Rejects DGCTimeoutError on timeout, DGCRuntimeError once the backend is
|
|
51
|
+
* gone and nothing is left.
|
|
52
|
+
*/
|
|
53
|
+
next(timeoutMs?: number | null, predicate?: (frame: Frame) => boolean): Promise<Frame>;
|
|
54
|
+
/**
|
|
55
|
+
* Send a command and wait for its reply. While waiting, other readers leave events for this
|
|
56
|
+
* request alone. A `command_rejected` or `error` for the same request throws
|
|
57
|
+
* DGCCommandRejectedError at once. `uncorrelatedReply` also accepts a `responseType` event
|
|
58
|
+
* without a request_id (commands acknowledged by a broadcast, such as clear_todos -> todos).
|
|
59
|
+
*/
|
|
60
|
+
request(command: Frame, responseType: string, options?: {
|
|
61
|
+
timeoutMs?: number | null;
|
|
62
|
+
uncorrelatedReply?: boolean;
|
|
63
|
+
}): Promise<Frame>;
|
|
64
|
+
private abandon;
|
|
65
|
+
private fail;
|
|
66
|
+
private signal;
|
|
67
|
+
/**
|
|
68
|
+
* Shut the runtime down: ask politely, then SIGTERM and SIGKILL its process group. Resolves once
|
|
69
|
+
* the child is gone (bounded to a few seconds).
|
|
70
|
+
*/
|
|
71
|
+
close(graceMs?: number): Promise<void>;
|
|
72
|
+
}
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `dgc serve` child and its NDJSON pipe. Mirrors the SDK copy of the wire client
|
|
3
|
+
* (sdk/python/dgc_sdk/wire/client.py):
|
|
4
|
+
*
|
|
5
|
+
* - Every failure is an SDK error (DGCRuntimeError, DGCProtocolError, DGCTimeoutError,
|
|
6
|
+
* DGCCommandRejectedError).
|
|
7
|
+
* - The first event must be a `ready` handshake offering protocol v14; anything else is a
|
|
8
|
+
* protocol error. After it, event types this SDK does not know (the CLI adds some within a
|
|
9
|
+
* protocol version) are skipped and counted, not fatal.
|
|
10
|
+
* - A correlated request's reply goes to that request even while a run reads the stream, and a
|
|
11
|
+
* `command_rejected` / `error` naming it fails it at once instead of running out its timeout.
|
|
12
|
+
* - Waits may be longer than Node's 24.8-day timer limit, or unbounded (`null`).
|
|
13
|
+
* - The child runs in its own process group; close() and failed starts reap it.
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { DGCCommandRejectedError, DGCProtocolError, DGCRuntimeError, DGCTimeoutError, } from "./errors.js";
|
|
17
|
+
import { PROTOCOL } from "./types.js";
|
|
18
|
+
/** Protocol v14 event types (dgc/editor_protocol.py EVENT_FIELDS). Others are skipped. */
|
|
19
|
+
export const KNOWN_EVENTS = new Set([
|
|
20
|
+
"agent_ended", "agent_started", "agent_updated", "agents", "artifact_ready", "artifacts",
|
|
21
|
+
"ask_request", "ask_resolved",
|
|
22
|
+
"chat_change", "chat_changes", "checkpoints", "command_rejected", "compacted", "config",
|
|
23
|
+
"context", "doc", "docs_catalog", "error", "goal_changed", "handoff", "handoff_started",
|
|
24
|
+
"history", "hook_activity", "hook_catalog", "image", "info", "mcp_call_complete",
|
|
25
|
+
"mcp_command_result", "mcp_context", "mcp_context_catalog", "mcp_input_request", "mcp_servers",
|
|
26
|
+
"mcp_tools", "memory", "mode_changed", "model_changed", "model_retry", "models",
|
|
27
|
+
"monitor_ended", "monitor_event", "monitor_started", "monitors", "options_request",
|
|
28
|
+
"options_resolved", "permission_decision", "permission_request", "permission_resolved",
|
|
29
|
+
"permissions", "plan_proposal", "prompt_accepted", "queued", "ready", "recall",
|
|
30
|
+
"request_expired", "retained_tasks", "rewound", "rule_added", "saved_plan", "session",
|
|
31
|
+
"session_named", "sessions", "skill_catalog", "skill_detail", "skill_package", "status",
|
|
32
|
+
"steering_update", "stream_end", "text_delta", "think_changed", "thinking_delta",
|
|
33
|
+
"thinking_end", "todos", "tool_call", "tool_denied", "tool_images", "tool_progress",
|
|
34
|
+
"tool_result", "turn_activity", "turn_end", "turn_eta", "turn_start", "usage_report",
|
|
35
|
+
"workspace_change", "workspace_changes", "workspace_roots",
|
|
36
|
+
]);
|
|
37
|
+
const DECISION_EVENTS = new Set(["permission_request", "plan_proposal", "options_request", "mcp_input_request"]);
|
|
38
|
+
const TURN_EVENTS = new Set(["turn_start", "turn_end", "request_expired", "prompt_accepted"]);
|
|
39
|
+
const MAX_EVENT_BYTES = 4 * 1024 * 1024;
|
|
40
|
+
const MAX_PENDING_EVENTS = 65_536;
|
|
41
|
+
const STDERR_LIMIT = 64 * 1024;
|
|
42
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
43
|
+
const LATE_REPLY_WINDOW_MS = 300_000;
|
|
44
|
+
/** A timer that works for any length (Node timers overflow past ~24.8 days) or none (`null`). */
|
|
45
|
+
export function longTimer(ms, fire) {
|
|
46
|
+
if (ms === null || !Number.isFinite(ms))
|
|
47
|
+
return () => { };
|
|
48
|
+
const deadline = Date.now() + Math.max(0, ms);
|
|
49
|
+
let handle;
|
|
50
|
+
const arm = () => {
|
|
51
|
+
const left = deadline - Date.now();
|
|
52
|
+
if (left <= 0) {
|
|
53
|
+
fire();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
handle = setTimeout(arm, Math.min(left, MAX_TIMER_MS));
|
|
57
|
+
};
|
|
58
|
+
arm();
|
|
59
|
+
return () => { if (handle !== undefined)
|
|
60
|
+
clearTimeout(handle); };
|
|
61
|
+
}
|
|
62
|
+
function isObject(value) {
|
|
63
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
64
|
+
}
|
|
65
|
+
export class Transport {
|
|
66
|
+
argv;
|
|
67
|
+
cwd;
|
|
68
|
+
env;
|
|
69
|
+
child = null;
|
|
70
|
+
buf = "";
|
|
71
|
+
events = [];
|
|
72
|
+
readers = [];
|
|
73
|
+
requests = [];
|
|
74
|
+
awaited = new Map();
|
|
75
|
+
abandoned = new Map();
|
|
76
|
+
lastSeq = -1;
|
|
77
|
+
readyFrame = null;
|
|
78
|
+
readyWaiter = null;
|
|
79
|
+
dead = null;
|
|
80
|
+
closing = false;
|
|
81
|
+
exited = null;
|
|
82
|
+
stderr = "";
|
|
83
|
+
ignored = new Map();
|
|
84
|
+
constructor(argv, cwd, env) {
|
|
85
|
+
this.argv = [...argv];
|
|
86
|
+
this.cwd = cwd;
|
|
87
|
+
this.env = env;
|
|
88
|
+
}
|
|
89
|
+
/** The runtime's process id, once started. */
|
|
90
|
+
get pid() {
|
|
91
|
+
return this.child?.pid;
|
|
92
|
+
}
|
|
93
|
+
/** The runtime's exit code, or null while it runs. */
|
|
94
|
+
get exitCode() {
|
|
95
|
+
return this.child ? this.child.exitCode : null;
|
|
96
|
+
}
|
|
97
|
+
/** The ready handshake, once received. */
|
|
98
|
+
get ready() {
|
|
99
|
+
return this.readyFrame;
|
|
100
|
+
}
|
|
101
|
+
/** The last lines of the runtime's stderr (bounded). */
|
|
102
|
+
get stderrTail() {
|
|
103
|
+
return this.stderr;
|
|
104
|
+
}
|
|
105
|
+
get closed() {
|
|
106
|
+
return this.dead !== null;
|
|
107
|
+
}
|
|
108
|
+
/** Event types skipped because this SDK does not know them yet, with counts. */
|
|
109
|
+
get ignoredEventTypes() {
|
|
110
|
+
return Object.fromEntries(this.ignored);
|
|
111
|
+
}
|
|
112
|
+
/** Launch the runtime and wait for its `ready` handshake (protocol checked). */
|
|
113
|
+
start(timeoutMs) {
|
|
114
|
+
if (this.child)
|
|
115
|
+
return Promise.reject(new DGCRuntimeError("this transport was already started"));
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
let stop = () => { };
|
|
118
|
+
this.readyWaiter = {
|
|
119
|
+
resolve: (frame) => { stop(); resolve(frame); },
|
|
120
|
+
reject: (error) => { stop(); reject(error); },
|
|
121
|
+
};
|
|
122
|
+
stop = longTimer(timeoutMs, () => {
|
|
123
|
+
this.fail(new DGCRuntimeError(`timed out after ${timeoutMs} ms waiting for the DGC ready handshake`));
|
|
124
|
+
});
|
|
125
|
+
let child;
|
|
126
|
+
try {
|
|
127
|
+
child = spawn(this.argv[0], this.argv.slice(1), {
|
|
128
|
+
cwd: this.cwd, env: this.env, stdio: ["pipe", "pipe", "pipe"],
|
|
129
|
+
// Its own process group, so close() can reap whatever it started.
|
|
130
|
+
detached: process.platform !== "win32",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
this.fail(new DGCRuntimeError(`could not launch the DGC backend ${JSON.stringify(this.argv[0])}: ${String(error)}`));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.child = child;
|
|
138
|
+
this.exited = new Promise((done) => {
|
|
139
|
+
child.once("exit", () => done());
|
|
140
|
+
child.once("error", () => { if (child.pid === undefined)
|
|
141
|
+
done(); });
|
|
142
|
+
});
|
|
143
|
+
child.on("error", (error) => {
|
|
144
|
+
this.fail(new DGCRuntimeError(`could not launch the DGC backend ${JSON.stringify(this.argv[0])}: ${error.message}`));
|
|
145
|
+
});
|
|
146
|
+
child.stdin?.on("error", () => { });
|
|
147
|
+
child.stdout?.setEncoding("utf8");
|
|
148
|
+
child.stdout?.on("data", (chunk) => this.onData(chunk));
|
|
149
|
+
child.stderr?.setEncoding("utf8");
|
|
150
|
+
child.stderr?.on("data", (chunk) => {
|
|
151
|
+
this.stderr = (this.stderr + chunk).slice(-STDERR_LIMIT);
|
|
152
|
+
});
|
|
153
|
+
let reported = false;
|
|
154
|
+
const report = (code, signal) => {
|
|
155
|
+
if (reported)
|
|
156
|
+
return;
|
|
157
|
+
reported = true;
|
|
158
|
+
if (this.closing) {
|
|
159
|
+
this.fail(new DGCRuntimeError("the DGC backend was closed"));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const how = code !== null ? `code ${code}` : `signal ${signal}`;
|
|
163
|
+
this.fail(new DGCRuntimeError(`the DGC backend exited unexpectedly (${how})`));
|
|
164
|
+
};
|
|
165
|
+
// "close" comes after the last output was read; a descendant holding the pipe open must
|
|
166
|
+
// not keep a dead backend looking alive, so "exit" reports too after a short grace.
|
|
167
|
+
child.on("close", (code, signal) => report(code, signal));
|
|
168
|
+
child.on("exit", (code, signal) => {
|
|
169
|
+
setTimeout(() => report(code, signal), 500).unref();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
onData(chunk) {
|
|
174
|
+
this.buf += chunk;
|
|
175
|
+
let nl;
|
|
176
|
+
while ((nl = this.buf.indexOf("\n")) !== -1) {
|
|
177
|
+
const line = this.buf.slice(0, nl).replace(/\r$/, "").trim();
|
|
178
|
+
this.buf = this.buf.slice(nl + 1);
|
|
179
|
+
if (!line)
|
|
180
|
+
continue;
|
|
181
|
+
if (this.dead)
|
|
182
|
+
return;
|
|
183
|
+
if (Buffer.byteLength(line, "utf8") > MAX_EVENT_BYTES) {
|
|
184
|
+
this.fail(new DGCProtocolError(`backend event frame exceeded ${MAX_EVENT_BYTES} bytes`));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
let frame;
|
|
188
|
+
try {
|
|
189
|
+
frame = JSON.parse(line);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
this.fail(new DGCProtocolError("backend emitted malformed NDJSON"));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
this.accept(frame);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
this.fail(error instanceof Error ? error : new DGCProtocolError(String(error)));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (Buffer.byteLength(this.buf, "utf8") > MAX_EVENT_BYTES) {
|
|
204
|
+
this.fail(new DGCProtocolError(`backend event frame exceeded ${MAX_EVENT_BYTES} bytes`));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
accept(frame) {
|
|
208
|
+
if (!isObject(frame) || typeof frame.type !== "string" || !frame.type) {
|
|
209
|
+
throw new DGCProtocolError(`backend violated protocol v${PROTOCOL}: an event without a type`);
|
|
210
|
+
}
|
|
211
|
+
const type = frame.type;
|
|
212
|
+
const seq = frame.seq;
|
|
213
|
+
if (typeof seq === "number" && Number.isInteger(seq)) {
|
|
214
|
+
if (seq <= this.lastSeq)
|
|
215
|
+
throw new DGCProtocolError("backend emitted a duplicate or out-of-order event sequence");
|
|
216
|
+
this.lastSeq = seq;
|
|
217
|
+
}
|
|
218
|
+
if (this.readyFrame === null) {
|
|
219
|
+
if (type !== "ready")
|
|
220
|
+
throw new DGCProtocolError("backend emitted an event before the ready handshake");
|
|
221
|
+
if (frame.protocol_version !== PROTOCOL) {
|
|
222
|
+
const version = typeof frame.version === "string" ? frame.version.slice(0, 64) : "";
|
|
223
|
+
throw new DGCProtocolError(`backend offered protocol v${String(frame.protocol_version)}; client requires v${PROTOCOL}`, { offeredProtocol: frame.protocol_version, backendVersion: version });
|
|
224
|
+
}
|
|
225
|
+
this.readyFrame = frame;
|
|
226
|
+
const waiter = this.readyWaiter;
|
|
227
|
+
this.readyWaiter = null;
|
|
228
|
+
waiter?.resolve(frame);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (type === "ready")
|
|
232
|
+
throw new DGCProtocolError("backend emitted more than one ready event");
|
|
233
|
+
if (!KNOWN_EVENTS.has(type)) {
|
|
234
|
+
// Added within protocol v14 by a newer CLI (remote_status and friends): tolerate it.
|
|
235
|
+
if (this.ignored.has(type) || this.ignored.size < 64)
|
|
236
|
+
this.ignored.set(type, (this.ignored.get(type) || 0) + 1);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const replyTo = typeof frame.request_id === "string" ? frame.request_id : "";
|
|
240
|
+
if (replyTo && this.abandoned.has(replyTo) && !DECISION_EVENTS.has(type) && !TURN_EVENTS.has(type)) {
|
|
241
|
+
if ((this.abandoned.get(replyTo) || 0) > Date.now())
|
|
242
|
+
return; // late reply to a timed-out request
|
|
243
|
+
this.abandoned.delete(replyTo);
|
|
244
|
+
}
|
|
245
|
+
for (let index = 0; index < this.requests.length; index++) {
|
|
246
|
+
const request = this.requests[index];
|
|
247
|
+
if (request.answers(frame)) {
|
|
248
|
+
this.requests.splice(index, 1);
|
|
249
|
+
request.resolve(frame);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (this.events.length >= MAX_PENDING_EVENTS) {
|
|
254
|
+
throw new DGCProtocolError("backend event retention limit was exceeded");
|
|
255
|
+
}
|
|
256
|
+
this.events.push(frame);
|
|
257
|
+
this.dispatch();
|
|
258
|
+
}
|
|
259
|
+
takeable(frame) {
|
|
260
|
+
const rid = frame.request_id;
|
|
261
|
+
return !(typeof rid === "string" && this.awaited.has(rid));
|
|
262
|
+
}
|
|
263
|
+
dispatch() {
|
|
264
|
+
let index = 0;
|
|
265
|
+
while (index < this.readers.length && this.events.length) {
|
|
266
|
+
const reader = this.readers[index];
|
|
267
|
+
const at = this.events.findIndex((frame) => this.takeable(frame) && (!reader.predicate || reader.predicate(frame)));
|
|
268
|
+
if (at < 0) {
|
|
269
|
+
index += 1;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const [frame] = this.events.splice(at, 1);
|
|
273
|
+
this.readers.splice(index, 1);
|
|
274
|
+
reader.resolve(frame);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** Write one command. Throws DGCRuntimeError when the backend is gone. */
|
|
278
|
+
send(command) {
|
|
279
|
+
if (this.dead)
|
|
280
|
+
throw new DGCRuntimeError(this.dead.message, { cause: this.dead });
|
|
281
|
+
const stdin = this.child?.stdin;
|
|
282
|
+
if (!stdin || !this.readyFrame)
|
|
283
|
+
throw new DGCRuntimeError("the DGC backend has not completed its ready handshake");
|
|
284
|
+
const text = JSON.stringify(command);
|
|
285
|
+
if (Buffer.byteLength(text, "utf8") > MAX_EVENT_BYTES)
|
|
286
|
+
throw new DGCRuntimeError("command is too large to send");
|
|
287
|
+
stdin.write(text + "\n");
|
|
288
|
+
}
|
|
289
|
+
/** Put an event back at the front of the stream. */
|
|
290
|
+
unread(frame) {
|
|
291
|
+
this.events.unshift(frame);
|
|
292
|
+
this.dispatch();
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The oldest event (matching `predicate`) nobody's request is waiting for. `timeoutMs` null
|
|
296
|
+
* waits without limit. Rejects DGCTimeoutError on timeout, DGCRuntimeError once the backend is
|
|
297
|
+
* gone and nothing is left.
|
|
298
|
+
*/
|
|
299
|
+
next(timeoutMs = 30_000, predicate) {
|
|
300
|
+
const at = this.events.findIndex((frame) => this.takeable(frame) && (!predicate || predicate(frame)));
|
|
301
|
+
if (at >= 0)
|
|
302
|
+
return Promise.resolve(this.events.splice(at, 1)[0]);
|
|
303
|
+
if (this.dead)
|
|
304
|
+
return Promise.reject(this.dead);
|
|
305
|
+
return new Promise((resolve, reject) => {
|
|
306
|
+
let stop = () => { };
|
|
307
|
+
const reader = {
|
|
308
|
+
predicate,
|
|
309
|
+
resolve: (frame) => { stop(); resolve(frame); },
|
|
310
|
+
reject: (error) => { stop(); reject(error); },
|
|
311
|
+
};
|
|
312
|
+
stop = longTimer(timeoutMs, () => {
|
|
313
|
+
const index = this.readers.indexOf(reader);
|
|
314
|
+
if (index >= 0)
|
|
315
|
+
this.readers.splice(index, 1);
|
|
316
|
+
reject(new DGCTimeoutError("timed out waiting for the next DGC event"));
|
|
317
|
+
});
|
|
318
|
+
this.readers.push(reader);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Send a command and wait for its reply. While waiting, other readers leave events for this
|
|
323
|
+
* request alone. A `command_rejected` or `error` for the same request throws
|
|
324
|
+
* DGCCommandRejectedError at once. `uncorrelatedReply` also accepts a `responseType` event
|
|
325
|
+
* without a request_id (commands acknowledged by a broadcast, such as clear_todos -> todos).
|
|
326
|
+
*/
|
|
327
|
+
request(command, responseType, options = {}) {
|
|
328
|
+
const requestId = typeof command.request_id === "string" ? command.request_id : undefined;
|
|
329
|
+
const commandType = typeof command.type === "string" ? command.type : "";
|
|
330
|
+
const failedPrefix = `Command '${commandType}' failed`;
|
|
331
|
+
const answers = (frame) => {
|
|
332
|
+
const kind = frame.type;
|
|
333
|
+
const replyId = frame.request_id;
|
|
334
|
+
if (requestId === undefined) {
|
|
335
|
+
if (kind === responseType)
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
else if (replyId === requestId) {
|
|
339
|
+
return kind === responseType || kind === "command_rejected" || kind === "error";
|
|
340
|
+
}
|
|
341
|
+
if (replyId !== undefined && replyId !== null)
|
|
342
|
+
return false;
|
|
343
|
+
if (options.uncorrelatedReply && kind === responseType)
|
|
344
|
+
return true;
|
|
345
|
+
// Refusals the backend cannot correlate still name the command type.
|
|
346
|
+
if (kind === "command_rejected")
|
|
347
|
+
return Boolean(commandType) && frame.command === commandType;
|
|
348
|
+
return kind === "error" && Boolean(commandType) && String(frame.message || "").startsWith(failedPrefix);
|
|
349
|
+
};
|
|
350
|
+
if (this.dead)
|
|
351
|
+
return Promise.reject(new DGCRuntimeError(this.dead.message, { cause: this.dead }));
|
|
352
|
+
if (requestId !== undefined)
|
|
353
|
+
this.awaited.set(requestId, (this.awaited.get(requestId) || 0) + 1);
|
|
354
|
+
const release = (timedOut) => {
|
|
355
|
+
if (requestId === undefined)
|
|
356
|
+
return;
|
|
357
|
+
const left = (this.awaited.get(requestId) || 1) - 1;
|
|
358
|
+
if (left > 0)
|
|
359
|
+
this.awaited.set(requestId, left);
|
|
360
|
+
else {
|
|
361
|
+
this.awaited.delete(requestId);
|
|
362
|
+
if (timedOut)
|
|
363
|
+
this.abandon(requestId);
|
|
364
|
+
}
|
|
365
|
+
this.dispatch();
|
|
366
|
+
};
|
|
367
|
+
return new Promise((resolve, reject) => {
|
|
368
|
+
let stop = () => { };
|
|
369
|
+
const entry = {
|
|
370
|
+
answers,
|
|
371
|
+
resolve: (frame) => {
|
|
372
|
+
stop();
|
|
373
|
+
release(false);
|
|
374
|
+
if (frame.type === responseType) {
|
|
375
|
+
resolve(frame);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const message = String(frame.message || frame.reason || `${commandType || "command"} was refused`);
|
|
379
|
+
reject(new DGCCommandRejectedError(message, {
|
|
380
|
+
reason: typeof frame.reason === "string" ? frame.reason : "",
|
|
381
|
+
command: typeof frame.command === "string" ? frame.command : commandType,
|
|
382
|
+
}));
|
|
383
|
+
},
|
|
384
|
+
reject: (error) => {
|
|
385
|
+
stop();
|
|
386
|
+
release(false);
|
|
387
|
+
reject(error);
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
this.requests.push(entry);
|
|
391
|
+
stop = longTimer(options.timeoutMs === undefined ? 15_000 : options.timeoutMs, () => {
|
|
392
|
+
const index = this.requests.indexOf(entry);
|
|
393
|
+
if (index >= 0)
|
|
394
|
+
this.requests.splice(index, 1);
|
|
395
|
+
release(true);
|
|
396
|
+
reject(new DGCTimeoutError(`timed out waiting for the ${responseType} reply to ${commandType || "command"}`));
|
|
397
|
+
});
|
|
398
|
+
try {
|
|
399
|
+
this.send(command);
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
const index = this.requests.indexOf(entry);
|
|
403
|
+
if (index >= 0)
|
|
404
|
+
this.requests.splice(index, 1);
|
|
405
|
+
entry.reject(error instanceof Error ? error : new DGCRuntimeError(String(error)));
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
abandon(requestId) {
|
|
410
|
+
const now = Date.now();
|
|
411
|
+
for (const [key, expiry] of this.abandoned)
|
|
412
|
+
if (expiry <= now)
|
|
413
|
+
this.abandoned.delete(key);
|
|
414
|
+
while (this.abandoned.size >= 1024)
|
|
415
|
+
this.abandoned.delete(this.abandoned.keys().next().value);
|
|
416
|
+
this.abandoned.set(requestId, now + LATE_REPLY_WINDOW_MS);
|
|
417
|
+
}
|
|
418
|
+
fail(error) {
|
|
419
|
+
if (this.dead)
|
|
420
|
+
return;
|
|
421
|
+
this.dead = error;
|
|
422
|
+
const waiter = this.readyWaiter;
|
|
423
|
+
this.readyWaiter = null;
|
|
424
|
+
waiter?.reject(error);
|
|
425
|
+
for (const reader of this.readers.splice(0))
|
|
426
|
+
reader.reject(error);
|
|
427
|
+
for (const request of this.requests.splice(0))
|
|
428
|
+
request.reject(error);
|
|
429
|
+
if (!this.closing && this.child && this.child.exitCode === null && this.child.signalCode === null) {
|
|
430
|
+
// A protocol violation or a failed handshake: the child is useless now; reap it.
|
|
431
|
+
void this.close();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
signal(signal) {
|
|
435
|
+
const child = this.child;
|
|
436
|
+
if (!child || child.pid === undefined)
|
|
437
|
+
return;
|
|
438
|
+
try {
|
|
439
|
+
if (process.platform !== "win32")
|
|
440
|
+
process.kill(-child.pid, signal);
|
|
441
|
+
else
|
|
442
|
+
child.kill(signal);
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
try {
|
|
446
|
+
child.kill(signal);
|
|
447
|
+
}
|
|
448
|
+
catch { /* already gone */ }
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Shut the runtime down: ask politely, then SIGTERM and SIGKILL its process group. Resolves once
|
|
453
|
+
* the child is gone (bounded to a few seconds).
|
|
454
|
+
*/
|
|
455
|
+
async close(graceMs = 2000) {
|
|
456
|
+
if (this.closing) {
|
|
457
|
+
await this.exited;
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
this.closing = true;
|
|
461
|
+
const child = this.child;
|
|
462
|
+
const alive = () => child !== null && child.exitCode === null && child.signalCode === null;
|
|
463
|
+
if (child && alive() && this.readyFrame && !this.dead) {
|
|
464
|
+
try {
|
|
465
|
+
child.stdin?.write(JSON.stringify({ type: "shutdown" }) + "\n");
|
|
466
|
+
}
|
|
467
|
+
catch { /* gone */ }
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
child?.stdin?.end();
|
|
471
|
+
}
|
|
472
|
+
catch { /* gone */ }
|
|
473
|
+
this.fail(new DGCRuntimeError("the DGC backend was closed"));
|
|
474
|
+
if (!child || child.pid === undefined)
|
|
475
|
+
return;
|
|
476
|
+
const wait = (ms) => Promise.race([
|
|
477
|
+
this.exited ?? Promise.resolve(),
|
|
478
|
+
new Promise((done) => setTimeout(done, ms).unref()),
|
|
479
|
+
]);
|
|
480
|
+
if (alive())
|
|
481
|
+
await wait(graceMs);
|
|
482
|
+
if (alive()) {
|
|
483
|
+
this.signal("SIGTERM");
|
|
484
|
+
await wait(1000);
|
|
485
|
+
}
|
|
486
|
+
if (alive()) {
|
|
487
|
+
this.signal("SIGKILL");
|
|
488
|
+
await wait(1000);
|
|
489
|
+
}
|
|
490
|
+
// A clean exit can still leave a descendant holding the group; sweep it.
|
|
491
|
+
this.signal("SIGKILL");
|
|
492
|
+
child.stdout?.destroy();
|
|
493
|
+
child.stderr?.destroy();
|
|
494
|
+
}
|
|
495
|
+
}
|