@roj-ai/isolate-runtime 0.0.0 → 0.2.14
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 +64 -0
- package/dist/alarm-scheduler.d.ts +78 -0
- package/dist/alarm-scheduler.d.ts.map +1 -0
- package/dist/alarm-scheduler.js +168 -0
- package/dist/alarm-scheduler.js.map +1 -0
- package/dist/build.d.ts +19 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +86 -0
- package/dist/build.js.map +1 -0
- package/dist/host-contract.d.ts +23 -0
- package/dist/host-contract.d.ts.map +1 -0
- package/dist/host-contract.js +6 -0
- package/dist/host-contract.js.map +1 -0
- package/dist/host.d.ts +9 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +7 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/info.d.ts +4 -0
- package/dist/info.d.ts.map +1 -0
- package/dist/info.js +5 -0
- package/dist/info.js.map +1 -0
- package/dist/modules.d.ts +26 -0
- package/dist/modules.d.ts.map +1 -0
- package/dist/modules.js +52 -0
- package/dist/modules.js.map +1 -0
- package/dist/runtime.d.ts +48 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +80 -0
- package/dist/runtime.js.map +1 -0
- package/dist/testing.d.ts +17 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +25 -0
- package/dist/testing.js.map +1 -0
- package/package.json +53 -4
- package/src/alarm-scheduler.ts +234 -0
- package/src/build.ts +95 -0
- package/src/host-contract.ts +25 -0
- package/src/host.ts +9 -0
- package/src/index.ts +12 -0
- package/src/info.ts +6 -0
- package/src/modules.ts +58 -0
- package/src/runtime.ts +115 -0
- package/src/testing.ts +33 -0
- package/src/worker/facet.ts +29 -0
- package/src/worker/main.ts +6 -0
- package/src/worker/roj-app-bundle.d.ts +4 -0
package/dist/modules.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Worker Loader module map a host builds once per runtime version (`./build`) and completes with
|
|
3
|
+
* one app bundle per instance. Free of Bun and Node APIs, so a host can use it inside a Worker.
|
|
4
|
+
*/
|
|
5
|
+
/** The module name the main module imports the app's config from. */
|
|
6
|
+
export const APP_BUNDLE_MODULE = 'roj-app-bundle';
|
|
7
|
+
/** The runtime's modules plus one app bundle: what a host passes to `LOADER.get()`. */
|
|
8
|
+
export function withAppBundle(runtime, appBundle) {
|
|
9
|
+
return { mainModule: runtime.mainModule, modules: { ...runtime.modules, [APP_BUNDLE_MODULE]: { js: appBundle } } };
|
|
10
|
+
}
|
|
11
|
+
/** The loader resolves a specifier against the importing module's directory, a bare one included. */
|
|
12
|
+
function resolveModuleName(importer, specifier) {
|
|
13
|
+
if (specifier.startsWith('/'))
|
|
14
|
+
return specifier.slice(1);
|
|
15
|
+
const parts = importer.split('/').slice(0, -1);
|
|
16
|
+
for (const part of specifier.split('/')) {
|
|
17
|
+
if (part === '..')
|
|
18
|
+
parts.pop();
|
|
19
|
+
else if (part !== '.')
|
|
20
|
+
parts.push(part);
|
|
21
|
+
}
|
|
22
|
+
return parts.join('/');
|
|
23
|
+
}
|
|
24
|
+
const STATIC_IMPORT = /^(?:import|export)\b[^;'"]*?\bfrom\s*["']([^"']+)["']|^import\s*["']([^"']+)["']/gm;
|
|
25
|
+
const DYNAMIC_IMPORT = /\bimport\(\s*["']([^"']+)["']\s*\)/g;
|
|
26
|
+
/** Bun's `target: 'bun'` output loads CommonJS dependencies through it; workerd has no such API. */
|
|
27
|
+
const BUN_REQUIRE = 'import.meta.require';
|
|
28
|
+
/**
|
|
29
|
+
* Imports that would not resolve under the loader's rules, plus any use of `import.meta.require`,
|
|
30
|
+
* reported with that as the specifier. Bun's unminified ESM output keeps every static import at the
|
|
31
|
+
* start of a line, which is what the scan relies on. Platform builtins (`node:`, `cloudflare:`) are not checked.
|
|
32
|
+
*/
|
|
33
|
+
export function unresolvedImports(modules) {
|
|
34
|
+
const missing = [];
|
|
35
|
+
for (const [name, module] of Object.entries(modules)) {
|
|
36
|
+
const source = typeof module === 'string' ? module : module.js;
|
|
37
|
+
const specifiers = [
|
|
38
|
+
...[...source.matchAll(STATIC_IMPORT)].map(match => match[1] ?? match[2]),
|
|
39
|
+
...[...source.matchAll(DYNAMIC_IMPORT)].map(match => match[1]),
|
|
40
|
+
];
|
|
41
|
+
for (const specifier of specifiers) {
|
|
42
|
+
if (specifier === undefined || /^(?:node|cloudflare):/.test(specifier))
|
|
43
|
+
continue;
|
|
44
|
+
if (!(resolveModuleName(name, specifier) in modules))
|
|
45
|
+
missing.push({ module: name, specifier });
|
|
46
|
+
}
|
|
47
|
+
if (source.includes(BUN_REQUIRE))
|
|
48
|
+
missing.push({ module: name, specifier: BUN_REQUIRE });
|
|
49
|
+
}
|
|
50
|
+
return missing;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=modules.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"modules.js","sourceRoot":"","sources":["../src/modules.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAUH,qEAAqE;AACrE,MAAM,CAAC,MAAM,iBAAiB,GAAG,gBAAgB,CAAA;AAEjD,uFAAuF;AACvF,MAAM,UAAU,aAAa,CAAC,OAAuB,EAAE,SAAiB;IACvE,OAAO,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE,OAAO,EAAE,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,iBAAiB,CAAC,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE,CAAA;AACnH,CAAC;AAED,qGAAqG;AACrG,SAAS,iBAAiB,CAAC,QAAgB,EAAE,SAAiB;IAC7D,IAAI,SAAS,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;IACxD,MAAM,KAAK,GAAG,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAA;IAC9C,KAAK,MAAM,IAAI,IAAI,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QACzC,IAAI,IAAI,KAAK,IAAI;YAAE,KAAK,CAAC,GAAG,EAAE,CAAA;aACzB,IAAI,IAAI,KAAK,GAAG;YAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACxC,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;AACvB,CAAC;AAED,MAAM,aAAa,GAAG,oFAAoF,CAAA;AAC1G,MAAM,cAAc,GAAG,qCAAqC,CAAA;AAC5D,oGAAoG;AACpG,MAAM,WAAW,GAAG,qBAAqB,CAAA;AAEzC;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,OAAqC;IACtE,MAAM,OAAO,GAA4C,EAAE,CAAA;IAC3D,KAAK,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACtD,MAAM,MAAM,GAAG,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAA;QAC9D,MAAM,UAAU,GAAG;YAClB,GAAG,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC;YACzE,GAAG,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;SAC9D,CAAA;QACD,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;YACpC,IAAI,SAAS,KAAK,SAAS,IAAI,uBAAuB,CAAC,IAAI,CAAC,SAAS,CAAC;gBAAE,SAAQ;YAChF,IAAI,CAAC,CAAC,iBAAiB,CAAC,IAAI,EAAE,SAAS,CAAC,IAAI,OAAO,CAAC;gBAAE,OAAO,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC,CAAA;QAChG,CAAC;QACD,IAAI,MAAM,CAAC,QAAQ,CAAC,WAAW,CAAC;YAAE,OAAO,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,CAAC,CAAA;IACzF,CAAC;IACD,OAAO,OAAO,CAAA;AACf,CAAC"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Roj runtime for a Durable Object that runs untrusted app code.
|
|
3
|
+
*
|
|
4
|
+
* Everything persistent lives in the object's own SQLite: the kompjutr filesystem,
|
|
5
|
+
* the event log, the LLM call log and the wake rows. Everything else comes from
|
|
6
|
+
* {@link RuntimeHost}, the only capability the object holds. Outbound `fetch`
|
|
7
|
+
* goes wherever the host routed the isolate's global outbound, which is where
|
|
8
|
+
* provider keys and tool credentials are added — the runtime never holds them.
|
|
9
|
+
*
|
|
10
|
+
* It answers the same HTTP surface as the sandbox runtime (`/rpc`, `/status`,
|
|
11
|
+
* `/sessions/*`), so a host can reuse its sandbox client. Requests reach it only
|
|
12
|
+
* through the host, which authenticates the caller and stamps `_caller`.
|
|
13
|
+
*/
|
|
14
|
+
import { type DurableObjectStorageLike } from '@kompjutr/do';
|
|
15
|
+
import type { Config, RojConfig } from '@roj-ai/sdk';
|
|
16
|
+
import { type AlarmSchedulerStats, type SyncKvLike } from './alarm-scheduler.js';
|
|
17
|
+
import type { RuntimeHost } from './host-contract.js';
|
|
18
|
+
/** The parts of `DurableObjectState` the runtime uses. */
|
|
19
|
+
export interface IsolateRuntimeState {
|
|
20
|
+
storage: DurableObjectStorageLike & {
|
|
21
|
+
kv: SyncKvLike;
|
|
22
|
+
};
|
|
23
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
24
|
+
}
|
|
25
|
+
export interface IsolateRuntimeOptions {
|
|
26
|
+
userConfig: RojConfig;
|
|
27
|
+
host: RuntimeHost;
|
|
28
|
+
state: IsolateRuntimeState;
|
|
29
|
+
/** Overrides of the built-in config, e.g. `llmMock` in tests. */
|
|
30
|
+
config?: Partial<Config>;
|
|
31
|
+
/** See `AlarmSchedulerHost.inFlightRetryMs`. */
|
|
32
|
+
inFlightRetryMs?: number;
|
|
33
|
+
}
|
|
34
|
+
export interface IsolateRuntime {
|
|
35
|
+
/** Loads open sessions, which recovers agents an eviction left mid-turn. Await before serving. */
|
|
36
|
+
start(): Promise<void>;
|
|
37
|
+
fetch(request: Request): Promise<Response>;
|
|
38
|
+
/** Call from the host's `alarm()`. Returns the wake keys delivered. */
|
|
39
|
+
deliverWakes(): Promise<string[]>;
|
|
40
|
+
schedulerStats(): AlarmSchedulerStats;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Stands in for the provider key, so the SDK's key check passes. The host's
|
|
44
|
+
* egress replaces the authorization header on provider requests.
|
|
45
|
+
*/
|
|
46
|
+
export declare const EGRESS_PROVIDED_KEY = "provided-by-host-egress";
|
|
47
|
+
export declare function createIsolateRuntime(options: IsolateRuntimeOptions): IsolateRuntime;
|
|
48
|
+
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,KAAK,wBAAwB,EAAa,MAAM,cAAc,CAAA;AAGvE,OAAO,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAEpD,OAAO,EAAwB,KAAK,mBAAmB,EAAE,KAAK,UAAU,EAAE,MAAM,sBAAsB,CAAA;AACtG,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAGrD,0DAA0D;AAC1D,MAAM,WAAW,mBAAmB;IACnC,OAAO,EAAE,wBAAwB,GAAG;QAAE,EAAE,EAAE,UAAU,CAAA;KAAE,CAAA;IACtD,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAA;CAC1C;AAED,MAAM,WAAW,qBAAqB;IACrC,UAAU,EAAE,SAAS,CAAA;IACrB,IAAI,EAAE,WAAW,CAAA;IACjB,KAAK,EAAE,mBAAmB,CAAA;IAC1B,iEAAiE;IACjE,MAAM,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,CAAA;IACxB,gDAAgD;IAChD,eAAe,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,cAAc;IAC9B,kGAAkG;IAClG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACtB,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;IAC1C,uEAAuE;IACvE,YAAY,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAA;IACjC,cAAc,IAAI,mBAAmB,CAAA;CACrC;AAID;;;GAGG;AACH,eAAO,MAAM,mBAAmB,4BAA4B,CAAA;AAE5D,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,qBAAqB,GAAG,cAAc,CA0DnF"}
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Roj runtime for a Durable Object that runs untrusted app code.
|
|
3
|
+
*
|
|
4
|
+
* Everything persistent lives in the object's own SQLite: the kompjutr filesystem,
|
|
5
|
+
* the event log, the LLM call log and the wake rows. Everything else comes from
|
|
6
|
+
* {@link RuntimeHost}, the only capability the object holds. Outbound `fetch`
|
|
7
|
+
* goes wherever the host routed the isolate's global outbound, which is where
|
|
8
|
+
* provider keys and tool credentials are added — the runtime never holds them.
|
|
9
|
+
*
|
|
10
|
+
* It answers the same HTTP surface as the sandbox runtime (`/rpc`, `/status`,
|
|
11
|
+
* `/sessions/*`), so a host can reuse its sandbox client. Requests reach it only
|
|
12
|
+
* through the host, which authenticates the caller and stamps `_caller`.
|
|
13
|
+
*/
|
|
14
|
+
import { Workspace } from '@kompjutr/do';
|
|
15
|
+
import { createKompjutrPlatform, KompjutrEventStore } from '@roj-ai/kompjutr-platform';
|
|
16
|
+
import { bootstrap, createSystemFromServices } from '@roj-ai/sdk';
|
|
17
|
+
import { createApp } from '@roj-ai/sdk/transport/http/app';
|
|
18
|
+
import { createAlarmScheduler } from './alarm-scheduler.js';
|
|
19
|
+
import { ISOLATE_RUNTIME_NAME, ISOLATE_RUNTIME_VERSION } from './info.js';
|
|
20
|
+
const DATA_ROOT = '/data';
|
|
21
|
+
/**
|
|
22
|
+
* Stands in for the provider key, so the SDK's key check passes. The host's
|
|
23
|
+
* egress replaces the authorization header on provider requests.
|
|
24
|
+
*/
|
|
25
|
+
export const EGRESS_PROVIDED_KEY = 'provided-by-host-egress';
|
|
26
|
+
export function createIsolateRuntime(options) {
|
|
27
|
+
const { state, host } = options;
|
|
28
|
+
// `loadConfig()` reads process.env and the cwd, which an isolate has neither of.
|
|
29
|
+
const config = {
|
|
30
|
+
port: 0,
|
|
31
|
+
host: 'localhost',
|
|
32
|
+
dataPath: DATA_ROOT,
|
|
33
|
+
persistence: 'memory',
|
|
34
|
+
logLevel: 'warn',
|
|
35
|
+
logFormat: 'json',
|
|
36
|
+
llmLoggingEnabled: true,
|
|
37
|
+
openRouterApiKey: EGRESS_PROVIDED_KEY,
|
|
38
|
+
agentRuntime: { name: ISOLATE_RUNTIME_NAME, version: ISOLATE_RUNTIME_VERSION },
|
|
39
|
+
...options.config,
|
|
40
|
+
};
|
|
41
|
+
const workspace = new Workspace({ storage: state.storage });
|
|
42
|
+
workspace.filesystem.mkdir(DATA_ROOT, { recursive: true });
|
|
43
|
+
const scheduler = createAlarmScheduler({
|
|
44
|
+
storage: {
|
|
45
|
+
kv: state.storage.kv,
|
|
46
|
+
setAlarm: scheduledTime => host.setAlarm(scheduledTime),
|
|
47
|
+
deleteAlarm: () => host.deleteAlarm(),
|
|
48
|
+
},
|
|
49
|
+
waitUntil: promise => state.waitUntil(promise),
|
|
50
|
+
inFlightRetryMs: options.inFlightRetryMs,
|
|
51
|
+
});
|
|
52
|
+
const services = bootstrap(config, options.userConfig, createKompjutrPlatform(workspace, { scheduler }), {
|
|
53
|
+
pluginProfile: 'isolate',
|
|
54
|
+
eventStore: new KompjutrEventStore(workspace.db),
|
|
55
|
+
});
|
|
56
|
+
const system = createSystemFromServices(services, {
|
|
57
|
+
onUserOutput: notification => {
|
|
58
|
+
state.waitUntil(host.broadcast({ type: notification.type, payload: notification.payload, ts: Date.now() }));
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const app = createApp({ ...services, sessionRuntime: system.sessionManager });
|
|
62
|
+
return {
|
|
63
|
+
async start() {
|
|
64
|
+
await system.sessionManager.loadAllSessions();
|
|
65
|
+
await scheduler.flush();
|
|
66
|
+
},
|
|
67
|
+
async fetch(request) {
|
|
68
|
+
const response = await app.fetch(request);
|
|
69
|
+
await scheduler.flush();
|
|
70
|
+
return response;
|
|
71
|
+
},
|
|
72
|
+
async deliverWakes() {
|
|
73
|
+
const keys = await scheduler.deliverDue(key => system.sessionManager.dispatchWake(key));
|
|
74
|
+
await scheduler.flush();
|
|
75
|
+
return keys;
|
|
76
|
+
},
|
|
77
|
+
schedulerStats: () => scheduler.stats(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=runtime.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.js","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAiC,SAAS,EAAE,MAAM,cAAc,CAAA;AACvE,OAAO,EAAE,sBAAsB,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AACtF,OAAO,EAAE,SAAS,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAA;AAEjE,OAAO,EAAE,SAAS,EAAE,MAAM,gCAAgC,CAAA;AAC1D,OAAO,EAAE,oBAAoB,EAA6C,MAAM,sBAAsB,CAAA;AAEtG,OAAO,EAAE,oBAAoB,EAAE,uBAAuB,EAAE,MAAM,WAAW,CAAA;AA2BzE,MAAM,SAAS,GAAG,OAAO,CAAA;AAEzB;;;GAGG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,yBAAyB,CAAA;AAE5D,MAAM,UAAU,oBAAoB,CAAC,OAA8B;IAClE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,OAAO,CAAA;IAC/B,iFAAiF;IACjF,MAAM,MAAM,GAAW;QACtB,IAAI,EAAE,CAAC;QACP,IAAI,EAAE,WAAW;QACjB,QAAQ,EAAE,SAAS;QACnB,WAAW,EAAE,QAAQ;QACrB,QAAQ,EAAE,MAAM;QAChB,SAAS,EAAE,MAAM;QACjB,iBAAiB,EAAE,IAAI;QACvB,gBAAgB,EAAE,mBAAmB;QACrC,YAAY,EAAE,EAAE,IAAI,EAAE,oBAAoB,EAAE,OAAO,EAAE,uBAAuB,EAAE;QAC9E,GAAG,OAAO,CAAC,MAAM;KACjB,CAAA;IAED,MAAM,SAAS,GAAG,IAAI,SAAS,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC,CAAA;IAC3D,SAAS,CAAC,UAAU,CAAC,KAAK,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAC1D,MAAM,SAAS,GAAG,oBAAoB,CAAC;QACtC,OAAO,EAAE;YACR,EAAE,EAAE,KAAK,CAAC,OAAO,CAAC,EAAE;YACpB,QAAQ,EAAE,aAAa,CAAC,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,aAAa,CAAC;YACvD,WAAW,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,WAAW,EAAE;SACrC;QACD,SAAS,EAAE,OAAO,CAAC,EAAE,CAAC,KAAK,CAAC,SAAS,CAAC,OAAO,CAAC;QAC9C,eAAe,EAAE,OAAO,CAAC,eAAe;KACxC,CAAC,CAAA;IACF,MAAM,QAAQ,GAAG,SAAS,CAAC,MAAM,EAAE,OAAO,CAAC,UAAU,EAAE,sBAAsB,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,CAAC,EAAE;QACxG,aAAa,EAAE,SAAS;QACxB,UAAU,EAAE,IAAI,kBAAkB,CAAC,SAAS,CAAC,EAAE,CAAC;KAChD,CAAC,CAAA;IACF,MAAM,MAAM,GAAG,wBAAwB,CAAC,QAAQ,EAAE;QACjD,YAAY,EAAE,YAAY,CAAC,EAAE;YAC5B,KAAK,CAAC,SAAS,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,YAAY,CAAC,OAAO,EAAE,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,CAAA;QAC5G,CAAC;KACD,CAAC,CAAA;IACF,MAAM,GAAG,GAAG,SAAS,CAAC,EAAE,GAAG,QAAQ,EAAE,cAAc,EAAE,MAAM,CAAC,cAAc,EAAE,CAAC,CAAA;IAE7E,OAAO;QACN,KAAK,CAAC,KAAK;YACV,MAAM,MAAM,CAAC,cAAc,CAAC,eAAe,EAAE,CAAA;YAC7C,MAAM,SAAS,CAAC,KAAK,EAAE,CAAA;QACxB,CAAC;QAED,KAAK,CAAC,KAAK,CAAC,OAAO;YAClB,MAAM,QAAQ,GAAG,MAAM,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;YACzC,MAAM,SAAS,CAAC,KAAK,EAAE,CAAA;YACvB,OAAO,QAAQ,CAAA;QAChB,CAAC;QAED,KAAK,CAAC,YAAY;YACjB,MAAM,IAAI,GAAG,MAAM,SAAS,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC,MAAM,CAAC,cAAc,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC,CAAA;YACvF,MAAM,SAAS,CAAC,KAAK,EAAE,CAAA;YACvB,OAAO,IAAI,CAAA;QACZ,CAAC;QAED,cAAc,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,KAAK,EAAE;KACvC,CAAA;AACF,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** In-memory stand-ins for the Durable Object storage the scheduler uses. Tests only. */
|
|
2
|
+
import type { AlarmStorageLike, SyncKvLike } from './alarm-scheduler.js';
|
|
3
|
+
export declare class MemoryKv implements SyncKvLike {
|
|
4
|
+
#private;
|
|
5
|
+
list(options?: {
|
|
6
|
+
prefix?: string;
|
|
7
|
+
}): Iterable<[string, unknown]>;
|
|
8
|
+
put(key: string, value: unknown): void;
|
|
9
|
+
delete(key: string): boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare class MemoryAlarmStorage implements AlarmStorageLike {
|
|
12
|
+
readonly kv: MemoryKv;
|
|
13
|
+
alarm: number | null;
|
|
14
|
+
setAlarm(scheduledTime: number): Promise<void>;
|
|
15
|
+
deleteAlarm(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=testing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA,yFAAyF;AAEzF,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAA;AAExE,qBAAa,QAAS,YAAW,UAAU;;IAG1C,IAAI,CAAC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,QAAQ,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAKhE,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI;IAItC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;CAG5B;AAED,qBAAa,kBAAmB,YAAW,gBAAgB;IAC1D,QAAQ,CAAC,EAAE,WAAiB;IAC5B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAO;IAErB,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI9C,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;CAGlC"}
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** In-memory stand-ins for the Durable Object storage the scheduler uses. Tests only. */
|
|
2
|
+
export class MemoryKv {
|
|
3
|
+
#rows = new Map();
|
|
4
|
+
list(options) {
|
|
5
|
+
const prefix = options?.prefix ?? '';
|
|
6
|
+
return [...this.#rows].filter(([key]) => key.startsWith(prefix)).sort(([a], [b]) => a.localeCompare(b));
|
|
7
|
+
}
|
|
8
|
+
put(key, value) {
|
|
9
|
+
this.#rows.set(key, value);
|
|
10
|
+
}
|
|
11
|
+
delete(key) {
|
|
12
|
+
return this.#rows.delete(key);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class MemoryAlarmStorage {
|
|
16
|
+
kv = new MemoryKv();
|
|
17
|
+
alarm = null;
|
|
18
|
+
async setAlarm(scheduledTime) {
|
|
19
|
+
this.alarm = scheduledTime;
|
|
20
|
+
}
|
|
21
|
+
async deleteAlarm() {
|
|
22
|
+
this.alarm = null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=testing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA,yFAAyF;AAIzF,MAAM,OAAO,QAAQ;IACX,KAAK,GAAG,IAAI,GAAG,EAAmB,CAAA;IAE3C,IAAI,CAAC,OAA6B;QACjC,MAAM,MAAM,GAAG,OAAO,EAAE,MAAM,IAAI,EAAE,CAAA;QACpC,OAAO,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAA;IACxG,CAAC;IAED,GAAG,CAAC,GAAW,EAAE,KAAc;QAC9B,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;IAC3B,CAAC;IAED,MAAM,CAAC,GAAW;QACjB,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;IAC9B,CAAC;CACD;AAED,MAAM,OAAO,kBAAkB;IACrB,EAAE,GAAG,IAAI,QAAQ,EAAE,CAAA;IAC5B,KAAK,GAAkB,IAAI,CAAA;IAE3B,KAAK,CAAC,QAAQ,CAAC,aAAqB;QACnC,IAAI,CAAC,KAAK,GAAG,aAAa,CAAA;IAC3B,CAAC;IAED,KAAK,CAAC,WAAW;QAChB,IAAI,CAAC,KAAK,GAAG,IAAI,CAAA;IAClB,CAAC;CACD"}
|
package/package.json
CHANGED
|
@@ -1,13 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@roj-ai/isolate-runtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
3
|
+
"version": "0.2.14",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"roj": {
|
|
8
|
+
"bunOnlyExports": [
|
|
9
|
+
"./build"
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./host": {
|
|
19
|
+
"types": "./dist/host.d.ts",
|
|
20
|
+
"import": "./dist/host.js",
|
|
21
|
+
"default": "./dist/host.js"
|
|
22
|
+
},
|
|
23
|
+
"./build": {
|
|
24
|
+
"types": "./dist/build.d.ts",
|
|
25
|
+
"import": "./dist/build.js",
|
|
26
|
+
"default": "./dist/build.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src",
|
|
31
|
+
"dist",
|
|
32
|
+
"!src/**/*.test.ts",
|
|
33
|
+
"!dist/**/*.test.*"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
5
38
|
"repository": {
|
|
6
39
|
"type": "git",
|
|
7
40
|
"url": "git+https://github.com/contember/roj.git",
|
|
8
41
|
"directory": "packages/isolate-runtime"
|
|
9
42
|
},
|
|
10
|
-
"
|
|
11
|
-
|
|
43
|
+
"homepage": "https://github.com/contember/roj/tree/main/packages/isolate-runtime#readme",
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/contember/roj/issues"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"type-check": "tsc --noEmit && tsc -p tsconfig.worker.json && tsc -p e2e/tsconfig.json",
|
|
49
|
+
"test:e2e": "bun test ./e2e"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@kompjutr/do": "0.1.4",
|
|
53
|
+
"@roj-ai/kompjutr-platform": "^0.2.14",
|
|
54
|
+
"@roj-ai/sdk": "^0.2.14"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@cloudflare/workers-types": "5.20260925.1",
|
|
58
|
+
"@types/bun": "^1.3.3",
|
|
59
|
+
"typescript": "^5.9.3",
|
|
60
|
+
"wrangler": "4.143.0"
|
|
12
61
|
}
|
|
13
62
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler over a Durable Object's single alarm slot.
|
|
3
|
+
*
|
|
4
|
+
* A DO has no process between wakes, so it implements the plain {@link Scheduler}
|
|
5
|
+
* port rather than `LiveScheduler`: the host delivers through {@link AlarmScheduler.deliverDue}
|
|
6
|
+
* from its own `alarm()` handler, in whichever isolate happens to be alive by then.
|
|
7
|
+
* The slot is reached through {@link AlarmStorageLike}, so a DO facet, which has no
|
|
8
|
+
* alarm of its own, can borrow its parent's.
|
|
9
|
+
*
|
|
10
|
+
* Two shapes of the SDK dictate the design. It arms without awaiting —
|
|
11
|
+
* `scheduleProcessing()` and `shutdown()` are synchronous, so the promises are
|
|
12
|
+
* dropped — and it cancels a key then re-arms it in the same frame. So the state
|
|
13
|
+
* that decides what is due (in-memory key→dueAt maps and their rows in the DO's
|
|
14
|
+
* synchronous KV) moves synchronously, where nothing can reorder it; the alarm
|
|
15
|
+
* slot, which is asynchronous storage I/O, is reconciled afterwards against the
|
|
16
|
+
* maps as they stand when the write runs — never against the arguments that queued it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Scheduler, WakeHandler } from '@roj-ai/sdk/platform'
|
|
20
|
+
|
|
21
|
+
/** Synchronous key-value surface of a SQLite-backed Durable Object (`storage.kv`). */
|
|
22
|
+
export interface SyncKvLike {
|
|
23
|
+
list(options?: { prefix?: string }): Iterable<[string, unknown]>
|
|
24
|
+
put(key: string, value: unknown): void
|
|
25
|
+
delete(key: string): boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The parts of `DurableObjectStorage` a wake needs: one alarm, and rows to survive in. */
|
|
29
|
+
export interface AlarmStorageLike {
|
|
30
|
+
kv: SyncKvLike
|
|
31
|
+
setAlarm(scheduledTime: number): Promise<void>
|
|
32
|
+
deleteAlarm(): Promise<void>
|
|
33
|
+
/** Optional so a test double need not implement it; see `applyAlarm`. */
|
|
34
|
+
sync?(): Promise<void>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AlarmSchedulerHost {
|
|
38
|
+
storage: AlarmStorageLike
|
|
39
|
+
/**
|
|
40
|
+
* Keeps a promise nobody awaits alive. `DurableObjectState.waitUntil` puts it
|
|
41
|
+
* on the actor's IoContext, so an arm issued during a request outlives the
|
|
42
|
+
* frame that issued it.
|
|
43
|
+
*/
|
|
44
|
+
waitUntil?(promise: Promise<unknown>): void
|
|
45
|
+
/** How long after a delivery starts it is delivered again unless it finished. Default 5 minutes. */
|
|
46
|
+
inFlightRetryMs?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AlarmSchedulerStats {
|
|
50
|
+
/** Wakes armed and not yet delivered. */
|
|
51
|
+
pending: number
|
|
52
|
+
/** Deliveries started and not finished, each with a retry armed. */
|
|
53
|
+
inFlight: number
|
|
54
|
+
/** What the alarm slot holds, as far as this isolate knows. */
|
|
55
|
+
armedAt: number | null
|
|
56
|
+
/** Alarms drained since this isolate booted. */
|
|
57
|
+
drains: number
|
|
58
|
+
/** Wakes handed to a delivery handler since this isolate booted. */
|
|
59
|
+
delivered: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AlarmScheduler extends Scheduler {
|
|
63
|
+
/**
|
|
64
|
+
* Drain every wake now due, re-arm for the next, then hand each drained key to
|
|
65
|
+
* `deliver`. Call from the DO's `alarm()`; returns the keys delivered.
|
|
66
|
+
*/
|
|
67
|
+
deliverDue(deliver: WakeHandler): Promise<string[]>
|
|
68
|
+
/** Settle the pending alarm writes. Call before an entry point returns. Throws when the slot cannot be written. */
|
|
69
|
+
flush(): Promise<void>
|
|
70
|
+
/**
|
|
71
|
+
* Ignore wakes and cancels while `fn` runs.
|
|
72
|
+
*
|
|
73
|
+
* `SessionManager.shutdown()` cancels every agent's wakes — right when a
|
|
74
|
+
* session is being torn down, wrong when the isolate is merely going away,
|
|
75
|
+
* which drops exactly the wake this port exists to preserve. Only the host
|
|
76
|
+
* knows which of the two it is doing.
|
|
77
|
+
*/
|
|
78
|
+
suspend<T>(fn: () => Promise<T>): Promise<T>
|
|
79
|
+
stats(): AlarmSchedulerStats
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Namespaced so wakes cannot collide with whatever else the host keeps in storage. */
|
|
83
|
+
const WAKE_PREFIX = 'roj:wake:'
|
|
84
|
+
const RETRY_PREFIX = 'roj:retry:'
|
|
85
|
+
|
|
86
|
+
const DEFAULT_IN_FLIGHT_RETRY_MS = 5 * 60_000
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Clock slack when draining. workerd pins `Date.now()` between I/O, so an alarm
|
|
90
|
+
* can read as fractionally early; without slack that costs an extra round trip.
|
|
91
|
+
*/
|
|
92
|
+
const DRAIN_SLACK_MS = 10
|
|
93
|
+
|
|
94
|
+
/** A key→time map mirrored into KV rows, so it outlives the isolate. */
|
|
95
|
+
function persistedTimes(kv: SyncKvLike, prefix: string) {
|
|
96
|
+
const times = new Map<string, number>()
|
|
97
|
+
for (const [key, value] of kv.list({ prefix })) {
|
|
98
|
+
if (typeof value === 'number') times.set(key.slice(prefix.length), value)
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
times,
|
|
102
|
+
set(key: string, at: number): void {
|
|
103
|
+
times.set(key, at)
|
|
104
|
+
kv.put(prefix + key, at)
|
|
105
|
+
},
|
|
106
|
+
delete(key: string): boolean {
|
|
107
|
+
// The map mirrors the rows, so a key it does not hold has no row either.
|
|
108
|
+
if (!times.delete(key)) return false
|
|
109
|
+
kv.delete(prefix + key)
|
|
110
|
+
return true
|
|
111
|
+
},
|
|
112
|
+
dueBy(cutoff: number): string[] {
|
|
113
|
+
return [...times].filter(([, at]) => at <= cutoff).map(([key]) => key)
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createAlarmScheduler(host: AlarmSchedulerHost): AlarmScheduler {
|
|
119
|
+
const storage = host.storage
|
|
120
|
+
const track = host.waitUntil?.bind(host)
|
|
121
|
+
const inFlightRetryMs = host.inFlightRetryMs ?? DEFAULT_IN_FLIGHT_RETRY_MS
|
|
122
|
+
const wakes = persistedTimes(storage.kv, WAKE_PREFIX)
|
|
123
|
+
// A delivery lost to an eviction never reaches its end, and its wake row is already gone.
|
|
124
|
+
// The retry row brings the alarm back, so the session loads and recovers its agents.
|
|
125
|
+
const retries = persistedTimes(storage.kv, RETRY_PREFIX)
|
|
126
|
+
let armedAt: number | undefined
|
|
127
|
+
let tail: Promise<void> = Promise.resolve()
|
|
128
|
+
let suspended = 0
|
|
129
|
+
let drains = 0
|
|
130
|
+
let delivered = 0
|
|
131
|
+
|
|
132
|
+
const earliest = (): number | undefined => {
|
|
133
|
+
let min: number | undefined
|
|
134
|
+
for (const at of [...wakes.times.values(), ...retries.times.values()]) {
|
|
135
|
+
if (min === undefined || at < min) min = at
|
|
136
|
+
}
|
|
137
|
+
return min
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const applyAlarm = async (): Promise<void> => {
|
|
141
|
+
const target = earliest()
|
|
142
|
+
if (target === armedAt) return
|
|
143
|
+
if (target === undefined) await storage.deleteAlarm()
|
|
144
|
+
else await storage.setAlarm(target)
|
|
145
|
+
armedAt = target
|
|
146
|
+
// Awaiting the write only queues it; sync() is what waits for it to be durable,
|
|
147
|
+
// and an isolate aborted before that point loses the arm.
|
|
148
|
+
await storage.sync?.()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Queue one alarm write. Serialized, because there is only one slot to race over. */
|
|
152
|
+
const reconcile = (): Promise<void> => {
|
|
153
|
+
const next = tail.then(applyAlarm)
|
|
154
|
+
// The tail must never reject, or every later reconcile inherits this failure.
|
|
155
|
+
const settled = next.catch(() => undefined)
|
|
156
|
+
tail = settled
|
|
157
|
+
track?.(settled)
|
|
158
|
+
return next
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// A previous isolate may have died between writing rows and committing its alarm.
|
|
162
|
+
if (wakes.times.size > 0 || retries.times.size > 0) void reconcile().catch(() => undefined)
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
async wake(key: string, delayMs: number): Promise<void> {
|
|
166
|
+
if (suspended > 0) return
|
|
167
|
+
// Synchronous, so the cancel-then-arm pair the SDK issues in one frame
|
|
168
|
+
// cannot land out of order however long the alarm write takes.
|
|
169
|
+
wakes.set(key, Date.now() + Math.max(delayMs, 0))
|
|
170
|
+
await reconcile()
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async cancel(key: string): Promise<void> {
|
|
174
|
+
if (suspended > 0) return
|
|
175
|
+
if (!wakes.delete(key)) return
|
|
176
|
+
await reconcile()
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
async deliverDue(deliver: WakeHandler): Promise<string[]> {
|
|
180
|
+
// workerd clears the slot before delivering, so this isolate holds nothing.
|
|
181
|
+
armedAt = undefined
|
|
182
|
+
drains += 1
|
|
183
|
+
|
|
184
|
+
const cutoff = Date.now() + DRAIN_SLACK_MS
|
|
185
|
+
const due = [...new Set([...wakes.dueBy(cutoff), ...retries.dueBy(cutoff)])]
|
|
186
|
+
const retryAt = Date.now() + inFlightRetryMs
|
|
187
|
+
for (const key of due) {
|
|
188
|
+
wakes.delete(key)
|
|
189
|
+
retries.set(key, retryAt)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Re-arm before dispatching: a wake that runs long, or throws, must not
|
|
193
|
+
// strand the ones behind it.
|
|
194
|
+
await reconcile()
|
|
195
|
+
|
|
196
|
+
// Concurrently: one wake runs a whole agent turn, and the slot fires one alarm at a time.
|
|
197
|
+
await Promise.all(due.map(async key => {
|
|
198
|
+
delivered += 1
|
|
199
|
+
try {
|
|
200
|
+
await deliver(key)
|
|
201
|
+
retries.delete(key)
|
|
202
|
+
} catch {
|
|
203
|
+
// The handler owns its error reporting. The retry row stays, so the wake is delivered again.
|
|
204
|
+
}
|
|
205
|
+
}))
|
|
206
|
+
if (due.length > 0) await reconcile()
|
|
207
|
+
return due
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
async flush(): Promise<void> {
|
|
211
|
+
// Deliveries and agent work chain further reconciles as they run.
|
|
212
|
+
let seen: Promise<void> | undefined
|
|
213
|
+
while (seen !== tail) {
|
|
214
|
+
seen = tail
|
|
215
|
+
await seen
|
|
216
|
+
}
|
|
217
|
+
// A failed write leaves rows the slot does not cover. Try once more, and let a second failure surface.
|
|
218
|
+
if (earliest() !== armedAt) await reconcile()
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
async suspend<T>(fn: () => Promise<T>): Promise<T> {
|
|
222
|
+
suspended += 1
|
|
223
|
+
try {
|
|
224
|
+
return await fn()
|
|
225
|
+
} finally {
|
|
226
|
+
suspended -= 1
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
stats(): AlarmSchedulerStats {
|
|
231
|
+
return { pending: wakes.times.size, inFlight: retries.times.size, armedAt: armedAt ?? null, drains, delivered }
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
}
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the modules a host hands to the Worker Loader to run the runtime as a facet.
|
|
3
|
+
*
|
|
4
|
+
* The SDK is built once per runtime version and shared by every app bundle. An app
|
|
5
|
+
* bundle is `roj build` output in `externalSdk` mode: it imports `@roj-ai/sdk` and its
|
|
6
|
+
* subpaths as bare specifiers, which resolve to small shim modules re-exporting the
|
|
7
|
+
* shared build. The runtime's main module imports the same shims, so the app and the
|
|
8
|
+
* runtime share one SDK instance.
|
|
9
|
+
*
|
|
10
|
+
* The loader resolves a specifier relative to the importing module's directory, a bare
|
|
11
|
+
* one included. The shims sit at the root, so the app bundle and the main module reach
|
|
12
|
+
* them by name; the shims reach the SDK by absolute path.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { join, posix } from 'node:path'
|
|
17
|
+
import { APP_BUNDLE_MODULE, type LoaderModule, type RuntimeModules } from './modules.js'
|
|
18
|
+
|
|
19
|
+
export type { LoaderModule, RuntimeModules } from './modules.js'
|
|
20
|
+
export { APP_BUNDLE_MODULE, unresolvedImports, withAppBundle } from './modules.js'
|
|
21
|
+
|
|
22
|
+
const MAIN_MODULE = 'main.js'
|
|
23
|
+
const SDK_DIR = 'sdk'
|
|
24
|
+
const PLATFORM_EXTERNALS = ['node:*', 'cloudflare:*']
|
|
25
|
+
/** SDK subpaths an isolate cannot run or an app bundle has no use for. */
|
|
26
|
+
const EXCLUDED_SDK_EXPORTS = new Set(['./package.json', './bun-platform', './testing'])
|
|
27
|
+
|
|
28
|
+
const packageRoot = join(import.meta.dir, '..')
|
|
29
|
+
|
|
30
|
+
async function bunBuild(options: Parameters<typeof Bun.build>[0]): Promise<void> {
|
|
31
|
+
const result = await Bun.build({ target: 'node', format: 'esm', ...options })
|
|
32
|
+
if (!result.success) throw new AggregateError(result.logs, 'isolate runtime build failed')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function sdkSubpaths(): Promise<string[]> {
|
|
36
|
+
const manifestPath = Bun.resolveSync('@roj-ai/sdk/package.json', packageRoot)
|
|
37
|
+
const manifest: { exports: Record<string, unknown>; roj?: { bunOnlyExports?: string[] } } = await Bun.file(manifestPath).json()
|
|
38
|
+
const bunOnly = new Set(manifest.roj?.bunOnlyExports ?? [])
|
|
39
|
+
return Object.keys(manifest.exports).filter(subpath => !EXCLUDED_SDK_EXPORTS.has(subpath) && !bunOnly.has(subpath))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `.` → `@roj-ai/sdk`, `./tools/shell` → `@roj-ai/sdk/tools/shell`. */
|
|
43
|
+
const specifierOf = (subpath: string) => (subpath === '.' ? '@roj-ai/sdk' : `@roj-ai/sdk/${subpath.slice(2)}`)
|
|
44
|
+
/** `.` → `index`, `./tools/shell` → `tools__shell`: flat, so every entry lands in one directory. */
|
|
45
|
+
const entryNameOf = (subpath: string) => (subpath === '.' ? 'index' : subpath.slice(2).replaceAll('/', '__'))
|
|
46
|
+
|
|
47
|
+
async function readModules(dir: string, prefix: string): Promise<Record<string, string>> {
|
|
48
|
+
const modules: Record<string, string> = {}
|
|
49
|
+
for (const file of await readdir(dir)) {
|
|
50
|
+
modules[posix.join(prefix, file)] = await readFile(join(dir, file), 'utf8')
|
|
51
|
+
}
|
|
52
|
+
return modules
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Builds the runtime from the installed `@roj-ai/*` packages. Needs Bun. */
|
|
56
|
+
export async function buildRuntimeModules(): Promise<RuntimeModules> {
|
|
57
|
+
// Entries must resolve `@roj-ai/sdk` the way this package does, so they live inside it.
|
|
58
|
+
const workDir = join(packageRoot, `.isolate-build-${crypto.randomUUID()}`)
|
|
59
|
+
try {
|
|
60
|
+
const subpaths = await sdkSubpaths()
|
|
61
|
+
const entriesDir = join(workDir, 'entries')
|
|
62
|
+
await mkdir(entriesDir, { recursive: true })
|
|
63
|
+
const entries: string[] = []
|
|
64
|
+
for (const subpath of subpaths) {
|
|
65
|
+
const entry = join(entriesDir, `${entryNameOf(subpath)}.ts`)
|
|
66
|
+
await writeFile(entry, `export * from '${specifierOf(subpath)}'\n`)
|
|
67
|
+
entries.push(entry)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
await bunBuild({
|
|
71
|
+
entrypoints: entries,
|
|
72
|
+
outdir: join(workDir, SDK_DIR),
|
|
73
|
+
splitting: true,
|
|
74
|
+
external: PLATFORM_EXTERNALS,
|
|
75
|
+
naming: { entry: '[name].js', chunk: 'chunk-[hash].js' },
|
|
76
|
+
})
|
|
77
|
+
await bunBuild({
|
|
78
|
+
entrypoints: [join(packageRoot, 'src/worker/main.ts')],
|
|
79
|
+
outdir: join(workDir, 'main'),
|
|
80
|
+
naming: MAIN_MODULE,
|
|
81
|
+
external: [...PLATFORM_EXTERNALS, '@roj-ai/sdk', '@roj-ai/sdk/*', APP_BUNDLE_MODULE],
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const modules: Record<string, LoaderModule> = {
|
|
85
|
+
[MAIN_MODULE]: await readFile(join(workDir, 'main', MAIN_MODULE), 'utf8'),
|
|
86
|
+
...(await readModules(join(workDir, SDK_DIR), SDK_DIR)),
|
|
87
|
+
}
|
|
88
|
+
for (const subpath of subpaths) {
|
|
89
|
+
modules[specifierOf(subpath)] = { js: `export * from '/${SDK_DIR}/${entryNameOf(subpath)}.js'\n` }
|
|
90
|
+
}
|
|
91
|
+
return { mainModule: MAIN_MODULE, modules }
|
|
92
|
+
} finally {
|
|
93
|
+
await rm(workDir, { recursive: true, force: true })
|
|
94
|
+
}
|
|
95
|
+
}
|