@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.
Files changed (50) hide show
  1. package/README.md +64 -0
  2. package/dist/alarm-scheduler.d.ts +78 -0
  3. package/dist/alarm-scheduler.d.ts.map +1 -0
  4. package/dist/alarm-scheduler.js +168 -0
  5. package/dist/alarm-scheduler.js.map +1 -0
  6. package/dist/build.d.ts +19 -0
  7. package/dist/build.d.ts.map +1 -0
  8. package/dist/build.js +86 -0
  9. package/dist/build.js.map +1 -0
  10. package/dist/host-contract.d.ts +23 -0
  11. package/dist/host-contract.d.ts.map +1 -0
  12. package/dist/host-contract.js +6 -0
  13. package/dist/host-contract.js.map +1 -0
  14. package/dist/host.d.ts +9 -0
  15. package/dist/host.d.ts.map +1 -0
  16. package/dist/host.js +7 -0
  17. package/dist/host.js.map +1 -0
  18. package/dist/index.d.ts +7 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +4 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/info.d.ts +4 -0
  23. package/dist/info.d.ts.map +1 -0
  24. package/dist/info.js +5 -0
  25. package/dist/info.js.map +1 -0
  26. package/dist/modules.d.ts +26 -0
  27. package/dist/modules.d.ts.map +1 -0
  28. package/dist/modules.js +52 -0
  29. package/dist/modules.js.map +1 -0
  30. package/dist/runtime.d.ts +48 -0
  31. package/dist/runtime.d.ts.map +1 -0
  32. package/dist/runtime.js +80 -0
  33. package/dist/runtime.js.map +1 -0
  34. package/dist/testing.d.ts +17 -0
  35. package/dist/testing.d.ts.map +1 -0
  36. package/dist/testing.js +25 -0
  37. package/dist/testing.js.map +1 -0
  38. package/package.json +53 -4
  39. package/src/alarm-scheduler.ts +234 -0
  40. package/src/build.ts +95 -0
  41. package/src/host-contract.ts +25 -0
  42. package/src/host.ts +9 -0
  43. package/src/index.ts +12 -0
  44. package/src/info.ts +6 -0
  45. package/src/modules.ts +58 -0
  46. package/src/runtime.ts +115 -0
  47. package/src/testing.ts +33 -0
  48. package/src/worker/facet.ts +29 -0
  49. package/src/worker/main.ts +6 -0
  50. package/src/worker/roj-app-bundle.d.ts +4 -0
@@ -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"}
@@ -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":"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"}
@@ -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"}
@@ -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.0.0",
4
- "description": "Placeholder that reserves the package name for trusted publishing. Real releases come from CI.",
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
- "publishConfig": {
11
- "access": "public"
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
+ }