operon-agents 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +42 -0
- package/dist/app-server/client.js +8 -5
- package/dist/app-server/main.js +1 -1
- package/dist/app-server/params.d.ts +115 -0
- package/dist/app-server/params.js +81 -0
- package/dist/app-server/protocol.d.ts +10 -4
- package/dist/app-server/protocol.js +45 -37
- package/dist/app-server/server.d.ts +0 -1
- package/dist/app-server/server.js +46 -35
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/cron/cron-expr.js +1 -1
- package/dist/cron/extension.js +2 -2
- package/dist/extensions/index.d.ts +19 -5
- package/dist/extensions/index.js +66 -10
- package/dist/extensions/loader.d.ts +2 -2
- package/dist/extensions/loader.js +23 -15
- package/dist/extensions/manager.d.ts +15 -13
- package/dist/extensions/manager.js +28 -25
- package/dist/extensions/runtime.d.ts +69 -74
- package/dist/extensions/runtime.js +183 -381
- package/dist/extensions/types.d.ts +55 -45
- package/dist/harness.d.ts +339 -180
- package/dist/harness.js +673 -420
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -0
- package/dist/internal.d.ts +1 -0
- package/dist/internal.js +1 -0
- package/dist/local.d.ts +25 -8
- package/dist/local.js +103 -33
- package/dist/providers.d.ts +144 -0
- package/dist/providers.js +280 -0
- package/dist/telemetry-posthog.d.ts +88 -0
- package/dist/telemetry-posthog.js +142 -0
- package/dist/telemetry.d.ts +4 -0
- package/dist/telemetry.js +3 -0
- package/dist/workspace-mcp.d.ts +62 -0
- package/dist/workspace-mcp.js +222 -0
- package/package.json +10 -3
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type { ExtensionDefinition, ExtensionHost } from "./types.ts";
|
|
1
|
+
import { type Capability } from "operon-agents-core";
|
|
2
|
+
import type { ExtensionDefinition, ExtensionHost, SessionStartReason } from "./types.ts";
|
|
3
|
+
import { ExtensionRuntime } from "./runtime.ts";
|
|
3
4
|
export { ExtensionRuntime } from "./runtime.ts";
|
|
4
5
|
export { ExtensionLoader, createExtensionLoader } from "./loader.ts";
|
|
5
6
|
export { ServiceRegistry, ServiceUnavailableError, deadServiceHandle } from "./services.ts";
|
|
@@ -7,17 +8,30 @@ export { HarnessExtensionManager, stageDefinition } from "./manager.ts";
|
|
|
7
8
|
export type { ExtensionHostBridge, StagingOptions, HeldSession, StagedDefinition } from "./manager.ts";
|
|
8
9
|
export type { ServiceOptions, ServiceUnavailableReason } from "./services.ts";
|
|
9
10
|
export type { ExtensionManifest, ExtensionFileState, ExtensionFileStatus, ExtensionAttachTarget } from "./loader.ts";
|
|
10
|
-
export type { ExtensionActions, ExtensionAPI, ExtensionCommand, ExtensionCommandResult, ExtensionRecordEntry, ExtensionSteerOptions, ExtensionDefinition, ExtensionHostContext, ExtensionEventContext, ExtensionEventMap, ExtensionEventName, ExtensionHandler, ExtensionHost, ExtensionModelRequestEvent, ExtensionModelRequestResult, ExtensionModelResponseEvent, ExtensionResultMap, ExtensionRunSettledEvent, ExtensionRunSettledResult, ExtensionRunStartEvent, ExtensionRunStartResult, ExtensionSessionContext,
|
|
11
|
+
export type { ExtensionActions, ExtensionAPI, ExtensionCommand, ExtensionCommandResult, ExtensionRecordEntry, ExtensionSteerOptions, ExtensionDefinition, ExtensionHostContext, ExtensionEventContext, ExtensionEventMap, ExtensionEventName, ExtensionHandler, ExtensionHost, ExtensionModelRequestEvent, ExtensionModelRequestResult, ExtensionModelResponseEvent, ExtensionResultMap, ExtensionRunSettledEvent, ExtensionRunSettledResult, ExtensionRunStartEvent, ExtensionRunStartResult, ExtensionSessionContext, ExtensionSessionEventContext, ExtensionSessionEndEvent, ExtensionSessionStartEvent, ExtensionState, ExtensionToolSpec, ExtensionStepEndEvent, ExtensionStepEndResult, ExtensionStepStartEvent, ExtensionStepStartResult, ExtensionToolAuthorizeEvent, ExtensionToolAuthorizeResult, ExtensionToolCallEvent, ExtensionToolCallResult, ExtensionToolResultEvent, ExtensionCompactionBeforeEvent, ExtensionCompactionBeforeResult, ExtensionProviderHeadersEvent, ExtensionProviderHeadersResult, ExtensionProviderPayloadEvent, ExtensionProviderPayloadResult, ExtensionProviderResponseEvent, ProviderHeaders, HarnessSessionHandle, ModelProvider, SessionEndReason, SessionStartReason, } from "./types.ts";
|
|
12
|
+
/** The extension runtime behind `capability`, or undefined when it is not an extension. */
|
|
13
|
+
export declare function extensionRuntimeOf(capability: Capability): ExtensionRuntime | undefined;
|
|
11
14
|
/**
|
|
12
|
-
*
|
|
15
|
+
* One extension, as one of the session's capabilities — named by its id, `detachable`, and
|
|
16
|
+
* assembled, isolated, attached and detached exactly like any other capability.
|
|
13
17
|
*
|
|
14
18
|
* Every decision point in the extension contract is a `LoopHooks` slot — that is the only
|
|
15
19
|
* channel where a handler's return value can reach the loop. Observation does NOT come through
|
|
16
20
|
* here: observation goes through `api.onEvent`, which passes the session's event stream straight
|
|
17
21
|
* through (with fault isolation and teardown), rather than through this hook table.
|
|
18
22
|
*/
|
|
23
|
+
export declare function extensionCapability(definition: ExtensionDefinition, options?: {
|
|
24
|
+
readonly host?: ExtensionHost;
|
|
25
|
+
readonly params?: unknown;
|
|
26
|
+
readonly startReason?: SessionStartReason;
|
|
27
|
+
}): Capability;
|
|
28
|
+
/**
|
|
29
|
+
* A capability per extension, in order — what a session mounts for a list of definitions. A
|
|
30
|
+
* definition this session opted out of (`params[id] === false`) is left out entirely: never set
|
|
31
|
+
* up, never reported as attached.
|
|
32
|
+
*/
|
|
19
33
|
export declare function extensionsCapability(definitions: readonly ExtensionDefinition[], options?: {
|
|
20
34
|
readonly host?: ExtensionHost;
|
|
21
35
|
readonly params?: Readonly<Record<string, unknown>>;
|
|
22
|
-
}): Capability;
|
|
36
|
+
}): Capability[];
|
|
23
37
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/extensions/index.js
CHANGED
|
@@ -1,26 +1,63 @@
|
|
|
1
|
+
import { readSessionLog } from "operon-agents-core";
|
|
1
2
|
import { ExtensionRuntime } from "./runtime.js";
|
|
2
3
|
export { ExtensionRuntime } from "./runtime.js";
|
|
3
4
|
export { ExtensionLoader, createExtensionLoader } from "./loader.js";
|
|
4
5
|
export { ServiceRegistry, ServiceUnavailableError, deadServiceHandle } from "./services.js";
|
|
5
6
|
export { HarnessExtensionManager, stageDefinition } from "./manager.js";
|
|
7
|
+
/** The runtime behind each extension capability — how the harness reaches an extension it mounted. */
|
|
8
|
+
const runtimes = new WeakMap();
|
|
9
|
+
/** The extension runtime behind `capability`, or undefined when it is not an extension. */
|
|
10
|
+
export function extensionRuntimeOf(capability) {
|
|
11
|
+
return runtimes.get(capability);
|
|
12
|
+
}
|
|
6
13
|
/**
|
|
7
|
-
*
|
|
14
|
+
* One extension, as one of the session's capabilities — named by its id, `detachable`, and
|
|
15
|
+
* assembled, isolated, attached and detached exactly like any other capability.
|
|
8
16
|
*
|
|
9
17
|
* Every decision point in the extension contract is a `LoopHooks` slot — that is the only
|
|
10
18
|
* channel where a handler's return value can reach the loop. Observation does NOT come through
|
|
11
19
|
* here: observation goes through `api.onEvent`, which passes the session's event stream straight
|
|
12
20
|
* through (with fault isolation and teardown), rather than through this hook table.
|
|
13
21
|
*/
|
|
14
|
-
export function
|
|
15
|
-
const runtime = new ExtensionRuntime(
|
|
16
|
-
|
|
17
|
-
name:
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
export function extensionCapability(definition, options = {}) {
|
|
23
|
+
const runtime = new ExtensionRuntime(definition, options);
|
|
24
|
+
const capability = {
|
|
25
|
+
name: definition.id,
|
|
26
|
+
contract: "detachable",
|
|
27
|
+
// The runtime already gives every handler its own budget (`timeoutMs`) and isolates its
|
|
28
|
+
// failure; an outer deadline would only cut a chain of individually-healthy handlers short.
|
|
29
|
+
hookTimeoutMs: Number.POSITIVE_INFINITY,
|
|
30
|
+
// Read once the extension has opened: what it published with `api.expose`, so
|
|
31
|
+
// `session.service(id)` is the extension's own control surface.
|
|
32
|
+
get service() {
|
|
33
|
+
return runtime.exposedHandle();
|
|
34
|
+
},
|
|
35
|
+
// An extension can touch the whole session surface, so this list IS the blast radius —
|
|
36
|
+
// written down once, here, instead of discovered by reading the runtime for lookups.
|
|
37
|
+
openSession: async (ctx) => {
|
|
38
|
+
if (ctx.controls === undefined)
|
|
39
|
+
throw new Error("an extension needs the session's controls");
|
|
40
|
+
await runtime.open({
|
|
41
|
+
environment: ctx.environment,
|
|
42
|
+
events: ctx.events,
|
|
43
|
+
steer: ctx.steer,
|
|
44
|
+
controls: ctx.controls,
|
|
45
|
+
readLog: () => readSessionLog(ctx),
|
|
46
|
+
store: ctx.store,
|
|
47
|
+
}, ctx);
|
|
48
|
+
},
|
|
49
|
+
closeSession: (reason) => runtime.close(reason),
|
|
50
|
+
toolProviders: [{ id: definition.id, listTools: () => runtime.listTools() }],
|
|
20
51
|
toolFilters: [runtime.filterTools],
|
|
21
52
|
gates: { compaction: runtime.compactionGate },
|
|
22
|
-
// Stable array
|
|
53
|
+
// Stable array references — `session` fills them before any run assembles.
|
|
23
54
|
injectors: runtime.listInjectors(),
|
|
55
|
+
commands: runtime.commands,
|
|
56
|
+
// Read at each run's assembly: absent while no provider-tier handler is registered, so such a
|
|
57
|
+
// request is left byte-identical.
|
|
58
|
+
get provider() {
|
|
59
|
+
return runtime.hasProviderHandlers() ? runtime.providerHooks : undefined;
|
|
60
|
+
},
|
|
24
61
|
hooks: {
|
|
25
62
|
beforeRun: async (ctx) => runtime.beforeRun(ctx, ctx.input),
|
|
26
63
|
beforeStep: async (ctx) => runtime.beforeStep(ctx, ctx.context, ctx.system),
|
|
@@ -32,10 +69,29 @@ export function extensionsCapability(definitions, options = {}) {
|
|
|
32
69
|
authorizeToolExecution: async (ctx) => runtime.authorizeToolCall(ctx, ctx.toolCall.name, ctx.toolCall.id, ctx.tool, ctx.args, ctx.plan),
|
|
33
70
|
finalizeToolResult: async (ctx) => runtime.afterToolResult(ctx, ctx.toolCall.name, ctx.toolCall.id, ctx.tool, ctx.args, ctx.result),
|
|
34
71
|
},
|
|
35
|
-
openSession: (ctx) => runtime.open(ctx),
|
|
36
|
-
closeSession: () => runtime.close(),
|
|
37
72
|
start: (ctx) => runtime.attachRun(ctx),
|
|
38
73
|
stop: () => runtime.detachRun(),
|
|
39
74
|
};
|
|
75
|
+
runtimes.set(capability, runtime);
|
|
76
|
+
return capability;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A capability per extension, in order — what a session mounts for a list of definitions. A
|
|
80
|
+
* definition this session opted out of (`params[id] === false`) is left out entirely: never set
|
|
81
|
+
* up, never reported as attached.
|
|
82
|
+
*/
|
|
83
|
+
export function extensionsCapability(definitions, options = {}) {
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const definition of definitions) {
|
|
87
|
+
if (seen.has(definition.id))
|
|
88
|
+
throw new Error(`duplicate extension id "${definition.id}"`);
|
|
89
|
+
seen.add(definition.id);
|
|
90
|
+
const params = options.params?.[definition.id];
|
|
91
|
+
if (params === false)
|
|
92
|
+
continue;
|
|
93
|
+
out.push(extensionCapability(definition, { ...(options.host !== undefined ? { host: options.host } : {}), params }));
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
40
96
|
}
|
|
41
97
|
//# sourceMappingURL=index.js.map
|
|
@@ -30,7 +30,7 @@ export declare function compareVersions(a: string, b: string): number;
|
|
|
30
30
|
/**
|
|
31
31
|
* What must be known about an extension BEFORE its code is imported — find it (`id`), import
|
|
32
32
|
* it (`entry`, gated by `engine`), show it (`version`, `name`, `description`) — and nothing more. What the
|
|
33
|
-
* extension provides (`
|
|
33
|
+
* extension provides (`harness`) and consumes (`uses`) lives on the definition it exports, the
|
|
34
34
|
* same on both channels. Only `id` is validated; unknown fields pass through untouched, so a
|
|
35
35
|
* manifest written for a newer loader still loads here.
|
|
36
36
|
*/
|
|
@@ -102,7 +102,7 @@ export declare class ExtensionLoader {
|
|
|
102
102
|
}>;
|
|
103
103
|
/** The definitions currently held. On the standalone-loader path these reach sessions through
|
|
104
104
|
* `attachExtension`/`reloadInto`; a harness built with `extensionDir` registers them itself
|
|
105
|
-
* (`harness.extensions.load`), which is the path that also runs `
|
|
105
|
+
* (`harness.extensions.load`), which is the path that also runs `harness` halves. */
|
|
106
106
|
definitions(): ExtensionDefinition[];
|
|
107
107
|
/** Convenience: reload a extension and swap it on a live session (detach old, attach new). */
|
|
108
108
|
reloadInto(session: ExtensionAttachTarget, id: string): Promise<ExtensionDefinition>;
|
|
@@ -10,6 +10,7 @@ import { readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
|
10
10
|
import { join, resolve } from "node:path";
|
|
11
11
|
import { createRequire } from "node:module";
|
|
12
12
|
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { z } from "zod";
|
|
13
14
|
/**
|
|
14
15
|
* File-extension loader — the shell that turns a extension directory into extension VALUES for
|
|
15
16
|
* `attachExtension`. The framework core stays file-blind: everything here ends in a plain
|
|
@@ -63,6 +64,15 @@ export function compareVersions(a, b) {
|
|
|
63
64
|
}
|
|
64
65
|
return 0;
|
|
65
66
|
}
|
|
67
|
+
/** The manifest as read from disk: only the fields above are checked; the rest pass through. */
|
|
68
|
+
const ExtensionManifestSchema = z.looseObject({
|
|
69
|
+
id: z.string().refine((id) => id.trim().length > 0, 'must declare a non-empty string "id"'),
|
|
70
|
+
version: z.string().optional(),
|
|
71
|
+
entry: z.string().optional(),
|
|
72
|
+
engine: z.string().regex(VERSION_RE, 'must be a version like "1.2.0"').optional(),
|
|
73
|
+
name: z.string().optional(),
|
|
74
|
+
description: z.string().optional(),
|
|
75
|
+
});
|
|
66
76
|
const APPROVALS_FILE = ".approvals.json";
|
|
67
77
|
export class ExtensionLoader {
|
|
68
78
|
directory;
|
|
@@ -155,7 +165,7 @@ export class ExtensionLoader {
|
|
|
155
165
|
}
|
|
156
166
|
/** The definitions currently held. On the standalone-loader path these reach sessions through
|
|
157
167
|
* `attachExtension`/`reloadInto`; a harness built with `extensionDir` registers them itself
|
|
158
|
-
* (`harness.extensions.load`), which is the path that also runs `
|
|
168
|
+
* (`harness.extensions.load`), which is the path that also runs `harness` halves. */
|
|
159
169
|
definitions() {
|
|
160
170
|
return [...this.loaded.values()];
|
|
161
171
|
}
|
|
@@ -177,23 +187,21 @@ export class ExtensionLoader {
|
|
|
177
187
|
async scan(dirName) {
|
|
178
188
|
const dir = join(this.directory, dirName);
|
|
179
189
|
const manifestPath = join(dir, "manifest.json");
|
|
180
|
-
let
|
|
190
|
+
let raw;
|
|
181
191
|
try {
|
|
182
|
-
|
|
192
|
+
raw = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
183
193
|
}
|
|
184
194
|
catch (error) {
|
|
185
195
|
throw new Error(`unreadable manifest.json: ${messageOf(error)}`);
|
|
186
196
|
}
|
|
187
|
-
|
|
188
|
-
|
|
197
|
+
const parsed = ExtensionManifestSchema.safeParse(raw);
|
|
198
|
+
if (!parsed.success) {
|
|
199
|
+
const issues = parsed.error.issues.map((issue) => `${issue.path.length > 0 ? `"${issue.path.join(".")}" ` : ""}${issue.message}`);
|
|
200
|
+
throw new Error(`manifest.json: ${issues.join("; ")}`);
|
|
189
201
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
if (compareVersions(FRAMEWORK_VERSION, manifest.engine) < 0) {
|
|
195
|
-
throw new Error(`requires operon-agents >= ${manifest.engine}; this host runs ${FRAMEWORK_VERSION}`);
|
|
196
|
-
}
|
|
202
|
+
const manifest = parsed.data;
|
|
203
|
+
if (manifest.engine !== undefined && compareVersions(FRAMEWORK_VERSION, manifest.engine) < 0) {
|
|
204
|
+
throw new Error(`requires operon-agents >= ${manifest.engine}; this host runs ${FRAMEWORK_VERSION}`);
|
|
197
205
|
}
|
|
198
206
|
const entryPath = join(dir, manifest.entry ?? "index.js");
|
|
199
207
|
const stats = await stat(entryPath).catch(() => {
|
|
@@ -241,11 +249,11 @@ export class ExtensionLoader {
|
|
|
241
249
|
}
|
|
242
250
|
let candidate = moduleExports.default;
|
|
243
251
|
// A factory default export lets a extension construct per-load state; called with nothing —
|
|
244
|
-
// everything a extension may use arrives later through its
|
|
252
|
+
// everything a extension may use arrives later through its session(ctx).
|
|
245
253
|
if (typeof candidate === "function")
|
|
246
254
|
candidate = candidate();
|
|
247
255
|
if (!isExtensionDefinition(candidate)) {
|
|
248
|
-
throw new Error(`extension "${scanned.manifest.id}" default export is not an extension definition ({ id,
|
|
256
|
+
throw new Error(`extension "${scanned.manifest.id}" default export is not an extension definition ({ id, session })`);
|
|
249
257
|
}
|
|
250
258
|
if (candidate.id !== scanned.manifest.id) {
|
|
251
259
|
throw new Error(`extension id mismatch: manifest says "${scanned.manifest.id}", code says "${candidate.id}"`);
|
|
@@ -277,7 +285,7 @@ export function createExtensionLoader(options) {
|
|
|
277
285
|
function isExtensionDefinition(value) {
|
|
278
286
|
return (typeof value === "object" && value !== null
|
|
279
287
|
&& typeof value.id === "string"
|
|
280
|
-
&& typeof value.
|
|
288
|
+
&& typeof value.session === "function");
|
|
281
289
|
}
|
|
282
290
|
function messageOf(error) {
|
|
283
291
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -24,16 +24,16 @@ export interface ExtensionHostBridge {
|
|
|
24
24
|
withBarrier(extensionIds: readonly string[], timeoutMs: number, fn: (held: () => readonly HeldSession[]) => Promise<void>): Promise<void>;
|
|
25
25
|
warn(message: string): void;
|
|
26
26
|
}
|
|
27
|
-
/** What staging one `
|
|
27
|
+
/** What staging one `harness`-bearing definition produced, before anything is published. */
|
|
28
28
|
export interface StagedDefinition {
|
|
29
|
-
/** The one service `
|
|
30
|
-
* definition has no `
|
|
29
|
+
/** The one service `harness` returned, to register under the extension's id; absent when the
|
|
30
|
+
* definition has no `harness`. */
|
|
31
31
|
readonly service?: {
|
|
32
32
|
readonly name: string;
|
|
33
33
|
readonly instance: unknown;
|
|
34
34
|
readonly options: ServiceOptions;
|
|
35
35
|
};
|
|
36
|
-
/** The definition itself — its `
|
|
36
|
+
/** The definition itself — its `session` is the per-session half, mounted into every session. */
|
|
37
37
|
readonly sessionDef: ExtensionDefinition;
|
|
38
38
|
}
|
|
39
39
|
export interface StagingOptions {
|
|
@@ -44,12 +44,14 @@ export interface StagingOptions {
|
|
|
44
44
|
/** The extension's data folder, already created. */
|
|
45
45
|
readonly dataDir?: string;
|
|
46
46
|
createSession: ExtensionHostContext["createSession"];
|
|
47
|
+
/** Resolve one of the definition's `uses` names to its service handle. */
|
|
48
|
+
service(name: string): unknown;
|
|
47
49
|
warn(message: string): void;
|
|
48
50
|
}
|
|
49
51
|
/**
|
|
50
|
-
* Run a `
|
|
51
|
-
* service it returns — nothing is registered here, so a throwing `
|
|
52
|
-
* Shared by the file manager and the harness's by-value channel. Stays synchronous when `
|
|
52
|
+
* Run a `harness`-bearing definition's `harness` half against a staging host and collect the one
|
|
53
|
+
* service it returns — nothing is registered here, so a throwing `harness` publishes nothing.
|
|
54
|
+
* Shared by the file manager and the harness's by-value channel. Stays synchronous when `harness`
|
|
53
55
|
* is, which the by-value channel relies on to have a synchronous extension's service visible the
|
|
54
56
|
* moment `createHarness` returns.
|
|
55
57
|
*/
|
|
@@ -59,7 +61,7 @@ export declare class HarnessExtensionManager {
|
|
|
59
61
|
private readonly bridge;
|
|
60
62
|
/** Root of the extensions' data folders (`<root>/<id>`) — outside every code folder. */
|
|
61
63
|
private readonly dataRoot;
|
|
62
|
-
/** Every loaded extension by id, and whether its `
|
|
64
|
+
/** Every loaded extension by id, and whether its `harness` published a service. */
|
|
63
65
|
private readonly loaded;
|
|
64
66
|
/** Definitions newborn sessions receive: every loaded extension's per-session half. */
|
|
65
67
|
private readonly sessionDefs;
|
|
@@ -73,10 +75,10 @@ export declare class HarnessExtensionManager {
|
|
|
73
75
|
sessionDefinitions(): ExtensionDefinition[];
|
|
74
76
|
/**
|
|
75
77
|
* Load (and approve) one extension — a USER action, like `ExtensionLoader.load`. Runs the
|
|
76
|
-
* definition's `
|
|
78
|
+
* definition's `harness` (if any) and publishes the result as its service, then hands the
|
|
77
79
|
* definition to future sessions. Loading an already-loaded extension reloads it: every
|
|
78
80
|
* 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 `
|
|
81
|
+
* service replaced), and everyone resumes — the same act whether or not there is a `harness`.
|
|
80
82
|
*/
|
|
81
83
|
load(id: string, options?: {
|
|
82
84
|
readonly timeoutMs?: number;
|
|
@@ -86,14 +88,14 @@ export declare class HarnessExtensionManager {
|
|
|
86
88
|
readonly timeoutMs?: number;
|
|
87
89
|
}): Promise<void>;
|
|
88
90
|
/**
|
|
89
|
-
* Unload an extension. A `
|
|
91
|
+
* Unload an extension. A `harness`-bearing one is refused while any session still runs an
|
|
90
92
|
* extension that `uses` its service — named, so the caller knows what to detach. Its own
|
|
91
93
|
* per-session half does not block (it is removed as part of the unload).
|
|
92
94
|
*/
|
|
93
95
|
unload(id: string): Promise<void>;
|
|
94
96
|
private register;
|
|
95
|
-
/** Run `
|
|
96
|
-
* `
|
|
97
|
+
/** Run `harness` with a staging host: the service is COLLECTED, not registered, so a throwing
|
|
98
|
+
* `harness` publishes nothing. Services default to replaceable — reload is their point. */
|
|
97
99
|
private stage;
|
|
98
100
|
/** Sessions still running an extension that `uses` `id`'s service — read off the attached
|
|
99
101
|
* definitions themselves, so by-value and file consumers count alike. No graph walk: the
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
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 `
|
|
3
|
+
* is a FILE whose default export is an `ExtensionDefinition`; the manager runs its `harness` half
|
|
4
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 `
|
|
5
|
+
* the SAME definition to every session (its `session` is the per-session half). One extension,
|
|
6
6
|
* one approval, one reload — the manager turns a reload into the coordinated act: barrier →
|
|
7
7
|
* swap the session half → replace the service → release.
|
|
8
8
|
*
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* single one as for ordinary extensions — manual load/reload IS the approval (mtime-scoped).
|
|
11
11
|
* Nothing here auto-loads or watches.
|
|
12
12
|
*
|
|
13
|
-
* `stageDefinition` — running `
|
|
14
|
-
* throwing `
|
|
15
|
-
* (`createHarness({ extensions })` runs a `
|
|
13
|
+
* `stageDefinition` — running `harness` against a staging host and collecting the one service, so a
|
|
14
|
+
* throwing `harness` publishes nothing — is shared with the harness's by-value channel
|
|
15
|
+
* (`createHarness({ extensions })` runs a `harness`-bearing definition through it with no loader).
|
|
16
16
|
*/
|
|
17
17
|
import { mkdir } from "node:fs/promises";
|
|
18
18
|
import { join, resolve } from "node:path";
|
|
@@ -22,17 +22,18 @@ function isPromiseLike(value) {
|
|
|
22
22
|
return typeof value === "object" && value !== null && typeof value.then === "function";
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
|
-
* Run a `
|
|
26
|
-
* service it returns — nothing is registered here, so a throwing `
|
|
27
|
-
* Shared by the file manager and the harness's by-value channel. Stays synchronous when `
|
|
25
|
+
* Run a `harness`-bearing definition's `harness` half against a staging host and collect the one
|
|
26
|
+
* service it returns — nothing is registered here, so a throwing `harness` publishes nothing.
|
|
27
|
+
* Shared by the file manager and the harness's by-value channel. Stays synchronous when `harness`
|
|
28
28
|
* is, which the by-value channel relies on to have a synchronous extension's service visible the
|
|
29
29
|
* moment `createHarness` returns.
|
|
30
30
|
*/
|
|
31
31
|
export function stageDefinition(definition, options) {
|
|
32
|
-
if (definition.
|
|
32
|
+
if (definition.harness === undefined)
|
|
33
33
|
return { sessionDef: definition };
|
|
34
34
|
const host = {
|
|
35
35
|
createSession: (sessionOptions) => options.createSession(sessionOptions),
|
|
36
|
+
services: Object.freeze(Object.fromEntries((definition.uses ?? []).map((name) => [name, options.service(name)]))),
|
|
36
37
|
...(options.dir !== undefined ? { dir: options.dir } : {}),
|
|
37
38
|
...(options.dataDir !== undefined ? { dataDir: options.dataDir } : {}),
|
|
38
39
|
warn: (message) => options.warn(`[extension ${definition.id}] ${message}`),
|
|
@@ -41,7 +42,7 @@ export function stageDefinition(definition, options) {
|
|
|
41
42
|
service: { name: definition.id, instance, options: { replaceable: options.defaultReplaceable } },
|
|
42
43
|
sessionDef: definition,
|
|
43
44
|
});
|
|
44
|
-
const result = definition.
|
|
45
|
+
const result = definition.harness(host);
|
|
45
46
|
return isPromiseLike(result) ? Promise.resolve(result).then(finish) : finish(result);
|
|
46
47
|
}
|
|
47
48
|
const DEFAULT_BARRIER_TIMEOUT_MS = 30_000;
|
|
@@ -50,7 +51,7 @@ export class HarnessExtensionManager {
|
|
|
50
51
|
bridge;
|
|
51
52
|
/** Root of the extensions' data folders (`<root>/<id>`) — outside every code folder. */
|
|
52
53
|
dataRoot;
|
|
53
|
-
/** Every loaded extension by id, and whether its `
|
|
54
|
+
/** Every loaded extension by id, and whether its `harness` published a service. */
|
|
54
55
|
loaded = new Map();
|
|
55
56
|
/** Definitions newborn sessions receive: every loaded extension's per-session half. */
|
|
56
57
|
sessionDefs = new Map();
|
|
@@ -68,16 +69,16 @@ export class HarnessExtensionManager {
|
|
|
68
69
|
}
|
|
69
70
|
/**
|
|
70
71
|
* Load (and approve) one extension — a USER action, like `ExtensionLoader.load`. Runs the
|
|
71
|
-
* definition's `
|
|
72
|
+
* definition's `harness` (if any) and publishes the result as its service, then hands the
|
|
72
73
|
* definition to future sessions. Loading an already-loaded extension reloads it: every
|
|
73
74
|
* 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 `
|
|
75
|
+
* service replaced), and everyone resumes — the same act whether or not there is a `harness`.
|
|
75
76
|
*/
|
|
76
77
|
async load(id, options = {}) {
|
|
77
78
|
const definition = await this.loader.load(id);
|
|
78
|
-
// `uses`
|
|
79
|
-
// provider revokes the approval the import just recorded, so a
|
|
80
|
-
// behind. Same rule for both kinds: load providers first.
|
|
79
|
+
// Shape and `uses` live on the definition, so they are readable only after the import; a
|
|
80
|
+
// bad definition or a missing provider revokes the approval the import just recorded, so a
|
|
81
|
+
// failed load leaves nothing behind. Same rule for both kinds: load providers first.
|
|
81
82
|
for (const name of definition.uses ?? []) {
|
|
82
83
|
if (!this.bridge.services.has(name)) {
|
|
83
84
|
await this.loader.unload(id);
|
|
@@ -91,7 +92,7 @@ export class HarnessExtensionManager {
|
|
|
91
92
|
return this.load(id, options);
|
|
92
93
|
}
|
|
93
94
|
/**
|
|
94
|
-
* Unload an extension. A `
|
|
95
|
+
* Unload an extension. A `harness`-bearing one is refused while any session still runs an
|
|
95
96
|
* extension that `uses` its service — named, so the caller knows what to detach. Its own
|
|
96
97
|
* per-session half does not block (it is removed as part of the unload).
|
|
97
98
|
*/
|
|
@@ -124,6 +125,7 @@ export class HarnessExtensionManager {
|
|
|
124
125
|
async register(id, definition, options) {
|
|
125
126
|
const staged = await this.stage(definition);
|
|
126
127
|
const previous = this.loaded.get(id);
|
|
128
|
+
const record = { hasService: staged.service !== undefined };
|
|
127
129
|
if (previous === undefined) {
|
|
128
130
|
// First load: register the service, then hand the session half to future sessions.
|
|
129
131
|
// Already-open sessions are not touched (a reload only re-attaches to sessions that hold
|
|
@@ -132,11 +134,11 @@ export class HarnessExtensionManager {
|
|
|
132
134
|
this.bridge.services.register(staged.service.name, staged.service.instance, staged.service.options);
|
|
133
135
|
}
|
|
134
136
|
this.sessionDefs.set(id, staged.sessionDef);
|
|
135
|
-
this.loaded.set(id,
|
|
137
|
+
this.loaded.set(id, record);
|
|
136
138
|
return;
|
|
137
139
|
}
|
|
138
|
-
// Reload: one coordinated act — barrier on the session half, swap half + service in the
|
|
139
|
-
// quiet moment. Version skew between the
|
|
140
|
+
// Reload: one coordinated act — barrier on the session half, swap half + service(s) in the
|
|
141
|
+
// quiet moment. Version skew between the halves is structurally impossible.
|
|
140
142
|
await this.bridge.withBarrier([id], options.timeoutMs ?? DEFAULT_BARRIER_TIMEOUT_MS, async (held) => {
|
|
141
143
|
for (const session of held()) {
|
|
142
144
|
if (session.attachedExtensionIds().includes(id))
|
|
@@ -156,20 +158,21 @@ export class HarnessExtensionManager {
|
|
|
156
158
|
await session.attachExtension(staged.sessionDef);
|
|
157
159
|
}
|
|
158
160
|
});
|
|
159
|
-
this.loaded.set(id,
|
|
161
|
+
this.loaded.set(id, record);
|
|
160
162
|
}
|
|
161
|
-
/** Run `
|
|
162
|
-
* `
|
|
163
|
+
/** Run `harness` with a staging host: the service is COLLECTED, not registered, so a throwing
|
|
164
|
+
* `harness` publishes nothing. Services default to replaceable — reload is their point. */
|
|
163
165
|
async stage(definition) {
|
|
164
|
-
// The data folder exists before `
|
|
166
|
+
// The data folder exists before `harness` runs, so the extension can use it right away.
|
|
165
167
|
const dataDir = join(this.dataRoot, definition.id);
|
|
166
|
-
if (definition.
|
|
168
|
+
if (definition.harness !== undefined)
|
|
167
169
|
await mkdir(dataDir, { recursive: true });
|
|
168
170
|
return stageDefinition(definition, {
|
|
169
171
|
defaultReplaceable: true,
|
|
170
172
|
dir: await this.loader.manifestDir(definition.id),
|
|
171
173
|
dataDir,
|
|
172
174
|
createSession: (sessionOptions) => this.bridge.createSession(sessionOptions),
|
|
175
|
+
service: (name) => this.bridge.services.handle(name),
|
|
173
176
|
warn: (message) => this.bridge.warn(message),
|
|
174
177
|
});
|
|
175
178
|
}
|