@opengeni/api-router 0.30.3 → 2.2.0-canary.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/dist/api-websocket.d.ts +18 -0
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth.d.ts +6 -0
- package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
- package/dist/chunk-3TP54PPX.js.map +1 -0
- package/dist/codemode.d.ts +8 -1
- package/dist/company-brain-okf.d.ts +24 -0
- package/dist/connected-machine-computer-access.d.ts +11 -0
- package/dist/connection-authority-owner.d.ts +59 -0
- package/dist/connection-ownership.d.ts +47 -0
- package/dist/controller-data-plane.d.ts +16 -0
- package/dist/editable-artifact-websocket.d.ts +5 -15
- package/dist/editable-artifact-workspace-files.d.ts +1 -1
- package/dist/http/api-error.d.ts +10 -0
- package/dist/http/cors.d.ts +5 -0
- package/dist/http/interaction-control-error.d.ts +13 -0
- package/dist/http/sse.d.ts +2 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +326 -82
- package/dist/index.js.map +1 -1
- package/dist/integrations/atlassian.d.ts +17 -0
- package/dist/integrations/fiken.d.ts +7 -2
- package/dist/integrations/google-drive.d.ts +18 -0
- package/dist/integrations/oauth-client.d.ts +22 -3
- package/dist/integrations/oauth-profiles.d.ts +195 -0
- package/dist/integrations/personal-github-repositories.d.ts +45 -0
- package/dist/integrations/personal-github.d.ts +40 -0
- package/dist/integrations/pr-review-provider.d.ts +22 -0
- package/dist/integrations/provider-oauth.d.ts +8 -0
- package/dist/integrations/slack-app-home.d.ts +24 -0
- package/dist/integrations/slack-bot.d.ts +157 -9
- package/dist/integrations/slack-interactions.d.ts +39 -5
- package/dist/integrations/slack-routing.d.ts +120 -0
- package/dist/integrations/social-oauth.d.ts +5 -0
- package/dist/interaction-frame-proxy.d.ts +68 -0
- package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
- package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
- package/dist/mcp/documents.d.ts +1 -0
- package/dist/mcp/remember.d.ts +28 -0
- package/dist/mcp/request-abort.d.ts +19 -0
- package/dist/mcp/scheduled-task-view.d.ts +4 -4
- package/dist/mcp/server.d.ts +15 -2
- package/dist/mcp/session-view.d.ts +4 -0
- package/dist/mcp/session-wait.d.ts +137 -0
- package/dist/routes/automations.d.ts +4 -0
- package/dist/routes/billing.d.ts +5 -0
- package/dist/routes/browser-sessions.d.ts +4 -1
- package/dist/routes/company-brain.d.ts +3 -0
- package/dist/routes/company-profile.d.ts +2 -1
- package/dist/routes/connection-authorities.d.ts +3 -0
- package/dist/routes/personal-github-git-broker.d.ts +29 -0
- package/dist/routes/personal-github.d.ts +3 -0
- package/dist/routes/pr-review.d.ts +3 -0
- package/dist/routes/sessions.d.ts +1 -0
- package/dist/routes/user-resource-authorities.d.ts +3 -0
- package/dist/routes/workspace-capture.d.ts +10 -2
- package/dist/routes/workspace-learning.d.ts +3 -0
- package/dist/routes/workspaces.d.ts +19 -0
- package/dist/sandbox/channel-a.d.ts +11 -1
- package/dist/sandbox/connection-authority.d.ts +3 -0
- package/dist/sandbox/enrollment.d.ts +2 -0
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/metrics-ingestion.d.ts +10 -20
- package/dist/sandbox/viewer.d.ts +24 -1
- package/dist/sandbox-file-artifacts.d.ts +11 -0
- package/dist/scheduled-task-deletion.d.ts +14 -0
- package/dist/slack-reaction-files.d.ts +1 -1
- package/dist/temporal-schedule-cleanup.d.ts +1 -0
- package/package.json +20 -18
- package/src/api-websocket.ts +23 -0
- package/src/app.ts +161 -23
- package/src/auth/managed-auth.ts +25 -3
- package/src/codemode.ts +53 -23
- package/src/codex-realtime.ts +8 -2
- package/src/company-brain-okf.ts +340 -0
- package/src/connected-machine-computer-access.ts +33 -0
- package/src/connection-authority-owner.ts +61 -0
- package/src/connection-ownership.ts +180 -0
- package/src/controller-data-plane.ts +47 -0
- package/src/editable-artifact-native-kernel.ts +26 -15
- package/src/editable-artifact-office-import.ts +28 -3
- package/src/editable-artifact-production.ts +13 -7
- package/src/editable-artifact-websocket.ts +11 -18
- package/src/editable-artifact-workspace-files.ts +31 -6
- package/src/http/api-error.ts +28 -0
- package/src/http/auth.ts +4 -0
- package/src/http/cors.ts +35 -0
- package/src/http/interaction-control-error.ts +164 -0
- package/src/http/sse.ts +112 -66
- package/src/index.ts +46 -6
- package/src/integrations/atlassian.ts +146 -35
- package/src/integrations/fiken.ts +102 -22
- package/src/integrations/google-drive.ts +301 -92
- package/src/integrations/oauth-client.ts +272 -143
- package/src/integrations/oauth-profiles.ts +477 -0
- package/src/integrations/personal-github-repositories.ts +445 -0
- package/src/integrations/personal-github.ts +705 -0
- package/src/integrations/pr-review-provider.ts +246 -0
- package/src/integrations/provider-oauth.ts +121 -5
- package/src/integrations/slack-app-home.ts +300 -0
- package/src/integrations/slack-bot.ts +342 -57
- package/src/integrations/slack-interactions.ts +1898 -259
- package/src/integrations/slack-routing.ts +337 -0
- package/src/integrations/social-oauth.ts +25 -0
- package/src/interaction-frame-proxy.ts +409 -0
- package/src/mcp/company-brain-governed-writes.ts +262 -0
- package/src/mcp/company-profile-agent-admin.ts +205 -0
- package/src/mcp/documents.ts +37 -6
- package/src/mcp/files.ts +16 -1
- package/src/mcp/remember.ts +181 -0
- package/src/mcp/request-abort.ts +44 -0
- package/src/mcp/scheduled-task-view.ts +1 -0
- package/src/mcp/server.ts +719 -369
- package/src/mcp/session-view.ts +54 -0
- package/src/mcp/session-wait.ts +554 -0
- package/src/model-catalog.ts +20 -12
- package/src/routes/api-integrations.ts +4 -0
- package/src/routes/api-keys.ts +1 -0
- package/src/routes/automations.ts +534 -0
- package/src/routes/billing.ts +57 -1
- package/src/routes/browser-sessions.ts +329 -79
- package/src/routes/channels.ts +17 -1
- package/src/routes/codex.ts +50 -0
- package/src/routes/company-brain.ts +367 -0
- package/src/routes/company-profile.ts +1 -1
- package/src/routes/computer-sessions.ts +320 -93
- package/src/routes/connection-authorities.ts +139 -0
- package/src/routes/connections.ts +144 -34
- package/src/routes/documents.ts +353 -4
- package/src/routes/editable-artifacts.ts +11 -3
- package/src/routes/enrollments.ts +116 -26
- package/src/routes/environments.ts +127 -55
- package/src/routes/files.ts +72 -11
- package/src/routes/install.ts +82 -28
- package/src/routes/integration-facets.ts +29 -0
- package/src/routes/interaction-resources.ts +20 -2
- package/src/routes/machines.ts +275 -13
- package/src/routes/organization-memberships.ts +717 -28
- package/src/routes/packs.ts +4 -3
- package/src/routes/personal-github-git-broker.ts +785 -0
- package/src/routes/personal-github.ts +319 -0
- package/src/routes/pr-review.ts +531 -0
- package/src/routes/rigs.ts +104 -39
- package/src/routes/scheduled-tasks.ts +19 -21
- package/src/routes/sessions.ts +775 -38
- package/src/routes/social.ts +14 -2
- package/src/routes/supergrok.ts +23 -4
- package/src/routes/transcription-recordings.ts +8 -2
- package/src/routes/user-resource-authorities.ts +138 -0
- package/src/routes/workspace-artifacts.ts +4 -1
- package/src/routes/workspace-capture.ts +21 -0
- package/src/routes/workspace-learning.ts +228 -0
- package/src/routes/workspaces.ts +59 -6
- package/src/sandbox/auth-callout.ts +38 -12
- package/src/sandbox/channel-a.ts +242 -19
- package/src/sandbox/connection-authority.ts +3 -0
- package/src/sandbox/enrollment.ts +15 -3
- package/src/sandbox/machines.ts +186 -64
- package/src/sandbox/metrics-ingestion.ts +220 -58
- package/src/sandbox/viewer.ts +243 -19
- package/src/sandbox-file-artifacts.ts +329 -0
- package/src/scheduled-task-deletion.ts +127 -0
- package/src/slack-reaction-files.ts +16 -5
- package/src/temporal-schedule-cleanup.ts +13 -0
- package/dist/chunk-WCXHI3FF.js.map +0 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { ConnectionOwnership } from "@opengeni/contracts";
|
|
2
|
+
import type { AccessGrantAuthorization } from "@opengeni/core";
|
|
3
|
+
import { HTTPException } from "hono/http-exception";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Who may own a *personal* Connection.
|
|
7
|
+
*
|
|
8
|
+
* Connection setup defaults to a workspace-owned Connection; personal ownership
|
|
9
|
+
* is the explicit "Connect only for me" choice (AGENTS.md, "Connection
|
|
10
|
+
* ownership and executable personal authority are separate from turn
|
|
11
|
+
* initiation"). Only a managed human can own one, for two independent reasons:
|
|
12
|
+
* personal authority executes only through the immutable delegation snapshot
|
|
13
|
+
* frozen on a *human's* causal turn or scheduled task, and
|
|
14
|
+
* `bind_connection_authority` (migration 0256) can mint the `user` authority
|
|
15
|
+
* scope only for a subject holding an active organization membership. A
|
|
16
|
+
* machine-owned personal row therefore lands on the `legacy_user` compatibility
|
|
17
|
+
* lane and can never become a real organization-scoped authority.
|
|
18
|
+
*
|
|
19
|
+
* `principalKind` is the trusted, allow-listed signal: exactly `human_session`
|
|
20
|
+
* passes and every other value - including absent/unknown provenance - fails
|
|
21
|
+
* closed. The delegated-token contract forbids a `human_session` claim from
|
|
22
|
+
* carrying `serviceInitiator` or exact agent-attempt authority, so the claim
|
|
23
|
+
* cannot be combined with machine authority.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Subject namespaces OpenGeni itself mints for machines. This is deliberately
|
|
28
|
+
* NOT an allow-list of human subjects: `docs/embedding.md` states that
|
|
29
|
+
* `subjectId` "remains opaque to OpenGeni" and that the kind must not be
|
|
30
|
+
* inferred from a subject-id prefix "because the host owns that namespace", so
|
|
31
|
+
* a trusted embedding host legitimately signs `human_session` over an opaque
|
|
32
|
+
* subject that is not `user:`-prefixed. Restricting personal ownership to
|
|
33
|
+
* `user:`/`dev` would break those hosts and would itself be the prefix-based
|
|
34
|
+
* kind inference the embedding contract forbids.
|
|
35
|
+
*
|
|
36
|
+
* The allow-list that actually decides is `principalKind === "human_session"`.
|
|
37
|
+
* This list is defence-in-depth against exactly one residual threat: a holder
|
|
38
|
+
* of the first-party delegation secret signing a human claim over an
|
|
39
|
+
* OpenGeni-minted machine subject.
|
|
40
|
+
*
|
|
41
|
+
* `connection-ownership.test.ts` asserts that every machine subject named in
|
|
42
|
+
* its list is rejected. Note what that does and does not buy: it catches a
|
|
43
|
+
* namespace being *removed* from this constant, and it documents the known
|
|
44
|
+
* machine subjects, but it is a hand-maintained list on both sides, so it
|
|
45
|
+
* cannot fail when a genuinely new machine namespace is introduced elsewhere in
|
|
46
|
+
* the repo. Adding one here is a manual step. That residual gap is acceptable
|
|
47
|
+
* only because this check is not the deciding signal anywhere: a new machine
|
|
48
|
+
* namespace still arrives with a non-`human_session` `principalKind` and is
|
|
49
|
+
* refused on that basis.
|
|
50
|
+
*
|
|
51
|
+
* It is also no longer the sole signal on any OAuth callback: a callback that
|
|
52
|
+
* persists a personal owner requires the HMAC-signed `personalOwnerVerified`
|
|
53
|
+
* state claim minted by a start path that saw the live principal.
|
|
54
|
+
*/
|
|
55
|
+
const RESERVED_MACHINE_SUBJECT_NAMESPACES = [
|
|
56
|
+
// packages/core/src/access/index.ts
|
|
57
|
+
"api_key",
|
|
58
|
+
"configured",
|
|
59
|
+
// packages/runtime/src/index.ts (signFirstPartyDelegatedBearer default)
|
|
60
|
+
"worker",
|
|
61
|
+
// packages/runtime/src/sandbox/codemode-authority.ts
|
|
62
|
+
"sandbox",
|
|
63
|
+
// apps/worker/src/activities/scheduled-tasks.ts
|
|
64
|
+
"scheduled_task",
|
|
65
|
+
// packages/db/src/session-queue-commands.ts
|
|
66
|
+
"attempt",
|
|
67
|
+
// Service principals (e.g. governed-learning activation).
|
|
68
|
+
"service",
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
/** True only for an exact authenticated managed human. Unknown provenance fails closed. */
|
|
72
|
+
export function isPersonalConnectionOwnerPrincipal(access: AccessGrantAuthorization): boolean {
|
|
73
|
+
const { grant } = access;
|
|
74
|
+
return (
|
|
75
|
+
// The anti-substitution invariant `requireConnectionAuthorityOwner` uses for
|
|
76
|
+
// the same question: every context grant agrees on principal kind, delegated
|
|
77
|
+
// flag and service flags, and the selected grant's account resolves to
|
|
78
|
+
// exactly one matching account grant. Without it a grant whose account has
|
|
79
|
+
// no matching account grant (a surviving membership row in an organization
|
|
80
|
+
// where the membership is no longer active) could mint a personal
|
|
81
|
+
// Connection that the sibling helper would refuse.
|
|
82
|
+
access.contextIntegrity &&
|
|
83
|
+
access.authenticatedSubjectId === grant.subjectId &&
|
|
84
|
+
grant.principalKind === "human_session" &&
|
|
85
|
+
!grant.serviceInitiator &&
|
|
86
|
+
!grant.serviceInitiatorContext &&
|
|
87
|
+
isPersonalConnectionOwnerSubject(grant.subjectId)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Rejects an OpenGeni-minted machine subject; see the namespace list's rationale. */
|
|
92
|
+
export function isPersonalConnectionOwnerSubject(subjectId: string): boolean {
|
|
93
|
+
const separator = subjectId.indexOf(":");
|
|
94
|
+
if (separator < 0) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
const namespace = subjectId.slice(0, separator);
|
|
98
|
+
return !RESERVED_MACHINE_SUBJECT_NAMESPACES.some((reserved) => reserved === namespace);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const PERSONAL_CONNECTION_PRINCIPAL_MESSAGE =
|
|
102
|
+
"personal Connection ownership requires an authenticated human; API keys, configured keys, " +
|
|
103
|
+
"services, and agent attempts can create only workspace-owned Connections";
|
|
104
|
+
|
|
105
|
+
/** Personal-only connectors cannot degrade to workspace ownership, so they say so exactly. */
|
|
106
|
+
export function personalOnlyConnectionPrincipalMessage(label: string): string {
|
|
107
|
+
return (
|
|
108
|
+
`${label} connects only as a personal Connection, which requires an authenticated human; ` +
|
|
109
|
+
"API keys, configured keys, services, and agent attempts cannot own one"
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Rejects a non-human principal before a personal Connection is created.
|
|
115
|
+
* `label` names a personal-only connector, whose message must not suggest a
|
|
116
|
+
* workspace-owned alternative that its provider profile forbids.
|
|
117
|
+
*
|
|
118
|
+
* This shares that helper's core caller-integrity checks, then additionally
|
|
119
|
+
* rejects every reserved machine-subject namespace as defence-in-depth. The
|
|
120
|
+
* sibling guards a self-service authority surface where the caller claims to
|
|
121
|
+
* *be* the owner (403 "not you"), while this one rejects an ownership *value*
|
|
122
|
+
* that is unavailable to the caller, alongside `assertOwnershipAllowed`'s
|
|
123
|
+
* existing 422 convention.
|
|
124
|
+
*/
|
|
125
|
+
export function assertPersonalConnectionOwnerPrincipal(
|
|
126
|
+
access: AccessGrantAuthorization,
|
|
127
|
+
label?: string,
|
|
128
|
+
): void {
|
|
129
|
+
if (isPersonalConnectionOwnerPrincipal(access)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
throw new HTTPException(422, {
|
|
133
|
+
message: label
|
|
134
|
+
? personalOnlyConnectionPrincipalMessage(label)
|
|
135
|
+
: PERSONAL_CONNECTION_PRINCIPAL_MESSAGE,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The same fence expressed for a flow that resolves ownership after admission. */
|
|
140
|
+
export function assertConnectionOwnershipAllowedForPrincipal(
|
|
141
|
+
ownership: ConnectionOwnership,
|
|
142
|
+
personalOwnershipAllowed: boolean,
|
|
143
|
+
): void {
|
|
144
|
+
if (ownership === "personal" && !personalOwnershipAllowed) {
|
|
145
|
+
throw new HTTPException(422, { message: PERSONAL_CONNECTION_PRINCIPAL_MESSAGE });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The claim a start path mints into its HMAC-signed OAuth state when it has
|
|
151
|
+
* verified the live principal may own a personal Connection.
|
|
152
|
+
*
|
|
153
|
+
* An OAuth callback carries signed state, not a live principal, so it cannot
|
|
154
|
+
* re-evaluate `principalKind`. Requiring this claim makes the callback enforce
|
|
155
|
+
* exactly what the start path decided with the full signal, instead of guessing
|
|
156
|
+
* from the subject's shape. A state minted before this claim existed simply
|
|
157
|
+
* lacks it and fails closed for personal ownership - which is precisely the
|
|
158
|
+
* in-flight rolling-deploy window these fences exist to close.
|
|
159
|
+
*/
|
|
160
|
+
export const PERSONAL_OWNER_VERIFIED_STATE_CLAIM = "personalOwnerVerified" as const;
|
|
161
|
+
|
|
162
|
+
/** True only for an explicit boolean `true` claim; absent or malformed fails closed. */
|
|
163
|
+
export function personalOwnerVerifiedInState(payload: Record<string, unknown>): boolean {
|
|
164
|
+
return payload[PERSONAL_OWNER_VERIFIED_STATE_CLAIM] === true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Callback-side fence for every path that persists a personal owner. Requires
|
|
169
|
+
* both the signed start-time verification and a non-machine subject.
|
|
170
|
+
*/
|
|
171
|
+
export function personalOwnerStateAccepted(state: {
|
|
172
|
+
ownership: ConnectionOwnership;
|
|
173
|
+
subjectId: string;
|
|
174
|
+
personalOwnerVerified: boolean;
|
|
175
|
+
}): boolean {
|
|
176
|
+
if (state.ownership !== "personal") {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
return state.personalOwnerVerified && isPersonalConnectionOwnerSubject(state.subjectId);
|
|
180
|
+
}
|
|
@@ -1,8 +1,55 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BrowserControlRequestError,
|
|
3
3
|
BrowserControlTransportError,
|
|
4
|
+
exposedPortAllowsHostFetch,
|
|
5
|
+
exposedPortEndpointFromUrl,
|
|
6
|
+
parseOpenSandboxSignedUriPath,
|
|
7
|
+
signedEndpointNeedsRefresh,
|
|
4
8
|
} from "@opengeni/runtime/sandbox";
|
|
5
9
|
|
|
10
|
+
/** Modal/Daytona/Blaxel tunnels serve browserd at `/`, so a cached
|
|
11
|
+
* controller-only session can host-fetch JSON. OpenSandbox's lifecycle proxy
|
|
12
|
+
* prefixes `/v1/sandboxes/<id>/proxy/<port>` and rewrites Authorization; JSON
|
|
13
|
+
* must stay on an exec-capable in-box curl session. OSEP-0011 signed URIs are
|
|
14
|
+
* Authorization-preserving and host-fetch like a native tunnel, but they are
|
|
15
|
+
* not durable cache keys. */
|
|
16
|
+
export function controllerCacheAllowsHostFetch(url: string): boolean {
|
|
17
|
+
try {
|
|
18
|
+
return exposedPortAllowsHostFetch(exposedPortEndpointFromUrl(url));
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isOpenSandboxSignedControllerUrl(url: string): boolean {
|
|
25
|
+
try {
|
|
26
|
+
return parseOpenSandboxSignedUriPath(new URL(url).pathname) !== null;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Signed Channel B URLs expire. Do not treat a lease-cached signed URI as an
|
|
33
|
+
* infinite controller endpoint. Unsigned native `/` tunnels stay cacheable. */
|
|
34
|
+
export function controllerCachedUrlIsUsable(url: string, nowMs = Date.now()): boolean {
|
|
35
|
+
if (!controllerCacheAllowsHostFetch(url)) return false;
|
|
36
|
+
if (!isOpenSandboxSignedControllerUrl(url)) return true;
|
|
37
|
+
try {
|
|
38
|
+
return !signedEndpointNeedsRefresh(new URL(url).pathname, nowMs);
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function shouldPersistControllerDataPlaneUrl(input: {
|
|
45
|
+
backend: string | null | undefined;
|
|
46
|
+
signedEndpoints: boolean;
|
|
47
|
+
url: string;
|
|
48
|
+
}): boolean {
|
|
49
|
+
if (input.backend === "opensandbox" && input.signedEndpoints) return false;
|
|
50
|
+
return controllerCachedUrlIsUsable(input.url);
|
|
51
|
+
}
|
|
52
|
+
|
|
6
53
|
/** Prefer a lease-fenced controller endpoint for the idempotent create path.
|
|
7
54
|
* Only a transport-class failure invalidates the cache and provisions once;
|
|
8
55
|
* semantic failures belong to the caller and must never be replayed. */
|
|
@@ -17,6 +17,13 @@ import {
|
|
|
17
17
|
} from "@opengeni/contracts/editable-artifact-causal-frontier";
|
|
18
18
|
import { decodeEditableArtifactSerializedCommit } from "@opengeni/contracts/editable-artifact-serialized-commit";
|
|
19
19
|
import { decodeEditableArtifactMutationIntent } from "@opengeni/contracts/editable-artifacts";
|
|
20
|
+
import {
|
|
21
|
+
COMMITTED_TRANSACTION_PROTOCOL_VERSION,
|
|
22
|
+
DOCUMENT_ARTIFACT_MODEL_SCHEMA_VERSION,
|
|
23
|
+
PRESENTATION_ARTIFACT_MODEL_SCHEMA_VERSION,
|
|
24
|
+
SPREADSHEET_ARTIFACT_MODEL_SCHEMA_VERSION,
|
|
25
|
+
SPREADSHEET_COLLABORATION_SNAPSHOT_VERSION,
|
|
26
|
+
} from "@opengeni/contracts/editable-artifact-versions";
|
|
20
27
|
import {
|
|
21
28
|
MAX_EDITABLE_ARTIFACT_SNAPSHOT_BYTES,
|
|
22
29
|
causalFrontiersEqual,
|
|
@@ -39,9 +46,6 @@ import {
|
|
|
39
46
|
} from "@opengeni/core";
|
|
40
47
|
import type { BoundedObjectReadPort } from "@opengeni/storage";
|
|
41
48
|
|
|
42
|
-
const MODEL_SCHEMA_VERSION = 1;
|
|
43
|
-
const OPERATION_PROTOCOL_VERSION = 1;
|
|
44
|
-
const CRDT_STATE_VERSION = 1;
|
|
45
49
|
const SNAPSHOT_CHUNK_BYTES = 1024 * 1024;
|
|
46
50
|
|
|
47
51
|
type RuntimeFacadeModule = Readonly<{
|
|
@@ -104,7 +108,7 @@ export class NativeEditableArtifactKernelAdapter
|
|
|
104
108
|
modality: "spreadsheet" as const,
|
|
105
109
|
committedTransactionBytes,
|
|
106
110
|
kernelVersion: this.runtime.buildIdentity,
|
|
107
|
-
modelSchemaVersion:
|
|
111
|
+
modelSchemaVersion: SPREADSHEET_ARTIFACT_MODEL_SCHEMA_VERSION,
|
|
108
112
|
});
|
|
109
113
|
}
|
|
110
114
|
if (session.modality !== request.modality) {
|
|
@@ -116,7 +120,7 @@ export class NativeEditableArtifactKernelAdapter
|
|
|
116
120
|
nativeReceiptBytes,
|
|
117
121
|
resultingStateHash: editableArtifactStateHash(session.stateHash()),
|
|
118
122
|
kernelVersion: this.runtime.buildIdentity,
|
|
119
|
-
modelSchemaVersion:
|
|
123
|
+
modelSchemaVersion: modelSchemaVersionFor(request.modality),
|
|
120
124
|
});
|
|
121
125
|
} finally {
|
|
122
126
|
session.dispose();
|
|
@@ -188,7 +192,7 @@ export class NativeEditableArtifactKernelAdapter
|
|
|
188
192
|
),
|
|
189
193
|
stateHash: editableArtifactStateHash(session.stateHash()),
|
|
190
194
|
coveredHeadSequence: 0 as const,
|
|
191
|
-
modelSchemaVersion:
|
|
195
|
+
modelSchemaVersion: modelSchemaVersionFor(input.modality),
|
|
192
196
|
kernelVersion: this.runtime.buildIdentity,
|
|
193
197
|
};
|
|
194
198
|
if (session.modality === "spreadsheet") {
|
|
@@ -196,8 +200,8 @@ export class NativeEditableArtifactKernelAdapter
|
|
|
196
200
|
...common,
|
|
197
201
|
modality: "spreadsheet" as const,
|
|
198
202
|
coveredCausalFrontier: domainFrontier(session.frontier()),
|
|
199
|
-
operationProtocolVersion:
|
|
200
|
-
crdtStateVersion:
|
|
203
|
+
operationProtocolVersion: COMMITTED_TRANSACTION_PROTOCOL_VERSION,
|
|
204
|
+
crdtStateVersion: SPREADSHEET_COLLABORATION_SNAPSHOT_VERSION,
|
|
201
205
|
});
|
|
202
206
|
}
|
|
203
207
|
return Object.freeze({
|
|
@@ -239,7 +243,7 @@ export class NativeEditableArtifactKernelAdapter
|
|
|
239
243
|
),
|
|
240
244
|
stateHash: editableArtifactStateHash(session.stateHash()),
|
|
241
245
|
coveredHeadSequence: input.state.artifact.headSequence,
|
|
242
|
-
modelSchemaVersion:
|
|
246
|
+
modelSchemaVersion: modelSchemaVersionFor(input.state.modality),
|
|
243
247
|
kernelVersion: this.runtime.buildIdentity,
|
|
244
248
|
} as const;
|
|
245
249
|
if (session.modality === "spreadsheet") {
|
|
@@ -248,8 +252,8 @@ export class NativeEditableArtifactKernelAdapter
|
|
|
248
252
|
...common,
|
|
249
253
|
modality: session.modality,
|
|
250
254
|
coveredCausalFrontier: domainFrontier(session.frontier()),
|
|
251
|
-
operationProtocolVersion:
|
|
252
|
-
crdtStateVersion:
|
|
255
|
+
operationProtocolVersion: COMMITTED_TRANSACTION_PROTOCOL_VERSION,
|
|
256
|
+
crdtStateVersion: SPREADSHEET_COLLABORATION_SNAPSHOT_VERSION,
|
|
253
257
|
});
|
|
254
258
|
}
|
|
255
259
|
if (session.modality !== input.state.modality) throw new Error("Native modality mismatch");
|
|
@@ -310,12 +314,12 @@ export class NativeEditableArtifactKernelAdapter
|
|
|
310
314
|
canonicalContentHash,
|
|
311
315
|
coveredHeadSequence,
|
|
312
316
|
stateHash,
|
|
313
|
-
modelSchemaVersion:
|
|
317
|
+
modelSchemaVersion: SPREADSHEET_ARTIFACT_MODEL_SCHEMA_VERSION,
|
|
314
318
|
kernelVersion: this.runtime.buildIdentity,
|
|
315
319
|
fullCrdtStateVerified: true as const,
|
|
316
320
|
coveredCausalFrontier,
|
|
317
|
-
operationProtocolVersion:
|
|
318
|
-
crdtStateVersion:
|
|
321
|
+
operationProtocolVersion: COMMITTED_TRANSACTION_PROTOCOL_VERSION,
|
|
322
|
+
crdtStateVersion: SPREADSHEET_COLLABORATION_SNAPSHOT_VERSION,
|
|
319
323
|
...(replayedTarget ? { replayedTarget } : {}),
|
|
320
324
|
});
|
|
321
325
|
}
|
|
@@ -339,7 +343,7 @@ export class NativeEditableArtifactKernelAdapter
|
|
|
339
343
|
canonicalContentHash,
|
|
340
344
|
coveredHeadSequence,
|
|
341
345
|
stateHash,
|
|
342
|
-
modelSchemaVersion:
|
|
346
|
+
modelSchemaVersion: modelSchemaVersionFor(session.modality),
|
|
343
347
|
kernelVersion: this.runtime.buildIdentity,
|
|
344
348
|
nativeRevision,
|
|
345
349
|
...(replayedTarget ? { replayedTarget } : {}),
|
|
@@ -350,6 +354,13 @@ export class NativeEditableArtifactKernelAdapter
|
|
|
350
354
|
}
|
|
351
355
|
}
|
|
352
356
|
|
|
357
|
+
function modelSchemaVersionFor(modality: "spreadsheet" | "document" | "presentation"): 1 | 2 {
|
|
358
|
+
if (modality === "spreadsheet") return SPREADSHEET_ARTIFACT_MODEL_SCHEMA_VERSION;
|
|
359
|
+
return modality === "document"
|
|
360
|
+
? DOCUMENT_ARTIFACT_MODEL_SCHEMA_VERSION
|
|
361
|
+
: PRESENTATION_ARTIFACT_MODEL_SCHEMA_VERSION;
|
|
362
|
+
}
|
|
363
|
+
|
|
353
364
|
function assertRuntime(
|
|
354
365
|
candidate: unknown,
|
|
355
366
|
location: ConfiguredArtifactRuntimeLocation,
|
|
@@ -22,6 +22,7 @@ import { getFilesForSubject } from "@opengeni/db";
|
|
|
22
22
|
import {
|
|
23
23
|
MAX_BOUNDED_OBJECT_CHUNK_BYTES,
|
|
24
24
|
type BoundedImmutableObjectWritePort,
|
|
25
|
+
retryWhileMissing,
|
|
25
26
|
type ObjectStorage,
|
|
26
27
|
} from "@opengeni/storage";
|
|
27
28
|
import type { Database } from "@opengeni/db";
|
|
@@ -200,7 +201,19 @@ async function readVerifiedWorkspaceFile(
|
|
|
200
201
|
signal?: AbortSignal,
|
|
201
202
|
): Promise<Uint8Array> {
|
|
202
203
|
throwIfAborted(signal);
|
|
203
|
-
const before = await
|
|
204
|
+
const before = await retryWhileMissing(async () => {
|
|
205
|
+
try {
|
|
206
|
+
return await storage.headFile(file);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (typeof storage.fileExists === "function" && !(await storage.fileExists(file))) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
if (!before) {
|
|
215
|
+
throw new EditableArtifactOfficeImportError("source_changed");
|
|
216
|
+
}
|
|
204
217
|
if (
|
|
205
218
|
before.ContentLength !== file.sizeBytes ||
|
|
206
219
|
before.ContentType !== file.contentType ||
|
|
@@ -215,14 +228,26 @@ async function readVerifiedWorkspaceFile(
|
|
|
215
228
|
for (let start = 0; start < file.sizeBytes; start += MAX_BOUNDED_OBJECT_CHUNK_BYTES) {
|
|
216
229
|
throwIfAborted(signal);
|
|
217
230
|
const end = Math.min(file.sizeBytes, start + MAX_BOUNDED_OBJECT_CHUNK_BYTES) - 1;
|
|
218
|
-
const chunk = await storage.getFileRange(file, { start, end });
|
|
231
|
+
const chunk = await retryWhileMissing(async () => storage.getFileRange(file, { start, end }));
|
|
219
232
|
if (!chunk || chunk.byteLength !== end - start + 1) {
|
|
220
233
|
throw new EditableArtifactOfficeImportError("source_changed");
|
|
221
234
|
}
|
|
222
235
|
bytes.set(chunk, start);
|
|
223
236
|
digest.update(chunk);
|
|
224
237
|
}
|
|
225
|
-
const after = await
|
|
238
|
+
const after = await retryWhileMissing(async () => {
|
|
239
|
+
try {
|
|
240
|
+
return await storage.headFile(file);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (typeof storage.fileExists === "function" && !(await storage.fileExists(file))) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
throw error;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
if (!after) {
|
|
249
|
+
throw new EditableArtifactOfficeImportError("source_changed");
|
|
250
|
+
}
|
|
226
251
|
const sha256 = digest.digest("hex");
|
|
227
252
|
if (
|
|
228
253
|
after.ContentLength !== before.ContentLength ||
|
|
@@ -30,7 +30,6 @@ import {
|
|
|
30
30
|
type EditableArtifactMaterializationProfilePort,
|
|
31
31
|
} from "@opengeni/core/editable-artifacts";
|
|
32
32
|
import {
|
|
33
|
-
EDITABLE_ARTIFACT_LIVE_PROTOCOL_VERSION,
|
|
34
33
|
EditableArtifactApplication,
|
|
35
34
|
EditableArtifactCompatibilityError,
|
|
36
35
|
EditableArtifactLiveServer,
|
|
@@ -41,6 +40,7 @@ import {
|
|
|
41
40
|
type EditableArtifactLiveAuthorizationInvalidationPort,
|
|
42
41
|
type EditableArtifactLiveCompatibilityPort,
|
|
43
42
|
} from "@opengeni/core/editable-artifact-live";
|
|
43
|
+
import { currentEditableArtifactCompatibility } from "@opengeni/contracts/editable-artifacts";
|
|
44
44
|
import {
|
|
45
45
|
PostgresEditableArtifactLiveReadStore,
|
|
46
46
|
PostgresEditableArtifactLiveTicketStore,
|
|
@@ -194,10 +194,14 @@ export async function createStandaloneEditableArtifactApplication(input: {
|
|
|
194
194
|
assertCompatible(
|
|
195
195
|
request: Parameters<EditableArtifactLiveCompatibilityPort["assertCompatible"]>[0],
|
|
196
196
|
) {
|
|
197
|
+
const current = currentEditableArtifactCompatibility(request.artifact.modality);
|
|
197
198
|
if (
|
|
198
|
-
request.
|
|
199
|
-
request.
|
|
200
|
-
request.
|
|
199
|
+
request.modality !== request.artifact.modality ||
|
|
200
|
+
request.liveProtocolVersion !== current.liveProtocolVersion ||
|
|
201
|
+
request.modelSchemaVersion !== current.modelSchemaVersion ||
|
|
202
|
+
request.snapshotVersion !== current.snapshotVersion ||
|
|
203
|
+
request.commandProtocolVersion !== current.commandProtocolVersion ||
|
|
204
|
+
request.committedTransactionProtocolVersion !== current.committedTransactionProtocolVersion
|
|
201
205
|
) {
|
|
202
206
|
throw new EditableArtifactCompatibilityError();
|
|
203
207
|
}
|
|
@@ -334,14 +338,16 @@ class RandomEditableArtifactDurableExportIds implements EditableArtifactDurableE
|
|
|
334
338
|
function materializationProfiles(
|
|
335
339
|
capabilities: EditableArtifactNativeMaterializerCapabilities,
|
|
336
340
|
): EditableArtifactMaterializationProfilePort {
|
|
341
|
+
const spreadsheetCompatibility = currentEditableArtifactCompatibility("spreadsheet");
|
|
337
342
|
if (
|
|
338
343
|
capabilities.protocol !== "OGAMC001" ||
|
|
339
344
|
capabilities.runtimeKind !== "native" ||
|
|
340
345
|
capabilities.maxOutputBytes !== EDITABLE_ARTIFACT_EXPORT_MAX_DOWNLOAD_BYTES ||
|
|
341
346
|
capabilities.codecVersions["opengeni.xlsx"] === undefined ||
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
347
|
+
capabilities.supportedModelSchemaVersions[0] !== spreadsheetCompatibility.modelSchemaVersion ||
|
|
348
|
+
capabilities.supportedOperationProtocolVersions[0] !==
|
|
349
|
+
spreadsheetCompatibility.committedTransactionProtocolVersion ||
|
|
350
|
+
capabilities.supportedSnapshotProtocolVersions[0] !== spreadsheetCompatibility.snapshotVersion
|
|
345
351
|
) {
|
|
346
352
|
throw new Error("Verified artifact materializer profile is incompatible");
|
|
347
353
|
}
|
|
@@ -9,31 +9,22 @@ import {
|
|
|
9
9
|
type EditableArtifactLiveSession,
|
|
10
10
|
type EditableArtifactLiveSinkPort,
|
|
11
11
|
} from "@opengeni/core";
|
|
12
|
+
import type {
|
|
13
|
+
ApiWebSocketConnection,
|
|
14
|
+
ApiWebSocketLike,
|
|
15
|
+
ApiWebSocketUpgradeServer,
|
|
16
|
+
} from "./api-websocket";
|
|
12
17
|
|
|
13
18
|
export const EDITABLE_ARTIFACT_LIVE_WEBSOCKET_PATH = "/v1/editable-artifacts/live";
|
|
14
|
-
export const EDITABLE_ARTIFACT_LIVE_WEBSOCKET_PROTOCOL = "opengeni-artifact-
|
|
19
|
+
export const EDITABLE_ARTIFACT_LIVE_WEBSOCKET_PROTOCOL = "opengeni-artifact-v2";
|
|
15
20
|
export const EDITABLE_ARTIFACT_LIVE_WEBSOCKET_MAX_MESSAGE_BYTES = 8 * 1024 * 1024 + 64 * 1024;
|
|
16
21
|
|
|
17
22
|
const MAX_QUEUED_MESSAGES = 64;
|
|
18
23
|
const MAX_QUEUED_BYTES = 12 * 1024 * 1024;
|
|
19
24
|
|
|
20
|
-
export type EditableArtifactWebSocketLike =
|
|
21
|
-
data: EditableArtifactWebSocketConnection;
|
|
22
|
-
send(data: Uint8Array, compress?: boolean): number;
|
|
23
|
-
close(code?: number, reason?: string): void;
|
|
24
|
-
readonly bufferedAmount?: number;
|
|
25
|
-
getBufferedAmount?(): number;
|
|
26
|
-
}>;
|
|
25
|
+
export type EditableArtifactWebSocketLike = ApiWebSocketLike;
|
|
27
26
|
|
|
28
|
-
export type EditableArtifactWebSocketUpgradeServer =
|
|
29
|
-
upgrade(
|
|
30
|
-
request: Request,
|
|
31
|
-
options: Readonly<{
|
|
32
|
-
data: EditableArtifactWebSocketConnection;
|
|
33
|
-
headers: HeadersInit;
|
|
34
|
-
}>,
|
|
35
|
-
): boolean;
|
|
36
|
-
}>;
|
|
27
|
+
export type EditableArtifactWebSocketUpgradeServer = ApiWebSocketUpgradeServer;
|
|
37
28
|
|
|
38
29
|
export type EditableArtifactWebSocketHandler = Readonly<{
|
|
39
30
|
open(socket: EditableArtifactWebSocketLike): void;
|
|
@@ -81,7 +72,9 @@ export class EditableArtifactWebSocketTransport {
|
|
|
81
72
|
}
|
|
82
73
|
|
|
83
74
|
/** One socket, one consumed ticket, one stream epoch. */
|
|
84
|
-
export class EditableArtifactWebSocketConnection
|
|
75
|
+
export class EditableArtifactWebSocketConnection
|
|
76
|
+
implements EditableArtifactLiveSinkPort, ApiWebSocketConnection
|
|
77
|
+
{
|
|
85
78
|
private socket: EditableArtifactWebSocketLike | null = null;
|
|
86
79
|
private session: EditableArtifactLiveSession | null = null;
|
|
87
80
|
private work: Promise<void> = Promise.resolve();
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type EditableArtifactDurableExportService,
|
|
6
6
|
} from "@opengeni/core/editable-artifacts";
|
|
7
7
|
import { completeFileUpload, prepareGeneratedWorkspaceFile, type Database } from "@opengeni/db";
|
|
8
|
-
import type
|
|
8
|
+
import { retryWhileMissing, type ObjectHead, type ObjectStorage } from "@opengeni/storage";
|
|
9
9
|
|
|
10
10
|
const UPLOAD_INTENT_TTL_MS = 60 * 60_000;
|
|
11
11
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
@@ -81,7 +81,7 @@ export class EditableArtifactWorkspaceFileAdapter implements EditableArtifactAge
|
|
|
81
81
|
if (existing) {
|
|
82
82
|
assertStoredExport(existing, download.byteSize, download.mimeType, sha256);
|
|
83
83
|
} else {
|
|
84
|
-
await storage.putObjectStreamIfAbsent({
|
|
84
|
+
const created = await storage.putObjectStreamIfAbsent({
|
|
85
85
|
key: objectKey,
|
|
86
86
|
contentType: download.mimeType,
|
|
87
87
|
chunks: download.chunks(input.signal ? { signal: input.signal } : {}),
|
|
@@ -89,18 +89,28 @@ export class EditableArtifactWorkspaceFileAdapter implements EditableArtifactAge
|
|
|
89
89
|
sha256,
|
|
90
90
|
...(input.signal ? { signal: input.signal } : {}),
|
|
91
91
|
});
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
if (!created) {
|
|
93
|
+
await assertVisibleExport(
|
|
94
|
+
storage,
|
|
95
|
+
objectKey,
|
|
96
|
+
download.byteSize,
|
|
97
|
+
download.mimeType,
|
|
98
|
+
sha256,
|
|
99
|
+
input.signal,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
94
102
|
}
|
|
95
103
|
await download.assertUnchanged(input.signal);
|
|
96
104
|
throwIfAborted(input.signal);
|
|
97
105
|
file = await this.completeFile(this.dependencies.db, input.scope.workspaceId, uploadId);
|
|
98
106
|
} else {
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
await assertVisibleExport(
|
|
108
|
+
storage,
|
|
109
|
+
objectKey,
|
|
101
110
|
download.byteSize,
|
|
102
111
|
download.mimeType,
|
|
103
112
|
sha256,
|
|
113
|
+
input.signal,
|
|
104
114
|
);
|
|
105
115
|
await download.assertUnchanged(input.signal);
|
|
106
116
|
}
|
|
@@ -133,6 +143,21 @@ export class EditableArtifactWorkspaceFileAdapter implements EditableArtifactAge
|
|
|
133
143
|
}
|
|
134
144
|
}
|
|
135
145
|
|
|
146
|
+
async function assertVisibleExport(
|
|
147
|
+
storage: ObjectStorage,
|
|
148
|
+
objectKey: string,
|
|
149
|
+
byteSize: number,
|
|
150
|
+
contentType: string,
|
|
151
|
+
sha256: string,
|
|
152
|
+
signal?: AbortSignal,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const head = await retryWhileMissing(
|
|
155
|
+
async () => await storage.headObject!(objectKey),
|
|
156
|
+
signal ? { signal } : {},
|
|
157
|
+
);
|
|
158
|
+
assertStoredExport(head, byteSize, contentType, sha256);
|
|
159
|
+
}
|
|
160
|
+
|
|
136
161
|
function assertStoredExport(
|
|
137
162
|
head: ObjectHead | null,
|
|
138
163
|
byteSize: number,
|
package/src/http/api-error.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ErrorCode } from "@opengeni/contracts";
|
|
2
|
+
import { WorkspaceControlBusyError } from "@opengeni/db";
|
|
2
3
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
3
4
|
import { HTTPException } from "hono/http-exception";
|
|
4
5
|
|
|
@@ -6,6 +7,7 @@ type ApiHttpErrorOptions = {
|
|
|
6
7
|
code: ErrorCode;
|
|
7
8
|
message: string;
|
|
8
9
|
retryable?: boolean;
|
|
10
|
+
outcomeUnknown?: boolean;
|
|
9
11
|
details?: Record<string, unknown>;
|
|
10
12
|
};
|
|
11
13
|
|
|
@@ -13,6 +15,7 @@ type ApiHttpErrorOptions = {
|
|
|
13
15
|
export class ApiHttpError extends HTTPException {
|
|
14
16
|
readonly code: ErrorCode;
|
|
15
17
|
readonly retryable: boolean | undefined;
|
|
18
|
+
readonly outcomeUnknown: boolean | undefined;
|
|
16
19
|
readonly details: Record<string, unknown> | undefined;
|
|
17
20
|
|
|
18
21
|
constructor(status: number, options: ApiHttpErrorOptions) {
|
|
@@ -20,6 +23,31 @@ export class ApiHttpError extends HTTPException {
|
|
|
20
23
|
this.name = "ApiHttpError";
|
|
21
24
|
this.code = options.code;
|
|
22
25
|
this.retryable = options.retryable;
|
|
26
|
+
this.outcomeUnknown = options.outcomeUnknown;
|
|
23
27
|
this.details = options.details;
|
|
24
28
|
}
|
|
25
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A request-scoped session/workspace mutation could not enter the workspace
|
|
33
|
+
* control prefix within its bounded wait. The transaction rolled back before
|
|
34
|
+
* any write, so the outcome is known and the client may retry. `app.onError`
|
|
35
|
+
* applies this once for every route; Slack interactions keep the raw typed
|
|
36
|
+
* error so their retry classifier treats it as transient.
|
|
37
|
+
*/
|
|
38
|
+
export function workspaceControlBusyHttpError(error: unknown): ApiHttpError | null {
|
|
39
|
+
const busy =
|
|
40
|
+
error instanceof WorkspaceControlBusyError
|
|
41
|
+
? error
|
|
42
|
+
: error instanceof HTTPException && error.cause instanceof WorkspaceControlBusyError
|
|
43
|
+
? error.cause
|
|
44
|
+
: null;
|
|
45
|
+
if (!busy) return null;
|
|
46
|
+
return new ApiHttpError(503, {
|
|
47
|
+
code: "upstream_unavailable",
|
|
48
|
+
message: "The workspace is busy applying other session commands; retry shortly.",
|
|
49
|
+
retryable: true,
|
|
50
|
+
outcomeUnknown: false,
|
|
51
|
+
details: { code: busy.code, lockTimeoutMs: busy.lockTimeoutMs },
|
|
52
|
+
});
|
|
53
|
+
}
|
package/src/http/auth.ts
CHANGED
|
@@ -40,6 +40,9 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
40
40
|
if (path === "/v1/webhooks/stripe") {
|
|
41
41
|
return true;
|
|
42
42
|
}
|
|
43
|
+
if (c.req.method === "POST" && path.startsWith("/v1/webhooks/automations/")) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
43
46
|
if (
|
|
44
47
|
path === "/v1/github/setup" ||
|
|
45
48
|
path === "/v1/github/install/callback" ||
|
|
@@ -51,6 +54,7 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
|
|
|
51
54
|
if (
|
|
52
55
|
path === "/v1/integrations/oauth/callback" ||
|
|
53
56
|
path === "/v1/integrations/provider-oauth/callback" ||
|
|
57
|
+
path === "/v1/integrations/github-personal/oauth/callback" ||
|
|
54
58
|
path === "/v1/integrations/google-drive/callback" ||
|
|
55
59
|
path === "/v1/integrations/oauth/client-metadata.json" ||
|
|
56
60
|
path === "/v1/integrations/slack/callback" ||
|