operon-agents 0.1.0-alpha.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/LICENSE +55 -0
- package/README.md +18 -0
- package/dist/app-server/client.d.ts +80 -0
- package/dist/app-server/client.js +187 -0
- package/dist/app-server/codec.d.ts +52 -0
- package/dist/app-server/codec.js +252 -0
- package/dist/app-server/index.d.ts +22 -0
- package/dist/app-server/index.js +22 -0
- package/dist/app-server/main.d.ts +3 -0
- package/dist/app-server/main.js +85 -0
- package/dist/app-server/protocol.d.ts +217 -0
- package/dist/app-server/protocol.js +100 -0
- package/dist/app-server/server.d.ts +51 -0
- package/dist/app-server/server.js +274 -0
- package/dist/core.d.ts +12 -0
- package/dist/core.js +12 -0
- package/dist/cron/clock.d.ts +11 -0
- package/dist/cron/clock.js +19 -0
- package/dist/cron/cron-expr.d.ts +15 -0
- package/dist/cron/cron-expr.js +267 -0
- package/dist/cron/extension.d.ts +25 -0
- package/dist/cron/extension.js +141 -0
- package/dist/cron/index.d.ts +20 -0
- package/dist/cron/index.js +11 -0
- package/dist/cron/jitter.d.ts +18 -0
- package/dist/cron/jitter.js +46 -0
- package/dist/cron/manager.d.ts +55 -0
- package/dist/cron/manager.js +123 -0
- package/dist/cron/persist.d.ts +10 -0
- package/dist/cron/persist.js +22 -0
- package/dist/cron/scheduler.d.ts +25 -0
- package/dist/cron/scheduler.js +192 -0
- package/dist/cron/session-store.d.ts +14 -0
- package/dist/cron/session-store.js +50 -0
- package/dist/cron/time-format.d.ts +2 -0
- package/dist/cron/time-format.js +12 -0
- package/dist/cron/tools.d.ts +7 -0
- package/dist/cron/tools.js +161 -0
- package/dist/cron/types.d.ts +9 -0
- package/dist/cron/types.js +2 -0
- package/dist/extensions/index.d.ts +23 -0
- package/dist/extensions/index.js +41 -0
- package/dist/extensions/loader.d.ts +121 -0
- package/dist/extensions/loader.js +285 -0
- package/dist/extensions/manager.d.ts +103 -0
- package/dist/extensions/manager.js +190 -0
- package/dist/extensions/runtime.d.ts +188 -0
- package/dist/extensions/runtime.js +1127 -0
- package/dist/extensions/services.d.ts +85 -0
- package/dist/extensions/services.js +265 -0
- package/dist/extensions/types.d.ts +594 -0
- package/dist/extensions/types.js +2 -0
- package/dist/harness.d.ts +746 -0
- package/dist/harness.js +1483 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +29 -0
- package/dist/internal.d.ts +9 -0
- package/dist/internal.js +9 -0
- package/dist/llm.d.ts +2 -0
- package/dist/llm.js +2 -0
- package/dist/local.d.ts +24 -0
- package/dist/local.js +58 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +2 -0
- package/dist/protocol.d.ts +2 -0
- package/dist/protocol.js +2 -0
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +2 -0
- package/dist/tracing.d.ts +2 -0
- package/dist/tracing.js +2 -0
- package/package.json +78 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
/**
|
|
14
|
+
* File-extension loader — the shell that turns a extension directory into extension VALUES for
|
|
15
|
+
* `attachExtension`. The framework core stays file-blind: everything here ends in a plain
|
|
16
|
+
* `ExtensionDefinition`, handed over the same channel host-authored extensions use.
|
|
17
|
+
*
|
|
18
|
+
* Layout: one folder per extension under `directory`, holding a `manifest.json` plus a bundled
|
|
19
|
+
* entry file (dependencies baked in by the author at publish time — no install step here).
|
|
20
|
+
*
|
|
21
|
+
* Trust model — loading is consent, and consent is MANUAL:
|
|
22
|
+
* - `load()` / `reload()` are explicit host actions; performing one approves the extension at
|
|
23
|
+
* its current file mtime, recorded in `.approvals.json` beside the extensions.
|
|
24
|
+
* - `loadApproved()` (startup) only honors extensions whose entry is byte-for-byte the one
|
|
25
|
+
* approved (mtime match). A changed or new extension is reported, never silently loaded —
|
|
26
|
+
* an agent with write access must not be able to plant code that runs on next open.
|
|
27
|
+
*
|
|
28
|
+
* Reload mechanics: native `import()` with a `?v=<mtime>` suffix — a changed file gets a
|
|
29
|
+
* fresh module, an unchanged one hits the cache. Old module instances stay resident until
|
|
30
|
+
* process exit; that is the accepted cost of not restarting.
|
|
31
|
+
*/
|
|
32
|
+
/** This framework's version — what a manifest's `engine` is checked against. */
|
|
33
|
+
export const FRAMEWORK_VERSION = createRequire(import.meta.url)("../../package.json").version;
|
|
34
|
+
const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
35
|
+
/** Semver ordering for plain `x.y.z[-pre]` strings (a prerelease sorts below its release). */
|
|
36
|
+
export function compareVersions(a, b) {
|
|
37
|
+
const pa = VERSION_RE.exec(a), pb = VERSION_RE.exec(b);
|
|
38
|
+
if (pa === null || pb === null)
|
|
39
|
+
throw new Error(`not a version: ${JSON.stringify(pa === null ? a : b)}`);
|
|
40
|
+
for (let i = 1; i <= 3; i++) {
|
|
41
|
+
const d = Number(pa[i]) - Number(pb[i]);
|
|
42
|
+
if (d !== 0)
|
|
43
|
+
return d;
|
|
44
|
+
}
|
|
45
|
+
const preA = pa[4], preB = pb[4];
|
|
46
|
+
if (preA === undefined && preB === undefined)
|
|
47
|
+
return 0;
|
|
48
|
+
if (preA === undefined)
|
|
49
|
+
return 1;
|
|
50
|
+
if (preB === undefined)
|
|
51
|
+
return -1;
|
|
52
|
+
const xs = preA.split("."), ys = preB.split(".");
|
|
53
|
+
for (let i = 0; i < Math.max(xs.length, ys.length); i++) {
|
|
54
|
+
const x = xs[i], y = ys[i];
|
|
55
|
+
if (x === undefined)
|
|
56
|
+
return -1;
|
|
57
|
+
if (y === undefined)
|
|
58
|
+
return 1;
|
|
59
|
+
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
|
|
60
|
+
const d = nx && ny ? Number(x) - Number(y) : nx ? -1 : ny ? 1 : x.localeCompare(y);
|
|
61
|
+
if (d !== 0)
|
|
62
|
+
return d;
|
|
63
|
+
}
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
const APPROVALS_FILE = ".approvals.json";
|
|
67
|
+
export class ExtensionLoader {
|
|
68
|
+
directory;
|
|
69
|
+
approvalsPath;
|
|
70
|
+
/** Live definitions by extension id — the values a host hands to `attachExtension`. */
|
|
71
|
+
loaded = new Map();
|
|
72
|
+
constructor(options) {
|
|
73
|
+
this.directory = resolve(options.directory);
|
|
74
|
+
this.approvalsPath = join(this.directory, APPROVALS_FILE);
|
|
75
|
+
}
|
|
76
|
+
/** The parsed manifest of one extension, by id. */
|
|
77
|
+
async manifest(id) {
|
|
78
|
+
return (await this.find(id)).manifest;
|
|
79
|
+
}
|
|
80
|
+
/** The folder a extension lives in — where an extension keeps its data files. */
|
|
81
|
+
async manifestDir(id) {
|
|
82
|
+
return (await this.find(id)).dir;
|
|
83
|
+
}
|
|
84
|
+
/** Every extension folder under the directory, with its trust/load state. */
|
|
85
|
+
async list() {
|
|
86
|
+
const approvals = await this.readApprovals();
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const entry of await this.readExtensionDirs()) {
|
|
89
|
+
try {
|
|
90
|
+
const scanned = await this.scan(entry);
|
|
91
|
+
out.push({
|
|
92
|
+
id: scanned.manifest.id,
|
|
93
|
+
dir: scanned.dir,
|
|
94
|
+
...(scanned.manifest.version !== undefined ? { version: scanned.manifest.version } : {}),
|
|
95
|
+
...(scanned.manifest.engine !== undefined ? { engine: scanned.manifest.engine } : {}),
|
|
96
|
+
...(scanned.manifest.name !== undefined ? { name: scanned.manifest.name } : {}),
|
|
97
|
+
...(scanned.manifest.description !== undefined ? { description: scanned.manifest.description } : {}),
|
|
98
|
+
state: this.stateOf(scanned, approvals),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
out.push({ id: entry, dir: join(this.directory, entry), state: "error", error: messageOf(error) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Import one extension and approve it as it stands. The call itself is the consent — reserve
|
|
109
|
+
* it for user-initiated actions, never for something an agent triggers on its own.
|
|
110
|
+
*/
|
|
111
|
+
async load(id) {
|
|
112
|
+
const scanned = await this.find(id);
|
|
113
|
+
const definition = await this.importDefinition(scanned);
|
|
114
|
+
await this.approve(scanned);
|
|
115
|
+
this.loaded.set(id, definition);
|
|
116
|
+
return definition;
|
|
117
|
+
}
|
|
118
|
+
/** Re-import after an edit and roll the approval forward to the file as it is now. */
|
|
119
|
+
async reload(id) {
|
|
120
|
+
return this.load(id);
|
|
121
|
+
}
|
|
122
|
+
/** Forget a extension: drop the held definition and revoke its approval. */
|
|
123
|
+
async unload(id) {
|
|
124
|
+
this.loaded.delete(id);
|
|
125
|
+
const approvals = await this.readApprovals();
|
|
126
|
+
if (id in approvals) {
|
|
127
|
+
const { [id]: _dropped, ...rest } = approvals;
|
|
128
|
+
await this.writeApprovals(rest);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Startup path: import every extension still byte-identical to what was approved. Changed and
|
|
133
|
+
* new extensions are skipped and reported so the host can surface them for a manual decision.
|
|
134
|
+
*/
|
|
135
|
+
async loadApproved() {
|
|
136
|
+
const loaded = [];
|
|
137
|
+
const skipped = [];
|
|
138
|
+
for (const status of await this.list()) {
|
|
139
|
+
if (status.state === "approved" || status.state === "loaded") {
|
|
140
|
+
try {
|
|
141
|
+
const scanned = await this.find(status.id);
|
|
142
|
+
const definition = await this.importDefinition(scanned);
|
|
143
|
+
this.loaded.set(status.id, definition);
|
|
144
|
+
loaded.push(definition);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
skipped.push({ ...status, state: "error", error: messageOf(error) });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
skipped.push(status);
|
|
153
|
+
}
|
|
154
|
+
return { loaded, skipped };
|
|
155
|
+
}
|
|
156
|
+
/** The definitions currently held. On the standalone-loader path these reach sessions through
|
|
157
|
+
* `attachExtension`/`reloadInto`; a harness built with `extensionDir` registers them itself
|
|
158
|
+
* (`harness.extensions.load`), which is the path that also runs `create` halves. */
|
|
159
|
+
definitions() {
|
|
160
|
+
return [...this.loaded.values()];
|
|
161
|
+
}
|
|
162
|
+
/** Convenience: reload a extension and swap it on a live session (detach old, attach new). */
|
|
163
|
+
async reloadInto(session, id) {
|
|
164
|
+
const definition = await this.reload(id);
|
|
165
|
+
await session.detachExtension(id).catch(() => undefined);
|
|
166
|
+
await session.attachExtension(definition);
|
|
167
|
+
return definition;
|
|
168
|
+
}
|
|
169
|
+
// ==========================================================================
|
|
170
|
+
// Internals
|
|
171
|
+
// ==========================================================================
|
|
172
|
+
async readExtensionDirs() {
|
|
173
|
+
const entries = await readdir(this.directory, { withFileTypes: true }).catch(() => []);
|
|
174
|
+
// Dot-folders are the loader's own (`.data` holds extensions' data dirs), never extensions.
|
|
175
|
+
return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name).sort();
|
|
176
|
+
}
|
|
177
|
+
async scan(dirName) {
|
|
178
|
+
const dir = join(this.directory, dirName);
|
|
179
|
+
const manifestPath = join(dir, "manifest.json");
|
|
180
|
+
let manifest;
|
|
181
|
+
try {
|
|
182
|
+
manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
throw new Error(`unreadable manifest.json: ${messageOf(error)}`);
|
|
186
|
+
}
|
|
187
|
+
if (typeof manifest.id !== "string" || !manifest.id.trim()) {
|
|
188
|
+
throw new Error("manifest.json must declare a non-empty string \"id\"");
|
|
189
|
+
}
|
|
190
|
+
if (manifest.engine !== undefined) {
|
|
191
|
+
if (typeof manifest.engine !== "string" || !VERSION_RE.test(manifest.engine)) {
|
|
192
|
+
throw new Error(`manifest "engine" must be a version like "1.2.0", got ${JSON.stringify(manifest.engine)}`);
|
|
193
|
+
}
|
|
194
|
+
if (compareVersions(FRAMEWORK_VERSION, manifest.engine) < 0) {
|
|
195
|
+
throw new Error(`requires operon-agents >= ${manifest.engine}; this host runs ${FRAMEWORK_VERSION}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const entryPath = join(dir, manifest.entry ?? "index.js");
|
|
199
|
+
const stats = await stat(entryPath).catch(() => {
|
|
200
|
+
throw new Error(`entry file missing: ${entryPath}`);
|
|
201
|
+
});
|
|
202
|
+
return { manifest, dir, entryPath, mtimeMs: stats.mtimeMs };
|
|
203
|
+
}
|
|
204
|
+
async find(id) {
|
|
205
|
+
let failure;
|
|
206
|
+
for (const dirName of await this.readExtensionDirs()) {
|
|
207
|
+
try {
|
|
208
|
+
const scanned = await this.scan(dirName);
|
|
209
|
+
if (scanned.manifest.id === id)
|
|
210
|
+
return scanned;
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
// A broken sibling folder must not block loading the one asked for — but the folder
|
|
214
|
+
// named like the id IS the one asked for, and its reason (bad manifest, missing entry,
|
|
215
|
+
// an `engine` this host is too old for) is the answer, not "not found".
|
|
216
|
+
if (dirName === id)
|
|
217
|
+
failure = error;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (failure !== undefined)
|
|
221
|
+
throw new Error(`extension "${id}": ${messageOf(failure)}`);
|
|
222
|
+
throw new Error(`no extension with id "${id}" under ${this.directory}`);
|
|
223
|
+
}
|
|
224
|
+
stateOf(scanned, approvals) {
|
|
225
|
+
const approval = approvals[scanned.manifest.id];
|
|
226
|
+
if (!approval)
|
|
227
|
+
return "new";
|
|
228
|
+
if (approval.mtimeMs !== scanned.mtimeMs || approval.entry !== scanned.entryPath)
|
|
229
|
+
return "changed";
|
|
230
|
+
return this.loaded.has(scanned.manifest.id) ? "loaded" : "approved";
|
|
231
|
+
}
|
|
232
|
+
async importDefinition(scanned) {
|
|
233
|
+
// The mtime suffix makes an edited file a NEW module while an unchanged one stays cached.
|
|
234
|
+
const url = `${pathToFileURL(scanned.entryPath).href}?v=${scanned.mtimeMs}`;
|
|
235
|
+
let moduleExports;
|
|
236
|
+
try {
|
|
237
|
+
moduleExports = (await import(__rewriteRelativeImportExtension(url)));
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
throw new Error(`extension "${scanned.manifest.id}" failed to import: ${messageOf(error)}`);
|
|
241
|
+
}
|
|
242
|
+
let candidate = moduleExports.default;
|
|
243
|
+
// A factory default export lets a extension construct per-load state; called with nothing —
|
|
244
|
+
// everything a extension may use arrives later through its setup(ctx).
|
|
245
|
+
if (typeof candidate === "function")
|
|
246
|
+
candidate = candidate();
|
|
247
|
+
if (!isExtensionDefinition(candidate)) {
|
|
248
|
+
throw new Error(`extension "${scanned.manifest.id}" default export is not an extension definition ({ id, setup })`);
|
|
249
|
+
}
|
|
250
|
+
if (candidate.id !== scanned.manifest.id) {
|
|
251
|
+
throw new Error(`extension id mismatch: manifest says "${scanned.manifest.id}", code says "${candidate.id}"`);
|
|
252
|
+
}
|
|
253
|
+
return candidate;
|
|
254
|
+
}
|
|
255
|
+
async approve(scanned) {
|
|
256
|
+
const approvals = await this.readApprovals();
|
|
257
|
+
await this.writeApprovals({
|
|
258
|
+
...approvals,
|
|
259
|
+
[scanned.manifest.id]: { entry: scanned.entryPath, mtimeMs: scanned.mtimeMs, approvedAt: Date.now() },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
async readApprovals() {
|
|
263
|
+
try {
|
|
264
|
+
return JSON.parse(await readFile(this.approvalsPath, "utf8"));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return {};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async writeApprovals(approvals) {
|
|
271
|
+
await writeFile(this.approvalsPath, `${JSON.stringify(approvals, null, 2)}\n`, "utf8");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
export function createExtensionLoader(options) {
|
|
275
|
+
return new ExtensionLoader(options);
|
|
276
|
+
}
|
|
277
|
+
function isExtensionDefinition(value) {
|
|
278
|
+
return (typeof value === "object" && value !== null
|
|
279
|
+
&& typeof value.id === "string"
|
|
280
|
+
&& typeof value.setup === "function");
|
|
281
|
+
}
|
|
282
|
+
function messageOf(error) {
|
|
283
|
+
return error instanceof Error ? error.message : String(error);
|
|
284
|
+
}
|
|
285
|
+
//# sourceMappingURL=loader.js.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ExtensionDefinition, ExtensionHostContext } from "./types.ts";
|
|
2
|
+
import { type ExtensionFileStatus } from "./loader.ts";
|
|
3
|
+
import { ServiceRegistry, type ServiceOptions } from "./services.ts";
|
|
4
|
+
/** The session surface the barrier hands to a swap function. */
|
|
5
|
+
export interface HeldSession {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
attachExtension(definition: ExtensionDefinition): Promise<void>;
|
|
8
|
+
detachExtension(extensionId: string): Promise<void>;
|
|
9
|
+
attachedExtensionIds(): readonly string[];
|
|
10
|
+
attachedExtensions(): readonly {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly uses: readonly string[];
|
|
13
|
+
}[];
|
|
14
|
+
}
|
|
15
|
+
/** The narrow harness face the manager operates through (no import cycle with harness.ts). */
|
|
16
|
+
export interface ExtensionHostBridge {
|
|
17
|
+
readonly services: ServiceRegistry;
|
|
18
|
+
createSession(options?: Record<string, unknown>): Promise<{
|
|
19
|
+
readonly id: string;
|
|
20
|
+
}>;
|
|
21
|
+
sessions(): readonly HeldSession[];
|
|
22
|
+
/** Rendezvous every session holding one of `extensionIds` at its run boundary, run `fn` in
|
|
23
|
+
* the quiet moment (held() reads live — sessions born gated during the barrier appear). */
|
|
24
|
+
withBarrier(extensionIds: readonly string[], timeoutMs: number, fn: (held: () => readonly HeldSession[]) => Promise<void>): Promise<void>;
|
|
25
|
+
warn(message: string): void;
|
|
26
|
+
}
|
|
27
|
+
/** What staging one `create`-bearing definition produced, before anything is published. */
|
|
28
|
+
export interface StagedDefinition {
|
|
29
|
+
/** The one service `create` returned, to register under the extension's id; absent when the
|
|
30
|
+
* definition has no `create`. */
|
|
31
|
+
readonly service?: {
|
|
32
|
+
readonly name: string;
|
|
33
|
+
readonly instance: unknown;
|
|
34
|
+
readonly options: ServiceOptions;
|
|
35
|
+
};
|
|
36
|
+
/** The definition itself — its `setup` is the per-session half, mounted into every session. */
|
|
37
|
+
readonly sessionDef: ExtensionDefinition;
|
|
38
|
+
}
|
|
39
|
+
export interface StagingOptions {
|
|
40
|
+
/** What the registered service's `replaceable` defaults to. File extensions default to true
|
|
41
|
+
* (reload is their point); by-value ones to false (a change is a restart). */
|
|
42
|
+
readonly defaultReplaceable: boolean;
|
|
43
|
+
readonly dir?: string;
|
|
44
|
+
/** The extension's data folder, already created. */
|
|
45
|
+
readonly dataDir?: string;
|
|
46
|
+
createSession: ExtensionHostContext["createSession"];
|
|
47
|
+
warn(message: string): void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Run a `create`-bearing definition's `create` half against a staging host and collect the one
|
|
51
|
+
* service it returns — nothing is registered here, so a throwing `create` publishes nothing.
|
|
52
|
+
* Shared by the file manager and the harness's by-value channel. Stays synchronous when `create`
|
|
53
|
+
* is, which the by-value channel relies on to have a synchronous extension's service visible the
|
|
54
|
+
* moment `createHarness` returns.
|
|
55
|
+
*/
|
|
56
|
+
export declare function stageDefinition(definition: ExtensionDefinition, options: StagingOptions): StagedDefinition | Promise<StagedDefinition>;
|
|
57
|
+
export declare class HarnessExtensionManager {
|
|
58
|
+
private readonly loader;
|
|
59
|
+
private readonly bridge;
|
|
60
|
+
/** Root of the extensions' data folders (`<root>/<id>`) — outside every code folder. */
|
|
61
|
+
private readonly dataRoot;
|
|
62
|
+
/** Every loaded extension by id, and whether its `create` published a service. */
|
|
63
|
+
private readonly loaded;
|
|
64
|
+
/** Definitions newborn sessions receive: every loaded extension's per-session half. */
|
|
65
|
+
private readonly sessionDefs;
|
|
66
|
+
constructor(options: {
|
|
67
|
+
readonly directory: string;
|
|
68
|
+
readonly dataDir: string;
|
|
69
|
+
readonly bridge: ExtensionHostBridge;
|
|
70
|
+
});
|
|
71
|
+
list(): Promise<ExtensionFileStatus[]>;
|
|
72
|
+
/** What every new session is born with. Wire into the harness's extension evaluation. */
|
|
73
|
+
sessionDefinitions(): ExtensionDefinition[];
|
|
74
|
+
/**
|
|
75
|
+
* Load (and approve) one extension — a USER action, like `ExtensionLoader.load`. Runs the
|
|
76
|
+
* definition's `create` (if any) and publishes the result as its service, then hands the
|
|
77
|
+
* definition to future sessions. Loading an already-loaded extension reloads it: every
|
|
78
|
+
* session holding it meets at its run boundary, the per-session half is swapped (and the
|
|
79
|
+
* service replaced), and everyone resumes — the same act whether or not there is a `create`.
|
|
80
|
+
*/
|
|
81
|
+
load(id: string, options?: {
|
|
82
|
+
readonly timeoutMs?: number;
|
|
83
|
+
}): Promise<void>;
|
|
84
|
+
/** Alias of `load` — the manager's load already swaps a live extension coordinated. */
|
|
85
|
+
reload(id: string, options?: {
|
|
86
|
+
readonly timeoutMs?: number;
|
|
87
|
+
}): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* Unload an extension. A `create`-bearing one is refused while any session still runs an
|
|
90
|
+
* extension that `uses` its service — named, so the caller knows what to detach. Its own
|
|
91
|
+
* per-session half does not block (it is removed as part of the unload).
|
|
92
|
+
*/
|
|
93
|
+
unload(id: string): Promise<void>;
|
|
94
|
+
private register;
|
|
95
|
+
/** Run `create` with a staging host: the service is COLLECTED, not registered, so a throwing
|
|
96
|
+
* `create` publishes nothing. Services default to replaceable — reload is their point. */
|
|
97
|
+
private stage;
|
|
98
|
+
/** Sessions still running an extension that `uses` `id`'s service — read off the attached
|
|
99
|
+
* definitions themselves, so by-value and file consumers count alike. No graph walk: the
|
|
100
|
+
* graph is depth 1. */
|
|
101
|
+
private consumersOf;
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=manager.d.ts.map
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file-extension manager (design: docs/architecture.md §5.4-5.5). An extension
|
|
3
|
+
* is a FILE whose default export is an `ExtensionDefinition`; the manager runs its `create` half
|
|
4
|
+
* once if it has one, registers the result as a service under the extension's `id`, and hands
|
|
5
|
+
* the SAME definition to every session (its `setup` is the per-session half). One extension,
|
|
6
|
+
* one approval, one reload — the manager turns a reload into the coordinated act: barrier →
|
|
7
|
+
* swap the session half → replace the service → release.
|
|
8
|
+
*
|
|
9
|
+
* Trust: the code runs at HOST trust and holds `createSession`. The control point is the same
|
|
10
|
+
* single one as for ordinary extensions — manual load/reload IS the approval (mtime-scoped).
|
|
11
|
+
* Nothing here auto-loads or watches.
|
|
12
|
+
*
|
|
13
|
+
* `stageDefinition` — running `create` against a staging host and collecting the one service, so a
|
|
14
|
+
* throwing `create` publishes nothing — is shared with the harness's by-value channel
|
|
15
|
+
* (`createHarness({ extensions })` runs a `create`-bearing definition through it with no loader).
|
|
16
|
+
*/
|
|
17
|
+
import { mkdir } from "node:fs/promises";
|
|
18
|
+
import { join, resolve } from "node:path";
|
|
19
|
+
import { ExtensionLoader } from "./loader.js";
|
|
20
|
+
import { ServiceRegistry } from "./services.js";
|
|
21
|
+
function isPromiseLike(value) {
|
|
22
|
+
return typeof value === "object" && value !== null && typeof value.then === "function";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Run a `create`-bearing definition's `create` half against a staging host and collect the one
|
|
26
|
+
* service it returns — nothing is registered here, so a throwing `create` publishes nothing.
|
|
27
|
+
* Shared by the file manager and the harness's by-value channel. Stays synchronous when `create`
|
|
28
|
+
* is, which the by-value channel relies on to have a synchronous extension's service visible the
|
|
29
|
+
* moment `createHarness` returns.
|
|
30
|
+
*/
|
|
31
|
+
export function stageDefinition(definition, options) {
|
|
32
|
+
if (definition.create === undefined)
|
|
33
|
+
return { sessionDef: definition };
|
|
34
|
+
const host = {
|
|
35
|
+
createSession: (sessionOptions) => options.createSession(sessionOptions),
|
|
36
|
+
...(options.dir !== undefined ? { dir: options.dir } : {}),
|
|
37
|
+
...(options.dataDir !== undefined ? { dataDir: options.dataDir } : {}),
|
|
38
|
+
warn: (message) => options.warn(`[extension ${definition.id}] ${message}`),
|
|
39
|
+
};
|
|
40
|
+
const finish = (instance) => ({
|
|
41
|
+
service: { name: definition.id, instance, options: { replaceable: options.defaultReplaceable } },
|
|
42
|
+
sessionDef: definition,
|
|
43
|
+
});
|
|
44
|
+
const result = definition.create(host);
|
|
45
|
+
return isPromiseLike(result) ? Promise.resolve(result).then(finish) : finish(result);
|
|
46
|
+
}
|
|
47
|
+
const DEFAULT_BARRIER_TIMEOUT_MS = 30_000;
|
|
48
|
+
export class HarnessExtensionManager {
|
|
49
|
+
loader;
|
|
50
|
+
bridge;
|
|
51
|
+
/** Root of the extensions' data folders (`<root>/<id>`) — outside every code folder. */
|
|
52
|
+
dataRoot;
|
|
53
|
+
/** Every loaded extension by id, and whether its `create` published a service. */
|
|
54
|
+
loaded = new Map();
|
|
55
|
+
/** Definitions newborn sessions receive: every loaded extension's per-session half. */
|
|
56
|
+
sessionDefs = new Map();
|
|
57
|
+
constructor(options) {
|
|
58
|
+
this.loader = new ExtensionLoader({ directory: options.directory });
|
|
59
|
+
this.dataRoot = resolve(options.dataDir);
|
|
60
|
+
this.bridge = options.bridge;
|
|
61
|
+
}
|
|
62
|
+
list() {
|
|
63
|
+
return this.loader.list();
|
|
64
|
+
}
|
|
65
|
+
/** What every new session is born with. Wire into the harness's extension evaluation. */
|
|
66
|
+
sessionDefinitions() {
|
|
67
|
+
return [...this.sessionDefs.values()];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Load (and approve) one extension — a USER action, like `ExtensionLoader.load`. Runs the
|
|
71
|
+
* definition's `create` (if any) and publishes the result as its service, then hands the
|
|
72
|
+
* definition to future sessions. Loading an already-loaded extension reloads it: every
|
|
73
|
+
* session holding it meets at its run boundary, the per-session half is swapped (and the
|
|
74
|
+
* service replaced), and everyone resumes — the same act whether or not there is a `create`.
|
|
75
|
+
*/
|
|
76
|
+
async load(id, options = {}) {
|
|
77
|
+
const definition = await this.loader.load(id);
|
|
78
|
+
// `uses` lives on the definition, so it is readable only after the import; a missing
|
|
79
|
+
// provider revokes the approval the import just recorded, so a failed load leaves nothing
|
|
80
|
+
// behind. Same rule for both kinds: load providers first.
|
|
81
|
+
for (const name of definition.uses ?? []) {
|
|
82
|
+
if (!this.bridge.services.has(name)) {
|
|
83
|
+
await this.loader.unload(id);
|
|
84
|
+
throw new Error(`extension "${id}" uses service "${name}", which is not registered — load the extension that provides it first`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
await this.register(id, definition, options);
|
|
88
|
+
}
|
|
89
|
+
/** Alias of `load` — the manager's load already swaps a live extension coordinated. */
|
|
90
|
+
reload(id, options = {}) {
|
|
91
|
+
return this.load(id, options);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Unload an extension. A `create`-bearing one is refused while any session still runs an
|
|
95
|
+
* extension that `uses` its service — named, so the caller knows what to detach. Its own
|
|
96
|
+
* per-session half does not block (it is removed as part of the unload).
|
|
97
|
+
*/
|
|
98
|
+
async unload(id) {
|
|
99
|
+
const record = this.loaded.get(id);
|
|
100
|
+
if (record === undefined) {
|
|
101
|
+
// Never loaded: just revoke whatever approval is on file.
|
|
102
|
+
await this.loader.unload(id);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const consumers = this.consumersOf(id);
|
|
106
|
+
if (consumers.length > 0) {
|
|
107
|
+
throw new Error(`cannot unload extension "${id}": service still consumed by ${consumers.join(", ")} — detach those first`);
|
|
108
|
+
}
|
|
109
|
+
// Own per-session half: detach everywhere (each session applies it at its own boundary — a
|
|
110
|
+
// pure removal needs no barrier; there is no two-version coexistence to prevent).
|
|
111
|
+
for (const session of this.bridge.sessions()) {
|
|
112
|
+
if (session.attachedExtensionIds().includes(id))
|
|
113
|
+
await session.detachExtension(id);
|
|
114
|
+
}
|
|
115
|
+
this.sessionDefs.delete(id);
|
|
116
|
+
if (record.hasService)
|
|
117
|
+
await this.bridge.services.unregister(id);
|
|
118
|
+
this.loaded.delete(id);
|
|
119
|
+
await this.loader.unload(id);
|
|
120
|
+
}
|
|
121
|
+
// ==========================================================================
|
|
122
|
+
// Internals
|
|
123
|
+
// ==========================================================================
|
|
124
|
+
async register(id, definition, options) {
|
|
125
|
+
const staged = await this.stage(definition);
|
|
126
|
+
const previous = this.loaded.get(id);
|
|
127
|
+
if (previous === undefined) {
|
|
128
|
+
// First load: register the service, then hand the session half to future sessions.
|
|
129
|
+
// Already-open sessions are not touched (a reload only re-attaches to sessions that hold
|
|
130
|
+
// the half) — attach it explicitly where an open session should get it.
|
|
131
|
+
if (staged.service !== undefined) {
|
|
132
|
+
this.bridge.services.register(staged.service.name, staged.service.instance, staged.service.options);
|
|
133
|
+
}
|
|
134
|
+
this.sessionDefs.set(id, staged.sessionDef);
|
|
135
|
+
this.loaded.set(id, { hasService: staged.service !== undefined });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Reload: one coordinated act — barrier on the session half, swap half + service in the
|
|
139
|
+
// quiet moment. Version skew between the two halves is structurally impossible.
|
|
140
|
+
await this.bridge.withBarrier([id], options.timeoutMs ?? DEFAULT_BARRIER_TIMEOUT_MS, async (held) => {
|
|
141
|
+
for (const session of held()) {
|
|
142
|
+
if (session.attachedExtensionIds().includes(id))
|
|
143
|
+
await session.detachExtension(id);
|
|
144
|
+
}
|
|
145
|
+
if (staged.service !== undefined) {
|
|
146
|
+
if (this.bridge.services.has(id))
|
|
147
|
+
await this.bridge.services.replace(id, staged.service.instance);
|
|
148
|
+
else
|
|
149
|
+
this.bridge.services.register(staged.service.name, staged.service.instance, staged.service.options);
|
|
150
|
+
}
|
|
151
|
+
else if (previous.hasService) {
|
|
152
|
+
await this.bridge.services.unregister(id);
|
|
153
|
+
}
|
|
154
|
+
this.sessionDefs.set(id, staged.sessionDef);
|
|
155
|
+
for (const session of held()) {
|
|
156
|
+
await session.attachExtension(staged.sessionDef);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
this.loaded.set(id, { hasService: staged.service !== undefined });
|
|
160
|
+
}
|
|
161
|
+
/** Run `create` with a staging host: the service is COLLECTED, not registered, so a throwing
|
|
162
|
+
* `create` publishes nothing. Services default to replaceable — reload is their point. */
|
|
163
|
+
async stage(definition) {
|
|
164
|
+
// The data folder exists before `create` runs, so the extension can use it right away.
|
|
165
|
+
const dataDir = join(this.dataRoot, definition.id);
|
|
166
|
+
if (definition.create !== undefined)
|
|
167
|
+
await mkdir(dataDir, { recursive: true });
|
|
168
|
+
return stageDefinition(definition, {
|
|
169
|
+
defaultReplaceable: true,
|
|
170
|
+
dir: await this.loader.manifestDir(definition.id),
|
|
171
|
+
dataDir,
|
|
172
|
+
createSession: (sessionOptions) => this.bridge.createSession(sessionOptions),
|
|
173
|
+
warn: (message) => this.bridge.warn(message),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
/** Sessions still running an extension that `uses` `id`'s service — read off the attached
|
|
177
|
+
* definitions themselves, so by-value and file consumers count alike. No graph walk: the
|
|
178
|
+
* graph is depth 1. */
|
|
179
|
+
consumersOf(id) {
|
|
180
|
+
const found = [];
|
|
181
|
+
for (const session of this.bridge.sessions()) {
|
|
182
|
+
for (const attached of session.attachedExtensions()) {
|
|
183
|
+
if (attached.uses.includes(id))
|
|
184
|
+
found.push(`"${attached.id}" (session ${session.id})`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return found;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
//# sourceMappingURL=manager.js.map
|