@intentic/extension-api 1.325.0 → 1.327.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 +26 -1
- package/dist/api.d.ts +2 -3
- package/dist/api.d.ts.map +1 -1
- package/dist/background.d.ts +4 -9
- package/dist/background.d.ts.map +1 -1
- package/dist/background.js.map +1 -1
- package/dist/diff.d.ts +2 -2
- package/dist/diff.d.ts.map +1 -1
- package/dist/disposable.d.ts +4 -0
- package/dist/disposable.d.ts.map +1 -0
- package/dist/disposable.js +2 -0
- package/dist/disposable.js.map +1 -0
- package/dist/runtime.d.ts +49 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +359 -0
- package/dist/runtime.js.map +1 -0
- package/dist/server.d.ts +40 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +2 -50
- package/dist/stream.js.map +1 -1
- package/dist/testing.d.ts +47 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +152 -0
- package/dist/testing.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +41 -5
- package/src/api.ts +2 -3
- package/src/background.ts +5 -12
- package/src/diff.ts +4 -4
- package/src/disposable.ts +5 -0
- package/src/runtime.integration.test.ts +159 -0
- package/src/runtime.ts +535 -0
- package/src/server.ts +91 -4
- package/src/stream.ts +4 -55
- package/src/surface.json +482 -0
- package/src/testing.ts +237 -0
- package/src/version.ts +23 -1
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { createBackoff, sleep } from "@intentic/base/async";
|
|
5
|
+
import { errorMessage, undefinedIfMissing } from "@intentic/base/errors";
|
|
6
|
+
import { queueOnFile, writeFileAtomic } from "@intentic/base/fs";
|
|
7
|
+
import { sandboxRouteAllowed } from "@intentic/extension-manifest/permissions";
|
|
8
|
+
import type * as SandboxContract from "@intentic/sandbox-contract";
|
|
9
|
+
import { readDocument } from "@intentic/sandbox-contract/documents";
|
|
10
|
+
import {
|
|
11
|
+
EXTENSION_EVENT_KINDS,
|
|
12
|
+
EXTENSION_EVENTS_HEARTBEAT_MS,
|
|
13
|
+
EXTENSION_PROCESS_ENV,
|
|
14
|
+
type ExtensionEventKind,
|
|
15
|
+
ExtensionEventSchema,
|
|
16
|
+
extensionEventsUrl,
|
|
17
|
+
ExtensionHealthSchema,
|
|
18
|
+
ExtensionOwnSettingsSchema,
|
|
19
|
+
ExtensionPermissionsSchema,
|
|
20
|
+
extensionSettingsUrl,
|
|
21
|
+
} from "@intentic/sandbox-contract/extension-protocol";
|
|
22
|
+
import { EXTENSION_TOKEN_HEADER } from "@intentic/sandbox-contract/headers";
|
|
23
|
+
import type * as OrpcClient from "@orpc/client";
|
|
24
|
+
import type * as OpenApiFetch from "@orpc/openapi-client/fetch";
|
|
25
|
+
import type { Disposable } from "./disposable.js";
|
|
26
|
+
import type {
|
|
27
|
+
BackendRouteHandler,
|
|
28
|
+
ExtensionHealth,
|
|
29
|
+
ExtensionProcessApi,
|
|
30
|
+
ExtensionServerApi,
|
|
31
|
+
ExtensionServerContext,
|
|
32
|
+
ExtensionServerModule,
|
|
33
|
+
ServerActivation,
|
|
34
|
+
ServerDocument,
|
|
35
|
+
ServerDocumentOptions,
|
|
36
|
+
StoredJson,
|
|
37
|
+
ToolCard,
|
|
38
|
+
ToolDefinition,
|
|
39
|
+
} from "./server.js";
|
|
40
|
+
import { extensionApiVersion } from "./version.js";
|
|
41
|
+
|
|
42
|
+
// The backend half's types, from here as well as from the package root: the root also carries the browser half, whose
|
|
43
|
+
// types name Vue's, which a gateway's program has no business loading.
|
|
44
|
+
export type {
|
|
45
|
+
BackendRouteHandler,
|
|
46
|
+
ExtensionHealth,
|
|
47
|
+
ExtensionHealthState,
|
|
48
|
+
ExtensionProcessApi,
|
|
49
|
+
ExtensionServerApi,
|
|
50
|
+
ExtensionServerContext,
|
|
51
|
+
ExtensionServerModule,
|
|
52
|
+
ExtensionSettingValues,
|
|
53
|
+
ServerActivation,
|
|
54
|
+
ServerDocument,
|
|
55
|
+
ServerDocumentOptions,
|
|
56
|
+
StoredJson,
|
|
57
|
+
ToolCallContext,
|
|
58
|
+
ToolCard,
|
|
59
|
+
ToolContent,
|
|
60
|
+
ToolDefinition,
|
|
61
|
+
ToolEffect,
|
|
62
|
+
ToolResult,
|
|
63
|
+
} from "./server.js";
|
|
64
|
+
|
|
65
|
+
// The backend api, built once for every place an extension's Node code runs: the daemon's backend host, which runs every
|
|
66
|
+
// `server` bundle in one process, and each declared process (`contributes.processes`), which connects itself from the
|
|
67
|
+
// environment the daemon started it with. One implementation, so a gateway and a server bundle reach the daemon, read
|
|
68
|
+
// their settings, hear about the workspace and keep their files the same way. Node only, and free of Vue, unlike the
|
|
69
|
+
// package's root: a gateway that imports this loads none of the browser half.
|
|
70
|
+
|
|
71
|
+
type DaemonApi = ExtensionServerApi["daemon"];
|
|
72
|
+
type Rpc = DaemonApi["rpc"];
|
|
73
|
+
type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
74
|
+
|
|
75
|
+
// What one `api.tools.serve` registration answers with, per card.
|
|
76
|
+
export type ToolSource = (card: ToolCard | undefined) => readonly ToolDefinition[] | Promise<readonly ToolDefinition[]>;
|
|
77
|
+
|
|
78
|
+
export interface DaemonApiOptions {
|
|
79
|
+
// The daemon's loopback origin.
|
|
80
|
+
readonly url: string;
|
|
81
|
+
// The extension's minted token, which the daemon judges every request by.
|
|
82
|
+
readonly token: string;
|
|
83
|
+
// The extension's routing id, for the sentence a refusal says.
|
|
84
|
+
readonly id: string;
|
|
85
|
+
// The manifest's `permissions.daemon`: a typed call outside it is refused before anything is sent.
|
|
86
|
+
readonly permissions: readonly string[];
|
|
87
|
+
// Where requests go; the global `fetch` when absent, read at call time so a test can stand in for it.
|
|
88
|
+
readonly fetch?: Fetch | undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Loads a module synchronously, which Node 22+ and Bun both do for ESM; used only for what a process should not pay for
|
|
92
|
+
// until it is asked for.
|
|
93
|
+
const load = createRequire(import.meta.url);
|
|
94
|
+
|
|
95
|
+
// The typed client over the daemon's contract, gated by the extension's declared reach and parsing every answer by the
|
|
96
|
+
// procedure's own schema.
|
|
97
|
+
const typedClient = (options: DaemonApiOptions, send: Fetch): Rpc => {
|
|
98
|
+
// SAFETY: each name is the package the type beside it is taken from, loaded by its own export map.
|
|
99
|
+
const contract = load("@intentic/sandbox-contract") as typeof SandboxContract;
|
|
100
|
+
// SAFETY: as above.
|
|
101
|
+
const { createORPCClient } = load("@orpc/client") as typeof OrpcClient;
|
|
102
|
+
// SAFETY: as above.
|
|
103
|
+
const { OpenAPILink } = load("@orpc/openapi-client/fetch") as typeof OpenApiFetch;
|
|
104
|
+
return createORPCClient<Rpc>(
|
|
105
|
+
new OpenAPILink(contract.sandboxContract, {
|
|
106
|
+
url: options.url,
|
|
107
|
+
headers: { [EXTENSION_TOKEN_HEADER]: options.token },
|
|
108
|
+
fetch: (request, init) => send(request, init),
|
|
109
|
+
interceptors: [
|
|
110
|
+
// A procedure this build's contract does not declare is refused too: reaching one means a hand-built path.
|
|
111
|
+
({ path, input, next }) => {
|
|
112
|
+
const request = contract.sandboxRequestFor(path, input);
|
|
113
|
+
if (request === undefined) {
|
|
114
|
+
throw new Error(`extension "${options.id}" called daemon procedure ${path.join(".")}, which this build's contract does not declare`);
|
|
115
|
+
}
|
|
116
|
+
if (!sandboxRouteAllowed(options.permissions, request.method, request.path)) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`extension "${options.id}" called undeclared daemon route ${request.method} ${request.path}: declare it in permissions.daemon in the manifest`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return next();
|
|
122
|
+
},
|
|
123
|
+
async ({ path, next }) => {
|
|
124
|
+
const answer = await next();
|
|
125
|
+
const schema = contract.sandboxAnswerSchema(path);
|
|
126
|
+
return schema === undefined ? answer : schema.parse(answer);
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// One extension's `api.daemon`, every door presenting its minted token, which the daemon judges each request by. The
|
|
134
|
+
// typed client is also judged here, before anything is sent: on the method and path a call resolves to, against the same
|
|
135
|
+
// `permissions.daemon` list, so a refusal names the missing declaration instead of arriving as the daemon's 403. It is
|
|
136
|
+
// built on first use: the contract it is built from holds every schema the daemon has, tens of megabytes a gateway that
|
|
137
|
+
// never makes a typed call should not pay for at start.
|
|
138
|
+
export const createDaemonApi = (options: DaemonApiOptions): DaemonApi => {
|
|
139
|
+
const send: Fetch = (input, init) => (options.fetch ?? globalThis.fetch)(input, init);
|
|
140
|
+
const granted = (init?: RequestInit): Headers => {
|
|
141
|
+
const headers = new Headers(init?.headers);
|
|
142
|
+
headers.set(EXTENSION_TOKEN_HEADER, options.token);
|
|
143
|
+
return headers;
|
|
144
|
+
};
|
|
145
|
+
let client: Rpc | undefined;
|
|
146
|
+
return {
|
|
147
|
+
get rpc(): Rpc {
|
|
148
|
+
client ??= typedClient(options, send);
|
|
149
|
+
return client;
|
|
150
|
+
},
|
|
151
|
+
request: (path, init) => send(`${options.url}${path}`, { ...init, headers: granted(init) }),
|
|
152
|
+
json: async <T>(path: string, init?: RequestInit): Promise<T> => {
|
|
153
|
+
const headers = granted(init);
|
|
154
|
+
if (init?.body !== undefined && !headers.has("content-type")) {
|
|
155
|
+
headers.set("content-type", "application/json");
|
|
156
|
+
}
|
|
157
|
+
const response = await send(`${options.url}${path}`, { ...init, headers });
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
throw new Error(`daemon answered ${response.status} for ${init?.method ?? "GET"} ${path}: ${await response.text()}`);
|
|
160
|
+
}
|
|
161
|
+
// SAFETY: the caller names the shape the route answers; `api.daemon.rpc` is the door that checks it.
|
|
162
|
+
return (await response.json()) as T;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
type Listener = (values: readonly string[]) => void;
|
|
168
|
+
|
|
169
|
+
// How long a connection may go without a frame before it is taken for dead: three missed heartbeats.
|
|
170
|
+
const SILENCE_MS = EXTENSION_EVENTS_HEARTBEAT_MS * 3;
|
|
171
|
+
|
|
172
|
+
// Every complete line in `buffer`, and what is left after the last one.
|
|
173
|
+
const splitLines = (buffer: string) => {
|
|
174
|
+
const parts = buffer.split("\n");
|
|
175
|
+
const rest = parts.pop() ?? "";
|
|
176
|
+
return { lines: parts.map((line) => line.trim()).filter((line) => line !== ""), rest };
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// The values a frame carries for its listeners, or undefined for a line this version cannot read (not JSON, or a kind a
|
|
180
|
+
// newer daemon sends), which is skipped rather than fatal.
|
|
181
|
+
const frameOf = (line: string): { readonly kind: ExtensionEventKind; readonly values: readonly string[] } | undefined => {
|
|
182
|
+
let raw: unknown;
|
|
183
|
+
try {
|
|
184
|
+
raw = JSON.parse(line);
|
|
185
|
+
} catch {
|
|
186
|
+
// allow(silent-catch): a line that is not JSON is a frame this version cannot read, skipped like an unknown kind
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
const parsed = ExtensionEventSchema.safeParse(raw);
|
|
190
|
+
if (!parsed.success) {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
const event = parsed.data;
|
|
194
|
+
switch (event.kind) {
|
|
195
|
+
case "heartbeat":
|
|
196
|
+
return undefined;
|
|
197
|
+
case "files":
|
|
198
|
+
return { kind: event.kind, values: event.paths };
|
|
199
|
+
case "settings":
|
|
200
|
+
return { kind: event.kind, values: event.keys };
|
|
201
|
+
default:
|
|
202
|
+
return { kind: event.kind, values: event.repos };
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// The extension's event stream: opened by the first listener, closed with the last, reconnected with backoff while
|
|
207
|
+
// anything listens. A drop means frames were missed, so a reconnect tells every listener "anything may have changed" with
|
|
208
|
+
// an empty list, which is what a reader of an empty batch already does.
|
|
209
|
+
const createEventHub = (open: (signal: AbortSignal) => Promise<Response>, log: (message: string) => void) => {
|
|
210
|
+
const listeners = new Map<ExtensionEventKind, Set<Listener>>(EXTENSION_EVENT_KINDS.map((kind) => [kind, new Set<Listener>()]));
|
|
211
|
+
let running: AbortController | undefined;
|
|
212
|
+
const count = (): number => [...listeners.values()].reduce((sum, set) => sum + set.size, 0);
|
|
213
|
+
|
|
214
|
+
const deliver = (kind: ExtensionEventKind, values: readonly string[]): void => {
|
|
215
|
+
for (const listener of listeners.get(kind) ?? []) {
|
|
216
|
+
try {
|
|
217
|
+
listener(values);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
// A throwing listener is its own bug; the stream and every other listener carry on.
|
|
220
|
+
log(`a ${kind} listener threw: ${errorMessage(error)}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// One connection, read to its end; resolves when the daemon ends it or it falls silent.
|
|
226
|
+
const connect = async (outer: AbortSignal): Promise<void> => {
|
|
227
|
+
const connection = new AbortController();
|
|
228
|
+
const abort = (): void => connection.abort();
|
|
229
|
+
outer.addEventListener("abort", abort, { once: true });
|
|
230
|
+
let silence = setTimeout(abort, SILENCE_MS);
|
|
231
|
+
try {
|
|
232
|
+
const response = await open(connection.signal);
|
|
233
|
+
if (!response.ok || response.body === null) {
|
|
234
|
+
throw new Error(`the daemon answered ${response.status}`);
|
|
235
|
+
}
|
|
236
|
+
const reader = response.body.getReader();
|
|
237
|
+
const decoder = new TextDecoder();
|
|
238
|
+
let buffer = "";
|
|
239
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
240
|
+
clearTimeout(silence);
|
|
241
|
+
silence = setTimeout(abort, SILENCE_MS);
|
|
242
|
+
const split = splitLines(buffer + decoder.decode(chunk.value, { stream: true }));
|
|
243
|
+
buffer = split.rest;
|
|
244
|
+
for (const line of split.lines) {
|
|
245
|
+
const frame = frameOf(line);
|
|
246
|
+
if (frame !== undefined) {
|
|
247
|
+
deliver(frame.kind, frame.values);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} finally {
|
|
252
|
+
clearTimeout(silence);
|
|
253
|
+
outer.removeEventListener("abort", abort);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Every connection after the first follows a gap, which a listener hears about as an empty batch.
|
|
258
|
+
const run = async (signal: AbortSignal): Promise<void> => {
|
|
259
|
+
let connections = 0;
|
|
260
|
+
const backoff = createBackoff({ floorMs: 1_000, capMs: 30_000, stableMs: SILENCE_MS });
|
|
261
|
+
while (!signal.aborted) {
|
|
262
|
+
if (connections > 0) {
|
|
263
|
+
for (const kind of EXTENSION_EVENT_KINDS) {
|
|
264
|
+
deliver(kind, []);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
connections += 1;
|
|
268
|
+
const opened = Date.now();
|
|
269
|
+
try {
|
|
270
|
+
await connect(signal);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (!signal.aborted) {
|
|
273
|
+
log(`the event stream dropped (${errorMessage(error)}); reconnecting`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (signal.aborted) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
await sleep(backoff.next(Date.now() - opened), { signal, unref: true });
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const subscribe = (kind: ExtensionEventKind, listener: Listener): Disposable => {
|
|
284
|
+
listeners.get(kind)?.add(listener);
|
|
285
|
+
if (running === undefined) {
|
|
286
|
+
running = new AbortController();
|
|
287
|
+
void run(running.signal);
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
dispose: () => {
|
|
291
|
+
listeners.get(kind)?.delete(listener);
|
|
292
|
+
if (count() === 0) {
|
|
293
|
+
running?.abort();
|
|
294
|
+
running = undefined;
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
subscribe,
|
|
302
|
+
close: (): void => {
|
|
303
|
+
for (const set of listeners.values()) {
|
|
304
|
+
set.clear();
|
|
305
|
+
}
|
|
306
|
+
running?.abort();
|
|
307
|
+
running = undefined;
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
// A JSON file under the extension's own state directory, read through the contract's one document reading.
|
|
313
|
+
const openDocument = <T>(stateDir: string, path: string, options: ServerDocumentOptions<T>): ServerDocument<T> => {
|
|
314
|
+
const file = resolve(stateDir, path);
|
|
315
|
+
const inside = relative(stateDir, file);
|
|
316
|
+
if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
|
|
317
|
+
throw new Error(`document "${path}" must name a file inside the extension's state directory`);
|
|
318
|
+
}
|
|
319
|
+
const evolution = { history: options.history ?? [], granularity: options.granularity ?? "object" } as const;
|
|
320
|
+
const readAs = async () => {
|
|
321
|
+
const text = await readFile(file, "utf8").catch(undefinedIfMissing);
|
|
322
|
+
// SAFETY: readDocument hands its parse what JSON.parse produced and the conversions rewrote, which is JSON.
|
|
323
|
+
return text === undefined ? undefined : readDocument<T>(text, evolution, { kind: "whole", parse: (raw) => options.parse(raw as StoredJson) });
|
|
324
|
+
};
|
|
325
|
+
return {
|
|
326
|
+
read: async () => (await readAs())?.value ?? options.fallback(),
|
|
327
|
+
update: (change) =>
|
|
328
|
+
queueOnFile(file, async () => {
|
|
329
|
+
const stored = await readAs();
|
|
330
|
+
if (stored !== undefined && stored.value === undefined) {
|
|
331
|
+
throw new Error(`${path} holds what this version of the extension cannot read; it is left as it is`, { cause: stored.problems[0] });
|
|
332
|
+
}
|
|
333
|
+
const before = stored?.value ?? options.fallback();
|
|
334
|
+
const after = change(before);
|
|
335
|
+
if (after === before) {
|
|
336
|
+
return before;
|
|
337
|
+
}
|
|
338
|
+
const written = stored === undefined ? after : stored.carry(after);
|
|
339
|
+
await writeFileAtomic(file, `${JSON.stringify(written, undefined, 2)}\n`);
|
|
340
|
+
return after;
|
|
341
|
+
}),
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
export interface ExtensionRuntimeOptions {
|
|
346
|
+
// The extension's routing id: its /x/<id> namespace, the install id.
|
|
347
|
+
readonly id: string;
|
|
348
|
+
readonly daemonUrl: string;
|
|
349
|
+
readonly token: string;
|
|
350
|
+
// The manifest's `permissions.daemon`.
|
|
351
|
+
readonly permissions: readonly string[];
|
|
352
|
+
readonly workspaceRoot: string;
|
|
353
|
+
readonly extensionDir: string;
|
|
354
|
+
readonly stateDir: string;
|
|
355
|
+
readonly cacheDir: string;
|
|
356
|
+
// Where a log line goes; stdout with the id as a prefix when absent, which is what reaches the daemon's log.
|
|
357
|
+
readonly log?: ((message: string) => void) | undefined;
|
|
358
|
+
readonly fetch?: Fetch | undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface ExtensionRuntime {
|
|
362
|
+
readonly api: ExtensionProcessApi;
|
|
363
|
+
// Ends what the runtime holds open on the extension's behalf (the event stream). The host calls it as it lets go of
|
|
364
|
+
// an activation; a process may leave it to its own exit.
|
|
365
|
+
close(): void;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export const createExtensionRuntime = (options: ExtensionRuntimeOptions): ExtensionRuntime => {
|
|
369
|
+
const log = options.log ?? ((message: string) => void process.stdout.write(`[${options.id}] ${message}\n`));
|
|
370
|
+
const daemon = createDaemonApi({ url: options.daemonUrl, token: options.token, id: options.id, permissions: options.permissions, fetch: options.fetch });
|
|
371
|
+
const events = createEventHub((signal) => daemon.request(extensionEventsUrl(), { signal }), log);
|
|
372
|
+
return {
|
|
373
|
+
api: {
|
|
374
|
+
apiVersion: extensionApiVersion,
|
|
375
|
+
workspaceRoot: options.workspaceRoot,
|
|
376
|
+
extensionDir: options.extensionDir,
|
|
377
|
+
stateDir: options.stateDir,
|
|
378
|
+
cacheDir: options.cacheDir,
|
|
379
|
+
log,
|
|
380
|
+
daemon,
|
|
381
|
+
document: (path, documentOptions) => openDocument(options.stateDir, path, documentOptions),
|
|
382
|
+
settings: {
|
|
383
|
+
get: async () => ExtensionOwnSettingsSchema.parse(await (await daemon.request(extensionSettingsUrl())).json()).settings,
|
|
384
|
+
onDidChange: (listener) => events.subscribe("settings", listener),
|
|
385
|
+
},
|
|
386
|
+
workspace: {
|
|
387
|
+
onDidChangeFiles: (listener) => events.subscribe("files", listener),
|
|
388
|
+
onDidChangeRefs: (listener) => events.subscribe("refs", listener),
|
|
389
|
+
onDidChangeRepos: (listener) => events.subscribe("repos", listener),
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
close: events.close,
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// The backend api a `server` bundle is handed: the runtime plus the two slots only the host serves, its route namespace
|
|
397
|
+
// and its tools. A second `mount` or `serve` replaces the first, per the api's contract.
|
|
398
|
+
export interface ServerApiSlots {
|
|
399
|
+
readonly api: ExtensionServerApi;
|
|
400
|
+
readonly handler: () => BackendRouteHandler | undefined;
|
|
401
|
+
readonly tools: () => ToolSource | undefined;
|
|
402
|
+
close(): void;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export const createServerApi = (options: ExtensionRuntimeOptions): ServerApiSlots => {
|
|
406
|
+
const runtime = createExtensionRuntime(options);
|
|
407
|
+
let mounted: BackendRouteHandler | undefined;
|
|
408
|
+
let tools: ToolSource | undefined;
|
|
409
|
+
return {
|
|
410
|
+
api: {
|
|
411
|
+
...runtime.api,
|
|
412
|
+
routes: {
|
|
413
|
+
mount: (handler) => {
|
|
414
|
+
mounted = handler;
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
tools: {
|
|
418
|
+
serve: (source) => {
|
|
419
|
+
tools = source;
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
handler: () => mounted,
|
|
424
|
+
tools: () => tools,
|
|
425
|
+
close: runtime.close,
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// A server bundle's exports: `activateServer` as a default export's member or a named export, matching the web loader's
|
|
430
|
+
// tolerance for UI bundles.
|
|
431
|
+
export type ServerBundleExports = Partial<ExtensionServerModule> & { readonly default?: ExtensionServerModule };
|
|
432
|
+
|
|
433
|
+
// Resolves activateServer and activates it. What it hands back is kept whole; nothing handed back reads as an activation
|
|
434
|
+
// with neither half.
|
|
435
|
+
export const activateServerModule = async (imported: ServerBundleExports, api: ExtensionServerApi, context: ExtensionServerContext): Promise<ServerActivation> => {
|
|
436
|
+
const resolved = imported.default ?? imported;
|
|
437
|
+
if (resolved.activateServer === undefined) {
|
|
438
|
+
throw new Error("the server bundle exports no activateServer(api, context)");
|
|
439
|
+
}
|
|
440
|
+
return (await resolved.activateServer(api, context)) ?? {};
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
// How long one extension's `deactivate` and `health` may take before the host goes on without them.
|
|
444
|
+
export const DEACTIVATE_DEADLINE_MS = 2_500;
|
|
445
|
+
export const HEALTH_DEADLINE_MS = 2_000;
|
|
446
|
+
|
|
447
|
+
// `work`'s answer, or `late` once `ms` passes, whichever comes first; the timer never holds the process open.
|
|
448
|
+
const within = async <T, L>(work: () => T | Promise<T>, ms: number, late: L): Promise<T | L> => {
|
|
449
|
+
let timer: NodeJS.Timeout | undefined;
|
|
450
|
+
const lateness = new Promise<L>((settle) => {
|
|
451
|
+
timer = setTimeout(() => settle(late), ms);
|
|
452
|
+
timer.unref();
|
|
453
|
+
});
|
|
454
|
+
try {
|
|
455
|
+
return await Promise.race([Promise.resolve().then(work), lateness]);
|
|
456
|
+
} finally {
|
|
457
|
+
clearTimeout(timer);
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// Runs an activation's `deactivate` under the deadline. A throw or a timeout is said, never raised: the code is being
|
|
462
|
+
// replaced either way, and the caller's next step must not wait on it.
|
|
463
|
+
export const deactivateWithin = async (activation: ServerActivation, log: (message: string) => void): Promise<void> => {
|
|
464
|
+
const deactivate = activation.deactivate;
|
|
465
|
+
if (deactivate === undefined) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
const outcome = await within(async () => {
|
|
470
|
+
await deactivate();
|
|
471
|
+
return "done" as const;
|
|
472
|
+
}, DEACTIVATE_DEADLINE_MS, "late" as const);
|
|
473
|
+
if (outcome === "late") {
|
|
474
|
+
log(`deactivate did not finish within ${DEACTIVATE_DEADLINE_MS} ms; its code is replaced anyway`);
|
|
475
|
+
}
|
|
476
|
+
} catch (error) {
|
|
477
|
+
log(`deactivate threw: ${errorMessage(error)}`);
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// An activation's own account of itself, read under the deadline and held to the closed vocabulary: no `health` reads as
|
|
482
|
+
// `ok`, and one that throws, does not answer in time or answers outside the vocabulary reads as `degraded`, saying so.
|
|
483
|
+
export const healthWithin = async (activation: ServerActivation): Promise<ExtensionHealth> => {
|
|
484
|
+
const health = activation.health;
|
|
485
|
+
if (health === undefined) {
|
|
486
|
+
return { state: "ok" };
|
|
487
|
+
}
|
|
488
|
+
const late: ExtensionHealth = { state: "degraded", detail: `its health check did not answer within ${HEALTH_DEADLINE_MS} ms` };
|
|
489
|
+
try {
|
|
490
|
+
const answer = await within(health, HEALTH_DEADLINE_MS, late);
|
|
491
|
+
const parsed = ExtensionHealthSchema.safeParse(answer);
|
|
492
|
+
if (!parsed.success) {
|
|
493
|
+
return { state: "degraded", detail: "its health check answered something other than ok, starting, degraded or failed" };
|
|
494
|
+
}
|
|
495
|
+
return parsed.data.detail === undefined ? { state: parsed.data.state } : { state: parsed.data.state, detail: parsed.data.detail };
|
|
496
|
+
} catch (error) {
|
|
497
|
+
return { state: "degraded", detail: `its health check threw: ${errorMessage(error)}` };
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// Reads the variable or says which one is missing: only the daemon starts an extension's process, and a process started
|
|
502
|
+
// any other way has no token to reach it with.
|
|
503
|
+
const required = (env: NodeJS.ProcessEnv, name: string): string => {
|
|
504
|
+
const value = env[name];
|
|
505
|
+
if (value === undefined || value === "") {
|
|
506
|
+
throw new Error(`missing ${name}: an extension's process is started by the sandbox daemon, which sets it`);
|
|
507
|
+
}
|
|
508
|
+
return value;
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const permissionsOf = (raw: string | undefined): readonly string[] => {
|
|
512
|
+
if (raw === undefined || raw === "") {
|
|
513
|
+
return [];
|
|
514
|
+
}
|
|
515
|
+
const parsed = ExtensionPermissionsSchema.safeParse(JSON.parse(raw));
|
|
516
|
+
if (!parsed.success) {
|
|
517
|
+
throw new Error(`${EXTENSION_PROCESS_ENV.permissions} is not a JSON array of permissions`);
|
|
518
|
+
}
|
|
519
|
+
return parsed.data;
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
// A declared process's api, from the environment the daemon started it with (EXTENSION_PROCESS_ENV). Throws, naming the
|
|
523
|
+
// variable, when one it needs is missing.
|
|
524
|
+
export const connectExtensionProcess = (env: NodeJS.ProcessEnv = process.env, options: { readonly log?: (message: string) => void } = {}): ExtensionRuntime =>
|
|
525
|
+
createExtensionRuntime({
|
|
526
|
+
id: required(env, EXTENSION_PROCESS_ENV.id),
|
|
527
|
+
daemonUrl: required(env, EXTENSION_PROCESS_ENV.daemon),
|
|
528
|
+
token: required(env, EXTENSION_PROCESS_ENV.token),
|
|
529
|
+
permissions: permissionsOf(env[EXTENSION_PROCESS_ENV.permissions]),
|
|
530
|
+
workspaceRoot: required(env, EXTENSION_PROCESS_ENV.workspace),
|
|
531
|
+
extensionDir: env[EXTENSION_PROCESS_ENV.dir] ?? process.cwd(),
|
|
532
|
+
stateDir: required(env, EXTENSION_PROCESS_ENV.state),
|
|
533
|
+
cacheDir: required(env, EXTENSION_PROCESS_ENV.cache),
|
|
534
|
+
log: options.log,
|
|
535
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { sandboxContract } from "@intentic/sandbox-contract";
|
|
2
|
+
import type { Conversion, Granularity } from "@intentic/sandbox-contract/documents";
|
|
2
3
|
import type { ToolEffect } from "@intentic/sandbox-contract/peer-mcp-server";
|
|
3
4
|
import type { ContractRouterClient } from "@orpc/contract";
|
|
5
|
+
import type { Disposable } from "./disposable.js";
|
|
4
6
|
|
|
5
7
|
// The effect vocabulary the daemon's own tools and every device's tools declare, re-exported so a tool here says what it
|
|
6
8
|
// does in the same words.
|
|
@@ -34,7 +36,7 @@ export interface ToolCallContext {
|
|
|
34
36
|
// Aborted when the agent's client gives up on the call, or the host's deadline for it passes.
|
|
35
37
|
readonly signal: AbortSignal;
|
|
36
38
|
// The conversation the calling turn belongs to, when it has one.
|
|
37
|
-
readonly conversationId?: string;
|
|
39
|
+
readonly conversationId?: string | undefined;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export interface ToolDefinition {
|
|
@@ -55,6 +57,57 @@ export interface ToolDefinition {
|
|
|
55
57
|
// the host answers 404.
|
|
56
58
|
export type BackendRouteHandler = (request: Request) => Promise<Response | undefined>;
|
|
57
59
|
|
|
60
|
+
// An extension's own setting values, keyed as its manifest's `contributes.settings` declares them.
|
|
61
|
+
export type ExtensionSettingValues = Readonly<Record<string, string | number | boolean>>;
|
|
62
|
+
|
|
63
|
+
// A file the extension keeps in its own state directory, read through the conversions its shape has had and written
|
|
64
|
+
// with what a newer version of the extension put in it kept in place: the evolution `sandboxDocument` gives the browser
|
|
65
|
+
// half, and the daemon's own stores have. A file that exists but this version cannot read is left alone: `update`
|
|
66
|
+
// rejects rather than writing the fallback over it.
|
|
67
|
+
export interface ServerDocument<T> {
|
|
68
|
+
// Today's value: the file converted and parsed; the fallback when it is absent or this version cannot read it.
|
|
69
|
+
read(): Promise<T>;
|
|
70
|
+
// Read-change-write, serialized against every other handle on the same file, written whole by a rename so a reader
|
|
71
|
+
// never sees half of it. Answers what is stored afterwards; returning `current` unchanged writes nothing.
|
|
72
|
+
update(change: (current: T) => T): Promise<T>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// What a stored document holds before its parse: JSON, after the conversions its shape has had.
|
|
76
|
+
export type StoredJson = string | number | boolean | null | readonly StoredJson[] | { readonly [key: string]: StoredJson };
|
|
77
|
+
|
|
78
|
+
export interface ServerDocumentOptions<T> {
|
|
79
|
+
// Today's shape, or undefined for a file this version cannot read.
|
|
80
|
+
readonly parse: (raw: StoredJson) => T | undefined;
|
|
81
|
+
readonly fallback: () => T;
|
|
82
|
+
// Append-only: a conversion is never edited or removed once shipped, only followed by another.
|
|
83
|
+
readonly history?: readonly Conversion[];
|
|
84
|
+
// Where the conversions apply: the document, each entry of a top-level array, or each value of an object keyed by id.
|
|
85
|
+
readonly granularity?: Granularity;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// How a backend is doing, by its own account (`ServerActivation.health`), shown on its row in the Extensions list.
|
|
89
|
+
// `starting`: activated, still getting ready. `degraded`: serving, but something it relies on is not (a connection
|
|
90
|
+
// down, a download that failed), which `detail` says. `failed`: not serving at all.
|
|
91
|
+
export type ExtensionHealthState = "ok" | "starting" | "degraded" | "failed";
|
|
92
|
+
|
|
93
|
+
export interface ExtensionHealth {
|
|
94
|
+
readonly state: ExtensionHealthState;
|
|
95
|
+
// One sentence for the owner, required in spirit for anything but `ok`: a state with no stated reason is an opinion.
|
|
96
|
+
readonly detail?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// What `activateServer` may hand back: the two things only the extension can do for itself.
|
|
100
|
+
export interface ServerActivation {
|
|
101
|
+
// Release what activation opened (listeners, timers, child processes, connections), before the host replaces this
|
|
102
|
+
// extension's code or stops. With one, a change that touches only this extension (a rebuild, an update, a toggle)
|
|
103
|
+
// reloads it alone and leaves every other backend running; without one, the whole host restarts. Bounded by the
|
|
104
|
+
// host's deadline, after which the code is replaced anyway.
|
|
105
|
+
readonly deactivate?: () => void | Promise<void>;
|
|
106
|
+
// Asked on the host's health sweep and answered within its deadline; one that throws or does not answer in time
|
|
107
|
+
// reads as `degraded`. Absent: the backend reads as `ok` while its activation stands.
|
|
108
|
+
readonly health?: () => ExtensionHealth | Promise<ExtensionHealth>;
|
|
109
|
+
}
|
|
110
|
+
|
|
58
111
|
export interface ExtensionServerApi {
|
|
59
112
|
// The host's @intentic/extension-api version, checked against `engines.intentic`.
|
|
60
113
|
readonly apiVersion: string;
|
|
@@ -62,8 +115,36 @@ export interface ExtensionServerApi {
|
|
|
62
115
|
readonly workspaceRoot: string;
|
|
63
116
|
// This extension's own checkout (absolute), where its bundled assets sit.
|
|
64
117
|
readonly extensionDir: string;
|
|
118
|
+
// This extension's own state directory (absolute), created before activation and keyed by `publisher.name`, so it
|
|
119
|
+
// survives an update, a re-install and dev mode alike. Never versioned, never backed up, not carried to another
|
|
120
|
+
// sandbox: resume marks, cached tokens, a listener's last port. Removing the extension deletes it.
|
|
121
|
+
readonly stateDir: string;
|
|
122
|
+
// This extension's own cache directory (absolute): what can be fetched or built again (a downloaded engine, an
|
|
123
|
+
// index). Created before activation, ignored by the workspace watcher, deleted with the extension.
|
|
124
|
+
readonly cacheDir: string;
|
|
65
125
|
// A line in the daemon's log, attributed to this extension.
|
|
66
126
|
readonly log: (message: string) => void;
|
|
127
|
+
// A JSON file of the extension's own under `stateDir` (`path` is relative to it), evolved by its conversions.
|
|
128
|
+
readonly document: <T>(path: string, options: ServerDocumentOptions<T>) => ServerDocument<T>;
|
|
129
|
+
// The extension's own settings (`contributes.settings`), secret values included, as the owner set them now. Reached
|
|
130
|
+
// without declaring anything in `permissions.daemon`: the daemon answers only about the extension asking.
|
|
131
|
+
readonly settings: {
|
|
132
|
+
get(): Promise<ExtensionSettingValues>;
|
|
133
|
+
// Fires with the keys whose value changed, however they changed: the Extensions tab, an agent, a hand edit.
|
|
134
|
+
onDidChange(listener: (keys: readonly string[]) => void): Disposable;
|
|
135
|
+
};
|
|
136
|
+
// Changes in the workspace as the daemon sees them, over one stream the host opens on the first listener and closes
|
|
137
|
+
// with the last. The browser half's `onDidChangeFiles` is narrowed to the extension's own `contributes.files`; this
|
|
138
|
+
// one is every change, since a backend reads the whole workspace anyway.
|
|
139
|
+
readonly workspace: {
|
|
140
|
+
// Workspace-relative paths written, created or deleted, in batches; an empty batch is a write the watcher
|
|
141
|
+
// could not name (a build under a pruned directory), which a reader treats as "anything may have changed".
|
|
142
|
+
onDidChangeFiles(listener: (paths: readonly string[]) => void): Disposable;
|
|
143
|
+
// Repositories whose refs moved: a commit, a checkout, a branch or tag, a rebase.
|
|
144
|
+
onDidChangeRefs(listener: (repos: readonly string[]) => void): Disposable;
|
|
145
|
+
// The repository set itself moved (a clone, a scaffold, a delete); answers the whole set.
|
|
146
|
+
onDidChangeRepos(listener: (repos: readonly string[]) => void): Disposable;
|
|
147
|
+
};
|
|
67
148
|
readonly routes: {
|
|
68
149
|
// Serves this extension's route namespace; the daemon proxies /x/<id>/* here through its ordinary auth. A
|
|
69
150
|
// second mount replaces the first.
|
|
@@ -93,8 +174,14 @@ export interface ExtensionServerContext {
|
|
|
93
174
|
readonly extensionId: string;
|
|
94
175
|
}
|
|
95
176
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
177
|
+
// What a declared process (`contributes.processes`) gets, from `connectExtensionProcess` in
|
|
178
|
+
// `@intentic/extension-api/runtime`: the backend's api without the two halves only the backend host can serve. A
|
|
179
|
+
// process answers on its own port, so it mounts no routes and serves tools there (`contributes.tools.process`).
|
|
180
|
+
export type ExtensionProcessApi = Omit<ExtensionServerApi, "routes" | "tools">;
|
|
181
|
+
|
|
182
|
+
// Shape of the manifest `server` bundle's default (or named) export; `activateServer` runs once per activation, which
|
|
183
|
+
// is once per backend-host start unless the extension returns a `deactivate`, after which a change to it alone
|
|
184
|
+
// activates it again in place.
|
|
98
185
|
export interface ExtensionServerModule {
|
|
99
|
-
activateServer(api: ExtensionServerApi, context: ExtensionServerContext): void | Promise<void>;
|
|
186
|
+
activateServer(api: ExtensionServerApi, context: ExtensionServerContext): void | ServerActivation | Promise<void | ServerActivation>;
|
|
100
187
|
}
|