@opengeni/api-router 0.30.1 → 2.1.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-2PQMSRCB.js → chunk-QKDFBBUE.js} +29110 -18137
- package/dist/chunk-QKDFBBUE.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-office-import.d.ts +7 -4
- 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 +364 -86
- 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 +19 -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 +149 -9
- package/dist/integrations/slack-interactions.d.ts +10 -4
- 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 +17 -3
- 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/files.d.ts +4 -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 +160 -27
- 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 +77 -9
- 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 +416 -101
- 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 +337 -57
- package/src/integrations/slack-interactions.ts +1274 -214
- package/src/integrations/slack-routing.ts +324 -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 +22 -2
- 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 +388 -98
- 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 +145 -34
- package/src/routes/documents.ts +221 -3
- 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 +152 -27
- 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 +777 -40
- 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-2PQMSRCB.js.map +0 -1
package/src/routes/install.ts
CHANGED
|
@@ -44,15 +44,34 @@ const BAKED_DIR = new URL("baked/", INSTALL_DIR);
|
|
|
44
44
|
// The static text artifacts served verbatim, with their content types. Each is
|
|
45
45
|
// read once at first request and memoized (committed files; immutable per deploy).
|
|
46
46
|
const TEXT_ASSETS: Record<string, { file: string; contentType: string }> = {
|
|
47
|
-
"/install.sh": {
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
"/install.sh": {
|
|
48
|
+
file: "install.sh",
|
|
49
|
+
contentType: "text/x-shellscript; charset=utf-8",
|
|
50
|
+
},
|
|
51
|
+
"/install.ps1": {
|
|
52
|
+
file: "install.ps1",
|
|
53
|
+
contentType: "text/plain; charset=utf-8",
|
|
54
|
+
},
|
|
55
|
+
"/uninstall.sh": {
|
|
56
|
+
file: "uninstall.sh",
|
|
57
|
+
contentType: "text/x-shellscript; charset=utf-8",
|
|
58
|
+
},
|
|
50
59
|
"/opengeni-agent-minisign.pub": {
|
|
51
60
|
file: "opengeni-agent-minisign.pub",
|
|
52
61
|
contentType: "text/plain; charset=utf-8",
|
|
53
62
|
},
|
|
54
63
|
};
|
|
55
64
|
|
|
65
|
+
// Non-executable resources needed to assemble a complete platform bundle. These
|
|
66
|
+
// stay committed beside the installer rather than depending on a release/CDN so
|
|
67
|
+
// an exact-SHA local or private deployment remains self-contained.
|
|
68
|
+
const COMMITTED_BINARY_ASSETS: Record<string, { file: string; contentType: string }> = {
|
|
69
|
+
"OpenGeni-Agent.icns": {
|
|
70
|
+
file: "OpenGeni-Agent.icns",
|
|
71
|
+
contentType: "image/icns",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
56
75
|
const assetCache = new Map<string, string>();
|
|
57
76
|
|
|
58
77
|
async function loadAsset(file: string): Promise<string> {
|
|
@@ -73,26 +92,40 @@ async function loadAsset(file: string): Promise<string> {
|
|
|
73
92
|
// `curl https://<this-host>/install.sh | sh` installs the exact agent that
|
|
74
93
|
// matches the API it enrolls against, with NO dependency on a public CDN (which a
|
|
75
94
|
// private/air-gapped deploy may not even resolve). When a public base URL is
|
|
76
|
-
// configured we therefore rewrite each script's
|
|
77
|
-
// that origin before serving. The user's
|
|
78
|
-
// still
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
95
|
+
// configured we therefore rewrite each script's release-asset AND connection-API
|
|
96
|
+
// default markers to that origin before serving. The user's
|
|
97
|
+
// OPENGENI_INSTALL_BASE_URL / OPENGENI_API_URL overrides still win at run time;
|
|
98
|
+
// only the built-in defaults change. The marker lines are kept shape-stable in
|
|
99
|
+
// agent/install/install.{sh,ps1}.
|
|
100
|
+
type DefaultRewrite = { from: string; to: string };
|
|
101
|
+
|
|
102
|
+
const DEFAULT_BASE_REWRITES: Record<string, (base: string) => DefaultRewrite[]> = {
|
|
103
|
+
"install.sh": (base) => [
|
|
104
|
+
{
|
|
105
|
+
from: 'OPENGENI_INSTALL_DEFAULT_BASE_URL="https://get.opengeni.ai"',
|
|
106
|
+
to: `OPENGENI_INSTALL_DEFAULT_BASE_URL="${base}"`,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
from: 'OPENGENI_API_DEFAULT_URL="https://app.opengeni.ai"',
|
|
110
|
+
to: `OPENGENI_API_DEFAULT_URL="${base}"`,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
"install.ps1": (base) => [
|
|
114
|
+
{
|
|
115
|
+
from: "$OpengeniInstallDefaultBaseUrl = 'https://get.opengeni.ai'",
|
|
116
|
+
to: `$OpengeniInstallDefaultBaseUrl = '${base}'`,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
from: "$OpengeniApiDefaultUrl = 'https://app.opengeni.ai'",
|
|
120
|
+
to: `$OpengeniApiDefaultUrl = '${base}'`,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
89
123
|
};
|
|
90
124
|
|
|
91
|
-
// Rewrite a served script's default release-asset
|
|
92
|
-
// own public origin. A no-op when no public base URL is configured
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// safe to serving it verbatim rather than crashing the install surface).
|
|
125
|
+
// Rewrite a served script's default release-asset and connection-API URLs to this
|
|
126
|
+
// deployment's own public origin. A no-op when no public base URL is configured,
|
|
127
|
+
// when the URL is not absolute http(s), or when either marker is absent (script
|
|
128
|
+
// drift — fail safe to serving it verbatim rather than splitting the two origins).
|
|
96
129
|
function rewriteDefaultBaseUrl(
|
|
97
130
|
file: string,
|
|
98
131
|
body: string,
|
|
@@ -102,11 +135,11 @@ function rewriteDefaultBaseUrl(
|
|
|
102
135
|
return body;
|
|
103
136
|
}
|
|
104
137
|
const base = publicBaseUrl.replace(/\/+$/, "");
|
|
105
|
-
const
|
|
106
|
-
if (!
|
|
138
|
+
const rules = DEFAULT_BASE_REWRITES[file]?.(base);
|
|
139
|
+
if (!rules || rules.some((rule) => !body.includes(rule.from))) {
|
|
107
140
|
return body;
|
|
108
141
|
}
|
|
109
|
-
return
|
|
142
|
+
return rules.reduce((rewritten, rule) => rewritten.replace(rule.from, rule.to), body);
|
|
110
143
|
}
|
|
111
144
|
|
|
112
145
|
// The content type for a baked release asset. The binary is an
|
|
@@ -173,6 +206,18 @@ export function registerInstallRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
173
206
|
// un-baked asset). The baked path makes the agent the API enrolls against
|
|
174
207
|
// identical to the API that serves it — no version skew, no extra hop.
|
|
175
208
|
async function serveAsset(asset: string, redirectUrl: string): Promise<Response> {
|
|
209
|
+
const committed = COMMITTED_BINARY_ASSETS[asset];
|
|
210
|
+
if (committed) {
|
|
211
|
+
const buf = await readFile(new URL(committed.file, INSTALL_DIR));
|
|
212
|
+
return new Response(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength), {
|
|
213
|
+
status: 200,
|
|
214
|
+
headers: {
|
|
215
|
+
"content-type": committed.contentType,
|
|
216
|
+
"cache-control": "public, max-age=3600",
|
|
217
|
+
"x-opengeni-agent-source": "committed",
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
176
221
|
const baked = await readBaked(asset);
|
|
177
222
|
if (baked !== null) {
|
|
178
223
|
return new Response(baked, {
|
|
@@ -189,7 +234,10 @@ export function registerInstallRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
189
234
|
}
|
|
190
235
|
// Not baked here → the GitHub Release is the source of truth (the public
|
|
191
236
|
// archive + the documented install.sh fallback). 302 so the client refetches.
|
|
192
|
-
return new Response(null, {
|
|
237
|
+
return new Response(null, {
|
|
238
|
+
status: 302,
|
|
239
|
+
headers: { location: redirectUrl },
|
|
240
|
+
});
|
|
193
241
|
}
|
|
194
242
|
|
|
195
243
|
// `latest` → the BAKED binary if present, else the operator-selected immutable
|
|
@@ -217,10 +265,14 @@ export function registerInstallRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
217
265
|
app.get(`/agent/${channel}/:manifestAsset`, (c) => {
|
|
218
266
|
const manifestAsset = c.req.param("manifestAsset");
|
|
219
267
|
if (manifestAsset !== "manifest.json" && manifestAsset !== "manifest.json.minisig") {
|
|
220
|
-
throw new HTTPException(404, {
|
|
268
|
+
throw new HTTPException(404, {
|
|
269
|
+
message: "update manifest asset not found",
|
|
270
|
+
});
|
|
221
271
|
}
|
|
222
272
|
if (!version) {
|
|
223
|
-
throw new HTTPException(404, {
|
|
273
|
+
throw new HTTPException(404, {
|
|
274
|
+
message: `${channel} agent channel is not configured`,
|
|
275
|
+
});
|
|
224
276
|
}
|
|
225
277
|
return new Response(null, {
|
|
226
278
|
status: 302,
|
|
@@ -240,7 +292,9 @@ export function registerInstallRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
240
292
|
// `/agent/latest/<asset>` is handled by the more specific route above; any
|
|
241
293
|
// other version segment must be the `v<ver>` shape.
|
|
242
294
|
if (!VERSION_SEG.test(versionSeg) || !ASSET_NAME.test(asset)) {
|
|
243
|
-
throw new HTTPException(400, {
|
|
295
|
+
throw new HTTPException(400, {
|
|
296
|
+
message: "invalid version or asset name",
|
|
297
|
+
});
|
|
244
298
|
}
|
|
245
299
|
return serveAsset(asset, `${releasesBase}/download/agent-${versionSeg}/${asset}`);
|
|
246
300
|
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "@opengeni/core";
|
|
14
14
|
import {
|
|
15
15
|
configureIntegrationFacet,
|
|
16
|
+
integrationFacetConfigureRequestDigest,
|
|
16
17
|
IntegrationFacetBindingOwnershipConflictError,
|
|
17
18
|
IntegrationFacetBindingVersionConflictError,
|
|
18
19
|
IntegrationFacetBindingVersionRequiredError,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
IntegrationFacetOperationIdempotencyError,
|
|
23
24
|
listIntegrationInstanceFacets,
|
|
24
25
|
removeIntegrationFacet,
|
|
26
|
+
replayCompletedIntegrationFacetOperation,
|
|
25
27
|
setIntegrationFacetLifecycle,
|
|
26
28
|
} from "@opengeni/db";
|
|
27
29
|
import type { Hono } from "hono";
|
|
@@ -126,6 +128,33 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
|
|
|
126
128
|
const instanceKey = decoded(c.req.param("instanceKey"));
|
|
127
129
|
const facetKey = decoded(c.req.param("facetKey"));
|
|
128
130
|
try {
|
|
131
|
+
const replayed =
|
|
132
|
+
await replayCompletedIntegrationFacetOperation<IntegrationFacetMutationResult>(deps.db, {
|
|
133
|
+
accountId: grant.accountId,
|
|
134
|
+
workspaceId,
|
|
135
|
+
subjectId: grant.subjectId,
|
|
136
|
+
capabilityId,
|
|
137
|
+
instanceKey,
|
|
138
|
+
facetKey,
|
|
139
|
+
idempotencyKey: payload.idempotencyKey,
|
|
140
|
+
kind: "configure",
|
|
141
|
+
expectedRequestDigest: integrationFacetConfigureRequestDigest({
|
|
142
|
+
capabilityId,
|
|
143
|
+
instanceKey,
|
|
144
|
+
facetKey,
|
|
145
|
+
displayName: payload.displayName,
|
|
146
|
+
config: payload.config,
|
|
147
|
+
...(payload.expectedVersion !== undefined
|
|
148
|
+
? { expectedVersion: payload.expectedVersion }
|
|
149
|
+
: {}),
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
if (replayed) {
|
|
153
|
+
return c.json(
|
|
154
|
+
IntegrationFacetMutationResult.parse(replayed),
|
|
155
|
+
payload.expectedVersion === undefined ? 201 : 200,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
129
158
|
const instance = await listIntegrationInstanceFacets(
|
|
130
159
|
deps.db,
|
|
131
160
|
workspaceId,
|
|
@@ -48,6 +48,8 @@ import {
|
|
|
48
48
|
SessionAuthorizationDeniedError,
|
|
49
49
|
SessionAuthorizationUnavailableError,
|
|
50
50
|
requireAccessGrant,
|
|
51
|
+
requireFreshAccessGrant,
|
|
52
|
+
requirePermission,
|
|
51
53
|
requireSessionAuthorization,
|
|
52
54
|
type ApiRouteDeps,
|
|
53
55
|
workflowIdForSession,
|
|
@@ -70,7 +72,18 @@ export function registerInteractionResourceRoutes(app: Hono, deps: ApiRouteDeps)
|
|
|
70
72
|
nonnegativeSafeIntegerQuery(context, "controlAfter", 0),
|
|
71
73
|
nonnegativeSafeIntegerQuery(context, "interactionAfter", 0),
|
|
72
74
|
context.req.raw.signal,
|
|
73
|
-
{
|
|
75
|
+
{
|
|
76
|
+
observability: deps.observability,
|
|
77
|
+
reauthorize: async () => {
|
|
78
|
+
const freshGrant = await requireFreshAccessGrant(
|
|
79
|
+
context,
|
|
80
|
+
deps,
|
|
81
|
+
workspaceId,
|
|
82
|
+
"sessions:read",
|
|
83
|
+
);
|
|
84
|
+
requirePermission(freshGrant, "workspace:read");
|
|
85
|
+
},
|
|
86
|
+
},
|
|
74
87
|
);
|
|
75
88
|
});
|
|
76
89
|
|
|
@@ -83,7 +96,12 @@ export function registerInteractionResourceRoutes(app: Hono, deps: ApiRouteDeps)
|
|
|
83
96
|
workspaceId,
|
|
84
97
|
after,
|
|
85
98
|
context.req.raw.signal,
|
|
86
|
-
{
|
|
99
|
+
{
|
|
100
|
+
observability: deps.observability,
|
|
101
|
+
reauthorize: async () => {
|
|
102
|
+
await requireFreshAccessGrant(context, deps, workspaceId, "sessions:read");
|
|
103
|
+
},
|
|
104
|
+
},
|
|
87
105
|
);
|
|
88
106
|
});
|
|
89
107
|
|
package/src/routes/machines.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// (default OFF → 404, invisible). Both routes need perm enrollments:read.
|
|
6
6
|
//
|
|
7
7
|
// GET /v1/workspaces/:ws/machines[?sessionId=...] -> MachinesResponse
|
|
8
|
-
// The dashboard list: the workspace's enrolled selfhosted machines (
|
|
9
|
-
// latest metrics + sharedSessionCount) and, when sessionId is
|
|
10
|
-
// session's synthetic Modal group box + the active-sandbox
|
|
8
|
+
// The dashboard list: the workspace's enrolled selfhosted machines (heartbeat
|
|
9
|
+
// state + latest metrics + sharedSessionCount) and, when sessionId is
|
|
10
|
+
// supplied, the session's synthetic Modal group box + the active-sandbox
|
|
11
|
+
// pointer. This GET does not ControlRpc-ping.
|
|
11
12
|
//
|
|
12
13
|
// GET /v1/workspaces/:ws/machines/:enrollmentId/metrics/series?window=1h
|
|
13
14
|
// -> { samples: MetricSample[] }
|
|
@@ -15,18 +16,31 @@
|
|
|
15
16
|
|
|
16
17
|
import {
|
|
17
18
|
MachineMetricsSeriesResponse,
|
|
19
|
+
MachineOperationPolicy,
|
|
18
20
|
MachinesResponse,
|
|
19
21
|
SwapActiveSandboxRequest,
|
|
20
22
|
SwapActiveSandboxResponse,
|
|
23
|
+
UpdateMachineOperationPolicyRequest,
|
|
24
|
+
UpdateMachineAgentResponse,
|
|
21
25
|
} from "@opengeni/contracts";
|
|
22
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
advanceEnrollmentAgentUpdate,
|
|
28
|
+
beginEnrollmentAgentUpdate,
|
|
29
|
+
getLiveEnrollmentConnection,
|
|
30
|
+
listEnrollments,
|
|
31
|
+
readMachineMetricsSeries,
|
|
32
|
+
requireSession,
|
|
33
|
+
updateEnrollmentOperationPolicy,
|
|
34
|
+
} from "@opengeni/db";
|
|
23
35
|
import type { Hono } from "hono";
|
|
24
36
|
import { HTTPException } from "hono/http-exception";
|
|
25
|
-
import { requireAccessGrant } from "@opengeni/core";
|
|
37
|
+
import { requireAccessGrant, requireAccessGrantAuthorization } from "@opengeni/core";
|
|
26
38
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
27
39
|
import { buildFleetContextForSession, swapActiveSandbox } from "@opengeni/core";
|
|
28
40
|
import { listMachines, metricRowToSample } from "../sandbox/machines";
|
|
29
41
|
import { ensureSessionGroupReady as ensureViewerSessionGroupReady } from "../sandbox/viewer";
|
|
42
|
+
import { ControlRequest, ErrorCode } from "@opengeni/agent-proto";
|
|
43
|
+
import { NatsControlRpc, subjectFor } from "@opengeni/runtime/sandbox";
|
|
30
44
|
|
|
31
45
|
// The supported series windows → milliseconds. An unknown/absent window defaults
|
|
32
46
|
// to 1h (the default). Bounded so a caller cannot request an unbounded
|
|
@@ -52,33 +66,55 @@ export function registerMachineRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
52
66
|
}
|
|
53
67
|
}
|
|
54
68
|
|
|
69
|
+
async function requireScopedEnrollment(
|
|
70
|
+
grant: Awaited<ReturnType<typeof requireAccessGrant>>,
|
|
71
|
+
enrollmentId: string,
|
|
72
|
+
) {
|
|
73
|
+
const enrollment = (await listEnrollments(db, grant, { status: "active" })).find(
|
|
74
|
+
(candidate) => candidate.id === enrollmentId,
|
|
75
|
+
);
|
|
76
|
+
if (!enrollment) {
|
|
77
|
+
throw new HTTPException(404, { message: "machine not found in this access scope" });
|
|
78
|
+
}
|
|
79
|
+
return enrollment;
|
|
80
|
+
}
|
|
81
|
+
|
|
55
82
|
// ── GET /workspaces/:ws/machines (the dashboard list) ───────────────────────
|
|
56
83
|
app.get("/v1/workspaces/:workspaceId/machines", async (c) => {
|
|
57
84
|
const workspaceId = c.req.param("workspaceId");
|
|
58
|
-
await requireAccessGrant(c, deps, workspaceId, "enrollments:read");
|
|
85
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "enrollments:read");
|
|
59
86
|
assertSelfhostedEnabled();
|
|
60
87
|
// sessionId is OPTIONAL: present → an in-session view (synthetic group box +
|
|
61
88
|
// active pointer); absent → the pure workspace dashboard.
|
|
62
89
|
const sessionId = c.req.query("sessionId") ?? null;
|
|
63
|
-
const response = await listMachines(
|
|
90
|
+
const response = await listMachines(
|
|
91
|
+
{ db, settings },
|
|
92
|
+
{
|
|
93
|
+
accountId: grant.accountId,
|
|
94
|
+
workspaceId,
|
|
95
|
+
subjectId: grant.subjectId,
|
|
96
|
+
sessionId,
|
|
97
|
+
},
|
|
98
|
+
);
|
|
64
99
|
return c.json(MachinesResponse.parse(response));
|
|
65
100
|
});
|
|
66
101
|
|
|
67
102
|
// ── GET /workspaces/:ws/machines/:enrollmentId/metrics/series ───────────────
|
|
68
103
|
app.get("/v1/workspaces/:workspaceId/machines/:enrollmentId/metrics/series", async (c) => {
|
|
69
104
|
const workspaceId = c.req.param("workspaceId");
|
|
70
|
-
await requireAccessGrant(c, deps, workspaceId, "enrollments:read");
|
|
105
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "enrollments:read");
|
|
71
106
|
assertSelfhostedEnabled();
|
|
72
107
|
const enrollmentId = c.req.param("enrollmentId");
|
|
73
108
|
// Validate the machine belongs to this workspace (RLS already scopes the read,
|
|
74
109
|
// but a clear 404 beats an empty series for an unknown/cross-workspace id).
|
|
75
|
-
const enrollment = await
|
|
76
|
-
if (!enrollment || enrollment.status !== "active") {
|
|
77
|
-
throw new HTTPException(404, { message: "machine not found in this workspace" });
|
|
78
|
-
}
|
|
110
|
+
const enrollment = await requireScopedEnrollment(grant, enrollmentId);
|
|
79
111
|
const windowMs = SERIES_WINDOWS_MS[c.req.query("window") ?? ""] ?? DEFAULT_SERIES_WINDOW_MS;
|
|
80
112
|
const since = new Date(Date.now() - windowMs);
|
|
81
|
-
const rows = await readMachineMetricsSeries(db, {
|
|
113
|
+
const rows = await readMachineMetricsSeries(db, {
|
|
114
|
+
workspaceId: enrollment.workspaceId,
|
|
115
|
+
enrollmentId,
|
|
116
|
+
since,
|
|
117
|
+
});
|
|
82
118
|
return c.json(
|
|
83
119
|
MachineMetricsSeriesResponse.parse({
|
|
84
120
|
samples: rows.map(metricRowToSample),
|
|
@@ -86,6 +122,230 @@ export function registerMachineRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
86
122
|
);
|
|
87
123
|
});
|
|
88
124
|
|
|
125
|
+
// ── PATCH /workspaces/:ws/machines/:enrollmentId/operation-policy ──────────
|
|
126
|
+
// Null limits deliberately mean unrestricted. The revision is an optimistic
|
|
127
|
+
// concurrency fence so two operators cannot silently overwrite each other.
|
|
128
|
+
app.patch("/v1/workspaces/:workspaceId/machines/:enrollmentId/operation-policy", async (c) => {
|
|
129
|
+
const workspaceId = c.req.param("workspaceId");
|
|
130
|
+
const authorization = await requireAccessGrantAuthorization(
|
|
131
|
+
c,
|
|
132
|
+
deps,
|
|
133
|
+
workspaceId,
|
|
134
|
+
"enrollments:manage",
|
|
135
|
+
);
|
|
136
|
+
const grant = authorization.grant;
|
|
137
|
+
assertSelfhostedEnabled();
|
|
138
|
+
const enrollmentId = c.req.param("enrollmentId");
|
|
139
|
+
let json: unknown;
|
|
140
|
+
try {
|
|
141
|
+
json = await c.req.json();
|
|
142
|
+
} catch {
|
|
143
|
+
throw new HTTPException(400, {
|
|
144
|
+
message: "operation policy body must be valid JSON",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const parsed = UpdateMachineOperationPolicyRequest.safeParse(json);
|
|
148
|
+
if (!parsed.success) {
|
|
149
|
+
throw new HTTPException(400, {
|
|
150
|
+
message: "invalid machine operation policy",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const body = parsed.data;
|
|
154
|
+
const current = await requireScopedEnrollment(grant, enrollmentId);
|
|
155
|
+
if (
|
|
156
|
+
current.scope === "organization" &&
|
|
157
|
+
authorization.accountGrant?.permissions.includes("account:admin") !== true
|
|
158
|
+
) {
|
|
159
|
+
throw new HTTPException(403, { message: "missing permission: account:admin" });
|
|
160
|
+
}
|
|
161
|
+
const updated = await updateEnrollmentOperationPolicy(db, {
|
|
162
|
+
accountId: grant.accountId,
|
|
163
|
+
workspaceId: current.workspaceId,
|
|
164
|
+
enrollmentId,
|
|
165
|
+
subjectId: grant.subjectId,
|
|
166
|
+
expectedRevision: body.expectedRevision,
|
|
167
|
+
memoryMaxBytes: body.memoryMaxBytes,
|
|
168
|
+
memoryHighBytes: body.memoryHighBytes,
|
|
169
|
+
...(body.cpuMaxMillicores !== undefined ? { cpuMaxMillicores: body.cpuMaxMillicores } : {}),
|
|
170
|
+
});
|
|
171
|
+
if (!updated) {
|
|
172
|
+
throw new HTTPException(409, {
|
|
173
|
+
message: "machine operation policy changed; refresh and retry",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return c.json(MachineOperationPolicy.parse(updated.operationPolicy));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ── POST /workspaces/:ws/machines/:enrollmentId/update ─────────────────────
|
|
180
|
+
// Reserve one exact-generation operation, ask the authoritative runner to
|
|
181
|
+
// drain and apply the signed promoted release, then let progress events + the
|
|
182
|
+
// successor Hello drive the durable state to completion.
|
|
183
|
+
app.post("/v1/workspaces/:workspaceId/machines/:enrollmentId/update", async (c) => {
|
|
184
|
+
const workspaceId = c.req.param("workspaceId");
|
|
185
|
+
const authorization = await requireAccessGrantAuthorization(
|
|
186
|
+
c,
|
|
187
|
+
deps,
|
|
188
|
+
workspaceId,
|
|
189
|
+
"enrollments:manage",
|
|
190
|
+
);
|
|
191
|
+
const grant = authorization.grant;
|
|
192
|
+
assertSelfhostedEnabled();
|
|
193
|
+
const enrollmentId = c.req.param("enrollmentId");
|
|
194
|
+
const visibleEnrollment = await requireScopedEnrollment(grant, enrollmentId);
|
|
195
|
+
if (
|
|
196
|
+
visibleEnrollment.scope === "organization" &&
|
|
197
|
+
authorization.accountGrant?.permissions.includes("account:admin") !== true
|
|
198
|
+
) {
|
|
199
|
+
throw new HTTPException(403, { message: "missing permission: account:admin" });
|
|
200
|
+
}
|
|
201
|
+
const originWorkspaceId = visibleEnrollment.workspaceId;
|
|
202
|
+
const live = await getLiveEnrollmentConnection(
|
|
203
|
+
db,
|
|
204
|
+
visibleEnrollment.scope === "user" ? grant : originWorkspaceId,
|
|
205
|
+
enrollmentId,
|
|
206
|
+
);
|
|
207
|
+
if (!live?.connectionInstanceId || !live.agentVersion) {
|
|
208
|
+
throw new HTTPException(409, {
|
|
209
|
+
message: "machine has no authoritative live agent build",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const channel = live.agentUpdateChannel ?? "stable";
|
|
213
|
+
const targetVersion =
|
|
214
|
+
channel === "beta"
|
|
215
|
+
? (settings.agentBetaVersion ?? settings.agentStableVersion)
|
|
216
|
+
: settings.agentStableVersion;
|
|
217
|
+
const reusableRequest =
|
|
218
|
+
live.agentUpdate?.status === "requested" &&
|
|
219
|
+
live.agentUpdate.targetVersion === targetVersion &&
|
|
220
|
+
live.agentUpdate.connectionInstanceId === live.connectionInstanceId &&
|
|
221
|
+
live.agentUpdate.connectionGeneration === live.connectionGeneration
|
|
222
|
+
? live.agentUpdate
|
|
223
|
+
: null;
|
|
224
|
+
if (
|
|
225
|
+
!reusableRequest &&
|
|
226
|
+
live.agentVersion === targetVersion &&
|
|
227
|
+
live.agentUpdate?.status !== "failed"
|
|
228
|
+
) {
|
|
229
|
+
throw new HTTPException(409, {
|
|
230
|
+
message: "machine already runs the promoted version",
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const operationId = reusableRequest?.operationId ?? crypto.randomUUID();
|
|
234
|
+
if (!reusableRequest) {
|
|
235
|
+
const reserved = await beginEnrollmentAgentUpdate(db, {
|
|
236
|
+
accountId: grant.accountId,
|
|
237
|
+
workspaceId: originWorkspaceId,
|
|
238
|
+
enrollmentId,
|
|
239
|
+
connectionInstanceId: live.connectionInstanceId,
|
|
240
|
+
connectionGeneration: live.connectionGeneration,
|
|
241
|
+
operationId,
|
|
242
|
+
targetVersion,
|
|
243
|
+
});
|
|
244
|
+
if (!reserved) {
|
|
245
|
+
throw new HTTPException(409, {
|
|
246
|
+
message: "machine update state changed; refresh and retry",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const rpc = new NatsControlRpc(async () => bus?.getRequestConnection() ?? null);
|
|
252
|
+
const request: ControlRequest = {
|
|
253
|
+
requestId: operationId,
|
|
254
|
+
// The subject already selects the exact authoritative process instance.
|
|
255
|
+
// `epoch` is the sandbox lease epoch, not the enrollment connection
|
|
256
|
+
// generation, so this process-scoped operation intentionally leaves it 0.
|
|
257
|
+
epoch: 0,
|
|
258
|
+
resourcePolicy: undefined,
|
|
259
|
+
op: {
|
|
260
|
+
$case: "agentUpdateApply",
|
|
261
|
+
agentUpdateApply: {
|
|
262
|
+
operationId,
|
|
263
|
+
targetVersion,
|
|
264
|
+
channel,
|
|
265
|
+
expectedCurrentVersion: live.agentVersion,
|
|
266
|
+
expectedCurrentSha256: live.agentBinarySha256 ?? "",
|
|
267
|
+
releaseBaseUrl: (settings.publicBaseUrl ?? new URL(c.req.url).origin).replace(/\/+$/, ""),
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
let response = await rpc.request(
|
|
272
|
+
subjectFor(originWorkspaceId, enrollmentId, live.connectionInstanceId),
|
|
273
|
+
request,
|
|
274
|
+
{ timeoutMs: 10_000 },
|
|
275
|
+
);
|
|
276
|
+
// The operation id is idempotent. One retry heals an ambiguous lost reply;
|
|
277
|
+
// the agent returns the same acceptance and never starts a second update.
|
|
278
|
+
if (response.error?.code === ErrorCode.ERROR_CODE_TIMEOUT) {
|
|
279
|
+
response = await rpc.request(
|
|
280
|
+
subjectFor(originWorkspaceId, enrollmentId, live.connectionInstanceId),
|
|
281
|
+
request,
|
|
282
|
+
{ timeoutMs: 10_000 },
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (response.error) {
|
|
286
|
+
// A second timeout remains ambiguous: progress/Hello may still prove the
|
|
287
|
+
// operation. Keep it requested rather than racing a false terminal failure.
|
|
288
|
+
if (response.error.code !== ErrorCode.ERROR_CODE_TIMEOUT) {
|
|
289
|
+
await advanceEnrollmentAgentUpdate(db, {
|
|
290
|
+
accountId: grant.accountId,
|
|
291
|
+
workspaceId: originWorkspaceId,
|
|
292
|
+
enrollmentId,
|
|
293
|
+
connectionInstanceId: live.connectionInstanceId,
|
|
294
|
+
connectionGeneration: live.connectionGeneration,
|
|
295
|
+
operationId,
|
|
296
|
+
status: "failed",
|
|
297
|
+
errorCode:
|
|
298
|
+
response.error.detail.failure_code ??
|
|
299
|
+
(response.error.code === ErrorCode.ERROR_CODE_AGENT_OFFLINE
|
|
300
|
+
? "agent_offline"
|
|
301
|
+
: "update_dispatch_rejected"),
|
|
302
|
+
retryable: response.error.retryable,
|
|
303
|
+
rolledBack: false,
|
|
304
|
+
});
|
|
305
|
+
throw new HTTPException(response.error.retryable ? 409 : 422, {
|
|
306
|
+
message: response.error.message,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
} else if (
|
|
310
|
+
response.result?.$case !== "agentUpdateApply" ||
|
|
311
|
+
!response.result.agentUpdateApply.accepted
|
|
312
|
+
) {
|
|
313
|
+
await advanceEnrollmentAgentUpdate(db, {
|
|
314
|
+
accountId: grant.accountId,
|
|
315
|
+
workspaceId: originWorkspaceId,
|
|
316
|
+
enrollmentId,
|
|
317
|
+
connectionInstanceId: live.connectionInstanceId,
|
|
318
|
+
connectionGeneration: live.connectionGeneration,
|
|
319
|
+
operationId,
|
|
320
|
+
status: "failed",
|
|
321
|
+
errorCode: "invalid_agent_response",
|
|
322
|
+
retryable: true,
|
|
323
|
+
rolledBack: false,
|
|
324
|
+
});
|
|
325
|
+
throw new HTTPException(502, {
|
|
326
|
+
message: "agent returned an invalid update response",
|
|
327
|
+
});
|
|
328
|
+
} else {
|
|
329
|
+
await advanceEnrollmentAgentUpdate(db, {
|
|
330
|
+
accountId: grant.accountId,
|
|
331
|
+
workspaceId: originWorkspaceId,
|
|
332
|
+
enrollmentId,
|
|
333
|
+
connectionInstanceId: live.connectionInstanceId,
|
|
334
|
+
connectionGeneration: live.connectionGeneration,
|
|
335
|
+
operationId,
|
|
336
|
+
status: "accepted",
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return c.json(
|
|
340
|
+
UpdateMachineAgentResponse.parse({
|
|
341
|
+
operationId,
|
|
342
|
+
accepted: true,
|
|
343
|
+
targetVersion,
|
|
344
|
+
}),
|
|
345
|
+
202,
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
|
|
89
349
|
// ── POST /workspaces/:ws/sessions/:sessionId/active-sandbox (swap) ───────────
|
|
90
350
|
// The user-authenticated equivalent of the M7 `sandbox_swap` MCP tool: repoint
|
|
91
351
|
// a session's active sandbox under the epoch fence. Same perm as PATCH session
|
|
@@ -102,6 +362,7 @@ export function registerMachineRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
102
362
|
accountId: grant.accountId,
|
|
103
363
|
workspaceId,
|
|
104
364
|
sessionId,
|
|
365
|
+
subjectId: grant.subjectId,
|
|
105
366
|
});
|
|
106
367
|
const result = await swapActiveSandbox(
|
|
107
368
|
{
|
|
@@ -116,6 +377,7 @@ export function registerMachineRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
116
377
|
accountId: fleetCtx.accountId,
|
|
117
378
|
workspaceId: fleetCtx.workspaceId,
|
|
118
379
|
session,
|
|
380
|
+
subjectId: fleetCtx.subjectId ?? null,
|
|
119
381
|
},
|
|
120
382
|
);
|
|
121
383
|
},
|