@mostra/sdk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mostra.ts ADDED
@@ -0,0 +1,607 @@
1
+ /**
2
+ * `createMostra` (HO-04, D25, D58, D59): a JavaScript host's handle on its own Mostra instance.
3
+ * It finds a verified runtime (the pinned bundle, downloaded once), spawns `mostra-runtime host`
4
+ * for the host's home over stdio, opens the protocol session, serves the host's ports and offers
5
+ * the calls every host makes. The instance lives exactly as long as this handle: `close()` ends
6
+ * it, and so does the host's process exiting.
7
+ *
8
+ * ```ts
9
+ * const mostra = await createMostra({
10
+ * home: "/var/lib/inbox/mostra",
11
+ * client: { name: "inbox-app", version: "1.4.0" },
12
+ * ports: { submissions: { deliver: async (envelope) => ({ accepted: true }) } },
13
+ * });
14
+ * const receipt = await mostra.publish("/work/inbox");
15
+ * const outcome = await mostra.wait(receipt.artifactId, "submitted");
16
+ * await mostra.close();
17
+ * ```
18
+ */
19
+ import { type ChildProcess, spawn } from "node:child_process";
20
+ import { resolve } from "node:path";
21
+ import {
22
+ type AnyMethod,
23
+ ARTIFACT_PROGRESS,
24
+ type ArtifactRef,
25
+ type ArtifactSummary,
26
+ artifactPublishMethod,
27
+ artifactVerifyMethod,
28
+ type CapabilityLink,
29
+ type ControlsPolicy,
30
+ createDiagnostic,
31
+ type DependencyPolicy,
32
+ type Diagnostic,
33
+ type DraftMirror,
34
+ EVENTS_NOTIFY,
35
+ encodeLine,
36
+ HOST_PROTOCOL_REVISIONS,
37
+ type HomeConfig,
38
+ type InstanceCapabilities,
39
+ type InstanceInfo,
40
+ LineSplitter,
41
+ type LivePushParams,
42
+ type LivePushResult,
43
+ livePushMethod,
44
+ type MethodDefinition,
45
+ type MostraInitializeResult,
46
+ mostraInitializeMethod,
47
+ type PortIdentityResolveParams,
48
+ type PortName,
49
+ type PortSecretsGetParams,
50
+ type PortTrustedControlsActivatedParams,
51
+ type PublishReceipt,
52
+ portDraftsMirrorMethod,
53
+ portIdentityResolveMethod,
54
+ portSecretsGetMethod,
55
+ portSubmissionsDeliverMethod,
56
+ portTrustedControlsActivatedMethod,
57
+ portTrustedControlsPolicyMethod,
58
+ type Results,
59
+ type ResultsMarkdown,
60
+ RpcError,
61
+ RpcPeer,
62
+ resultsGetMethod,
63
+ type SourceRef,
64
+ type StateGetResult,
65
+ type StatePatchParams,
66
+ type StatePatchResult,
67
+ type SubmissionEnvelope,
68
+ type SubmissionPolicy,
69
+ type SubmissionsDeliverResult,
70
+ type SubmitChannel,
71
+ stateGetMethod,
72
+ statePatchMethod,
73
+ type VerifyVerdict,
74
+ type ViewerSpec,
75
+ type ViewUrlParams,
76
+ viewUrlMethod,
77
+ WAIT_MAX_MS,
78
+ type WaitCondition,
79
+ type WaitResult,
80
+ waitMethod,
81
+ } from "@mostra/protocol";
82
+ import type { InstallProgress } from "./bundle/cache.ts";
83
+ import type { Environment } from "./bundle/setup.ts";
84
+ import { type EventPage, type EventStreamOptions, openEventStream } from "./events.ts";
85
+ import { type Runtime, resolveRuntime } from "./runtime.ts";
86
+ import { sdkVersion } from "./version.ts";
87
+
88
+ /**
89
+ * The ports a host may serve (INTERFACES §7). Each is optional and has a standalone default;
90
+ * Mostra calls a port only on the instance this handle spawned, and every call is idempotent by
91
+ * its stable ID, so a port may see the same call again after a crash.
92
+ */
93
+ export interface Ports {
94
+ /**
95
+ * Names the person behind a link minted without a viewer, from the request's headers (D143):
96
+ * `{ ref, name? }` (one viewer per `ref`), or `null` for the home's local user. Answer within
97
+ * 5 s; a failure refuses the link rather than guess.
98
+ */
99
+ readonly identity?: {
100
+ resolve(request: PortIdentityResolveParams): ViewerSpec | null | Promise<ViewerSpec | null>;
101
+ };
102
+ /** Receives each submission once it is durable (D58, D105). Answer within 30 s. */
103
+ readonly submissions?: {
104
+ deliver(
105
+ envelope: SubmissionEnvelope,
106
+ ): SubmissionsDeliverResult | Promise<SubmissionsDeliverResult>;
107
+ };
108
+ /** Receives each viewer draft's latest revision, best effort, never retried (IN-06). */
109
+ readonly drafts?: { mirror(draft: DraftMirror): void | Promise<void> };
110
+ /** Answers `secret()` first, before the home's store (D131); `null` falls back. */
111
+ readonly secrets?: {
112
+ get(request: PortSecretsGetParams): string | null | Promise<string | null>;
113
+ };
114
+ /** Decides each view's submit channels and trusted controls (D108). Answer within 5 s. */
115
+ readonly trustedControls?: {
116
+ policy(artifact: ArtifactSummary): ControlsPolicy | Promise<ControlsPolicy>;
117
+ activated?(event: PortTrustedControlsActivatedParams): void | Promise<void>;
118
+ };
119
+ }
120
+
121
+ /** Home settings a host chooses (INTERFACES §6 `HomeConfig`), kept in the home's `config.json`. */
122
+ export interface HomeSettings {
123
+ /** The submission policy for artifacts whose publish names none (D102). */
124
+ readonly submission?: SubmissionPolicy;
125
+ /** The channels that may submit (D40, D108); absent, both. */
126
+ readonly submitChannels?: readonly SubmitChannel[];
127
+ /** How packages enter an artifact's lockfile (D43, D95). */
128
+ readonly dependencies?: DependencyPolicy;
129
+ }
130
+
131
+ /** Options of {@link createMostra}. */
132
+ export interface MostraOptions {
133
+ /** The home this host owns (D45). Created 0700 when missing. */
134
+ readonly home: string;
135
+ /**
136
+ * Who the host is. The name owns the host's deliveries across restarts (D105), so keep it
137
+ * stable; it defaults to `mostra-sdk`.
138
+ */
139
+ readonly client?: { readonly name: string; readonly version?: string };
140
+ /** The ports this host serves. */
141
+ readonly ports?: Ports;
142
+ /**
143
+ * Whether submissions made while this host is away wait for it (D105): the registered ports
144
+ * are recorded as ports this host always provides. Default `true`; `false` leaves the home's
145
+ * record as it is.
146
+ */
147
+ readonly expectPorts?: boolean;
148
+ /** Home settings, kept in the home's `config.json` for every later client. */
149
+ readonly settings?: HomeSettings;
150
+ /** An extracted runtime bundle to use instead of the pinned one. */
151
+ readonly bundle?: string;
152
+ /** The gateway's bind address and URL host (D99); loopback by default. */
153
+ readonly bind?: string;
154
+ /** The host name URLs use, which a wildcard `bind` needs (D99). */
155
+ readonly urlHost?: string;
156
+ /** The instance's environment. Default: this process's. */
157
+ readonly env?: Environment;
158
+ /** Each line the instance logs (JSON, on its stderr). A callback that throws is ignored. */
159
+ readonly onLog?: (line: string) => void;
160
+ /** Progress of the first download or setup of the runtime bundle. A throw is ignored. */
161
+ readonly onProgress?: (progress: InstallProgress) => void;
162
+ /**
163
+ * An exception a port threw; the instance got `MOSTRA_HOST_INTERNAL_ERROR` and will retry. A
164
+ * callback that throws is ignored, and the instance is answered all the same.
165
+ */
166
+ readonly onPortError?: (error: unknown, method: string) => void;
167
+ /** Gives up finding the runtime and starting the instance. */
168
+ readonly signal?: AbortSignal;
169
+ }
170
+
171
+ /** How the instance ended. */
172
+ export interface InstanceExit {
173
+ /** Its exit code: 0 after a clean stop. */
174
+ readonly code: number | null;
175
+ /** The signal that ended it, if one did. */
176
+ readonly signal: NodeJS.Signals | null;
177
+ }
178
+
179
+ /** An artifact by ID (`art_…`), by source path, or as the protocol's reference. */
180
+ export type ArtifactInput = string | ArtifactRef;
181
+
182
+ /** A host's handle on its instance. */
183
+ export interface Mostra {
184
+ /** The instance: release, home, platform, PID and mode. */
185
+ readonly instance: InstanceInfo;
186
+ /** What the instance serves. */
187
+ readonly capabilities: InstanceCapabilities;
188
+ /** The negotiated host protocol revision. */
189
+ readonly revision: string;
190
+ /** The session's warnings (an unsupported port, for example). */
191
+ readonly warnings: readonly Diagnostic[];
192
+ /** The runtime it runs on. */
193
+ readonly runtime: Runtime;
194
+ /** Resolves when the instance has exited, for whatever reason. */
195
+ readonly closed: Promise<InstanceExit>;
196
+ /** Calls any host protocol method by its definition from `@mostra/protocol`. */
197
+ call<P, R>(method: MethodDefinition<P, R>, params: P, options?: CallOptions): Promise<R>;
198
+ /** `artifact/publish`: verifies (D41) and publishes; durable before it resolves (H-07). */
199
+ publish(
200
+ source: ArtifactInput,
201
+ options?: {
202
+ readonly force?: boolean;
203
+ readonly policy?: SubmissionPolicy;
204
+ readonly requestId?: string;
205
+ },
206
+ ): Promise<PublishReceipt>;
207
+ /** `artifact/verify`: one verdict, never a publish (D138). */
208
+ verify(
209
+ source: ArtifactInput,
210
+ options?: { readonly screenshots?: "on-failure" | "always" | "never" },
211
+ ): Promise<VerifyVerdict>;
212
+ /**
213
+ * `wait` (IN-10, D103): until the condition holds or `timeoutMs` passes (default: the longest
214
+ * timer, about 24.8 days). `still_waiting` carries the cursor to wait from next; an aborted wait
215
+ * rejects with the signal's reason and loses nothing.
216
+ */
217
+ wait(
218
+ artifact: ArtifactInput,
219
+ until: WaitCondition,
220
+ options?: {
221
+ readonly after?: string;
222
+ readonly timeoutMs?: number;
223
+ readonly signal?: AbortSignal;
224
+ },
225
+ ): Promise<WaitResult>;
226
+ /** `results/get`: a submission as JSON (the default) or Markdown (INTERFACES §8). */
227
+ results(
228
+ artifact: ArtifactInput,
229
+ options?: { readonly submission?: string; readonly format?: "json" },
230
+ ): Promise<Results>;
231
+ /** `results/get` as Markdown, for an agent to read. */
232
+ results(
233
+ artifact: ArtifactInput,
234
+ options: { readonly submission?: string; readonly format: "md" },
235
+ ): Promise<ResultsMarkdown>;
236
+ /** `state/get`: artifact-scoped fields (D63). */
237
+ getState(artifact: ArtifactInput, ids?: readonly string[]): Promise<StateGetResult>;
238
+ /** `state/patch`: writes artifact-scoped fields through compare-and-set (D63). */
239
+ patchState(
240
+ artifact: ArtifactInput,
241
+ patch: Omit<StatePatchParams, "artifact">,
242
+ ): Promise<StatePatchResult>;
243
+ /** `live/push`: a live source's next value (D56). */
244
+ pushLive(
245
+ artifact: ArtifactInput,
246
+ push: Omit<LivePushParams, "artifact">,
247
+ ): Promise<LivePushResult>;
248
+ /** `view/url`: a new capability link (D97). */
249
+ viewUrl(
250
+ artifact: ArtifactInput,
251
+ options?: Omit<ViewUrlParams, "artifact">,
252
+ ): Promise<CapabilityLink>;
253
+ /**
254
+ * The events matching `options.filter`, page by page, in cursor order, with no gap across a
255
+ * pause (`events/subscribe`). With a `name`, each page is acknowledged when the next one is
256
+ * asked for, so a later stream of that name resumes after the last page handled.
257
+ */
258
+ events(options?: EventStreamOptions): AsyncIterable<EventPage>;
259
+ /** Ends the instance: its stdio closes, and it is stopped if it doesn't exit within 10 s. */
260
+ close(): Promise<InstanceExit>;
261
+ }
262
+
263
+ /** Options of one call. */
264
+ export interface CallOptions {
265
+ /** Gives up after this many milliseconds with `MOSTRA_HOST_TIMEOUT`. Default: none. */
266
+ readonly timeoutMs?: number;
267
+ }
268
+
269
+ /** The port methods the SDK serves, by port. */
270
+ const PORT_METHODS = {
271
+ identity: [portIdentityResolveMethod],
272
+ submissions: [portSubmissionsDeliverMethod],
273
+ drafts: [portDraftsMirrorMethod],
274
+ secrets: [portSecretsGetMethod],
275
+ trustedControls: [portTrustedControlsPolicyMethod, portTrustedControlsActivatedMethod],
276
+ } as const satisfies Record<keyof Ports, readonly AnyMethod[]>;
277
+
278
+ /** The client name of an SDK host that names none. */
279
+ export const SDK_CLIENT_NAME = "mostra-sdk";
280
+
281
+ /** `art_…` is an artifact ID; anything else a source path, resolved against the working directory. */
282
+ export function artifactRef(input: ArtifactInput): ArtifactRef & SourceRef {
283
+ if (typeof input !== "string") return input as ArtifactRef & SourceRef;
284
+ return input.startsWith("art_") ? { artifactId: input } : { path: resolve(input) };
285
+ }
286
+
287
+ function closedError(message: string): RpcError {
288
+ return new RpcError(createDiagnostic("MOSTRA_HOST_CONNECTION_CLOSED", { message }));
289
+ }
290
+
291
+ /** The handler for one port method, its params already checked. */
292
+ function portHandler(ports: Ports, method: string): ((params: unknown) => unknown) | undefined {
293
+ switch (method) {
294
+ case portIdentityResolveMethod.method:
295
+ return (
296
+ ports.identity && ((params) => ports.identity?.resolve(params as PortIdentityResolveParams))
297
+ );
298
+ case portSubmissionsDeliverMethod.method:
299
+ return (
300
+ ports.submissions && ((params) => ports.submissions?.deliver(params as SubmissionEnvelope))
301
+ );
302
+ case portDraftsMirrorMethod.method:
303
+ return ports.drafts && ((params) => ports.drafts?.mirror(params as DraftMirror));
304
+ case portSecretsGetMethod.method:
305
+ return ports.secrets && ((params) => ports.secrets?.get(params as PortSecretsGetParams));
306
+ case portTrustedControlsPolicyMethod.method:
307
+ return (
308
+ ports.trustedControls &&
309
+ ((params) =>
310
+ ports.trustedControls?.policy((params as { artifact: ArtifactSummary }).artifact))
311
+ );
312
+ case portTrustedControlsActivatedMethod.method:
313
+ return (
314
+ ports.trustedControls &&
315
+ (async (params) => {
316
+ await ports.trustedControls?.activated?.(params as PortTrustedControlsActivatedParams);
317
+ return {};
318
+ })
319
+ );
320
+ default:
321
+ return undefined;
322
+ }
323
+ }
324
+
325
+ /** The port names `ports` registers; anything unknown is a mistake worth failing on. */
326
+ function portNames(ports: Ports): PortName[] {
327
+ const names: PortName[] = [];
328
+ for (const [name, value] of Object.entries(ports)) {
329
+ if (value === undefined) continue;
330
+ if (!(name in PORT_METHODS)) {
331
+ throw new TypeError(
332
+ `createMostra: no port "${name}" (the SDK serves ${Object.keys(PORT_METHODS).join(", ")}).`,
333
+ );
334
+ }
335
+ names.push(name as PortName);
336
+ }
337
+ return names;
338
+ }
339
+
340
+ /** Ends the child: stdin closed, then SIGTERM after `graceMs`, then SIGKILL after 5 s more. */
341
+ function stopChild(
342
+ child: ChildProcess,
343
+ exited: Promise<InstanceExit>,
344
+ graceMs: number,
345
+ ): Promise<InstanceExit> {
346
+ child.stdin?.end();
347
+ const term = setTimeout(() => child.kill("SIGTERM"), graceMs);
348
+ const kill = setTimeout(() => child.kill("SIGKILL"), graceMs + 5000);
349
+ return exited.finally(() => {
350
+ clearTimeout(term);
351
+ clearTimeout(kill);
352
+ });
353
+ }
354
+
355
+ /** `callback`, made unable to throw into its caller. */
356
+ function guarded<A extends unknown[]>(
357
+ callback: ((...args: A) => void) | undefined,
358
+ ): ((...args: A) => void) | undefined {
359
+ if (callback === undefined) return undefined;
360
+ return (...args: A) => {
361
+ try {
362
+ callback(...args);
363
+ } catch {
364
+ // The host's own code failed; Mostra's work goes on.
365
+ }
366
+ };
367
+ }
368
+
369
+ /**
370
+ * Spawns an instance for `home` and opens its session. Rejects with the instance's own diagnostic
371
+ * when it can't start (`MOSTRA_HOME_LOCKED`, `MOSTRA_PLATFORM_UNSUPPORTED` …), or with the
372
+ * runtime's (`MOSTRA_ENV_BUNDLE_*`) when no verified runtime can be found.
373
+ */
374
+ export async function createMostra(options: MostraOptions): Promise<Mostra> {
375
+ // The host's callbacks run inside Mostra's own handlers (a pipe's data listener, the RPC
376
+ // peer's error path, a download's progress): one that throws must never escape into the host's
377
+ // process as an uncaught exception or unhandled rejection, nor stop an answer (Opus review
378
+ // finding 6). A callback that throws is ignored.
379
+ const onLog = guarded(options.onLog);
380
+ const onProgress = guarded(options.onProgress);
381
+ const onPortError = guarded(options.onPortError);
382
+ const ports = options.ports ?? {};
383
+ const names = portNames(ports);
384
+ const env = options.env ?? process.env;
385
+ const home = resolve(options.home);
386
+ const runtime = await resolveRuntime({
387
+ bundle: options.bundle,
388
+ env,
389
+ signal: options.signal,
390
+ ...(onProgress ? { onProgress } : {}),
391
+ });
392
+ options.signal?.throwIfAborted();
393
+ const args = [runtime.runtimeEntry, "host", "--home", home];
394
+ if (options.bind !== undefined) args.push("--bind", options.bind);
395
+ if (options.urlHost !== undefined) args.push("--url-host", options.urlHost);
396
+ const child = spawn(runtime.node, args, {
397
+ stdio: ["pipe", "pipe", "pipe"],
398
+ env: env as NodeJS.ProcessEnv,
399
+ });
400
+ const logTail: string[] = [];
401
+ const exited = new Promise<InstanceExit>((done) => {
402
+ child.on("close", (code, signal) => done({ code, signal }));
403
+ child.on("error", () => done({ code: null, signal: null }));
404
+ });
405
+ let spawnError: Error | undefined;
406
+ child.on("error", (error) => {
407
+ spawnError = error;
408
+ });
409
+ const errLines = new LineSplitter();
410
+ child.stderr?.setEncoding("utf8");
411
+ child.stderr?.on("data", (chunk: string) => {
412
+ for (const line of errLines.push(chunk)) {
413
+ logTail.push(line);
414
+ if (logTail.length > 20) logTail.shift();
415
+ onLog?.(line);
416
+ }
417
+ });
418
+
419
+ const notificationHandlers = new Map<string, Set<(params: unknown) => void>>();
420
+ const peer = new RpcPeer({
421
+ send: (message) => {
422
+ if (child.stdin?.writable) child.stdin.write(encodeLine(message));
423
+ },
424
+ onRequest: async (method, params) => {
425
+ const definition = Object.values(PORT_METHODS)
426
+ .flat()
427
+ .find((candidate) => candidate.method === method) as
428
+ | MethodDefinition<unknown, unknown>
429
+ | undefined;
430
+ const handler = definition && portHandler(ports, method);
431
+ if (!definition || !handler) {
432
+ throw new RpcError(
433
+ createDiagnostic("MOSTRA_HOST_METHOD_UNKNOWN", {
434
+ message: `This host serves no method ${method}.`,
435
+ }),
436
+ -32601,
437
+ );
438
+ }
439
+ const parsed = definition.params.safeParse(params);
440
+ if (!parsed.success) {
441
+ throw new RpcError(
442
+ createDiagnostic("MOSTRA_HOST_INVALID_PARAMS", {
443
+ message: `The params of ${method} don't match its schema.`,
444
+ }),
445
+ -32602,
446
+ );
447
+ }
448
+ const answer = await handler(parsed.data);
449
+ const checked = definition.result.safeParse(answer ?? null);
450
+ if (!checked.success) {
451
+ throw new RpcError(
452
+ createDiagnostic("MOSTRA_HOST_INTERNAL_ERROR", {
453
+ message: `The host's ${method} answered something its schema refuses.`,
454
+ }),
455
+ );
456
+ }
457
+ return checked.data;
458
+ },
459
+ onNotification: (method, params) => {
460
+ for (const handler of notificationHandlers.get(method) ?? []) handler(params);
461
+ },
462
+ onInternalError: (error, method) => onPortError?.(error, method),
463
+ });
464
+ const lines = new LineSplitter();
465
+ child.stdout?.setEncoding("utf8");
466
+ child.stdout?.on("data", (chunk: string) => {
467
+ for (const line of lines.push(chunk)) peer.receiveLine(line);
468
+ });
469
+ child.stdout?.on("close", () => {
470
+ peer.close(
471
+ createDiagnostic("MOSTRA_HOST_CONNECTION_CLOSED", {
472
+ message: `The Mostra instance for ${home} exited${logTail.length > 0 ? `; its last log line: ${logTail.at(-1)}` : ""}.`,
473
+ }),
474
+ );
475
+ });
476
+ // A pipe to an instance that died fails its next write (EPIPE) on its own schedule. Without a
477
+ // listener that error is an uncaught exception in the host's own process: every pipe has one,
478
+ // and a write that failed ends the session at once, as the instance's exit does.
479
+ child.stdin?.on("error", (error: NodeJS.ErrnoException) => {
480
+ peer.close(
481
+ createDiagnostic("MOSTRA_HOST_CONNECTION_CLOSED", {
482
+ message: `The Mostra instance for ${home} stopped reading (${error.code ?? error.message}).`,
483
+ }),
484
+ );
485
+ });
486
+ child.stdout?.on("error", () => {});
487
+ child.stderr?.on("error", () => {});
488
+
489
+ const abort = (): void => {
490
+ void stopChild(child, exited, 0);
491
+ };
492
+ options.signal?.addEventListener("abort", abort, { once: true });
493
+ const homeConfig: HomeConfig = {
494
+ path: home,
495
+ // What this host always provides, an empty list included: a host that stopped serving a port
496
+ // clears the record, so its absence is never awaited (Opus review of WP-12, finding 7).
497
+ ...(options.expectPorts !== false ? { ports: names } : {}),
498
+ ...(options.settings?.submission ? { submission: options.settings.submission } : {}),
499
+ ...(options.settings?.submitChannels
500
+ ? { submitChannels: options.settings.submitChannels }
501
+ : {}),
502
+ ...(options.settings?.dependencies ? { dependencies: options.settings.dependencies } : {}),
503
+ };
504
+ let session: MostraInitializeResult;
505
+ try {
506
+ session = await peer.call(mostraInitializeMethod, {
507
+ client: {
508
+ name: options.client?.name ?? SDK_CLIENT_NAME,
509
+ version: options.client?.version ?? sdkVersion(),
510
+ },
511
+ revisions: [...HOST_PROTOCOL_REVISIONS],
512
+ capabilities: { notifications: [EVENTS_NOTIFY, ARTIFACT_PROGRESS] },
513
+ ports: names,
514
+ home: homeConfig,
515
+ });
516
+ } catch (error) {
517
+ await stopChild(child, exited, 2000);
518
+ if (spawnError) throw closedError(`Couldn't start ${runtime.node}: ${spawnError.message}`);
519
+ throw error;
520
+ } finally {
521
+ options.signal?.removeEventListener("abort", abort);
522
+ }
523
+
524
+ const call = <P, R>(
525
+ method: MethodDefinition<P, R>,
526
+ params: P,
527
+ callOptions?: CallOptions,
528
+ ): Promise<R> =>
529
+ peer.call(
530
+ method,
531
+ params,
532
+ callOptions?.timeoutMs !== undefined ? { timeoutMs: callOptions.timeoutMs } : {},
533
+ );
534
+ let closing: Promise<InstanceExit> | undefined;
535
+
536
+ const mostra: Mostra = {
537
+ instance: session.instance,
538
+ capabilities: session.capabilities,
539
+ revision: session.revision,
540
+ warnings: session.warnings,
541
+ runtime,
542
+ closed: exited,
543
+ call,
544
+ publish: (source, publishOptions = {}) =>
545
+ call(artifactPublishMethod, { source: artifactRef(source), ...publishOptions }),
546
+ verify: (source, verifyOptions = {}) =>
547
+ call(artifactVerifyMethod, { source: artifactRef(source), ...verifyOptions }),
548
+ wait: (artifact, until, waitOptions = {}) => {
549
+ const signal = waitOptions.signal;
550
+ // Checked before the request goes out: nothing is sent for a wait already given up, so no
551
+ // answer is left without a handler (GPT review finding 4).
552
+ if (signal?.aborted) return Promise.reject(signal.reason);
553
+ const pending = call(waitMethod, {
554
+ artifact: artifactRef(artifact),
555
+ until,
556
+ timeoutMs: waitOptions.timeoutMs ?? WAIT_MAX_MS,
557
+ ...(waitOptions.after !== undefined ? { after: waitOptions.after } : {}),
558
+ });
559
+ if (!signal) return pending;
560
+ // The instance's wait runs on until its own timeout; its late answer, or the connection's
561
+ // end, is handled here and dropped.
562
+ return new Promise<WaitResult>((done, fail) => {
563
+ const onAbort = (): void => fail(signal.reason);
564
+ signal.addEventListener("abort", onAbort, { once: true });
565
+ pending.then(done, fail).finally(() => signal.removeEventListener("abort", onAbort));
566
+ });
567
+ },
568
+ results: ((
569
+ artifact: ArtifactInput,
570
+ resultsOptions: { submission?: string; format?: "json" | "md" } = {},
571
+ ) =>
572
+ call(resultsGetMethod, {
573
+ artifact: artifactRef(artifact),
574
+ ...resultsOptions,
575
+ })) as Mostra["results"],
576
+ getState: (artifact, ids) =>
577
+ call(stateGetMethod, { artifact: artifactRef(artifact), ...(ids ? { ids: [...ids] } : {}) }),
578
+ patchState: (artifact, patch) =>
579
+ call(statePatchMethod, { artifact: artifactRef(artifact), ...patch }),
580
+ pushLive: (artifact, push) =>
581
+ call(livePushMethod, { artifact: artifactRef(artifact), ...push }),
582
+ viewUrl: (artifact, urlOptions = {}) =>
583
+ call(viewUrlMethod, { artifact: artifactRef(artifact), ...urlOptions }),
584
+ events: (streamOptions = {}) =>
585
+ openEventStream(
586
+ {
587
+ call,
588
+ onNotification: (handler) => {
589
+ let set = notificationHandlers.get(EVENTS_NOTIFY);
590
+ if (!set) {
591
+ set = new Set();
592
+ notificationHandlers.set(EVENTS_NOTIFY, set);
593
+ }
594
+ set.add(handler);
595
+ return () => set?.delete(handler);
596
+ },
597
+ closed: exited.then(() => undefined),
598
+ },
599
+ streamOptions,
600
+ ),
601
+ close: () => {
602
+ closing ??= stopChild(child, exited, 10_000);
603
+ return closing;
604
+ },
605
+ };
606
+ return mostra;
607
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Which runtime bundle this machine takes (RT-01, D35): one per operating system and architecture
3
+ * of v1, glibc only on Linux. Anything else fails closed before a download starts.
4
+ */
5
+ import {
6
+ BUNDLE_PLATFORMS,
7
+ type BundlePlatform,
8
+ createDiagnostic,
9
+ RpcError,
10
+ } from "@mostra/protocol";
11
+
12
+ /** What the platform check reads: the running process by default. */
13
+ export interface PlatformFacts {
14
+ /** `process.platform`. */
15
+ readonly platform: string;
16
+ /** `process.arch`. */
17
+ readonly arch: string;
18
+ /** The glibc release on Linux, `undefined` on musl or elsewhere. */
19
+ readonly glibc?: string | undefined;
20
+ }
21
+
22
+ /** What {@link glibcRuntime} needs of `process.report`. */
23
+ interface Reporter {
24
+ /** Node.js 22.13 and 23.3 or later; `@types/node` 24.19 doesn't declare it yet. */
25
+ excludeNetwork?: boolean;
26
+ getReport(): object;
27
+ }
28
+
29
+ /**
30
+ * The glibc release Node.js reports, read from its diagnostic report's header. The report runs in
31
+ * the caller's process (a host's server, say) and by default resolves the name of every open TCP
32
+ * handle, synchronously; with `excludeNetwork` it does none of that (Opus review finding 4), as
33
+ * detect-libc does. The setting is restored after.
34
+ */
35
+ export function glibcRuntime(report: Reporter | undefined): string | undefined {
36
+ if (report === undefined) return undefined;
37
+ const before = report.excludeNetwork ?? false;
38
+ report.excludeNetwork = true;
39
+ try {
40
+ return (report.getReport() as { header?: { glibcVersionRuntime?: string } }).header
41
+ ?.glibcVersionRuntime;
42
+ } finally {
43
+ report.excludeNetwork = before;
44
+ }
45
+ }
46
+
47
+ let facts: PlatformFacts | undefined;
48
+
49
+ /** The facts of the running process, read once. */
50
+ export function currentPlatform(): PlatformFacts {
51
+ facts ??=
52
+ process.platform === "linux"
53
+ ? { platform: process.platform, arch: process.arch, glibc: glibcRuntime(process.report) }
54
+ : { platform: process.platform, arch: process.arch };
55
+ return facts;
56
+ }
57
+
58
+ /**
59
+ * The bundle platform for these facts. Throws `MOSTRA_PLATFORM_UNSUPPORTED` for Windows, other
60
+ * architectures and Linux without glibc, whose libraries the bundle's Node.js and browser need.
61
+ */
62
+ export function bundlePlatform(facts: PlatformFacts = currentPlatform()): BundlePlatform {
63
+ const name = `${facts.platform}-${facts.arch}`;
64
+ const known = (BUNDLE_PLATFORMS as readonly string[]).includes(name);
65
+ if (known && facts.platform === "linux" && facts.glibc === undefined) {
66
+ throw unsupported(
67
+ `Mostra's Linux bundle needs glibc, and this system has none (musl, as on Alpine Linux); it supports macOS and glibc-based Linux on x64 and arm64.`,
68
+ );
69
+ }
70
+ if (!known) {
71
+ const label = facts.platform === "win32" ? `Windows (${name})` : name;
72
+ throw unsupported(
73
+ `Mostra doesn't run on ${label}; it supports macOS and Linux on x64 and arm64.`,
74
+ );
75
+ }
76
+ return name as BundlePlatform;
77
+ }
78
+
79
+ function unsupported(message: string): RpcError {
80
+ return new RpcError(createDiagnostic("MOSTRA_PLATFORM_UNSUPPORTED", { message }));
81
+ }