@independo/inderun-web 0.3.0-dev.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,261 @@
1
+ import type {
2
+ RoutePlan as SharedPlannerRoutePlan,
3
+ RoutePlannerInput as SharedPlannerInput,
4
+ TaskRequest
5
+ } from "@independo/inderun-contracts";
6
+ import type { HostServices } from "./host.js";
7
+ import type {
8
+ ProviderAdapter,
9
+ ProviderDescriptor,
10
+ ProviderDynamicCapabilities
11
+ } from "./provider.js";
12
+
13
+ export type { SharedPlannerInput, SharedPlannerRoutePlan };
14
+
15
+ /**
16
+ * Interaction mode the caller is requesting a route for. Passed to the shared
17
+ * planner, which rejects providers that cannot satisfy it.
18
+ */
19
+ export type InteractionMode = NonNullable<SharedPlannerInput["interactionMode"]>;
20
+
21
+ /**
22
+ * Minimal shape of the shared route-core module (the WASM wrapper), used to
23
+ * plan routes via JSON in/JSON out. `initSharedCore` initializes the WASM
24
+ * bindings when present.
25
+ */
26
+ export interface SharedPlannerModule {
27
+ initSharedCore?: () => Promise<void>;
28
+ planRouteJson: (inputJson: string) => string | Promise<string>;
29
+ }
30
+
31
+ /**
32
+ * Coarse, privacy-safe reason the WASM planner produced no route plan. Surfaced
33
+ * on the resulting `Internal` routing error and in the `console.warn`, so an
34
+ * operator can tell a missing bundle chunk from a blocked instantiation.
35
+ */
36
+ export type WasmUnavailableReason =
37
+ "import_failed" | "invalid_module_shape" | "init_failed" | "plan_failed";
38
+
39
+ /**
40
+ * Result of a {@link RoutePlanner} attempt. `routePlan` is `null` when the
41
+ * planner could not produce a plan, and `unavailableReason` then narrows down
42
+ * why.
43
+ */
44
+ export interface PlannerOutcome {
45
+ routePlan: SharedPlannerRoutePlan | null;
46
+ unavailableReason?: WasmUnavailableReason;
47
+ }
48
+
49
+ /**
50
+ * Strategy that turns planner input into a deterministic route plan, or an
51
+ * "unavailable" outcome (which the router reports as a routing failure).
52
+ */
53
+ export interface RoutePlanner {
54
+ planRoute(input: SharedPlannerInput): Promise<PlannerOutcome>;
55
+ }
56
+
57
+ /**
58
+ * A provider paired with the descriptor/capability projection the shared route
59
+ * planner consumes.
60
+ */
61
+ export interface ProviderRuntimeSnapshot {
62
+ provider: ProviderAdapter;
63
+ descriptor: SharedPlannerInput["providers"][number]["descriptor"];
64
+ capabilities: SharedPlannerInput["providers"][number]["capabilities"];
65
+ }
66
+
67
+ /**
68
+ * {@link RoutePlanner} backed by the Rust route core compiled to WASM. The
69
+ * module specifier is a static literal (not configurable) so bundlers such as
70
+ * Vite/webpack/Rollup can statically resolve and chunk it — a variable
71
+ * specifier defeats bundler analysis and silently never loads in the browser
72
+ * (see issue #109). The module is imported lazily and memoized; if it cannot
73
+ * be loaded, `planRoute` resolves an "unavailable" outcome, which the engine
74
+ * turns into an `Internal` routing failure carrying the reason below. There is
75
+ * no second planner to degrade to — a failure here is a failure to route, not a
76
+ * quieter route — so this also logs once via `console.warn` to name the cause.
77
+ */
78
+ export class WasmRoutePlanner implements RoutePlanner {
79
+ private modulePromise?: Promise<SharedPlannerModule | null>;
80
+ private loadFailure?: { reason: WasmUnavailableReason; error?: unknown };
81
+ private hasWarned = false;
82
+
83
+ async planRoute(input: SharedPlannerInput): Promise<PlannerOutcome> {
84
+ const module = await this.loadModule();
85
+ if (!module) {
86
+ // The load path only records why it failed; warning here covers both the
87
+ // first attempt and every later call served from the memoized promise.
88
+ const unavailableReason = this.loadFailure?.reason ?? "import_failed";
89
+ this.warnOnce(unavailableReason, this.loadFailure?.error);
90
+ return { routePlan: null, unavailableReason };
91
+ }
92
+
93
+ try {
94
+ const json = JSON.stringify(input);
95
+ const result = await module.planRouteJson(json);
96
+ return { routePlan: JSON.parse(result) as SharedPlannerRoutePlan };
97
+ } catch (error) {
98
+ this.warnOnce("plan_failed", error);
99
+ return { routePlan: null, unavailableReason: "plan_failed" };
100
+ }
101
+ }
102
+
103
+ private async loadModule(): Promise<SharedPlannerModule | null> {
104
+ if (!this.modulePromise) {
105
+ this.modulePromise = this.importModule();
106
+ }
107
+ return this.modulePromise;
108
+ }
109
+
110
+ private async importModule(): Promise<SharedPlannerModule | null> {
111
+ let mod: SharedPlannerModule;
112
+ try {
113
+ mod = (await import("@independo/inderun-route-core-wasm")) as SharedPlannerModule;
114
+ } catch (error) {
115
+ this.loadFailure = { reason: "import_failed", error };
116
+ return null;
117
+ }
118
+
119
+ if (typeof mod.planRouteJson !== "function") {
120
+ this.loadFailure = { reason: "invalid_module_shape" };
121
+ return null;
122
+ }
123
+
124
+ if (mod.initSharedCore) {
125
+ try {
126
+ await mod.initSharedCore();
127
+ } catch (error) {
128
+ this.loadFailure = { reason: "init_failed", error };
129
+ return null;
130
+ }
131
+ }
132
+
133
+ return mod;
134
+ }
135
+
136
+ private warnOnce(reason: WasmUnavailableReason, error?: unknown): void {
137
+ if (this.hasWarned) {
138
+ return;
139
+ }
140
+ this.hasWarned = true;
141
+ console.warn(`[IndeRun] WASM route planner unavailable (${reason}); routing will fail.`, error);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Describes each provider and evaluates its dynamic capabilities against the
147
+ * host.
148
+ *
149
+ * Registration order is passed through untouched: the shared planner ranks
150
+ * candidates itself and tie-breaks by provider id, so sorting here would only
151
+ * assert an ordering the planner is free to ignore.
152
+ */
153
+ export async function collectProviderRuntimeSnapshots(
154
+ registryProviders: ProviderAdapter[],
155
+ hostServices: HostServices
156
+ ): Promise<ProviderRuntimeSnapshot[]> {
157
+ return Promise.all(
158
+ registryProviders.map(async (provider) => {
159
+ const descriptor = provider.describe();
160
+ const capabilities = await provider.capabilities(hostServices);
161
+
162
+ return {
163
+ provider,
164
+ descriptor: toSharedPlannerDescriptor(descriptor),
165
+ capabilities: toSharedPlannerCapabilities(capabilities, descriptor, provider)
166
+ };
167
+ })
168
+ );
169
+ }
170
+
171
+ /**
172
+ * Assembles the normalized {@link SharedPlannerInput} the shared route core
173
+ * consumes, applying default constraints/preferences and the current network
174
+ * state.
175
+ */
176
+ export function buildSharedPlannerInput(
177
+ request: TaskRequest,
178
+ snapshots: ProviderRuntimeSnapshot[],
179
+ networkOnline: boolean,
180
+ interactionMode: InteractionMode = "run"
181
+ ): SharedPlannerInput {
182
+ const constraints = request.constraints ?? {};
183
+ const preferences = request.preferences ?? {};
184
+
185
+ return {
186
+ task: {
187
+ kind: request.task.kind
188
+ },
189
+ interactionMode,
190
+ constraints: {
191
+ privacy: constraints.privacy ?? "cloud_allowed",
192
+ cloud: constraints.cloud ?? "allowed",
193
+ networkOnline,
194
+ ...(constraints.timeoutMs !== undefined ? { timeoutMs: constraints.timeoutMs } : {})
195
+ },
196
+ preferences: {
197
+ optimizeFor: preferences.optimizeFor ?? "balanced"
198
+ },
199
+ providers: snapshots.map((snapshot) => ({
200
+ descriptor: snapshot.descriptor,
201
+ capabilities: snapshot.capabilities
202
+ }))
203
+ };
204
+ }
205
+
206
+ function toSharedPlannerDescriptor(
207
+ descriptor: ProviderDescriptor
208
+ ): SharedPlannerInput["providers"][number]["descriptor"] {
209
+ return {
210
+ id: descriptor.id,
211
+ type: descriptor.type,
212
+ supports: {
213
+ run: descriptor.supports.run,
214
+ streaming: descriptor.supports.streaming
215
+ },
216
+ cancel: descriptor.cancel,
217
+ tasks: descriptor.tasks,
218
+ ...(descriptor.privacy
219
+ ? {
220
+ privacy: {
221
+ dataLeavesDevice: descriptor.privacy.dataLeavesDevice,
222
+ ...(descriptor.privacy.regions ? { regions: descriptor.privacy.regions } : {})
223
+ }
224
+ }
225
+ : {})
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Projects the dynamic capability snapshot for the planner.
231
+ *
232
+ * `stream()` being absent from the adapter is folded in here rather than
233
+ * filtered out downstream: an adapter that declares `supports.streaming` but
234
+ * never implemented the method is not streaming-capable, and saying so as a
235
+ * planner-visible reason is what lets the route explanation name the provider
236
+ * instead of silently dropping it.
237
+ */
238
+ function toSharedPlannerCapabilities(
239
+ capabilities: ProviderDynamicCapabilities,
240
+ descriptor: ProviderDescriptor,
241
+ provider: ProviderAdapter
242
+ ): SharedPlannerInput["providers"][number]["capabilities"] {
243
+ const declaresStreaming = capabilities.streamingAvailable ?? descriptor.supports.streaming;
244
+ const implementsStream = typeof provider.stream === "function";
245
+ const streamingAvailable = declaresStreaming && implementsStream;
246
+ const streamingUnavailableReason =
247
+ capabilities.streamingUnavailableReason ??
248
+ (declaresStreaming && !implementsStream
249
+ ? `Provider '${descriptor.id}' declares streaming but does not implement stream().`
250
+ : undefined);
251
+
252
+ return {
253
+ available: capabilities.available,
254
+ ...(capabilities.reason ? { reason: capabilities.reason } : {}),
255
+ streamingAvailable,
256
+ ...(!streamingAvailable && streamingUnavailableReason ? { streamingUnavailableReason } : {}),
257
+ ...(capabilities.cancellationAvailable !== undefined
258
+ ? { cancellationAvailable: capabilities.cancellationAvailable }
259
+ : {})
260
+ };
261
+ }
@@ -0,0 +1,149 @@
1
+ import type { TaskRequest } from "@independo/inderun-contracts";
2
+ import type { HostServices } from "./host.js";
3
+ import type { ProviderAdapter } from "./provider.js";
4
+ import type { ProviderRegistry } from "./registry.js";
5
+ import {
6
+ createCapabilityMismatch,
7
+ createInternal,
8
+ createOffline,
9
+ createUnavailable
10
+ } from "./errors.js";
11
+ import {
12
+ buildSharedPlannerInput,
13
+ collectProviderRuntimeSnapshots,
14
+ type InteractionMode,
15
+ type ProviderRuntimeSnapshot,
16
+ type RoutePlanner,
17
+ type SharedPlannerRoutePlan,
18
+ WasmRoutePlanner
19
+ } from "./route-planner.js";
20
+
21
+ /**
22
+ * Output structure from the Router containing the chosen provider and a route selection description.
23
+ */
24
+ export interface RouteSelection {
25
+ /**
26
+ * The selected provider adapter to run.
27
+ */
28
+ provider: ProviderAdapter;
29
+ /**
30
+ * Ordered fallback providers to try if the primary provider fails before producing a final result.
31
+ */
32
+ fallbackProviders: ProviderAdapter[];
33
+ /**
34
+ * The shared route plan used to select this provider chain.
35
+ */
36
+ routePlan: SharedPlannerRoutePlan;
37
+ /**
38
+ * Explanation detailing the selection decision. Useful for debugging and telemetry.
39
+ */
40
+ explanation: string;
41
+ }
42
+
43
+ /**
44
+ * Routing engine module. Filters registered providers against task types, request constraints,
45
+ * and dynamic host capabilities (such as connectivity and battery/thermal constraints)
46
+ * to output deterministic execution pathways.
47
+ */
48
+ export class Router {
49
+ constructor(
50
+ private registry: ProviderRegistry,
51
+ private planner: RoutePlanner = new WasmRoutePlanner()
52
+ ) {}
53
+
54
+ /**
55
+ * Selects an optimal and compatible execution provider based on the task request and host conditions.
56
+ * @param request - The canonical task request payload.
57
+ * @param hostServices - Host services containing network status and hardware indicators.
58
+ * @returns Resolves to the selected RouteSelection object.
59
+ * @throws {IndeRunException} Under the following routing failure rules:
60
+ * - `CapabilityMismatch` when `execution === 'on_device'` but no local provider is registered or available.
61
+ * - `Offline` when `execution === 'cloud'` but the device lacks internet connectivity.
62
+ * - `Unavailable` when `execution === 'cloud'` but no cloud provider is registered or functional.
63
+ *
64
+ * The requested `interactionMode` is a planning input, not a post-filter: the
65
+ * shared planner rejects providers that cannot satisfy it and says why, so the
66
+ * whole returned chain is mode-compatible.
67
+ */
68
+ async selectRoute(
69
+ request: TaskRequest,
70
+ hostServices: HostServices,
71
+ interactionMode: InteractionMode = "run"
72
+ ): Promise<RouteSelection> {
73
+ const online = await hostServices.connectivity.isOnline();
74
+ const snapshots = await collectProviderRuntimeSnapshots(this.registry.list(), hostServices);
75
+
76
+ const planInput = buildSharedPlannerInput(request, snapshots, online, interactionMode);
77
+ const outcome = await this.planner.planRoute(planInput);
78
+
79
+ if (!outcome.routePlan) {
80
+ // There is no second planner to degrade to: routing is the shared Rust
81
+ // core's semantics or nothing. Failing here keeps provider selection
82
+ // identical everywhere rather than forking it on a module load failure.
83
+ const reason = outcome.unavailableReason ?? "import_failed";
84
+ throw createInternal(`Route planner unavailable (${reason}).`, {
85
+ details: { plannerUnavailableReason: reason }
86
+ });
87
+ }
88
+
89
+ return this.selectFromSharedPlan(snapshots, outcome.routePlan);
90
+ }
91
+
92
+ private selectFromSharedPlan(
93
+ snapshots: ProviderRuntimeSnapshot[],
94
+ routePlan: SharedPlannerRoutePlan
95
+ ): RouteSelection {
96
+ if (!routePlan.selectedProviderId) {
97
+ throw this.routePlanFailure(routePlan);
98
+ }
99
+
100
+ return this.buildSelectionFromRoutePlan(snapshots, routePlan);
101
+ }
102
+
103
+ private buildSelectionFromRoutePlan(
104
+ snapshots: ProviderRuntimeSnapshot[],
105
+ routePlan: SharedPlannerRoutePlan
106
+ ): RouteSelection {
107
+ const orderedProviders = routePlan.candidates
108
+ .map((candidate) =>
109
+ snapshots.find((snapshot) => snapshot.descriptor.id === candidate.providerId)
110
+ )
111
+ .filter((snapshot): snapshot is ProviderRuntimeSnapshot => snapshot !== undefined);
112
+
113
+ const selected = orderedProviders[0];
114
+ if (!selected) {
115
+ throw createInternal("Route plan selected a provider that is no longer registered.");
116
+ }
117
+
118
+ return {
119
+ provider: selected.provider,
120
+ fallbackProviders: orderedProviders.slice(1).map((snapshot) => snapshot.provider),
121
+ routePlan,
122
+ explanation: routePlan.explanation.summary
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Routing failure throws before any `route_decided` telemetry is emitted, so the
128
+ * plan's diagnostics are attached to the exception — that is the only channel
129
+ * through which a caller learns *why* each provider was rejected.
130
+ */
131
+ private routePlanFailure(routePlan: SharedPlannerRoutePlan): never {
132
+ const message = routePlan.explanation.summary;
133
+ const details = {
134
+ details: {
135
+ failureCode: routePlan.failureCode,
136
+ rejectedProviders: routePlan.rejectedProviders
137
+ }
138
+ };
139
+ switch (routePlan.failureCode) {
140
+ case "offline":
141
+ throw createOffline(message, details);
142
+ case "unavailable":
143
+ throw createUnavailable(message, details);
144
+ case "capability_mismatch":
145
+ default:
146
+ throw createCapabilityMismatch(message, details);
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * A single dispatched server-sent event.
3
+ *
4
+ * `event` and `id` are `undefined` when the stream did not carry those fields.
5
+ * `data` is the accumulated data payload with its trailing newline removed, and
6
+ * is passed through verbatim — sentinels such as OpenAI's `[DONE]` are the
7
+ * caller's concern, not the framing layer's.
8
+ */
9
+ export interface SseEvent {
10
+ event?: string;
11
+ data: string;
12
+ id?: string;
13
+ }
14
+
15
+ /**
16
+ * Frames a raw byte stream into server-sent events.
17
+ *
18
+ * Follows the WHATWG server-sent events stream parsing rules, minus
19
+ * reconnection: `retry` is parsed as a field and ignored, and there is no
20
+ * Last-Event-ID reconnect logic. Concretely that means:
21
+ *
22
+ * - lines end with LF, CRLF, or bare CR;
23
+ * - a line beginning with `:` is a comment (servers send these as keep-alives);
24
+ * - `field: value` strips exactly one leading space from the value, and a line
25
+ * with no colon is that field with an empty value;
26
+ * - `data` fields accumulate, joined by newlines;
27
+ * - a blank line dispatches the accumulated event, or resets without
28
+ * dispatching if no data was accumulated;
29
+ * - a stream that ends mid-event does **not** dispatch the partial event, since
30
+ * a truncated event must never be reported as a complete one.
31
+ *
32
+ * Chunk boundaries are meaningless: this decodes incrementally (`stream: true`)
33
+ * so a boundary may fall inside a multi-byte UTF-8 sequence or mid-line.
34
+ *
35
+ * Held to `contracts/fixtures/streaming/sse-framing.json` together with the
36
+ * Swift and Kotlin implementations of the same protocol.
37
+ *
38
+ * @param chunks - Raw response body chunks, in arrival order.
39
+ */
40
+ export async function* parseSseStream(chunks: AsyncIterable<Uint8Array>): AsyncGenerator<SseEvent> {
41
+ const decoder = new TextDecoder("utf-8");
42
+ let buffer = "";
43
+
44
+ let eventType: string | undefined;
45
+ let data: string | undefined;
46
+ let id: string | undefined;
47
+
48
+ const takeEvent = (): SseEvent | undefined => {
49
+ const dispatched = data === undefined ? undefined : buildEvent(eventType, data, id);
50
+ eventType = undefined;
51
+ data = undefined;
52
+ id = undefined;
53
+ return dispatched;
54
+ };
55
+
56
+ const handleLine = (line: string): SseEvent | undefined => {
57
+ if (line.length === 0) return takeEvent();
58
+ if (line.startsWith(":")) return undefined;
59
+
60
+ const colon = line.indexOf(":");
61
+ const field = colon === -1 ? line : line.slice(0, colon);
62
+ let value = colon === -1 ? "" : line.slice(colon + 1);
63
+ if (value.startsWith(" ")) value = value.slice(1);
64
+
65
+ switch (field) {
66
+ case "event":
67
+ eventType = value;
68
+ break;
69
+ case "data":
70
+ data = data === undefined ? value : `${data}\n${value}`;
71
+ break;
72
+ case "id":
73
+ // Per the SSE spec an id containing a NUL is ignored outright.
74
+ if (!value.includes("\0")) id = value;
75
+ break;
76
+ default:
77
+ // `retry` and anything unrecognized are dropped rather than erroring.
78
+ break;
79
+ }
80
+
81
+ return undefined;
82
+ };
83
+
84
+ for await (const chunk of chunks) {
85
+ buffer += decoder.decode(chunk, { stream: true });
86
+
87
+ for (;;) {
88
+ const line = takeLine();
89
+ if (line === undefined) break;
90
+ const dispatched = handleLine(line);
91
+ if (dispatched) yield dispatched;
92
+ }
93
+ }
94
+
95
+ // A trailing bare CR is ambiguous until the stream ends: it could still have
96
+ // been the first half of a CRLF.
97
+ buffer += decoder.decode();
98
+ for (;;) {
99
+ const line = takeLine(true);
100
+ if (line === undefined) break;
101
+ const dispatched = handleLine(line);
102
+ if (dispatched) yield dispatched;
103
+ }
104
+
105
+ function takeLine(atEnd = false): string | undefined {
106
+ for (let index = 0; index < buffer.length; index += 1) {
107
+ const char = buffer[index];
108
+ if (char === "\n") {
109
+ const line = buffer.slice(0, index);
110
+ buffer = buffer.slice(index + 1);
111
+ return line;
112
+ }
113
+ if (char === "\r") {
114
+ const isLast = index === buffer.length - 1;
115
+ // Withhold a trailing CR until we know whether an LF follows, unless
116
+ // the stream is over and no LF can arrive.
117
+ if (isLast && !atEnd) return undefined;
118
+ const skip = buffer[index + 1] === "\n" ? 2 : 1;
119
+ const line = buffer.slice(0, index);
120
+ buffer = buffer.slice(index + skip);
121
+ return line;
122
+ }
123
+ }
124
+ return undefined;
125
+ }
126
+ }
127
+
128
+ function buildEvent(eventType: string | undefined, data: string, id: string | undefined): SseEvent {
129
+ return {
130
+ ...(eventType !== undefined ? { event: eventType } : {}),
131
+ data,
132
+ ...(id !== undefined ? { id } : {})
133
+ };
134
+ }
@@ -0,0 +1,20 @@
1
+ import type { TelemetryEvent, TelemetryService } from "@independo/inderun-contracts";
2
+
3
+ export type {
4
+ TelemetryEvent,
5
+ TelemetryEventType,
6
+ TelemetryService
7
+ } from "@independo/inderun-contracts";
8
+
9
+ /**
10
+ * Default no-op telemetry handler that discards all events.
11
+ */
12
+ export class NoOpTelemetryService implements TelemetryService {
13
+ /**
14
+ * Discards the telemetry event.
15
+ * @param _event - The telemetry event to discard.
16
+ */
17
+ emit(_event: TelemetryEvent): void {
18
+ // No-op
19
+ }
20
+ }