@opengeni/api-router 1.4.0 → 1.4.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.
- package/dist/app.js +1 -1
- package/dist/{chunk-KKEXIBQG.js → chunk-F3LQ6VWR.js} +151 -104
- package/dist/{chunk-KKEXIBQG.js.map → chunk-F3LQ6VWR.js.map} +1 -1
- package/dist/http/api-error.d.ts +14 -0
- package/dist/index.js +1 -1
- package/dist/mcp/scheduled-task-view.d.ts +4 -4
- package/dist/routes/sessions.d.ts +2 -0
- package/package.json +20 -20
- package/src/app.ts +17 -1
- package/src/gateway-realtime.ts +11 -7
- package/src/http/api-error.ts +36 -1
- package/src/integrations/oauth-client.ts +8 -17
- package/src/routes/sessions.ts +78 -41
- package/src/routes/workspaces.ts +12 -7
package/dist/http/api-error.d.ts
CHANGED
|
@@ -37,6 +37,20 @@ export declare const DATABASE_UNAVAILABLE_ERROR_DETAIL_CODE = "DATABASE_UNAVAILA
|
|
|
37
37
|
* must reconcile by its own idempotency key before resending.
|
|
38
38
|
*/
|
|
39
39
|
export declare function databaseUnavailableHttpError(error: unknown, method: string): ApiHttpError | null;
|
|
40
|
+
/** `details.code` of the retryable 503 for a deadlock/serialization victim. */
|
|
41
|
+
export declare const DATABASE_CONTENTION_ERROR_DETAIL_CODE = "DATABASE_CONTENTION";
|
|
42
|
+
/**
|
|
43
|
+
* PostgreSQL aborted the request's transaction as a deadlock (40P01) or
|
|
44
|
+
* serialization (40001) victim after any in-process retry was exhausted. The
|
|
45
|
+
* aborted transaction committed nothing, so this is transient contention, not
|
|
46
|
+
* an internal error. A read is safe to repeat. For a mutation the default is
|
|
47
|
+
* `outcomeUnknown` because an earlier transaction of the same request may have
|
|
48
|
+
* committed; a route whose only write is the aborted idempotent transaction
|
|
49
|
+
* passes `outcomeUnknown: false`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function databaseContentionHttpError(error: unknown, method: string, options?: {
|
|
52
|
+
outcomeUnknown?: boolean;
|
|
53
|
+
}): ApiHttpError | null;
|
|
40
54
|
/**
|
|
41
55
|
* A typed agent-configuration failure (capability unavailable, conflict,
|
|
42
56
|
* widening, not enabled). Rendered as 422 validation_failed with the specific
|
package/dist/index.js
CHANGED
|
@@ -83,8 +83,8 @@ export declare function scheduledTaskMcpSummary(task: ScheduledTask): {
|
|
|
83
83
|
runMode: "existing_session" | "new_session_per_run" | "reusable_session";
|
|
84
84
|
overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
|
|
85
85
|
action: {
|
|
86
|
-
readonly sourceId?: never;
|
|
87
86
|
kind: "agent_turn";
|
|
87
|
+
sourceId?: never;
|
|
88
88
|
sourceGeneration?: never;
|
|
89
89
|
sourceLifecycleGeneration?: never;
|
|
90
90
|
destination?: never;
|
|
@@ -220,8 +220,8 @@ export declare function boundScheduledTaskDetailMcp(task: ScheduledTask): {
|
|
|
220
220
|
runMode: "existing_session" | "new_session_per_run" | "reusable_session";
|
|
221
221
|
overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
|
|
222
222
|
action: {
|
|
223
|
-
readonly sourceId?: never;
|
|
224
223
|
kind: "agent_turn";
|
|
224
|
+
sourceId?: never;
|
|
225
225
|
sourceGeneration?: never;
|
|
226
226
|
sourceLifecycleGeneration?: never;
|
|
227
227
|
destination?: never;
|
|
@@ -405,8 +405,8 @@ export declare function boundScheduledTaskDetailMcp(task: ScheduledTask): {
|
|
|
405
405
|
runMode: "existing_session" | "new_session_per_run" | "reusable_session";
|
|
406
406
|
overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
|
|
407
407
|
action: {
|
|
408
|
-
readonly sourceId?: never;
|
|
409
408
|
kind: "agent_turn";
|
|
409
|
+
sourceId?: never;
|
|
410
410
|
sourceGeneration?: never;
|
|
411
411
|
sourceLifecycleGeneration?: never;
|
|
412
412
|
destination?: never;
|
|
@@ -597,8 +597,8 @@ export declare function boundScheduledTaskMcpPage(input: {
|
|
|
597
597
|
runMode: "existing_session" | "new_session_per_run" | "reusable_session";
|
|
598
598
|
overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
|
|
599
599
|
action: {
|
|
600
|
-
readonly sourceId?: never;
|
|
601
600
|
kind: "agent_turn";
|
|
601
|
+
sourceId?: never;
|
|
602
602
|
sourceGeneration?: never;
|
|
603
603
|
sourceLifecycleGeneration?: never;
|
|
604
604
|
destination?: never;
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { ClientSessionEvent, SessionScopeSubjectId, SteerSessionMessageRequest, type SessionStatus, type WorkClaimSubjectFilter, type SessionGoalRevision, type SessionAuthorizationOperation } from "@opengeni/contracts";
|
|
2
2
|
import { decodeSessionListCursor, type SessionDiscoveryCursor } from "@opengeni/db";
|
|
3
3
|
import type { Context, Hono } from "hono";
|
|
4
|
+
import { HTTPException } from "hono/http-exception";
|
|
4
5
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
5
6
|
import { type ViewerServices } from "../sandbox/viewer.js";
|
|
6
7
|
type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
|
|
7
8
|
export declare function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void;
|
|
8
9
|
export declare function goalProposalMatchesExpectedRevision(revision: Pick<SessionGoalRevision, "baseObjectiveRevision">, expectedObjectiveRevision: number): boolean;
|
|
10
|
+
export declare function sessionRealtimeHttpError(error: unknown): HTTPException;
|
|
9
11
|
/**
|
|
10
12
|
* Map every mounted session-addressed HTTP path to the host-neutral operation
|
|
11
13
|
* the embedding port authorizes. Returning null is deliberately fail-closed in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/api-router",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.4",
|
|
4
4
|
"description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -43,25 +43,25 @@
|
|
|
43
43
|
"@hono/zod-validator": "^0.7.6",
|
|
44
44
|
"@llamaindex/liteparse": "2.14.2",
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
46
|
-
"@opengeni/agent-proto": "1.4.
|
|
47
|
-
"@opengeni/artifact-tool": "1.4.
|
|
48
|
-
"@opengeni/capabilities": "1.4.
|
|
49
|
-
"@opengeni/codemode": "1.4.
|
|
50
|
-
"@opengeni/codex": "1.4.
|
|
51
|
-
"@opengeni/config": "1.4.
|
|
52
|
-
"@opengeni/contracts": "1.4.
|
|
53
|
-
"@opengeni/core": "1.4.
|
|
54
|
-
"@opengeni/db": "1.4.
|
|
55
|
-
"@opengeni/documents": "1.4.
|
|
56
|
-
"@opengeni/events": "1.4.
|
|
57
|
-
"@opengeni/github": "1.4.
|
|
58
|
-
"@opengeni/interaction": "1.4.
|
|
59
|
-
"@opengeni/network": "1.4.
|
|
60
|
-
"@opengeni/observability": "1.4.
|
|
61
|
-
"@opengeni/runtime": "1.4.
|
|
62
|
-
"@opengeni/storage": "1.4.
|
|
63
|
-
"@opengeni/tool-gateway": "1.4.
|
|
64
|
-
"@opengeni/xai-subscription": "1.4.
|
|
46
|
+
"@opengeni/agent-proto": "1.4.4",
|
|
47
|
+
"@opengeni/artifact-tool": "1.4.4",
|
|
48
|
+
"@opengeni/capabilities": "1.4.4",
|
|
49
|
+
"@opengeni/codemode": "1.4.4",
|
|
50
|
+
"@opengeni/codex": "1.4.4",
|
|
51
|
+
"@opengeni/config": "1.4.4",
|
|
52
|
+
"@opengeni/contracts": "1.4.4",
|
|
53
|
+
"@opengeni/core": "1.4.4",
|
|
54
|
+
"@opengeni/db": "1.4.4",
|
|
55
|
+
"@opengeni/documents": "1.4.4",
|
|
56
|
+
"@opengeni/events": "1.4.4",
|
|
57
|
+
"@opengeni/github": "1.4.4",
|
|
58
|
+
"@opengeni/interaction": "1.4.4",
|
|
59
|
+
"@opengeni/network": "1.4.4",
|
|
60
|
+
"@opengeni/observability": "1.4.4",
|
|
61
|
+
"@opengeni/runtime": "1.4.4",
|
|
62
|
+
"@opengeni/storage": "1.4.4",
|
|
63
|
+
"@opengeni/tool-gateway": "1.4.4",
|
|
64
|
+
"@opengeni/xai-subscription": "1.4.4",
|
|
65
65
|
"@temporalio/client": "^1.17.0",
|
|
66
66
|
"better-auth": "^1.6.14",
|
|
67
67
|
"hono": "^4.12.18",
|
package/src/app.ts
CHANGED
|
@@ -99,7 +99,9 @@ import {
|
|
|
99
99
|
ApiHttpError,
|
|
100
100
|
agentConfigHttpError,
|
|
101
101
|
allowanceExhaustedHttpError,
|
|
102
|
+
DATABASE_CONTENTION_ERROR_DETAIL_CODE,
|
|
102
103
|
DATABASE_UNAVAILABLE_ERROR_DETAIL_CODE,
|
|
104
|
+
databaseContentionHttpError,
|
|
103
105
|
databaseUnavailableHttpError,
|
|
104
106
|
modelUnavailableHttpError,
|
|
105
107
|
scheduledTaskTargetAccessHttpError,
|
|
@@ -661,6 +663,10 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
661
663
|
"X-OpenGeni-Access-Key",
|
|
662
664
|
"X-OpenGeni-Api-Contract",
|
|
663
665
|
"X-OpenGeni-Actor-Epoch",
|
|
666
|
+
// Resumable voice-input chunk integrity/timing (cross-origin embeds).
|
|
667
|
+
"X-OpenGeni-Chunk-Duration-Milliseconds",
|
|
668
|
+
"X-OpenGeni-Chunk-Sha256",
|
|
669
|
+
"X-OpenGeni-Chunk-Start-Milliseconds",
|
|
664
670
|
"X-OpenGeni-Correlation-Id",
|
|
665
671
|
"X-OpenGeni-Session-Csrf",
|
|
666
672
|
// Session scope for a session proxy; the API itself ignores it.
|
|
@@ -1958,6 +1964,7 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
1958
1964
|
requestBodyValidationHttpError(rawError) ??
|
|
1959
1965
|
invalidPathIdentifierHttpError(rawError, new URL(c.req.url).pathname) ??
|
|
1960
1966
|
databaseUnavailableHttpError(rawError, c.req.method) ??
|
|
1967
|
+
databaseContentionHttpError(rawError, c.req.method) ??
|
|
1961
1968
|
rawError);
|
|
1962
1969
|
const compactionLock = codexCompactionV2ProviderLockedError(error);
|
|
1963
1970
|
const apiError = error instanceof ApiHttpError ? error : null;
|
|
@@ -1966,7 +1973,10 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
1966
1973
|
? compactionLock.code
|
|
1967
1974
|
: (apiError?.code ?? errorCodeForStatus(status));
|
|
1968
1975
|
if (status >= 500) logHttpFailure(c, status, code, rawError);
|
|
1969
|
-
if (
|
|
1976
|
+
if (
|
|
1977
|
+
apiError?.details?.code === DATABASE_UNAVAILABLE_ERROR_DETAIL_CODE ||
|
|
1978
|
+
apiError?.details?.code === DATABASE_CONTENTION_ERROR_DETAIL_CODE
|
|
1979
|
+
) {
|
|
1970
1980
|
c.header("retry-after", "1");
|
|
1971
1981
|
}
|
|
1972
1982
|
const envelope = ErrorEnvelope.parse({
|
|
@@ -2274,6 +2284,12 @@ export function assertConfiguredCodemodeSessionProxyPath(
|
|
|
2274
2284
|
}
|
|
2275
2285
|
|
|
2276
2286
|
function codemodeHttpError(error: unknown): HTTPException {
|
|
2287
|
+
// The journal admission is one idempotent transaction keyed by the
|
|
2288
|
+
// caller-owned operation id and is already retried in-process; every other
|
|
2289
|
+
// step is a read. An exhausted 40P01/40001 victim therefore committed
|
|
2290
|
+
// nothing and the client may resubmit the same operation id.
|
|
2291
|
+
const contention = databaseContentionHttpError(error, "POST", { outcomeUnknown: false });
|
|
2292
|
+
if (contention) return contention;
|
|
2277
2293
|
if (error instanceof SiteSessionPathError) {
|
|
2278
2294
|
// The proxied Site/SDK surface is an explicit allowlist; a route outside
|
|
2279
2295
|
// it (tool policy, visibility, forks, Steer, control, ...) does not exist
|
package/src/gateway-realtime.ts
CHANGED
|
@@ -72,6 +72,7 @@ export async function createGatewayRealtimeConnectionSecret(input: {
|
|
|
72
72
|
getSessionRealtimeContinuityEntries(input.db, input.workspaceId, input.sessionId),
|
|
73
73
|
mintGatewayClientSecret({
|
|
74
74
|
apiKey,
|
|
75
|
+
source: resolved.source,
|
|
75
76
|
upstreamModelId: resolved.upstreamModelId,
|
|
76
77
|
fetchImpl: input.fetchImpl ?? fetch,
|
|
77
78
|
}),
|
|
@@ -86,6 +87,7 @@ export async function createGatewayRealtimeConnectionSecret(input: {
|
|
|
86
87
|
|
|
87
88
|
async function mintGatewayClientSecret(input: {
|
|
88
89
|
apiKey: string;
|
|
90
|
+
source: "managed" | "workspace";
|
|
89
91
|
upstreamModelId: string;
|
|
90
92
|
fetchImpl: typeof fetch;
|
|
91
93
|
}): Promise<{ token: string; url: string; expiresAt: number | null }> {
|
|
@@ -105,17 +107,19 @@ async function mintGatewayClientSecret(input: {
|
|
|
105
107
|
} catch {
|
|
106
108
|
throw new GatewayRealtimeBrokerError(
|
|
107
109
|
"provider_error",
|
|
108
|
-
"
|
|
110
|
+
"The voice provider could not be reached. Try again.",
|
|
109
111
|
);
|
|
110
112
|
}
|
|
111
113
|
if (!response.ok) {
|
|
114
|
+
const rejected = response.status === 401 || response.status === 403;
|
|
115
|
+
// User-facing: say what to do, never which deployment credential failed.
|
|
112
116
|
throw new GatewayRealtimeBrokerError(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
: "
|
|
117
|
+
rejected ? "credential_unavailable" : "provider_error",
|
|
118
|
+
rejected
|
|
119
|
+
? input.source === "managed"
|
|
120
|
+
? "Opengeni voice is temporarily unavailable. Try another voice model."
|
|
121
|
+
: "Your AI Gateway key was rejected. Update it in workspace settings."
|
|
122
|
+
: "The voice provider could not start a call. Try again.",
|
|
119
123
|
response.status,
|
|
120
124
|
);
|
|
121
125
|
}
|
package/src/http/api-error.ts
CHANGED
|
@@ -5,7 +5,11 @@ import {
|
|
|
5
5
|
ScheduledTaskTargetAccessChange,
|
|
6
6
|
type ErrorCode,
|
|
7
7
|
} from "@opengeni/contracts";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
isDatabaseConnectionLoss,
|
|
10
|
+
nestedPostgresSqlState,
|
|
11
|
+
WorkspaceControlBusyError,
|
|
12
|
+
} from "@opengeni/db";
|
|
9
13
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
10
14
|
import { HTTPException } from "hono/http-exception";
|
|
11
15
|
|
|
@@ -116,6 +120,37 @@ export function databaseUnavailableHttpError(error: unknown, method: string): Ap
|
|
|
116
120
|
});
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
/** `details.code` of the retryable 503 for a deadlock/serialization victim. */
|
|
124
|
+
export const DATABASE_CONTENTION_ERROR_DETAIL_CODE = "DATABASE_CONTENTION";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* PostgreSQL aborted the request's transaction as a deadlock (40P01) or
|
|
128
|
+
* serialization (40001) victim after any in-process retry was exhausted. The
|
|
129
|
+
* aborted transaction committed nothing, so this is transient contention, not
|
|
130
|
+
* an internal error. A read is safe to repeat. For a mutation the default is
|
|
131
|
+
* `outcomeUnknown` because an earlier transaction of the same request may have
|
|
132
|
+
* committed; a route whose only write is the aborted idempotent transaction
|
|
133
|
+
* passes `outcomeUnknown: false`.
|
|
134
|
+
*/
|
|
135
|
+
export function databaseContentionHttpError(
|
|
136
|
+
error: unknown,
|
|
137
|
+
method: string,
|
|
138
|
+
options: { outcomeUnknown?: boolean } = {},
|
|
139
|
+
): ApiHttpError | null {
|
|
140
|
+
if (error instanceof ApiHttpError) return null;
|
|
141
|
+
if (error instanceof HTTPException && error.status < 500) return null;
|
|
142
|
+
const sqlState = nestedPostgresSqlState(error);
|
|
143
|
+
if (sqlState !== "40P01" && sqlState !== "40001") return null;
|
|
144
|
+
const safeMethod = method === "GET" || method === "HEAD" || method === "OPTIONS";
|
|
145
|
+
return new ApiHttpError(503, {
|
|
146
|
+
code: "upstream_unavailable",
|
|
147
|
+
message: "Opengeni hit transient database contention. Retry shortly.",
|
|
148
|
+
retryable: true,
|
|
149
|
+
outcomeUnknown: options.outcomeUnknown ?? !safeMethod,
|
|
150
|
+
details: { code: DATABASE_CONTENTION_ERROR_DETAIL_CODE, sqlState },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
119
154
|
/**
|
|
120
155
|
* A typed agent-configuration failure (capability unavailable, conflict,
|
|
121
156
|
* widening, not enabled). Rendered as 422 validation_failed with the specific
|
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
8
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
9
9
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
findIntegrationsOauthClient,
|
|
12
|
+
parseIntegrationsOauthClientsJson,
|
|
13
|
+
type Settings,
|
|
14
|
+
} from "@opengeni/config";
|
|
11
15
|
import {
|
|
12
16
|
OAuthStartResponse,
|
|
13
17
|
selectCanonicalPersonalSlackConnection,
|
|
@@ -1799,7 +1803,6 @@ function operatorClientEntryFor(
|
|
|
1799
1803
|
settings: Settings,
|
|
1800
1804
|
candidates: string[],
|
|
1801
1805
|
): ReturnType<typeof parseIntegrationsOauthClientsJson>[string] | null {
|
|
1802
|
-
const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
|
|
1803
1806
|
const candidateOrigins = candidates.flatMap((candidate) => {
|
|
1804
1807
|
try {
|
|
1805
1808
|
return [new URL(candidate).origin];
|
|
@@ -1816,22 +1819,10 @@ function operatorClientEntryFor(
|
|
|
1816
1819
|
return resolved;
|
|
1817
1820
|
}
|
|
1818
1821
|
}
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
candidates
|
|
1822
|
+
return findIntegrationsOauthClient(
|
|
1823
|
+
parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson),
|
|
1824
|
+
candidates,
|
|
1822
1825
|
);
|
|
1823
|
-
for (const key of exactKeys) {
|
|
1824
|
-
const entry = configured[key];
|
|
1825
|
-
if (entry) {
|
|
1826
|
-
return entry;
|
|
1827
|
-
}
|
|
1828
|
-
}
|
|
1829
|
-
for (const [key, entry] of Object.entries(configured)) {
|
|
1830
|
-
if (normalizedCandidates.has(normalizedIssuerKey(key))) {
|
|
1831
|
-
return entry;
|
|
1832
|
-
}
|
|
1833
|
-
}
|
|
1834
|
-
return null;
|
|
1835
1826
|
}
|
|
1836
1827
|
|
|
1837
1828
|
/** Secret-free readiness for the exact registered client used by Gmail setup. */
|
package/src/routes/sessions.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { listSessionEventSlices } from "@opengeni/db/session-event-slices";
|
|
|
7
7
|
import * as sessionPreviewSchema from "@opengeni/db/schema";
|
|
8
8
|
import { and, eq, sql } from "drizzle-orm";
|
|
9
9
|
import { SessionMessageSearchRequest } from "@opengeni/contracts";
|
|
10
|
-
import { scheduledSessionIds } from "@opengeni/db";
|
|
10
|
+
import { requireWorkspace, scheduledSessionIds } from "@opengeni/db";
|
|
11
11
|
import { isVerifiedDelegatedHumanAuthorization, withSiteSessionOrigin } from "@opengeni/core";
|
|
12
12
|
import { freezeSessionRealtimeConnectionAccounts } from "@opengeni/core";
|
|
13
13
|
import {
|
|
@@ -1192,22 +1192,34 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
1192
1192
|
if (!session) {
|
|
1193
1193
|
throw new HTTPException(404, { message: "session not found" });
|
|
1194
1194
|
}
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1195
|
+
// Independent reads of the already-authorized session: the effective
|
|
1196
|
+
// policy context depends only on the session row, not on the activity or
|
|
1197
|
+
// schedule annotations, so all three are read concurrently.
|
|
1198
|
+
const [activity, scheduleTargets, policy] = await Promise.all([
|
|
1199
|
+
backgroundCommandActivityForSessions(db, {
|
|
1200
|
+
accountId: grant.accountId,
|
|
1201
|
+
workspaceId,
|
|
1202
|
+
sessionIds: [sessionId],
|
|
1203
|
+
}),
|
|
1201
1204
|
hasPermission(grant.permissions, "scheduled_tasks:run") &&
|
|
1202
1205
|
hasPermission(grant.permissions, "sessions:control")
|
|
1203
|
-
?
|
|
1204
|
-
: new Set<string>()
|
|
1206
|
+
? scheduledSessionIds(db, workspaceId, [sessionId])
|
|
1207
|
+
: Promise.resolve(new Set<string>()),
|
|
1208
|
+
loadEffectivePolicyContext(deps, workspaceId, grant.subjectId, [session]),
|
|
1209
|
+
]);
|
|
1205
1210
|
return c.json(
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
+
sessionWithEffectiveToolPolicy(
|
|
1212
|
+
{
|
|
1213
|
+
...session,
|
|
1214
|
+
hasSchedules: scheduleTargets.has(sessionId),
|
|
1215
|
+
...(activity.get(sessionId)
|
|
1216
|
+
? { backgroundCommandActivity: activity.get(sessionId) }
|
|
1217
|
+
: {}),
|
|
1218
|
+
},
|
|
1219
|
+
policy.workspaceServerIds,
|
|
1220
|
+
policy.workspaceDefaultServerIds,
|
|
1221
|
+
policy.effectiveToolsContext,
|
|
1222
|
+
),
|
|
1211
1223
|
);
|
|
1212
1224
|
});
|
|
1213
1225
|
|
|
@@ -2057,29 +2069,37 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2057
2069
|
});
|
|
2058
2070
|
}
|
|
2059
2071
|
try {
|
|
2060
|
-
const result = await withWorkspaceSessionActivityRls(
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2072
|
+
const result = await withWorkspaceSessionActivityRls(
|
|
2073
|
+
db,
|
|
2074
|
+
workspaceId,
|
|
2075
|
+
async (scopedDb) => {
|
|
2076
|
+
// Acquire origin authority before inference/session locks. A deferred
|
|
2077
|
+
// realtime session has no initial worker turn; capture its exact
|
|
2078
|
+
// linked actor when the ledger actually admits ordinary agent work.
|
|
2079
|
+
await beforeCommit?.(scopedDb as unknown as Database);
|
|
2080
|
+
return syncSessionRealtimeLedgerInTransaction(
|
|
2081
|
+
scopedDb,
|
|
2082
|
+
{
|
|
2083
|
+
workspaceId,
|
|
2084
|
+
sessionId,
|
|
2085
|
+
realtimeId,
|
|
2086
|
+
ownerSubjectId: grant.subjectId,
|
|
2087
|
+
...parsed.data,
|
|
2088
|
+
controlLockTimeoutMs: workspaceControlRequestLockTimeoutMs(),
|
|
2089
|
+
},
|
|
2090
|
+
captureLinked
|
|
2091
|
+
? {
|
|
2092
|
+
afterDelegationAdmission: async ({ turnId }) =>
|
|
2093
|
+
captureLinked(scopedDb as unknown as Database, sessionId, turnId),
|
|
2094
|
+
}
|
|
2095
|
+
: {},
|
|
2096
|
+
);
|
|
2097
|
+
},
|
|
2098
|
+
undefined,
|
|
2099
|
+
// External reauthorization takes the organization-membership lock,
|
|
2100
|
+
// which precedes the tenancy fence and the canonical session prefix.
|
|
2101
|
+
Boolean(beforeCommit),
|
|
2102
|
+
);
|
|
2083
2103
|
await publishRealtimeMutation(grant.accountId, workspaceId, sessionId, result);
|
|
2084
2104
|
c.header("cache-control", "private, no-store");
|
|
2085
2105
|
return c.json({ accepted: result.accepted, outbound: result.outbound });
|
|
@@ -5181,11 +5201,19 @@ function codexRealtimeHttpFailure(error: CodexRealtimeBrokerError): {
|
|
|
5181
5201
|
}
|
|
5182
5202
|
}
|
|
5183
5203
|
|
|
5184
|
-
|
|
5204
|
+
/** Conflicts a person can hit by starting voice; the browser shows these verbatim. */
|
|
5205
|
+
const SESSION_REALTIME_USER_MESSAGES: Partial<Record<string, string>> = {
|
|
5206
|
+
REALTIME_ACTIVE:
|
|
5207
|
+
"Voice is already on for this session in another tab or window. End it there, or try again in a minute.",
|
|
5208
|
+
CONTROL_NOT_ACTIVE: "Resume this session to start voice.",
|
|
5209
|
+
SESSION_CANCELLED: "This session was cancelled, so voice can't start.",
|
|
5210
|
+
};
|
|
5211
|
+
|
|
5212
|
+
export function sessionRealtimeHttpError(error: unknown): HTTPException {
|
|
5185
5213
|
if (error instanceof HTTPException) return error;
|
|
5186
5214
|
if (error instanceof SessionRealtimeConflictError) {
|
|
5187
5215
|
return new HTTPException(error.code === "REALTIME_NOT_FOUND" ? 404 : 409, {
|
|
5188
|
-
message: error.message,
|
|
5216
|
+
message: SESSION_REALTIME_USER_MESSAGES[error.code] ?? error.message,
|
|
5189
5217
|
cause: error,
|
|
5190
5218
|
});
|
|
5191
5219
|
}
|
|
@@ -6205,9 +6233,18 @@ async function loadEffectivePolicyContext(
|
|
|
6205
6233
|
subjectId: string,
|
|
6206
6234
|
sessions: readonly Session[],
|
|
6207
6235
|
): Promise<EffectivePolicyContext> {
|
|
6236
|
+
// Both projections read the same workspace row; hydrate it once.
|
|
6237
|
+
const workspaceRead = requireWorkspace(deps.db, workspaceId);
|
|
6238
|
+
void workspaceRead.catch(() => undefined);
|
|
6208
6239
|
const [policy, effectiveToolsContext] = await Promise.all([
|
|
6209
|
-
workspaceSessionToolPolicyContext(
|
|
6210
|
-
|
|
6240
|
+
workspaceSessionToolPolicyContext(
|
|
6241
|
+
deps.db,
|
|
6242
|
+
workspaceId,
|
|
6243
|
+
deps.settings,
|
|
6244
|
+
subjectId,
|
|
6245
|
+
workspaceRead,
|
|
6246
|
+
),
|
|
6247
|
+
workspaceSessionEffectiveToolsContext(deps, workspaceId, subjectId, sessions, workspaceRead),
|
|
6211
6248
|
]);
|
|
6212
6249
|
return { ...policy, effectiveToolsContext };
|
|
6213
6250
|
}
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -980,12 +980,14 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
980
980
|
? model.id === AZURE_LIVE_MODEL_ID
|
|
981
981
|
: true,
|
|
982
982
|
);
|
|
983
|
-
const
|
|
984
|
-
managedCandidates.length > 0
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
983
|
+
const creditStanding =
|
|
984
|
+
managedCandidates.length > 0
|
|
985
|
+
? await createRealtimeVoiceBilling({
|
|
986
|
+
db: deps.db,
|
|
987
|
+
settings: deps.settings,
|
|
988
|
+
}).creditStanding(grant.accountId)
|
|
989
|
+
: "none";
|
|
990
|
+
const hasCredits = creditStanding === "spendable";
|
|
989
991
|
const models = [
|
|
990
992
|
...managedCandidates.map((model, index) => ({
|
|
991
993
|
...model,
|
|
@@ -994,7 +996,10 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
994
996
|
? { available: true, unavailableReason: null, unavailableCode: null }
|
|
995
997
|
: {
|
|
996
998
|
available: false,
|
|
997
|
-
unavailableReason:
|
|
999
|
+
unavailableReason:
|
|
1000
|
+
creditStanding === "promotional_only"
|
|
1001
|
+
? "Promotional credits don't cover live voice. Add credits to use it."
|
|
1002
|
+
: "Add Opengeni credits to use live voice",
|
|
998
1003
|
unavailableCode: "insufficient_credits",
|
|
999
1004
|
}),
|
|
1000
1005
|
recommended: index === 0,
|