@opengeni/core 0.12.10 → 0.14.4

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 (39) hide show
  1. package/dist/access/index.d.ts +22 -0
  2. package/dist/application/new-session-drafts.d.ts +14 -0
  3. package/dist/application/session-commands.d.ts +107 -0
  4. package/dist/billing/limits.d.ts +29 -0
  5. package/dist/dependencies.d.ts +137 -0
  6. package/dist/domain/capabilities.d.ts +62 -0
  7. package/dist/domain/environments.d.ts +33 -0
  8. package/dist/domain/insights.d.ts +11 -0
  9. package/dist/domain/packs.d.ts +27 -0
  10. package/dist/domain/resources.d.ts +32 -0
  11. package/dist/domain/scheduled-tasks.d.ts +72 -0
  12. package/dist/domain/session-tool-policy.d.ts +31 -0
  13. package/dist/domain/sessions.d.ts +256 -0
  14. package/dist/domain/slack-bot.d.ts +19 -0
  15. package/dist/domain/workspace-members.d.ts +34 -0
  16. package/dist/index.d.ts +23 -1199
  17. package/dist/index.js +693 -53
  18. package/dist/index.js.map +1 -1
  19. package/dist/managed-auth-type.d.ts +2 -0
  20. package/dist/rigs/index.d.ts +57 -0
  21. package/dist/sandbox/fleet.d.ts +197 -0
  22. package/dist/sandbox/routing.d.ts +55 -0
  23. package/dist/sandbox-types.d.ts +52 -0
  24. package/dist/session-authorization.d.ts +36 -0
  25. package/dist/transcription.d.ts +71 -0
  26. package/dist/workflow-wake-contract.d.ts +4 -0
  27. package/package.json +11 -11
  28. package/src/access/index.ts +73 -2
  29. package/src/application/new-session-drafts.ts +3 -0
  30. package/src/application/session-commands.ts +3 -1
  31. package/src/dependencies.ts +5 -0
  32. package/src/domain/insights.ts +480 -0
  33. package/src/domain/session-tool-policy.ts +17 -25
  34. package/src/domain/sessions.ts +75 -4
  35. package/src/domain/slack-bot.ts +2 -4
  36. package/src/index.ts +2 -0
  37. package/src/sandbox/fleet.ts +96 -33
  38. package/src/sandbox/routing.ts +29 -7
  39. package/src/transcription.ts +142 -0
@@ -1,4 +1,4 @@
1
- import { CODEX_MODEL_ID_PREFIX } from "@opengeni/codex";
1
+ import { CODEX_MODEL_ID_PREFIX, isCodexBilledModel } from "@opengeni/codex";
2
2
  import {
3
3
  canonicalizeConfiguredModelId,
4
4
  configuredAllowedModels,
@@ -16,6 +16,7 @@ import {
16
16
  ServiceTurnInitiator,
17
17
  ServiceTurnInitiatorContext,
18
18
  evaluateWorkspaceModelPolicy,
19
+ latencyModeForMetadata,
19
20
  reasoningEffortForMetadata,
20
21
  stableJson,
21
22
  type AccessGrant,
@@ -131,9 +132,9 @@ export class SessionSpawnDeniedError extends Error {
131
132
 
132
133
  /**
133
134
  * Resolve per-session first-party tool visibility without consulting
134
- * authorization. Top-level omission uses the minimal runtime default (stored
135
- * as null); child omission snapshots the parent's exact effective selection.
136
- * Explicit [] is authoritative and must never widen.
135
+ * authorization. Top-level omission snapshots the complete runtime default;
136
+ * child omission snapshots the parent's exact effective selection. Explicit
137
+ * [] is authoritative and must never widen.
137
138
  */
138
139
  export function resolveFirstPartyMcpToolsForCreate(
139
140
  requested: FirstPartyMcpToolName[] | undefined,
@@ -518,6 +519,8 @@ export async function createAndStartSession(input: {
518
519
  clientEventId?: string;
519
520
  model: string;
520
521
  reasoningEffort: Settings["openaiReasoningEffort"];
522
+ /** Session default Fast/standard; mirrored into metadata when set. */
523
+ latencyMode?: "standard" | "priority" | "fast";
521
524
  turnExecutionPolicy: TurnExecutionPolicyV1;
522
525
  sandboxBackend: Settings["sandboxBackend"];
523
526
  metadata: Record<string, unknown>;
@@ -587,6 +590,7 @@ export async function createAndStartSession(input: {
587
590
  ...input.metadata,
588
591
  model: input.model,
589
592
  reasoningEffort: input.reasoningEffort,
593
+ ...(input.latencyMode !== undefined ? { latencyMode: input.latencyMode } : {}),
590
594
  };
591
595
  // Keyed creation is intentionally handled only by the database admission
592
596
  // transaction below. Its workspace/key lock replays either the successful
@@ -834,6 +838,36 @@ export function assertConfiguredModel(settings: Settings, model: string | null |
834
838
  canonicalConfiguredModel(settings, model);
835
839
  }
836
840
 
841
+ export const CODEX_COMPACTION_V2_PROVIDER_LOCKED = "codex_compaction_v2_provider_locked" as const;
842
+
843
+ /** Session is frozen on Codex remote compaction v2; non-Codex models are refused. */
844
+ export class CodexCompactionV2ProviderLockedError extends Error {
845
+ readonly code = CODEX_COMPACTION_V2_PROVIDER_LOCKED;
846
+ readonly productModelId: string;
847
+
848
+ constructor(productModelId: string) {
849
+ super(
850
+ `session is locked to Codex remote compaction v2; model "${productModelId}" is not a Codex subscription model`,
851
+ );
852
+ this.name = "CodexCompactionV2ProviderLockedError";
853
+ this.productModelId = productModelId;
854
+ }
855
+ }
856
+
857
+ /**
858
+ * Fail closed when a remote_v2 session would run a non-Codex product model.
859
+ * Portable sessions and non-Codex sessions keep free mid-session provider swap.
860
+ */
861
+ export function assertSessionAllowsProductModel(
862
+ session: Pick<Session, "codexCompactionMode">,
863
+ productModelId: string | null | undefined,
864
+ ): void {
865
+ if (productModelId === null || productModelId === undefined) return;
866
+ if (session.codexCompactionMode !== "remote_v2") return;
867
+ if (isCodexBilledModel(productModelId)) return;
868
+ throw new CodexCompactionV2ProviderLockedError(productModelId);
869
+ }
870
+
837
871
  /**
838
872
  * Reject a model the WORKSPACE's model policy blocks, at the same choke points
839
873
  * as assertConfiguredModel — a 422 at the edge instead of a queued turn the
@@ -902,6 +936,13 @@ export function reasoningEffortForSession(
902
936
  return reasoningEffortForMetadata(metadata, fallback);
903
937
  }
904
938
 
939
+ export function latencyModeForSession(
940
+ metadata: Record<string, unknown>,
941
+ fallback: "standard" | "priority" | "fast" = "standard",
942
+ ): "standard" | "priority" | "fast" {
943
+ return latencyModeForMetadata(metadata, fallback);
944
+ }
945
+
905
946
  /**
906
947
  * Appends a `user.message` to an existing session and enqueues the resulting
907
948
  * turn, merging requested resources/tools into the session and waking the
@@ -922,6 +963,7 @@ export async function postUserMessageTurn(input: {
922
963
  resources: ResourceRef[];
923
964
  model?: string | null;
924
965
  reasoningEffort?: Settings["openaiReasoningEffort"] | null;
966
+ latencyMode?: "standard" | "priority" | "fast" | null;
925
967
  clientEventId?: string;
926
968
  mcpCredentialUpdates?: UpdateSessionMcpServerCredentialsInput[];
927
969
  delivery?: "send" | "steer";
@@ -941,6 +983,16 @@ export async function postUserMessageTurn(input: {
941
983
  // model inherits the session's model downstream (always a configured id).
942
984
  assertConfiguredModel(settings, requestedModel);
943
985
  await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, requestedModel);
986
+ const sessionForModelGate = await requireSession(db, workspaceId, sessionId);
987
+ const effectiveModelForGate = requestedModel ?? sessionForModelGate.model;
988
+ try {
989
+ assertSessionAllowsProductModel(sessionForModelGate, effectiveModelForGate);
990
+ } catch (error) {
991
+ if (error instanceof CodexCompactionV2ProviderLockedError) {
992
+ throw new HTTPException(422, { message: error.message, cause: error });
993
+ }
994
+ throw error;
995
+ }
944
996
  const operationKey = input.clientEventId ?? crypto.randomUUID();
945
997
  let result;
946
998
  try {
@@ -965,6 +1017,7 @@ export async function postUserMessageTurn(input: {
965
1017
  resources: input.resources,
966
1018
  model: requestedModel,
967
1019
  reasoningEffort: requestedReasoningEffort,
1020
+ latencyMode: input.latencyMode ?? null,
968
1021
  reasoningEffortFallback: input.reasoningEffortFallback ?? settings.openaiReasoningEffort,
969
1022
  turnExecutionPolicy: input.turnExecutionPolicy,
970
1023
  source: input.origin === "operator" ? "api" : "user",
@@ -1220,12 +1273,15 @@ export async function createSessionForRequest(
1220
1273
  // default-model session would otherwise be born blocked).
1221
1274
  await assertWorkspaceModelPolicyAllows(db, settings, workspaceId, model);
1222
1275
  const reasoningEffort = payload.reasoningEffort ?? settings.openaiReasoningEffort;
1276
+ const latencyMode = payload.latencyMode ?? "standard";
1223
1277
  const turnExecutionPolicy = resolveTurnExecutionPolicyV1(settings, {
1224
1278
  modelId: model,
1225
1279
  requestedModelId: payload.model ?? null,
1226
1280
  modelSource: payload.model === undefined ? "deployment" : "explicit",
1227
1281
  reasoningEffort,
1228
1282
  reasoningSource: payload.reasoningEffort === undefined ? "deployment" : "explicit",
1283
+ latencyMode,
1284
+ latencyModeSource: payload.latencyMode === undefined ? "deployment" : "explicit",
1229
1285
  });
1230
1286
  // Parent linkage was resolved above, before context validation. A child with
1231
1287
  // no explicit permission override inherits the creating session's effective
@@ -1547,6 +1603,7 @@ export async function createSessionForRequest(
1547
1603
  ...(payload.clientEventId ? { clientEventId: payload.clientEventId } : {}),
1548
1604
  model,
1549
1605
  reasoningEffort,
1606
+ latencyMode,
1550
1607
  turnExecutionPolicy,
1551
1608
  // A shared spawn inherits the box's backend; a caller-supplied
1552
1609
  // sandboxBackend on a shared spawn is ignored (it is the same box). A
@@ -1644,6 +1701,7 @@ export async function acceptSessionUserMessage(
1644
1701
  resources?: ResourceRef[];
1645
1702
  model?: string | null;
1646
1703
  reasoningEffort?: ReasoningEffort | null;
1704
+ latencyMode?: "standard" | "priority" | "fast" | null;
1647
1705
  clientEventId?: string;
1648
1706
  mcpCredentialUpdates?: SessionMcpCredentialUpdateInput[];
1649
1707
  delivery?: "send" | "steer";
@@ -1668,17 +1726,29 @@ export async function acceptSessionUserMessage(
1668
1726
  if (effectiveModel === null) {
1669
1727
  throw new Error("effective follow-up model unexpectedly resolved to null");
1670
1728
  }
1729
+ try {
1730
+ assertSessionAllowsProductModel(existingSession, effectiveModel);
1731
+ } catch (error) {
1732
+ if (error instanceof CodexCompactionV2ProviderLockedError) {
1733
+ throw new HTTPException(422, { message: error.message, cause: error });
1734
+ }
1735
+ throw error;
1736
+ }
1671
1737
  const sessionReasoningEffort = reasoningEffortForSession(
1672
1738
  existingSession.metadata,
1673
1739
  settings.openaiReasoningEffort,
1674
1740
  );
1675
1741
  const effectiveReasoningEffort = input.reasoningEffort ?? sessionReasoningEffort;
1742
+ const sessionLatencyMode = latencyModeForSession(existingSession.metadata, "standard");
1743
+ const effectiveLatencyMode = input.latencyMode ?? sessionLatencyMode;
1676
1744
  const turnExecutionPolicy = resolveTurnExecutionPolicyV1(settings, {
1677
1745
  modelId: effectiveModel,
1678
1746
  requestedModelId: input.model ?? null,
1679
1747
  modelSource: input.model == null ? "session" : "explicit",
1680
1748
  reasoningEffort: effectiveReasoningEffort,
1681
1749
  reasoningSource: input.reasoningEffort == null ? "session" : "explicit",
1750
+ latencyMode: effectiveLatencyMode,
1751
+ latencyModeSource: input.latencyMode == null ? "session" : "explicit",
1682
1752
  });
1683
1753
  const requestedResources = normalizeResources(input.resources ?? []);
1684
1754
  await requireLimit(deps, {
@@ -1716,6 +1786,7 @@ export async function acceptSessionUserMessage(
1716
1786
  resources: requestedResources,
1717
1787
  model: input.model ?? null,
1718
1788
  reasoningEffort: input.reasoningEffort ?? null,
1789
+ latencyMode: input.latencyMode ?? null,
1719
1790
  reasoningEffortFallback: sessionReasoningEffort,
1720
1791
  turnExecutionPolicy,
1721
1792
  mcpCredentialUpdates,
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
3
- OPENGENI_SLACK_BOT_REQUIRED_SCOPES,
4
3
  OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
5
4
  OPENGENI_SLACK_BOT_SESSION_METADATA_KEY,
6
5
  OpenGeniSlackBotConnectionMetadata,
6
+ areOpenGeniSlackBotScopesAccepted,
7
7
  type AccessGrant,
8
8
  type ConnectionMetadata,
9
9
  type OpenGeniSlackBotConnectionMetadata as OpenGeniSlackBotMetadata,
@@ -30,15 +30,13 @@ export function isOpenGeniSlackBotConnection(
30
30
  Pick<ConnectionMetadataWithVerification, "verifiedInstallAt" | "verifiedInstallVersion">
31
31
  >,
32
32
  ): boolean {
33
- const granted = new Set(connection.grantedScopes);
34
33
  return (
35
34
  connection.verifiedInstallAt != null &&
36
35
  connection.verifiedInstallVersion === connection.version &&
37
36
  connection.subjectId === null &&
38
37
  connection.providerDomain === "slack.com" &&
39
38
  connection.kind === "app_install" &&
40
- granted.size === OPENGENI_SLACK_BOT_REQUIRED_SCOPES.length &&
41
- OPENGENI_SLACK_BOT_REQUIRED_SCOPES.every((scope) => granted.has(scope)) &&
39
+ areOpenGeniSlackBotScopesAccepted(connection.grantedScopes) &&
42
40
  openGeniSlackBotMetadata(connection.metadata)?.credentialRole ===
43
41
  OPENGENI_SLACK_BOT_CREDENTIAL_ROLE
44
42
  );
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export * from "./workflow-wake-contract";
35
35
  // structural TYPES live here.
36
36
  export * from "./sandbox-types";
37
37
  export * from "./managed-auth-type";
38
+ export * from "./transcription";
38
39
 
39
40
  // Sandbox fleet/routing service — the closure of `domain/sessions.ts`
40
41
  // (`swapActiveSandbox` + `FleetContext`). apps/api re-imports these for its MCP
@@ -60,6 +61,7 @@ export * from "./domain/resources";
60
61
  export * from "./domain/session-tool-policy";
61
62
  export * from "./domain/scheduled-tasks";
62
63
  export * from "./domain/sessions";
64
+ export * from "./domain/insights";
63
65
  export * from "./domain/slack-bot";
64
66
  export * from "./domain/workspace-members";
65
67
  export * from "./application/new-session-drafts";
@@ -33,6 +33,7 @@ import {
33
33
  type BackendUnresolvableCode,
34
34
  type ControlRpc,
35
35
  type NatsRequestConnection,
36
+ type SelfhostedRelayConfig,
36
37
  } from "@opengeni/runtime/sandbox";
37
38
  import { HTTPException } from "hono/http-exception";
38
39
  import { relayConfigFromSettings } from "./routing";
@@ -512,11 +513,94 @@ export type RunOnResult = {
512
513
  stdout?: string;
513
514
  stderr?: string;
514
515
  exitCode?: number | null;
516
+ /** Exec only: whether the machine killed the child at its process deadline. */
517
+ timedOut?: boolean;
518
+ /** Exec only: the effective clamped process deadline enforced by the machine. */
519
+ deadlineMs?: number;
515
520
  content?: string;
516
521
  bytesWritten?: number;
517
522
  reason?: string;
518
523
  };
519
524
 
525
+ export type RunOnSelfhostedMachine = {
526
+ workspaceId: string;
527
+ agentId: string;
528
+ controlRpc: ControlRpc;
529
+ relay: SelfhostedRelayConfig;
530
+ /** Short request/reply deadline for read/write and other control operations. */
531
+ controlTimeoutMs: number;
532
+ /** Longer agent-side process deadline for exec. */
533
+ execTimeoutMs: number;
534
+ };
535
+
536
+ /**
537
+ * Execute the one-off machine operation once the workspace/enrollment lookup has
538
+ * succeeded. Kept separate from {@link runOnSandbox} so the command/deadline
539
+ * contract is deterministic against an in-memory ControlRpc without weakening
540
+ * the production ownership lookup or requiring a real machine.
541
+ */
542
+ export async function executeRunOnSelfhostedMachine(
543
+ machine: RunOnSelfhostedMachine,
544
+ target: string,
545
+ op: RunOnOp,
546
+ ): Promise<RunOnResult> {
547
+ const session = new SelfhostedSession({
548
+ workspaceId: machine.workspaceId,
549
+ agentId: machine.agentId,
550
+ controlRpc: machine.controlRpc,
551
+ relay: machine.relay,
552
+ timeoutMs: machine.controlTimeoutMs,
553
+ execTimeoutMs: machine.execTimeoutMs,
554
+ });
555
+
556
+ try {
557
+ if (op.kind === "exec") {
558
+ const deadlineMs = session.effectiveExecDeadlineMs;
559
+ const res = await session.exec({
560
+ cmd: op.cmd,
561
+ ...(op.workdir ? { workdir: op.workdir } : {}),
562
+ });
563
+ const timedOut = res.timedOut === true;
564
+ const hasTerminalExit = res.exitCode !== null;
565
+ return {
566
+ target,
567
+ kind: "exec",
568
+ // `ok` means the one-off operation reached a terminal response. Preserve
569
+ // the established non-zero-exit behavior, but never claim success when
570
+ // the machine killed the child or returned no terminal exit proof.
571
+ ok: !timedOut && hasTerminalExit,
572
+ stdout: res.stdout,
573
+ stderr: res.stderr,
574
+ exitCode: res.exitCode,
575
+ timedOut,
576
+ deadlineMs,
577
+ ...(timedOut
578
+ ? { reason: `command exceeded the ${deadlineMs} ms execution deadline` }
579
+ : !hasTerminalExit
580
+ ? { reason: "machine returned no terminal exit code" }
581
+ : {}),
582
+ };
583
+ }
584
+ if (op.kind === "read") {
585
+ const bytes = await session.readFile({ path: op.path });
586
+ return { target, kind: "read", ok: true, content: new TextDecoder().decode(bytes) };
587
+ }
588
+ const bytesWritten = await session.writeFile({ path: op.path, content: op.content });
589
+ return { target, kind: "write", ok: true, bytesWritten };
590
+ } catch (error) {
591
+ const reason = error instanceof Error ? error.message : String(error);
592
+ return {
593
+ target,
594
+ kind: op.kind,
595
+ ok: false,
596
+ reason,
597
+ // A transport failure is not evidence that the process itself timed out,
598
+ // so leave `timedOut` absent while still reporting the enforced deadline.
599
+ ...(op.kind === "exec" ? { deadlineMs: session.effectiveExecDeadlineMs } : {}),
600
+ };
601
+ }
602
+ }
603
+
520
604
  /**
521
605
  * Run a ONE-OFF op against a SPECIFIC target WITHOUT changing the active pointer
522
606
  * (the design `run_on`). Only selfhosted targets are routable as a one-off here
@@ -554,39 +638,18 @@ export async function runOnSandbox(
554
638
  return { target, kind: op.kind, ok: false, reason: `sandbox ${target} is not enrolled/active` };
555
639
  }
556
640
 
557
- const session = new SelfhostedSession({
558
- workspaceId: ctx.workspaceId,
559
- agentId: sandbox.enrollmentId,
560
- controlRpc: controlRpc(services.bus),
561
- relay: relayConfigFromSettings(services.settings),
562
- });
563
-
564
- try {
565
- if (op.kind === "exec") {
566
- const res = await session.exec({
567
- cmd: op.cmd,
568
- ...(op.workdir ? { workdir: op.workdir } : {}),
569
- });
570
- return {
571
- target,
572
- kind: "exec",
573
- ok: true,
574
- stdout: res.stdout,
575
- stderr: res.stderr,
576
- exitCode: res.exitCode,
577
- };
578
- }
579
- if (op.kind === "read") {
580
- const bytes = await session.readFile({ path: op.path });
581
- return { target, kind: "read", ok: true, content: new TextDecoder().decode(bytes) };
582
- }
583
- // write
584
- const bytesWritten = await session.writeFile({ path: op.path, content: op.content });
585
- return { target, kind: "write", ok: true, bytesWritten };
586
- } catch (error) {
587
- const reason = error instanceof Error ? error.message : String(error);
588
- return { target, kind: op.kind, ok: false, reason };
589
- }
641
+ return executeRunOnSelfhostedMachine(
642
+ {
643
+ workspaceId: ctx.workspaceId,
644
+ agentId: sandbox.enrollmentId,
645
+ controlRpc: controlRpc(services.bus),
646
+ relay: relayConfigFromSettings(services.settings),
647
+ controlTimeoutMs: services.settings.sandboxSelfhostedControlTimeoutMs,
648
+ execTimeoutMs: services.settings.sandboxSelfhostedExecTimeoutMs,
649
+ },
650
+ target,
651
+ op,
652
+ );
590
653
  }
591
654
 
592
655
  export type ProvisionResult =
@@ -11,7 +11,7 @@
11
11
  // The DB-coupled glue (readActiveSandbox / getSandbox / the selfhosted ControlRpc
12
12
  // over the events bus) lives here, not in the leaf (which stays db-free).
13
13
 
14
- import type { Settings } from "@opengeni/config";
14
+ import { sandboxArchiveCaptureTimeoutMs, type Settings } from "@opengeni/config";
15
15
  import {
16
16
  advanceWorkspaceGenerationForDirectRequest,
17
17
  advanceWorkspaceGenerationForRetainedProcess,
@@ -33,6 +33,7 @@ import {
33
33
  makeActiveBackendResolver,
34
34
  NatsControlRpc,
35
35
  RoutingSandboxSession,
36
+ resolveModalCheckpointProviderBindingForSession,
36
37
  type ControlRpc,
37
38
  type EstablishedSandboxSession,
38
39
  type NatsRequestConnection,
@@ -44,6 +45,13 @@ import {
44
45
  type SelfhostedRelayConfig,
45
46
  } from "@opengeni/runtime/sandbox";
46
47
 
48
+ type PersistableMutationAdmission = {
49
+ admission: SandboxWorkspaceMutationAdmission;
50
+ providerBinding: Awaited<
51
+ ReturnType<typeof resolveModalCheckpointProviderBindingForSession>
52
+ > | null;
53
+ };
54
+
47
55
  export type ChannelARoutingServices = {
48
56
  db: Database;
49
57
  settings: Settings;
@@ -151,7 +159,7 @@ export function wrapChannelABoxWithRouting(
151
159
  }: {
152
160
  op: string;
153
161
  backend: ResolvedActiveBackend;
154
- }): Promise<SandboxWorkspaceMutationAdmission | null> => {
162
+ }): Promise<PersistableMutationAdmission | null> => {
155
163
  // Connected Machines and other non-persistable targets intentionally do
156
164
  // not dirty or advance the cloud-home archive generation.
157
165
  if (
@@ -164,7 +172,11 @@ export function wrapChannelABoxWithRouting(
164
172
  if (backend.activeEpoch === undefined) {
165
173
  throw new Error("API-direct workspace mutation resolved without an active route epoch");
166
174
  }
167
- return await advanceWorkspaceGenerationForDirectRequest(db, {
175
+ const providerBinding =
176
+ homeLease.backend === "modal"
177
+ ? await resolveModalCheckpointProviderBindingForSession(settings, backend.session)
178
+ : null;
179
+ const admission = await advanceWorkspaceGenerationForDirectRequest(db, {
168
180
  accountId: ids.accountId,
169
181
  workspaceId: ids.workspaceId,
170
182
  sessionId: ids.sessionId,
@@ -176,7 +188,9 @@ export function wrapChannelABoxWithRouting(
176
188
  routeTargetId: backend.sandboxId,
177
189
  routeEpoch: backend.activeEpoch,
178
190
  operation: op,
191
+ captureWaitMs: sandboxArchiveCaptureTimeoutMs(settings),
179
192
  });
193
+ return { admission, providerBinding };
180
194
  }
181
195
  : undefined;
182
196
  const afterMutation = homeLease
@@ -198,16 +212,22 @@ export function wrapChannelABoxWithRouting(
198
212
  if (
199
213
  !admission ||
200
214
  typeof admission !== "object" ||
201
- typeof (admission as Partial<SandboxWorkspaceMutationAdmission>).id !== "string" ||
202
- typeof (admission as Partial<SandboxWorkspaceMutationAdmission>).workspaceGeneration !==
203
- "number" ||
204
215
  backend.leaseEpoch === undefined ||
205
216
  backend.providerInstanceId === undefined ||
206
217
  backend.activeEpoch === undefined
207
218
  ) {
208
219
  throw new Error("API-direct workspace mutation settlement lacked its exact admission");
209
220
  }
210
- const exactAdmission = admission as SandboxWorkspaceMutationAdmission;
221
+ const boundAdmission = admission as Partial<PersistableMutationAdmission>;
222
+ const exactAdmission = boundAdmission.admission;
223
+ if (
224
+ !exactAdmission ||
225
+ typeof exactAdmission.id !== "string" ||
226
+ typeof exactAdmission.workspaceGeneration !== "number" ||
227
+ !("providerBinding" in boundAdmission)
228
+ ) {
229
+ throw new Error("API-direct workspace mutation settlement lacked its bound admission");
230
+ }
211
231
  if (outcome === "resolved" && retainedProcess) {
212
232
  await retainWorkspaceMutationProcess(db, {
213
233
  accountId: ids.accountId,
@@ -218,6 +238,7 @@ export function wrapChannelABoxWithRouting(
218
238
  admissionId: exactAdmission.id,
219
239
  admittedWorkspaceGeneration: exactAdmission.workspaceGeneration,
220
240
  operation: op,
241
+ providerBinding: boundAdmission.providerBinding ?? null,
221
242
  owner: {
222
243
  kind: "direct",
223
244
  requestId: ids.directRequest.requestId,
@@ -263,6 +284,7 @@ export function wrapChannelABoxWithRouting(
263
284
  sessionId: ids.sessionId,
264
285
  processId: process.id,
265
286
  operation: op,
287
+ captureWaitMs: sandboxArchiveCaptureTimeoutMs(settings),
266
288
  })
267
289
  : undefined;
268
290
  const afterProcessMutation = homeLease
@@ -0,0 +1,142 @@
1
+ import type { TranscribeAudioResponse, VoiceInputErrorCode } from "@opengeni/contracts";
2
+
3
+ export type TranscriptionLimits = {
4
+ maxDurationSeconds: number;
5
+ maxSizeBytes: number;
6
+ acceptedMimeTypes: readonly string[];
7
+ };
8
+
9
+ export type TranscriptionRequest = {
10
+ workspaceId: string;
11
+ accountId: string;
12
+ audio: Uint8Array;
13
+ mimeType: string;
14
+ /** Optional client-reported duration; enforced as a soft ceiling before upstream. */
15
+ durationSeconds?: number | undefined;
16
+ signal?: AbortSignal | undefined;
17
+ requestId: string;
18
+ };
19
+
20
+ export type TranscriptionResult = TranscribeAudioResponse & {
21
+ /** Server-private provider id for operational metrics only. Never returned to clients. */
22
+ providerId: string;
23
+ audioSeconds: number;
24
+ latencyMs: number;
25
+ };
26
+
27
+ export class TranscriptionServiceError extends Error {
28
+ readonly code: VoiceInputErrorCode;
29
+ readonly status: number;
30
+ readonly retryable: boolean;
31
+
32
+ constructor(input: {
33
+ code: VoiceInputErrorCode;
34
+ message: string;
35
+ status?: number;
36
+ retryable?: boolean;
37
+ }) {
38
+ super(input.message);
39
+ this.name = "TranscriptionServiceError";
40
+ this.code = input.code;
41
+ this.status = input.status ?? statusForVoiceInputError(input.code);
42
+ this.retryable = input.retryable ?? false;
43
+ }
44
+ }
45
+
46
+ export function statusForVoiceInputError(code: VoiceInputErrorCode): number {
47
+ switch (code) {
48
+ case "permission_denied":
49
+ return 403;
50
+ case "policy_blocked":
51
+ return 403;
52
+ case "not_supported":
53
+ return 415;
54
+ case "unavailable":
55
+ return 503;
56
+ case "too_large":
57
+ return 413;
58
+ case "invalid_audio":
59
+ return 400;
60
+ case "timeout":
61
+ return 504;
62
+ case "cancelled":
63
+ return 499;
64
+ case "network":
65
+ case "provider":
66
+ return 502;
67
+ case "unknown":
68
+ default:
69
+ return 500;
70
+ }
71
+ }
72
+
73
+ /** Optional workspace scope for readiness checks during provider selection. */
74
+ export type TranscriptionAvailabilityContext = {
75
+ workspaceId?: string | undefined;
76
+ };
77
+
78
+ /**
79
+ * Extensible transcription provider port. Implementations own credentials and
80
+ * upstream request shape. Selection happens before audio is sent; providers must
81
+ * not fall back to another vendor after an upstream request may have started.
82
+ */
83
+ export type TranscriptionProvider = {
84
+ readonly id: string;
85
+ readonly experimental?: boolean | undefined;
86
+ /**
87
+ * Deployment readiness when called without a workspace. When `workspaceId` is
88
+ * provided, providers may require a workspace-attached credential (e.g. Codex).
89
+ */
90
+ available(context?: TranscriptionAvailabilityContext): boolean | Promise<boolean>;
91
+ transcribe(input: {
92
+ audio: Uint8Array;
93
+ mimeType: string;
94
+ filename: string;
95
+ workspaceId: string;
96
+ signal?: AbortSignal | undefined;
97
+ }): Promise<{ text: string; languages: string[] }>;
98
+ };
99
+
100
+ export type TranscriptionService = {
101
+ limits(): TranscriptionLimits;
102
+ /** True when at least one ready provider can serve requests. */
103
+ available(context?: TranscriptionAvailabilityContext): boolean | Promise<boolean>;
104
+ transcribe(request: TranscriptionRequest): Promise<TranscriptionResult>;
105
+ };
106
+
107
+ export function normalizeMimeType(mimeType: string): string {
108
+ return mimeType.trim().toLowerCase();
109
+ }
110
+
111
+ export function isAcceptedMimeType(mimeType: string, accepted: readonly string[]): boolean {
112
+ const normalized = normalizeMimeType(mimeType);
113
+ if (accepted.some((candidate) => normalizeMimeType(candidate) === normalized)) {
114
+ return true;
115
+ }
116
+ // Allow bare type matches against codec-qualified allowlist entries.
117
+ const bare = normalized.split(";")[0]?.trim() ?? normalized;
118
+ return accepted.some((candidate) => {
119
+ const allowed = normalizeMimeType(candidate);
120
+ return allowed === bare || allowed.split(";")[0]?.trim() === bare;
121
+ });
122
+ }
123
+
124
+ export function filenameForMimeType(mimeType: string): string {
125
+ const bare = normalizeMimeType(mimeType).split(";")[0] ?? "audio/webm";
126
+ switch (bare) {
127
+ case "audio/mp4":
128
+ case "audio/m4a":
129
+ return "audio.mp4";
130
+ case "audio/ogg":
131
+ return "audio.ogg";
132
+ case "audio/mpeg":
133
+ case "audio/mp3":
134
+ return "audio.mp3";
135
+ case "audio/wav":
136
+ case "audio/x-wav":
137
+ return "audio.wav";
138
+ case "audio/webm":
139
+ default:
140
+ return "audio.webm";
141
+ }
142
+ }