@opengeni/core 0.12.10 → 0.14.3
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/dist/access/index.d.ts +22 -0
- package/dist/application/new-session-drafts.d.ts +14 -0
- package/dist/application/session-commands.d.ts +107 -0
- package/dist/billing/limits.d.ts +29 -0
- package/dist/dependencies.d.ts +137 -0
- package/dist/domain/capabilities.d.ts +62 -0
- package/dist/domain/environments.d.ts +33 -0
- package/dist/domain/insights.d.ts +11 -0
- package/dist/domain/packs.d.ts +27 -0
- package/dist/domain/resources.d.ts +32 -0
- package/dist/domain/scheduled-tasks.d.ts +72 -0
- package/dist/domain/session-tool-policy.d.ts +31 -0
- package/dist/domain/sessions.d.ts +256 -0
- package/dist/domain/slack-bot.d.ts +19 -0
- package/dist/domain/workspace-members.d.ts +34 -0
- package/dist/index.d.ts +23 -1199
- package/dist/index.js +693 -53
- package/dist/index.js.map +1 -1
- package/dist/managed-auth-type.d.ts +2 -0
- package/dist/rigs/index.d.ts +57 -0
- package/dist/sandbox/fleet.d.ts +197 -0
- package/dist/sandbox/routing.d.ts +55 -0
- package/dist/sandbox-types.d.ts +52 -0
- package/dist/session-authorization.d.ts +36 -0
- package/dist/transcription.d.ts +71 -0
- package/dist/workflow-wake-contract.d.ts +4 -0
- package/package.json +11 -11
- package/src/access/index.ts +73 -2
- package/src/application/new-session-drafts.ts +3 -0
- package/src/application/session-commands.ts +3 -1
- package/src/dependencies.ts +5 -0
- package/src/domain/insights.ts +480 -0
- package/src/domain/session-tool-policy.ts +17 -25
- package/src/domain/sessions.ts +75 -4
- package/src/domain/slack-bot.ts +2 -4
- package/src/index.ts +2 -0
- package/src/sandbox/fleet.ts +96 -33
- package/src/sandbox/routing.ts +29 -7
- package/src/transcription.ts +142 -0
package/src/domain/sessions.ts
CHANGED
|
@@ -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
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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,
|
package/src/domain/slack-bot.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|
package/src/sandbox/fleet.ts
CHANGED
|
@@ -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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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 =
|
package/src/sandbox/routing.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|