@flowdular/sdk 0.1.0 → 0.2.1
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/.ai/skills/test-hardening/SKILL.md +1 -1
- package/README.md +69 -12
- package/assets/flowdular-banner.png +0 -0
- package/modules/agents/package.json +1 -1
- package/modules/auth/package.json +1 -1
- package/modules/auth/src/client/AuthenticationCore.tsrx +3 -1
- package/modules/auth/src/server/endpoints.ts +3 -1
- package/modules/auth/src/server/runtime.ts +4 -0
- package/modules/automations/package.json +1 -1
- package/modules/automations-workflows-integration/package.json +1 -1
- package/modules/profile/package.json +1 -1
- package/modules/sandbox/package.json +1 -1
- package/modules/system/package.json +1 -1
- package/modules/users/package.json +1 -1
- package/modules/workflows/package.json +1 -1
- package/package.json +5 -13
- package/packages/ai-provider/package.json +1 -1
- package/packages/cli-protocol/package.json +1 -1
- package/packages/client/package.json +1 -1
- package/packages/client/src/routing.ts +39 -2
- package/packages/client/src/shell/BrandMark.tsrx +5 -1
- package/packages/client/src/shell/navigation.ts +3 -1
- package/packages/client/src/state.ts +6 -2
- package/packages/contracts/package.json +1 -1
- package/packages/contracts/schemas/project.schema.json +16 -42
- package/packages/database/package.json +1 -1
- package/packages/database-pglite/package.json +2 -2
- package/packages/database-pglite/src/driver.ts +21 -3
- package/packages/database-testing/package.json +1 -1
- package/packages/dev-console/package.json +1 -1
- package/packages/harness/package.json +1 -1
- package/packages/kernel/package.json +1 -1
- package/packages/server/package.json +1 -1
- package/packages/server/src/application-routes.ts +80 -8
- package/packages/server/src/index.ts +4 -1
- package/packages/server/src/web-html.ts +9 -5
- package/packages/server/src/web.ts +59 -7
- package/packages/ui/package.json +1 -1
- package/packages/coding-agent/package.json +0 -44
- package/packages/coding-agent/src/drivers/byok.ts +0 -322
- package/packages/coding-agent/src/drivers/claude-code.ts +0 -290
- package/packages/coding-agent/src/drivers/codex.ts +0 -281
- package/packages/coding-agent/src/index.ts +0 -56
- package/packages/coding-agent/src/registry.ts +0 -119
- package/packages/coding-agent/src/roles/contract.ts +0 -60
- package/packages/coding-agent/src/roles/defaults.ts +0 -134
- package/packages/coding-agent/src/roles/handoff.ts +0 -41
- package/packages/coding-agent/src/roles/registry.ts +0 -156
- package/packages/coding-agent/src/roles/skills.ts +0 -72
- package/packages/coding-agent/src/roles/sync.ts +0 -88
- package/packages/coding-agent/src/types.ts +0 -102
- package/packages/coding-agent/src/workspace.ts +0 -275
- package/packages/sandbox/README.md +0 -691
- package/packages/sandbox/bin/flowdular-sandbox.mjs +0 -393
- package/packages/sandbox/dist/client/assets/App-ClMOrpyg.js +0 -11
- package/packages/sandbox/dist/client/assets/PreviewHost-BHiHrfbQ.js +0 -1
- package/packages/sandbox/dist/client/assets/auto-buGEq6de.js +0 -3
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-cyrillic-ext-wght-normal-d45eAU9y.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-cyrillic-wght-normal-BAAhND-U.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-greek-wght-normal-CmyJS8uq.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-latin-ext-wght-normal-CIII54If.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/ibm-plex-sans-vietnamese-wght-normal-Dg1JeJN0.woff2 +0 -0
- package/packages/sandbox/dist/client/assets/index-CvwhlQ87.js +0 -4
- package/packages/sandbox/dist/client/assets/styles-BPWaGf55.js +0 -3
- package/packages/sandbox/dist/client/assets/styles-Dx1_KOag.css +0 -1
- package/packages/sandbox/dist/client/favicon.svg +0 -1
- package/packages/sandbox/dist/server/assets/auto-CVGNYyb3.js +0 -12785
- package/packages/sandbox/dist/server/assets/chunk-DDJLRBDX-3OUNo51g.js +0 -4125
- package/packages/sandbox/dist/server/assets/nodefs-DVSI7kG0.js +0 -24
- package/packages/sandbox/dist/server/assets/opfs-ahp-D56iokt1.js +0 -388
- package/packages/sandbox/dist/server/assets/token-Bvaq7Anz.js +0 -55
- package/packages/sandbox/dist/server/assets/token-util-BqSvdIPQ.js +0 -3
- package/packages/sandbox/dist/server/assets/token-util-CktFFT4z.js +0 -494
- package/packages/sandbox/dist/server/entry.js +0 -91671
- package/packages/sandbox/dist/server/favicon.svg +0 -1
- package/packages/sandbox/dist/server/index.html +0 -161
- package/packages/sandbox/index.html +0 -159
- package/packages/sandbox/octane.config.ts +0 -36
- package/packages/sandbox/package.json +0 -83
- package/packages/sandbox/public/favicon.svg +0 -1
- package/packages/sandbox/public/og.png +0 -0
- package/packages/sandbox/src/App.tsrx +0 -924
- package/packages/sandbox/src/client/ApprovalHandoff.tsrx +0 -39
- package/packages/sandbox/src/client/ChatPane.tsrx +0 -694
- package/packages/sandbox/src/client/ConnectView.tsrx +0 -86
- package/packages/sandbox/src/client/DiffPane.tsrx +0 -150
- package/packages/sandbox/src/client/EjectModal.tsrx +0 -571
- package/packages/sandbox/src/client/GateResults.tsrx +0 -60
- package/packages/sandbox/src/client/GitHubSettingsModal.tsrx +0 -219
- package/packages/sandbox/src/client/NewSessionForm.tsrx +0 -214
- package/packages/sandbox/src/client/SessionBar.tsrx +0 -151
- package/packages/sandbox/src/client/SessionPicker.tsrx +0 -472
- package/packages/sandbox/src/client/SpecEditor.tsrx +0 -109
- package/packages/sandbox/src/client/SpecReviewCard.tsrx +0 -321
- package/packages/sandbox/src/client/WorkCard.tsrx +0 -128
- package/packages/sandbox/src/client/WorkbenchModal.tsrx +0 -161
- package/packages/sandbox/src/client/WorkspaceMenu.tsrx +0 -100
- package/packages/sandbox/src/client/api.ts +0 -797
- package/packages/sandbox/src/client/attachments.ts +0 -73
- package/packages/sandbox/src/client/dashboard.ts +0 -46
- package/packages/sandbox/src/client/github-settings.ts +0 -48
- package/packages/sandbox/src/client/highlight.ts +0 -105
- package/packages/sandbox/src/client/i18n.ts +0 -32
- package/packages/sandbox/src/client/locales/en.json +0 -466
- package/packages/sandbox/src/client/locales/pl.json +0 -466
- package/packages/sandbox/src/client/session-labels.ts +0 -77
- package/packages/sandbox/src/client/state.ts +0 -108
- package/packages/sandbox/src/index.ts +0 -10
- package/packages/sandbox/src/preview/PreviewHost.tsrx +0 -373
- package/packages/sandbox/src/preview/load-module.ts +0 -16
- package/packages/sandbox/src/server/attachments.ts +0 -308
- package/packages/sandbox/src/server/auto-review.ts +0 -181
- package/packages/sandbox/src/server/checkpoints.ts +0 -90
- package/packages/sandbox/src/server/config.ts +0 -460
- package/packages/sandbox/src/server/dashboard.ts +0 -245
- package/packages/sandbox/src/server/delivery/configuration.ts +0 -193
- package/packages/sandbox/src/server/delivery/git-pr.ts +0 -1473
- package/packages/sandbox/src/server/delivery/index.ts +0 -83
- package/packages/sandbox/src/server/delivery/local.ts +0 -151
- package/packages/sandbox/src/server/delivery/official-modules.ts +0 -516
- package/packages/sandbox/src/server/delivery/plan.ts +0 -218
- package/packages/sandbox/src/server/delivery/policies.ts +0 -239
- package/packages/sandbox/src/server/delivery/record.ts +0 -41
- package/packages/sandbox/src/server/delivery/steps.ts +0 -464
- package/packages/sandbox/src/server/delivery/types.ts +0 -158
- package/packages/sandbox/src/server/dependencies.ts +0 -88
- package/packages/sandbox/src/server/diff.ts +0 -230
- package/packages/sandbox/src/server/gates.ts +0 -388
- package/packages/sandbox/src/server/index.ts +0 -139
- package/packages/sandbox/src/server/path-guard.ts +0 -282
- package/packages/sandbox/src/server/planning.ts +0 -749
- package/packages/sandbox/src/server/platform-client.ts +0 -189
- package/packages/sandbox/src/server/preview-database-host.ts +0 -340
- package/packages/sandbox/src/server/preview-database-protocol.ts +0 -163
- package/packages/sandbox/src/server/preview-database-proxy.ts +0 -356
- package/packages/sandbox/src/server/preview-database.ts +0 -109
- package/packages/sandbox/src/server/preview-ipc.ts +0 -15
- package/packages/sandbox/src/server/preview-modules.ts +0 -83
- package/packages/sandbox/src/server/preview-revision.ts +0 -44
- package/packages/sandbox/src/server/preview-runtime.ts +0 -467
- package/packages/sandbox/src/server/preview-worker-manager.ts +0 -436
- package/packages/sandbox/src/server/preview-worker.ts +0 -168
- package/packages/sandbox/src/server/reference.ts +0 -251
- package/packages/sandbox/src/server/routes.ts +0 -2258
- package/packages/sandbox/src/server/runtime.ts +0 -188
- package/packages/sandbox/src/server/session-owner.ts +0 -23
- package/packages/sandbox/src/server/sessions.ts +0 -849
- package/packages/sandbox/src/server/spec.ts +0 -471
- package/packages/sandbox/src/server/turn-lifecycle.ts +0 -56
- package/packages/sandbox/src/server/turns.ts +0 -993
- package/packages/sandbox/src/server/workspace-install.ts +0 -266
- package/packages/sandbox/src/server/workspace-root.ts +0 -73
- package/packages/sandbox/src/styles.css +0 -1573
- package/packages/sandbox/vite.config.ts +0 -133
|
@@ -1,2258 +0,0 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { dirname } from 'node:path';
|
|
3
|
-
import { ServerRoute, type Context } from '@octanejs/app-core';
|
|
4
|
-
import {
|
|
5
|
-
MAX_ATTACHMENT_BYTES,
|
|
6
|
-
addAttachment,
|
|
7
|
-
assertAttachmentId,
|
|
8
|
-
readAttachment,
|
|
9
|
-
removeAttachment,
|
|
10
|
-
} from './attachments.ts';
|
|
11
|
-
import {
|
|
12
|
-
assertGitHubAccount,
|
|
13
|
-
assertGitHubDeliveryMode,
|
|
14
|
-
assertGitHubRepository,
|
|
15
|
-
assertGitName,
|
|
16
|
-
assertPlatformUrl,
|
|
17
|
-
openSecret,
|
|
18
|
-
safeConfiguration,
|
|
19
|
-
sealSecret,
|
|
20
|
-
} from './config.ts';
|
|
21
|
-
import {
|
|
22
|
-
DeliveryError,
|
|
23
|
-
assertEjectTarget,
|
|
24
|
-
readDeliveryConfiguration,
|
|
25
|
-
readDeliveryRecord,
|
|
26
|
-
resolveDeliveryTarget,
|
|
27
|
-
spawnCommand,
|
|
28
|
-
writeDeliveryRecord,
|
|
29
|
-
type DeliveryContext,
|
|
30
|
-
type EjectTarget,
|
|
31
|
-
} from './delivery/index.ts';
|
|
32
|
-
import {
|
|
33
|
-
PREVIEW_COOKIE,
|
|
34
|
-
type PreviewComposition,
|
|
35
|
-
type PreviewRuntime,
|
|
36
|
-
} from './preview-runtime.ts';
|
|
37
|
-
import { formatSession } from './gates.ts';
|
|
38
|
-
import {
|
|
39
|
-
SPEC_OWNER_ROLE,
|
|
40
|
-
assertBrief,
|
|
41
|
-
listWorkspaceModules,
|
|
42
|
-
planWork,
|
|
43
|
-
} from './planning.ts';
|
|
44
|
-
import {
|
|
45
|
-
collectDiffs,
|
|
46
|
-
forgetDiffs,
|
|
47
|
-
runSessionGates,
|
|
48
|
-
runTurn,
|
|
49
|
-
type TurnContext,
|
|
50
|
-
type TurnOutcome,
|
|
51
|
-
} from './turns.ts';
|
|
52
|
-
import {
|
|
53
|
-
addSessionModule,
|
|
54
|
-
appendChatEntry,
|
|
55
|
-
approveSpecification,
|
|
56
|
-
archiveSession,
|
|
57
|
-
assertSessionId,
|
|
58
|
-
basePathOf,
|
|
59
|
-
createSession,
|
|
60
|
-
deleteSession,
|
|
61
|
-
findSessionModule,
|
|
62
|
-
installSessionDependencies,
|
|
63
|
-
isSessionId,
|
|
64
|
-
listSessions,
|
|
65
|
-
modulePathOf,
|
|
66
|
-
readChat,
|
|
67
|
-
readSession,
|
|
68
|
-
restoreCheckpoint,
|
|
69
|
-
restoreSession,
|
|
70
|
-
sessionPaths,
|
|
71
|
-
updateSession,
|
|
72
|
-
type SandboxSession,
|
|
73
|
-
type SessionModule,
|
|
74
|
-
} from './sessions.ts';
|
|
75
|
-
import {
|
|
76
|
-
readModuleSpecReview,
|
|
77
|
-
specPathOf,
|
|
78
|
-
type ModuleSpecReview,
|
|
79
|
-
} from './spec.ts';
|
|
80
|
-
import type { BrowserSession, SandboxRuntime } from './runtime.ts';
|
|
81
|
-
import { PlatformClient } from './platform-client.ts';
|
|
82
|
-
import { SandboxSetupError } from './workspace-root.ts';
|
|
83
|
-
import { canReadSession } from './session-owner.ts';
|
|
84
|
-
import { buildDashboard } from './dashboard.ts';
|
|
85
|
-
import type { SessionOwner, ChatEntry } from './sessions.ts';
|
|
86
|
-
import {
|
|
87
|
-
processTurnChannels,
|
|
88
|
-
waitForTurn,
|
|
89
|
-
type TurnChannel,
|
|
90
|
-
type TurnSubscriber as Subscriber,
|
|
91
|
-
} from './turn-lifecycle.ts';
|
|
92
|
-
|
|
93
|
-
const SANDBOX_COOKIE = 'coreloom_sandbox';
|
|
94
|
-
/* Every sandbox mutation carries this header. A cross-site form post cannot
|
|
95
|
-
set it, so together with the origin check it is the CSRF boundary. */
|
|
96
|
-
export const SANDBOX_REQUEST_HEADER = 'x-flowdular-sandbox';
|
|
97
|
-
const TURN_TIMEOUT_MS = 20 * 60 * 1000;
|
|
98
|
-
/* How many handed-off turns may run without the operator saying anything. The
|
|
99
|
-
chain always stops on a failure, on a question, and on this count. */
|
|
100
|
-
const CHAIN_LIMIT = 4;
|
|
101
|
-
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
102
|
-
/* Paths the preview bridge never forwards: the platform's own authentication
|
|
103
|
-
and the sandbox's own records are not preview data. */
|
|
104
|
-
const BRIDGE_DENIED = [/^\/api\/auth(\/|$)/, /^\/api\/sandbox(\/|$)/];
|
|
105
|
-
|
|
106
|
-
const STATUS_BY_CODE: Readonly<Record<string, number>> = {
|
|
107
|
-
SANDBOX_SIGN_IN_REQUIRED: 401,
|
|
108
|
-
SANDBOX_NOT_CONNECTED: 401,
|
|
109
|
-
PLATFORM_TOKEN_MISSING: 401,
|
|
110
|
-
SANDBOX_HOST_REJECTED: 403,
|
|
111
|
-
SANDBOX_CROSS_SITE: 403,
|
|
112
|
-
SANDBOX_HEADER_REQUIRED: 403,
|
|
113
|
-
CONTENT_TYPE_REQUIRED: 415,
|
|
114
|
-
REQUEST_TOO_LARGE: 413,
|
|
115
|
-
INVALID_JSON: 400,
|
|
116
|
-
EJECT_SCOPE_MISSING: 403,
|
|
117
|
-
SESSION_NOT_FOUND: 404,
|
|
118
|
-
ATTACHMENT_NOT_FOUND: 404,
|
|
119
|
-
INVALID_ATTACHMENT_ID: 400,
|
|
120
|
-
ATTACHMENT_NAME_INVALID: 400,
|
|
121
|
-
ATTACHMENT_EMPTY: 400,
|
|
122
|
-
ATTACHMENT_TYPE_REJECTED: 415,
|
|
123
|
-
ATTACHMENT_TOO_LARGE: 413,
|
|
124
|
-
ATTACHMENT_LIMIT_REACHED: 409,
|
|
125
|
-
SESSION_RUNNING: 409,
|
|
126
|
-
SESSION_ARCHIVED: 409,
|
|
127
|
-
SESSION_DELIVERED: 409,
|
|
128
|
-
MODULE_NOT_FOUND: 404,
|
|
129
|
-
MODULE_NOT_IN_SESSION: 400,
|
|
130
|
-
MODULE_ALREADY_IN_SESSION: 409,
|
|
131
|
-
SPEC_NOT_FOUND: 404,
|
|
132
|
-
SPEC_NOT_APPROVED: 409,
|
|
133
|
-
EJECT_SPEC_MISSING: 409,
|
|
134
|
-
EJECT_SPEC_NOT_APPROVED: 409,
|
|
135
|
-
EJECT_SPEC_VERSION_UNCHANGED: 409,
|
|
136
|
-
EJECT_SPEC_SCENARIOS_UNCHANGED: 409,
|
|
137
|
-
EJECT_MIGRATION_IMMUTABLE: 409,
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
/* The specification is a document the operator reviews, not a file the browser
|
|
141
|
-
uploads: this is what a spec edit may carry. */
|
|
142
|
-
const MAX_SPEC_TEXT = 200_000;
|
|
143
|
-
const MAX_SPEC_COMMENT = 4_000;
|
|
144
|
-
const MAX_JSON_BODY_BYTES = 256_000;
|
|
145
|
-
|
|
146
|
-
export interface SandboxRouteOptions {
|
|
147
|
-
/* The port the launcher bound. A loopback request must name it in its Host
|
|
148
|
-
header, so a page on another local port cannot drive this sandbox. */
|
|
149
|
-
readonly port?: number;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function json(
|
|
153
|
-
body: unknown,
|
|
154
|
-
status = 200,
|
|
155
|
-
headers: HeadersInit = {},
|
|
156
|
-
): Response {
|
|
157
|
-
return Response.json(body, {
|
|
158
|
-
status,
|
|
159
|
-
headers: { 'cache-control': 'no-store', ...headers },
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function failure(
|
|
164
|
-
error: unknown,
|
|
165
|
-
extra: Record<string, unknown> = {},
|
|
166
|
-
): Response {
|
|
167
|
-
if (error instanceof SandboxSetupError) {
|
|
168
|
-
return json(
|
|
169
|
-
{
|
|
170
|
-
error: {
|
|
171
|
-
code: error.code,
|
|
172
|
-
message: error.message,
|
|
173
|
-
...(error instanceof DeliveryError && error.output
|
|
174
|
-
? { output: error.output }
|
|
175
|
-
: {}),
|
|
176
|
-
},
|
|
177
|
-
...extra,
|
|
178
|
-
},
|
|
179
|
-
STATUS_BY_CODE[error.code] ?? 400,
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
const message =
|
|
183
|
-
error instanceof Error ? error.message : 'The sandbox request failed.';
|
|
184
|
-
return json({ error: { code: 'SANDBOX_REQUEST_FAILED', message } }, 500);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function readCookie(request: Request, name: string): string | null {
|
|
188
|
-
const header = request.headers.get('cookie');
|
|
189
|
-
if (!header) return null;
|
|
190
|
-
for (const item of header.split(';')) {
|
|
191
|
-
const separator = item.indexOf('=');
|
|
192
|
-
if (separator < 1) continue;
|
|
193
|
-
if (item.slice(0, separator).trim() !== name) continue;
|
|
194
|
-
return decodeURIComponent(item.slice(separator + 1).trim());
|
|
195
|
-
}
|
|
196
|
-
return null;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async function body(
|
|
200
|
-
request: Request,
|
|
201
|
-
maximumBytes = MAX_JSON_BODY_BYTES,
|
|
202
|
-
): Promise<Record<string, unknown>> {
|
|
203
|
-
if (
|
|
204
|
-
!(request.headers.get('content-type') ?? '')
|
|
205
|
-
.toLowerCase()
|
|
206
|
-
.startsWith('application/json')
|
|
207
|
-
) {
|
|
208
|
-
throw new SandboxSetupError(
|
|
209
|
-
'CONTENT_TYPE_REQUIRED',
|
|
210
|
-
'Expected application/json.',
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
const contentLength = Number(request.headers.get('content-length'));
|
|
214
|
-
if (Number.isFinite(contentLength) && contentLength > maximumBytes) {
|
|
215
|
-
throw new SandboxSetupError(
|
|
216
|
-
'REQUEST_TOO_LARGE',
|
|
217
|
-
`The JSON request body is limited to ${maximumBytes} bytes.`,
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
const chunks: Uint8Array[] = [];
|
|
221
|
-
let bytes = 0;
|
|
222
|
-
if (request.body) {
|
|
223
|
-
const reader = request.body.getReader();
|
|
224
|
-
try {
|
|
225
|
-
for (;;) {
|
|
226
|
-
const part = await reader.read();
|
|
227
|
-
if (part.done) break;
|
|
228
|
-
bytes += part.value.byteLength;
|
|
229
|
-
if (bytes > maximumBytes) {
|
|
230
|
-
await reader.cancel().catch(() => undefined);
|
|
231
|
-
throw new SandboxSetupError(
|
|
232
|
-
'REQUEST_TOO_LARGE',
|
|
233
|
-
`The JSON request body is limited to ${maximumBytes} bytes.`,
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
chunks.push(part.value);
|
|
237
|
-
}
|
|
238
|
-
} finally {
|
|
239
|
-
reader.releaseLock();
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
const raw = Buffer.concat(
|
|
243
|
-
chunks.map((chunk) => Buffer.from(chunk)),
|
|
244
|
-
bytes,
|
|
245
|
-
).toString('utf8');
|
|
246
|
-
let value: unknown;
|
|
247
|
-
try {
|
|
248
|
-
value = JSON.parse(raw) as unknown;
|
|
249
|
-
} catch {
|
|
250
|
-
throw new SandboxSetupError('INVALID_JSON', 'Expected valid JSON.');
|
|
251
|
-
}
|
|
252
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
253
|
-
throw new SandboxSetupError('INVALID_INPUT', 'Expected a JSON object.');
|
|
254
|
-
}
|
|
255
|
-
return value as Record<string, unknown>;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function text(
|
|
259
|
-
value: Record<string, unknown>,
|
|
260
|
-
key: string,
|
|
261
|
-
maximum = 200,
|
|
262
|
-
): string {
|
|
263
|
-
const found = value[key];
|
|
264
|
-
if (typeof found !== 'string' || !found.trim() || found.length > maximum) {
|
|
265
|
-
throw new SandboxSetupError(
|
|
266
|
-
'INVALID_INPUT',
|
|
267
|
-
`${key} must be a string of at most ${maximum} characters.`,
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
return found.trim();
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function optionalText(
|
|
274
|
-
value: Record<string, unknown>,
|
|
275
|
-
key: string,
|
|
276
|
-
maximum = 200,
|
|
277
|
-
): string | null {
|
|
278
|
-
const found = value[key];
|
|
279
|
-
if (found === undefined || found === null || found === '') return null;
|
|
280
|
-
return text(value, key, maximum);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function sessionIdParam(context: Context): string {
|
|
284
|
-
return assertSessionId(context.params.id ?? '');
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function attachmentIdParam(context: Context): string {
|
|
288
|
-
return assertAttachmentId(context.params.attachmentId ?? '');
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/* Decodes a base64 field into bytes, refusing a payload that could not fit the
|
|
292
|
-
size limit before it is decoded, so an oversized upload never allocates. */
|
|
293
|
-
function base64Field(
|
|
294
|
-
value: Record<string, unknown>,
|
|
295
|
-
key: string,
|
|
296
|
-
maxBytes: number,
|
|
297
|
-
): Buffer {
|
|
298
|
-
const raw = value[key];
|
|
299
|
-
if (typeof raw !== 'string' || raw.length === 0) {
|
|
300
|
-
throw new SandboxSetupError(
|
|
301
|
-
'INVALID_INPUT',
|
|
302
|
-
`${key} must be a base64 string.`,
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
if (raw.length > Math.ceil(maxBytes / 3) * 4 + 4) {
|
|
306
|
-
throw new SandboxSetupError(
|
|
307
|
-
'ATTACHMENT_TOO_LARGE',
|
|
308
|
-
`Attachments are limited to ${maxBytes / (1024 * 1024)} MB.`,
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(raw)) {
|
|
312
|
-
throw new SandboxSetupError('INVALID_INPUT', `${key} is not valid base64.`);
|
|
313
|
-
}
|
|
314
|
-
return Buffer.from(raw, 'base64');
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/* A loopback sandbox trusts the machine it runs on and nothing else: the
|
|
318
|
-
request must be addressed to the loopback name and port it was started on,
|
|
319
|
-
so a page served from another local port cannot reach it by name. */
|
|
320
|
-
function assertLoopbackHost(request: Request, port: number | undefined): void {
|
|
321
|
-
const host = request.headers.get('host') ?? '';
|
|
322
|
-
const separator = host.lastIndexOf(':');
|
|
323
|
-
const bracketed = host.startsWith('[');
|
|
324
|
-
const name =
|
|
325
|
-
bracketed && host.includes(']')
|
|
326
|
-
? host.slice(0, host.indexOf(']') + 1)
|
|
327
|
-
: separator > 0
|
|
328
|
-
? host.slice(0, separator)
|
|
329
|
-
: host;
|
|
330
|
-
const requestPort =
|
|
331
|
-
separator > (bracketed ? host.indexOf(']') : -1)
|
|
332
|
-
? host.slice(separator + 1)
|
|
333
|
-
: '';
|
|
334
|
-
if (
|
|
335
|
-
!LOOPBACK_HOSTS.has(name.toLowerCase()) ||
|
|
336
|
-
(port !== undefined && requestPort !== String(port))
|
|
337
|
-
) {
|
|
338
|
-
throw new SandboxSetupError(
|
|
339
|
-
'SANDBOX_HOST_REJECTED',
|
|
340
|
-
'A loopback sandbox answers only requests addressed to its own loopback address and port.',
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/* The browser-facing half of the origin boundary: the fetch metadata and the
|
|
346
|
-
Origin header must name this server. A cross-site form post satisfies
|
|
347
|
-
neither. This is the only place that sees the origin the browser used, so
|
|
348
|
-
every request that leaves for an internal hop is checked here first. */
|
|
349
|
-
export function assertBrowserOrigin(request: Request): void {
|
|
350
|
-
const site = request.headers.get('sec-fetch-site');
|
|
351
|
-
if (site && site !== 'same-origin' && site !== 'none') {
|
|
352
|
-
throw new SandboxSetupError(
|
|
353
|
-
'SANDBOX_CROSS_SITE',
|
|
354
|
-
'Sandbox mutations are accepted from the sandbox page only.',
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
const origin = request.headers.get('origin');
|
|
358
|
-
if (!origin) return;
|
|
359
|
-
let originHost: string;
|
|
360
|
-
try {
|
|
361
|
-
originHost = new URL(origin).host;
|
|
362
|
-
} catch {
|
|
363
|
-
originHost = '';
|
|
364
|
-
}
|
|
365
|
-
if (!originHost || originHost !== (request.headers.get('host') ?? '')) {
|
|
366
|
-
throw new SandboxSetupError(
|
|
367
|
-
'SANDBOX_CROSS_SITE',
|
|
368
|
-
'Sandbox mutations are accepted from the sandbox page only.',
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/* Mutations must come from the sandbox's own page: the browser origin must
|
|
374
|
-
name this server, and the custom header must be present. */
|
|
375
|
-
export function assertSameOrigin(request: Request): void {
|
|
376
|
-
assertBrowserOrigin(request);
|
|
377
|
-
if (request.headers.get(SANDBOX_REQUEST_HEADER) !== '1') {
|
|
378
|
-
throw new SandboxSetupError(
|
|
379
|
-
'SANDBOX_HEADER_REQUIRED',
|
|
380
|
-
`Sandbox mutations carry the ${SANDBOX_REQUEST_HEADER} header.`,
|
|
381
|
-
);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function browserSession(
|
|
386
|
-
runtime: SandboxRuntime,
|
|
387
|
-
context: Context,
|
|
388
|
-
): BrowserSession | null {
|
|
389
|
-
return runtime.browserSession(readCookie(context.request, SANDBOX_COOKIE));
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
interface AuthorizeOptions {
|
|
393
|
-
/* Configuration and status calls are how a sandbox gets connected, so they
|
|
394
|
-
cannot demand a connection. */
|
|
395
|
-
readonly allowDisconnected?: boolean;
|
|
396
|
-
readonly mutation?: boolean;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/* Loopback binds to the local interface and uses the configured connection.
|
|
400
|
-
A self-hosted sandbox authenticates every browser with its own API token. */
|
|
401
|
-
async function authorize(
|
|
402
|
-
runtime: SandboxRuntime,
|
|
403
|
-
context: Context,
|
|
404
|
-
options: SandboxRouteOptions,
|
|
405
|
-
policy: AuthorizeOptions = {},
|
|
406
|
-
): Promise<void> {
|
|
407
|
-
if (policy.mutation) assertSameOrigin(context.request);
|
|
408
|
-
if (runtime.configuration().mode === 'loopback') {
|
|
409
|
-
assertLoopbackHost(context.request, options.port);
|
|
410
|
-
if (!policy.allowDisconnected && !runtime.connection().connected) {
|
|
411
|
-
throw new SandboxSetupError(
|
|
412
|
-
runtime.connection().error?.code ?? 'SANDBOX_NOT_CONNECTED',
|
|
413
|
-
runtime.connection().error?.message ??
|
|
414
|
-
'The sandbox is not connected to a Flowdular application yet.',
|
|
415
|
-
);
|
|
416
|
-
}
|
|
417
|
-
} else if (!browserSession(runtime, context)) {
|
|
418
|
-
throw new SandboxSetupError(
|
|
419
|
-
'SANDBOX_SIGN_IN_REQUIRED',
|
|
420
|
-
'Connect this browser with an API token issued by the Flowdular application.',
|
|
421
|
-
);
|
|
422
|
-
}
|
|
423
|
-
const id = context.url.pathname.startsWith('/sandbox/api/sessions/')
|
|
424
|
-
? sessionIdParam(context)
|
|
425
|
-
: context.url.pathname.startsWith('/api/')
|
|
426
|
-
? previewSessionId(context)
|
|
427
|
-
: null;
|
|
428
|
-
if (id) {
|
|
429
|
-
const session = await readSession(runtime.workspaceRoot, id);
|
|
430
|
-
if (
|
|
431
|
-
session.state === 'deleted' ||
|
|
432
|
-
!canReadSession(
|
|
433
|
-
session,
|
|
434
|
-
actingOwner(runtime, context),
|
|
435
|
-
runtime.configuration().mode === 'loopback',
|
|
436
|
-
)
|
|
437
|
-
) {
|
|
438
|
-
throw new SandboxSetupError(
|
|
439
|
-
'SESSION_NOT_FOUND',
|
|
440
|
-
'This session is not available to this account.',
|
|
441
|
-
);
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
function actingOwner(
|
|
447
|
-
runtime: SandboxRuntime,
|
|
448
|
-
context: Context,
|
|
449
|
-
): SessionOwner | null {
|
|
450
|
-
const authority =
|
|
451
|
-
runtime.configuration().mode === 'loopback'
|
|
452
|
-
? runtime.connection().authority
|
|
453
|
-
: browserSession(runtime, context)?.authority;
|
|
454
|
-
if (!authority) return null;
|
|
455
|
-
return {
|
|
456
|
-
platformUrl: runtime.configuration().platformUrl.replace(/\/+$/, ''),
|
|
457
|
-
accountId: authority.principal.accountId,
|
|
458
|
-
tenantId: authority.principal.tenantId,
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
function actingPlatform(
|
|
463
|
-
runtime: SandboxRuntime,
|
|
464
|
-
context: Context,
|
|
465
|
-
): PlatformClient | null {
|
|
466
|
-
if (runtime.configuration().mode === 'loopback') return runtime.platform();
|
|
467
|
-
const browser = browserSession(runtime, context);
|
|
468
|
-
return browser
|
|
469
|
-
? new PlatformClient({
|
|
470
|
-
platformUrl: runtime.configuration().platformUrl,
|
|
471
|
-
token: browser.token,
|
|
472
|
-
})
|
|
473
|
-
: null;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/* The capabilities that count are the acting principal's: the browser session
|
|
477
|
-
in self-hosted mode, the configured connection in loopback mode. */
|
|
478
|
-
function actingCapabilities(
|
|
479
|
-
runtime: SandboxRuntime,
|
|
480
|
-
context: Context,
|
|
481
|
-
): readonly string[] {
|
|
482
|
-
const authority =
|
|
483
|
-
runtime.configuration().mode === 'loopback'
|
|
484
|
-
? runtime.connection().authority
|
|
485
|
-
: browserSession(runtime, context)?.authority;
|
|
486
|
-
return authority?.authority.granted ? authority.authority.capabilities : [];
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
function sessionCookie(id: string, secure: boolean): string {
|
|
490
|
-
return [
|
|
491
|
-
`${SANDBOX_COOKIE}=${encodeURIComponent(id)}`,
|
|
492
|
-
'Path=/',
|
|
493
|
-
'HttpOnly',
|
|
494
|
-
'SameSite=Strict',
|
|
495
|
-
'Max-Age=43200',
|
|
496
|
-
...(secure ? ['Secure'] : []),
|
|
497
|
-
].join('; ');
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
/* One review per module: the draft specification, the copy the session started
|
|
501
|
-
from, the difference between them, and whether the operator approved it. The
|
|
502
|
-
card renders this and nothing it fetches itself. */
|
|
503
|
-
function sessionSpecs(
|
|
504
|
-
workspaceRoot: string,
|
|
505
|
-
session: SandboxSession,
|
|
506
|
-
): Promise<readonly ModuleSpecReview[]> {
|
|
507
|
-
const paths = sessionPaths(workspaceRoot, session.id, session.moduleSuffix);
|
|
508
|
-
return Promise.all(
|
|
509
|
-
session.modules.map((module) =>
|
|
510
|
-
readModuleSpecReview(
|
|
511
|
-
module,
|
|
512
|
-
modulePathOf(paths, module.directory),
|
|
513
|
-
basePathOf(paths, module.directory),
|
|
514
|
-
),
|
|
515
|
-
),
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
/* The one place a client-named module turns into a specification path. The
|
|
520
|
-
module is always one the session holds, so nothing a request says reaches the
|
|
521
|
-
file system by itself. */
|
|
522
|
-
function specFileOf(
|
|
523
|
-
workspaceRoot: string,
|
|
524
|
-
session: SandboxSession,
|
|
525
|
-
module: SessionModule,
|
|
526
|
-
): string {
|
|
527
|
-
return specPathOf(
|
|
528
|
-
modulePathOf(
|
|
529
|
-
sessionPaths(workspaceRoot, session.id, session.moduleSuffix),
|
|
530
|
-
module.directory,
|
|
531
|
-
),
|
|
532
|
-
);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
async function readSpecFile(
|
|
536
|
-
workspaceRoot: string,
|
|
537
|
-
session: SandboxSession,
|
|
538
|
-
module: SessionModule,
|
|
539
|
-
): Promise<string> {
|
|
540
|
-
try {
|
|
541
|
-
return await readFile(specFileOf(workspaceRoot, session, module), 'utf8');
|
|
542
|
-
} catch {
|
|
543
|
-
throw new SandboxSetupError(
|
|
544
|
-
'SPEC_NOT_FOUND',
|
|
545
|
-
`${module.id} has no specification in this session yet.`,
|
|
546
|
-
);
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
async function writeSpecFile(
|
|
551
|
-
workspaceRoot: string,
|
|
552
|
-
session: SandboxSession,
|
|
553
|
-
module: SessionModule,
|
|
554
|
-
text: string,
|
|
555
|
-
): Promise<void> {
|
|
556
|
-
const path = specFileOf(workspaceRoot, session, module);
|
|
557
|
-
/* The editor writes a draft. `approved` is reserved for the approval route,
|
|
558
|
-
which records the matching hash in session.json at the same time. */
|
|
559
|
-
const draftStatus = module.kind === 'new' ? 'draft' : 'in-review';
|
|
560
|
-
const draft = text.replace(
|
|
561
|
-
/^status:[ \t]*approved[ \t]*$/m,
|
|
562
|
-
`status: ${draftStatus}`,
|
|
563
|
-
);
|
|
564
|
-
await mkdir(dirname(path), { recursive: true });
|
|
565
|
-
await writeFile(path, draft, 'utf8');
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
async function sessionView(
|
|
569
|
-
runtime: SandboxRuntime,
|
|
570
|
-
session: SandboxSession,
|
|
571
|
-
busy = false,
|
|
572
|
-
) {
|
|
573
|
-
return {
|
|
574
|
-
session,
|
|
575
|
-
/* Workspace-relative locations only; the host file system layout never
|
|
576
|
-
reaches the browser. */
|
|
577
|
-
paths: {
|
|
578
|
-
module: `modules/${session.moduleSuffix}`,
|
|
579
|
-
modules: session.modules.map((module) => ({
|
|
580
|
-
id: module.id,
|
|
581
|
-
path: `modules/${module.directory}`,
|
|
582
|
-
})),
|
|
583
|
-
},
|
|
584
|
-
chat: await readChat(runtime.workspaceRoot, session),
|
|
585
|
-
diffs: await collectDiffs(runtime.workspaceRoot, session),
|
|
586
|
-
specs: await sessionSpecs(runtime.workspaceRoot, session),
|
|
587
|
-
/* A turn outlives the request that started it, so a browser that reloads
|
|
588
|
-
mid-turn has to be told the agent is still working. */
|
|
589
|
-
running: busy,
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
/* The preview session a request belongs to. The preview page sets the cookie
|
|
594
|
-
before it loads the module, and the iframe URL is the fallback so a request
|
|
595
|
-
made before that still reaches the right session. */
|
|
596
|
-
function previewSessionId(context: Context): string | null {
|
|
597
|
-
const cookie = readCookie(context.request, PREVIEW_COOKIE);
|
|
598
|
-
if (cookie) return isSessionId(cookie) ? cookie : null;
|
|
599
|
-
const referer = context.request.headers.get('referer');
|
|
600
|
-
if (!referer) return null;
|
|
601
|
-
try {
|
|
602
|
-
const segments = new URL(referer).pathname.split('/').filter(Boolean);
|
|
603
|
-
const id = segments[0] === 'preview' ? (segments[1] ?? '') : '';
|
|
604
|
-
return isSessionId(id) ? id : null;
|
|
605
|
-
} catch {
|
|
606
|
-
return null;
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
function assertNotArchived(session: SandboxSession): void {
|
|
611
|
-
if (session.archivedAt !== null) {
|
|
612
|
-
throw new SandboxSetupError(
|
|
613
|
-
'SESSION_ARCHIVED',
|
|
614
|
-
'This session is archived. Restore it before working on it again.',
|
|
615
|
-
);
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
function assertNotDelivered(session: SandboxSession): void {
|
|
620
|
-
if (session.ejectedAt !== null) {
|
|
621
|
-
throw new SandboxSetupError(
|
|
622
|
-
'SESSION_DELIVERED',
|
|
623
|
-
'This session was already delivered. Start a new session for a further specification change.',
|
|
624
|
-
);
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
/* A turn, or a chain of automatically continued turns, runs to completion on
|
|
629
|
-
the server whatever happens to the browser. Streams subscribe to it and can
|
|
630
|
-
leave at any time; stopping is an explicit action. The finished promise is
|
|
631
|
-
kept with the controller because a superseding turn must wait for the old
|
|
632
|
-
process to release the driver's thread before it resumes it. */
|
|
633
|
-
export function createSandboxRoutes(
|
|
634
|
-
runtime: SandboxRuntime,
|
|
635
|
-
preview: PreviewRuntime,
|
|
636
|
-
options: SandboxRouteOptions = {},
|
|
637
|
-
): readonly ServerRoute[] {
|
|
638
|
-
const running = processTurnChannels(runtime.workspaceRoot);
|
|
639
|
-
const secureCookies = runtime.configuration().mode !== 'loopback';
|
|
640
|
-
|
|
641
|
-
const turnContext = (platform = runtime.platform()): TurnContext => ({
|
|
642
|
-
workspaceRoot: runtime.workspaceRoot,
|
|
643
|
-
configuration: runtime.configuration(),
|
|
644
|
-
registry: runtime.registry(),
|
|
645
|
-
roles: runtime.roles(),
|
|
646
|
-
platform,
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
const publish = (channel: TurnChannel, event: string, payload: unknown) => {
|
|
650
|
-
for (const subscriber of channel.subscribers) {
|
|
651
|
-
/* A subscriber is a client connection; its failure must never stop
|
|
652
|
-
the turn or reach the other subscribers. */
|
|
653
|
-
try {
|
|
654
|
-
subscriber(event, payload);
|
|
655
|
-
} catch {
|
|
656
|
-
channel.subscribers.delete(subscriber);
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
};
|
|
660
|
-
|
|
661
|
-
/* Starts a turn detached from any request. Handoffs marked continue keep
|
|
662
|
-
running on the same channel while the session allows it, so the chain is
|
|
663
|
-
the server's decision and survives a closed tab. */
|
|
664
|
-
const startTurn = (
|
|
665
|
-
sessionId: string,
|
|
666
|
-
input: {
|
|
667
|
-
message: string;
|
|
668
|
-
role?: string;
|
|
669
|
-
module?: string;
|
|
670
|
-
driver?: string;
|
|
671
|
-
},
|
|
672
|
-
platform: PlatformClient | null,
|
|
673
|
-
): TurnChannel => {
|
|
674
|
-
const previous = running.get(sessionId);
|
|
675
|
-
const controller = new AbortController();
|
|
676
|
-
let release = () => undefined as void;
|
|
677
|
-
const channel: TurnChannel = {
|
|
678
|
-
controller,
|
|
679
|
-
finished: new Promise<void>((resolveFinished) => {
|
|
680
|
-
release = () => resolveFinished();
|
|
681
|
-
}),
|
|
682
|
-
subscribers: new Set(),
|
|
683
|
-
};
|
|
684
|
-
/* Claimed before anything awaits, so two requests arriving together
|
|
685
|
-
cannot both believe they are the only turn. */
|
|
686
|
-
running.set(sessionId, channel);
|
|
687
|
-
previous?.controller.abort('superseded');
|
|
688
|
-
const timer = setTimeout(
|
|
689
|
-
() => controller.abort('timeout'),
|
|
690
|
-
TURN_TIMEOUT_MS,
|
|
691
|
-
);
|
|
692
|
-
timer.unref?.();
|
|
693
|
-
|
|
694
|
-
void (async () => {
|
|
695
|
-
try {
|
|
696
|
-
if (previous) await waitForTurn(previous);
|
|
697
|
-
await updateSession(runtime.workspaceRoot, sessionId, {
|
|
698
|
-
chainDepth: 0,
|
|
699
|
-
});
|
|
700
|
-
let next: {
|
|
701
|
-
message: string;
|
|
702
|
-
role?: string;
|
|
703
|
-
module?: string;
|
|
704
|
-
driver?: string;
|
|
705
|
-
} | null = input;
|
|
706
|
-
let depth = 0;
|
|
707
|
-
while (next && !controller.signal.aborted) {
|
|
708
|
-
const iterator = runTurn(turnContext(platform), {
|
|
709
|
-
sessionId,
|
|
710
|
-
...next,
|
|
711
|
-
signal: controller.signal,
|
|
712
|
-
});
|
|
713
|
-
let step = await iterator.next();
|
|
714
|
-
while (!step.done) {
|
|
715
|
-
publish(channel, 'entry', step.value);
|
|
716
|
-
step = await iterator.next();
|
|
717
|
-
}
|
|
718
|
-
const outcome: TurnOutcome = step.value;
|
|
719
|
-
publish(channel, 'completed', outcome);
|
|
720
|
-
next = null;
|
|
721
|
-
if (
|
|
722
|
-
outcome.handoff.kind === 'continue' &&
|
|
723
|
-
outcome.session.autoContinue &&
|
|
724
|
-
depth + 1 < CHAIN_LIMIT &&
|
|
725
|
-
!controller.signal.aborted
|
|
726
|
-
) {
|
|
727
|
-
depth += 1;
|
|
728
|
-
await updateSession(runtime.workspaceRoot, sessionId, {
|
|
729
|
-
chainDepth: depth,
|
|
730
|
-
});
|
|
731
|
-
next = {
|
|
732
|
-
message: outcome.handoff.prompt,
|
|
733
|
-
role: outcome.handoff.role,
|
|
734
|
-
...(outcome.handoff.module
|
|
735
|
-
? { module: outcome.handoff.module }
|
|
736
|
-
: {}),
|
|
737
|
-
};
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
} catch (error) {
|
|
741
|
-
publish(channel, 'failed', {
|
|
742
|
-
code: error instanceof SandboxSetupError ? error.code : 'TURN_FAILED',
|
|
743
|
-
message: error instanceof Error ? error.message : 'The turn failed.',
|
|
744
|
-
});
|
|
745
|
-
} finally {
|
|
746
|
-
clearTimeout(timer);
|
|
747
|
-
/* A timed-out or superseded waiter still represents its predecessor.
|
|
748
|
-
Keep the chain owned until the actual writer has drained. */
|
|
749
|
-
if (previous) await previous.finished;
|
|
750
|
-
if (running.get(sessionId) === channel) running.delete(sessionId);
|
|
751
|
-
publish(channel, 'ended', { sessionId });
|
|
752
|
-
channel.subscribers.clear();
|
|
753
|
-
release();
|
|
754
|
-
}
|
|
755
|
-
})();
|
|
756
|
-
return channel;
|
|
757
|
-
};
|
|
758
|
-
|
|
759
|
-
/* One server-sent event stream over a channel. Closing the stream only
|
|
760
|
-
unsubscribes; the turn keeps running. */
|
|
761
|
-
const streamChannel = (channel: TurnChannel): Response => {
|
|
762
|
-
const encoder = new TextEncoder();
|
|
763
|
-
let subscriber: Subscriber | null = null;
|
|
764
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
765
|
-
start(streamController) {
|
|
766
|
-
let closed = false;
|
|
767
|
-
subscriber = (event, payload) => {
|
|
768
|
-
if (closed) return;
|
|
769
|
-
streamController.enqueue(
|
|
770
|
-
encoder.encode(
|
|
771
|
-
`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`,
|
|
772
|
-
),
|
|
773
|
-
);
|
|
774
|
-
if (event === 'ended') {
|
|
775
|
-
closed = true;
|
|
776
|
-
streamController.close();
|
|
777
|
-
}
|
|
778
|
-
};
|
|
779
|
-
channel.subscribers.add(subscriber);
|
|
780
|
-
},
|
|
781
|
-
cancel() {
|
|
782
|
-
if (subscriber) channel.subscribers.delete(subscriber);
|
|
783
|
-
},
|
|
784
|
-
});
|
|
785
|
-
return new Response(stream, {
|
|
786
|
-
headers: {
|
|
787
|
-
'content-type': 'text/event-stream; charset=utf-8',
|
|
788
|
-
'cache-control': 'no-store',
|
|
789
|
-
connection: 'keep-alive',
|
|
790
|
-
},
|
|
791
|
-
});
|
|
792
|
-
};
|
|
793
|
-
|
|
794
|
-
const state = new ServerRoute({
|
|
795
|
-
path: '/sandbox/api/state',
|
|
796
|
-
methods: ['GET'],
|
|
797
|
-
handler: async (context) => {
|
|
798
|
-
const configuration = runtime.configuration();
|
|
799
|
-
try {
|
|
800
|
-
await authorize(runtime, context, options, { allowDisconnected: true });
|
|
801
|
-
} catch (error) {
|
|
802
|
-
/* An unauthenticated self-hosted browser still needs to know it must
|
|
803
|
-
sign in, and where; nothing else leaves. */
|
|
804
|
-
return failure(error, {
|
|
805
|
-
configuration: {
|
|
806
|
-
mode: configuration.mode,
|
|
807
|
-
platformUrl: configuration.platformUrl,
|
|
808
|
-
},
|
|
809
|
-
});
|
|
810
|
-
}
|
|
811
|
-
/* A platform that was down when the sandbox started must not stay
|
|
812
|
-
unreachable forever: the state poll retries the connection. */
|
|
813
|
-
if (!runtime.connection().connected) await runtime.refresh();
|
|
814
|
-
const owner = actingOwner(runtime, context);
|
|
815
|
-
const sessions = (await listSessions(runtime.workspaceRoot, true)).filter(
|
|
816
|
-
(session) =>
|
|
817
|
-
canReadSession(session, owner, configuration.mode === 'loopback'),
|
|
818
|
-
);
|
|
819
|
-
const visible = sessions.filter((session) => session.state !== 'deleted');
|
|
820
|
-
return json({
|
|
821
|
-
configuration: safeConfiguration(configuration),
|
|
822
|
-
connection:
|
|
823
|
-
configuration.mode === 'self-hosted'
|
|
824
|
-
? {
|
|
825
|
-
connected: true,
|
|
826
|
-
authority: browserSession(runtime, context)!.authority,
|
|
827
|
-
error: null,
|
|
828
|
-
}
|
|
829
|
-
: runtime.connection(),
|
|
830
|
-
drivers: await runtime.registry().status(),
|
|
831
|
-
roles: runtime.roles().map((role) => ({
|
|
832
|
-
id: role.id,
|
|
833
|
-
name: role.name,
|
|
834
|
-
purpose: role.purpose,
|
|
835
|
-
allowedPaths: role.allowedPaths,
|
|
836
|
-
gates: role.gates,
|
|
837
|
-
handoff: role.handoff,
|
|
838
|
-
})),
|
|
839
|
-
sessions: visible,
|
|
840
|
-
dashboard: await buildDashboard(
|
|
841
|
-
runtime.workspaceRoot,
|
|
842
|
-
sessions,
|
|
843
|
-
owner,
|
|
844
|
-
new Set(running.keys()),
|
|
845
|
-
),
|
|
846
|
-
/* What a session may still add to itself. */
|
|
847
|
-
workspaceModules: await listWorkspaceModules(runtime.workspaceRoot),
|
|
848
|
-
running: visible
|
|
849
|
-
.filter((session) => running.has(session.id))
|
|
850
|
-
.map((session) => session.id),
|
|
851
|
-
});
|
|
852
|
-
},
|
|
853
|
-
});
|
|
854
|
-
|
|
855
|
-
/* The mode is the launcher's decision from the interface it bound and is
|
|
856
|
-
never accepted over HTTP. A new platform address needs a token pasted
|
|
857
|
-
with it, so the stored token can never be replayed to another host. */
|
|
858
|
-
const configure = new ServerRoute({
|
|
859
|
-
path: '/sandbox/api/config',
|
|
860
|
-
methods: ['POST'],
|
|
861
|
-
handler: async (context) => {
|
|
862
|
-
try {
|
|
863
|
-
await authorize(runtime, context, options, {
|
|
864
|
-
allowDisconnected: true,
|
|
865
|
-
mutation: true,
|
|
866
|
-
});
|
|
867
|
-
const value = await body(context.request);
|
|
868
|
-
const token = optionalText(value, 'platformToken', 4_096);
|
|
869
|
-
const byokCredential = optionalText(value, 'byokCredential', 16_384);
|
|
870
|
-
const githubToken = optionalText(value, 'githubToken', 16_384);
|
|
871
|
-
const configuration = runtime.configuration();
|
|
872
|
-
if (value.disconnect === true) {
|
|
873
|
-
return json({
|
|
874
|
-
configuration: safeConfiguration(runtime.configuration()),
|
|
875
|
-
connection: await runtime.update({ platformToken: null }),
|
|
876
|
-
});
|
|
877
|
-
}
|
|
878
|
-
const platformUrl =
|
|
879
|
-
value.platformUrl === undefined
|
|
880
|
-
? null
|
|
881
|
-
: assertPlatformUrl(text(value, 'platformUrl', 300));
|
|
882
|
-
if (
|
|
883
|
-
platformUrl !== null &&
|
|
884
|
-
platformUrl !== configuration.platformUrl &&
|
|
885
|
-
!token
|
|
886
|
-
) {
|
|
887
|
-
throw new SandboxSetupError(
|
|
888
|
-
'PLATFORM_TOKEN_REQUIRED',
|
|
889
|
-
'Changing the application address needs the API token for that application.',
|
|
890
|
-
);
|
|
891
|
-
}
|
|
892
|
-
const githubPatchRequested = [
|
|
893
|
-
'githubEnabled',
|
|
894
|
-
'githubOverridesProject',
|
|
895
|
-
'githubRemote',
|
|
896
|
-
'githubRepository',
|
|
897
|
-
'githubBaseBranch',
|
|
898
|
-
'githubBranchPrefix',
|
|
899
|
-
'githubMode',
|
|
900
|
-
'githubForkOwner',
|
|
901
|
-
'githubReviewers',
|
|
902
|
-
].some((key) => value[key] !== undefined);
|
|
903
|
-
if (
|
|
904
|
-
value.githubOverridesProject !== undefined &&
|
|
905
|
-
typeof value.githubOverridesProject !== 'boolean'
|
|
906
|
-
) {
|
|
907
|
-
throw new SandboxSetupError(
|
|
908
|
-
'GITHUB_CONFIG_INVALID',
|
|
909
|
-
'The GitHub project override flag must be a boolean.',
|
|
910
|
-
);
|
|
911
|
-
}
|
|
912
|
-
if (
|
|
913
|
-
value.githubEnabled !== undefined &&
|
|
914
|
-
typeof value.githubEnabled !== 'boolean'
|
|
915
|
-
) {
|
|
916
|
-
throw new SandboxSetupError(
|
|
917
|
-
'GITHUB_CONFIG_INVALID',
|
|
918
|
-
'The GitHub enabled flag must be a boolean.',
|
|
919
|
-
);
|
|
920
|
-
}
|
|
921
|
-
let github = configuration.github;
|
|
922
|
-
if (githubPatchRequested) {
|
|
923
|
-
const reviewersValue = value.githubReviewers;
|
|
924
|
-
if (
|
|
925
|
-
reviewersValue !== undefined &&
|
|
926
|
-
(!Array.isArray(reviewersValue) ||
|
|
927
|
-
reviewersValue.length > 20 ||
|
|
928
|
-
reviewersValue.some((entry) => typeof entry !== 'string'))
|
|
929
|
-
) {
|
|
930
|
-
throw new SandboxSetupError(
|
|
931
|
-
'GITHUB_CONFIG_INVALID',
|
|
932
|
-
'The GitHub reviewers must be a list of account names.',
|
|
933
|
-
);
|
|
934
|
-
}
|
|
935
|
-
github = {
|
|
936
|
-
...github,
|
|
937
|
-
...(typeof value.githubOverridesProject === 'boolean'
|
|
938
|
-
? { overridesProject: value.githubOverridesProject }
|
|
939
|
-
: { overridesProject: true }),
|
|
940
|
-
...(typeof value.githubEnabled === 'boolean'
|
|
941
|
-
? { enabled: value.githubEnabled }
|
|
942
|
-
: {}),
|
|
943
|
-
...(value.githubRemote === undefined
|
|
944
|
-
? {}
|
|
945
|
-
: {
|
|
946
|
-
remote: assertGitName(
|
|
947
|
-
text(value, 'githubRemote', 120),
|
|
948
|
-
'remote',
|
|
949
|
-
),
|
|
950
|
-
}),
|
|
951
|
-
...(value.githubRepository === undefined
|
|
952
|
-
? {}
|
|
953
|
-
: {
|
|
954
|
-
repository: assertGitHubRepository(
|
|
955
|
-
optionalText(value, 'githubRepository', 160),
|
|
956
|
-
),
|
|
957
|
-
}),
|
|
958
|
-
...(value.githubBaseBranch === undefined
|
|
959
|
-
? {}
|
|
960
|
-
: {
|
|
961
|
-
baseBranch: assertGitName(
|
|
962
|
-
text(value, 'githubBaseBranch', 120),
|
|
963
|
-
'baseBranch',
|
|
964
|
-
),
|
|
965
|
-
}),
|
|
966
|
-
...(value.githubBranchPrefix === undefined
|
|
967
|
-
? {}
|
|
968
|
-
: {
|
|
969
|
-
branchPrefix: assertGitName(
|
|
970
|
-
text(value, 'githubBranchPrefix', 120),
|
|
971
|
-
'branchPrefix',
|
|
972
|
-
),
|
|
973
|
-
}),
|
|
974
|
-
...(value.githubMode === undefined
|
|
975
|
-
? {}
|
|
976
|
-
: {
|
|
977
|
-
mode: assertGitHubDeliveryMode(text(value, 'githubMode', 20)),
|
|
978
|
-
}),
|
|
979
|
-
...(value.githubForkOwner === undefined
|
|
980
|
-
? {}
|
|
981
|
-
: {
|
|
982
|
-
forkOwner: assertGitHubAccount(
|
|
983
|
-
optionalText(value, 'githubForkOwner', 80),
|
|
984
|
-
),
|
|
985
|
-
}),
|
|
986
|
-
...(reviewersValue === undefined
|
|
987
|
-
? {}
|
|
988
|
-
: {
|
|
989
|
-
reviewers: (reviewersValue as string[]).map((entry) => {
|
|
990
|
-
if (entry.trim() === '') {
|
|
991
|
-
throw new SandboxSetupError(
|
|
992
|
-
'GITHUB_CONFIG_INVALID',
|
|
993
|
-
'The GitHub reviewers must be account names.',
|
|
994
|
-
);
|
|
995
|
-
}
|
|
996
|
-
const reviewer = assertGitHubAccount(entry.trim());
|
|
997
|
-
return reviewer as string;
|
|
998
|
-
}),
|
|
999
|
-
}),
|
|
1000
|
-
};
|
|
1001
|
-
}
|
|
1002
|
-
const connection = await runtime.update({
|
|
1003
|
-
...(platformUrl === null ? {} : { platformUrl }),
|
|
1004
|
-
...(token
|
|
1005
|
-
? {
|
|
1006
|
-
platformToken: await sealSecret(runtime.workspaceRoot, token),
|
|
1007
|
-
}
|
|
1008
|
-
: {}),
|
|
1009
|
-
...(value.driver === undefined
|
|
1010
|
-
? {}
|
|
1011
|
-
: { driver: text(value, 'driver', 64) }),
|
|
1012
|
-
...(value.driverModel === undefined
|
|
1013
|
-
? {}
|
|
1014
|
-
: { driverModel: optionalText(value, 'driverModel', 160) }),
|
|
1015
|
-
...(value.previewData === 'fixtures' || value.previewData === 'bridge'
|
|
1016
|
-
? { previewData: value.previewData }
|
|
1017
|
-
: {}),
|
|
1018
|
-
...(value.byokKind === undefined
|
|
1019
|
-
? {}
|
|
1020
|
-
: {
|
|
1021
|
-
byok: {
|
|
1022
|
-
kind: text(value, 'byokKind', 40) as never,
|
|
1023
|
-
model: text(value, 'byokModel', 160),
|
|
1024
|
-
...(optionalText(value, 'byokResourceName', 160)
|
|
1025
|
-
? { resourceName: text(value, 'byokResourceName', 160) }
|
|
1026
|
-
: {}),
|
|
1027
|
-
...(optionalText(value, 'byokBaseUrl', 2_048)
|
|
1028
|
-
? { baseURL: text(value, 'byokBaseUrl', 2_048) }
|
|
1029
|
-
: {}),
|
|
1030
|
-
credential: byokCredential
|
|
1031
|
-
? await sealSecret(runtime.workspaceRoot, byokCredential)
|
|
1032
|
-
: (configuration.byok?.credential ?? null),
|
|
1033
|
-
},
|
|
1034
|
-
}),
|
|
1035
|
-
...(githubPatchRequested ? { github } : {}),
|
|
1036
|
-
...(githubToken
|
|
1037
|
-
? {
|
|
1038
|
-
gitProviderToken: await sealSecret(
|
|
1039
|
-
runtime.workspaceRoot,
|
|
1040
|
-
githubToken,
|
|
1041
|
-
),
|
|
1042
|
-
}
|
|
1043
|
-
: value.githubClearToken === true
|
|
1044
|
-
? { gitProviderToken: null }
|
|
1045
|
-
: {}),
|
|
1046
|
-
});
|
|
1047
|
-
return json({
|
|
1048
|
-
configuration: safeConfiguration(runtime.configuration()),
|
|
1049
|
-
connection,
|
|
1050
|
-
});
|
|
1051
|
-
} catch (error) {
|
|
1052
|
-
return failure(error);
|
|
1053
|
-
}
|
|
1054
|
-
},
|
|
1055
|
-
});
|
|
1056
|
-
|
|
1057
|
-
/* Sign-in for a self-hosted browser. The application address may be set
|
|
1058
|
-
here only while the sandbox has no token yet, so a fresh deployment can
|
|
1059
|
-
be pointed at its application once and never redirected afterwards. */
|
|
1060
|
-
const connect = new ServerRoute({
|
|
1061
|
-
path: '/sandbox/api/connect',
|
|
1062
|
-
methods: ['POST'],
|
|
1063
|
-
handler: async (context) => {
|
|
1064
|
-
try {
|
|
1065
|
-
assertSameOrigin(context.request);
|
|
1066
|
-
if (runtime.configuration().mode === 'loopback') {
|
|
1067
|
-
assertLoopbackHost(context.request, options.port);
|
|
1068
|
-
}
|
|
1069
|
-
const value = await body(context.request);
|
|
1070
|
-
const platformUrl = optionalText(value, 'platformUrl', 300);
|
|
1071
|
-
if (
|
|
1072
|
-
platformUrl &&
|
|
1073
|
-
runtime.configuration().platformToken === null &&
|
|
1074
|
-
assertPlatformUrl(platformUrl) !== runtime.configuration().platformUrl
|
|
1075
|
-
) {
|
|
1076
|
-
await runtime.update({ platformUrl: assertPlatformUrl(platformUrl) });
|
|
1077
|
-
}
|
|
1078
|
-
const session = await runtime.openBrowserSession(
|
|
1079
|
-
text(value, 'token', 4_096),
|
|
1080
|
-
);
|
|
1081
|
-
return json({ authority: session.authority }, 200, {
|
|
1082
|
-
'set-cookie': sessionCookie(session.id, secureCookies),
|
|
1083
|
-
});
|
|
1084
|
-
} catch (error) {
|
|
1085
|
-
return failure(error);
|
|
1086
|
-
}
|
|
1087
|
-
},
|
|
1088
|
-
});
|
|
1089
|
-
|
|
1090
|
-
/* A session starts from one brief. The planner decides whether this is a new
|
|
1091
|
-
module or a change to existing ones, names them, and picks the specialist
|
|
1092
|
-
who takes the first turn, so nobody has to choose an agent up front. */
|
|
1093
|
-
const createSandboxSession = new ServerRoute({
|
|
1094
|
-
path: '/sandbox/api/sessions',
|
|
1095
|
-
methods: ['POST'],
|
|
1096
|
-
handler: async (context) => {
|
|
1097
|
-
try {
|
|
1098
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1099
|
-
const value = await body(context.request);
|
|
1100
|
-
const brief = assertBrief(text(value, 'brief', 20_000));
|
|
1101
|
-
const configuration = runtime.configuration();
|
|
1102
|
-
const driver =
|
|
1103
|
-
optionalText(value, 'driver', 64) ?? configuration.driver;
|
|
1104
|
-
const owner = actingOwner(runtime, context);
|
|
1105
|
-
if (!owner)
|
|
1106
|
-
throw new SandboxSetupError(
|
|
1107
|
-
'SANDBOX_NOT_CONNECTED',
|
|
1108
|
-
'Connect an account before starting work.',
|
|
1109
|
-
);
|
|
1110
|
-
const planningEvents: Omit<ChatEntry, 'sequence' | 'at'>[] = [];
|
|
1111
|
-
const plan = await planWork({
|
|
1112
|
-
onEvent: (event) => {
|
|
1113
|
-
if (
|
|
1114
|
-
event.type === 'turn.started' ||
|
|
1115
|
-
event.type === 'turn.completed'
|
|
1116
|
-
)
|
|
1117
|
-
planningEvents.push({ kind: 'event', role: 'planner', event });
|
|
1118
|
-
},
|
|
1119
|
-
brief,
|
|
1120
|
-
driver,
|
|
1121
|
-
registry: runtime.registry(),
|
|
1122
|
-
roles: runtime.roles(),
|
|
1123
|
-
modules: await listWorkspaceModules(runtime.workspaceRoot),
|
|
1124
|
-
});
|
|
1125
|
-
|
|
1126
|
-
const session = await createSession({
|
|
1127
|
-
owner,
|
|
1128
|
-
workspaceRoot: runtime.workspaceRoot,
|
|
1129
|
-
kind: plan.kind,
|
|
1130
|
-
moduleId: plan.moduleId,
|
|
1131
|
-
modules: plan.modules,
|
|
1132
|
-
title: plan.title,
|
|
1133
|
-
brief,
|
|
1134
|
-
blueprint:
|
|
1135
|
-
plan.kind === 'new-module'
|
|
1136
|
-
? 'new-module@1.0.0'
|
|
1137
|
-
: 'edit-module@1.0.0',
|
|
1138
|
-
role: plan.firstRole,
|
|
1139
|
-
driver,
|
|
1140
|
-
model: configuration.driverModel,
|
|
1141
|
-
...(plan.sourceModule ? { sourceModule: plan.sourceModule } : {}),
|
|
1142
|
-
});
|
|
1143
|
-
|
|
1144
|
-
/* The classification is the first thing the operator sees, so a
|
|
1145
|
-
wrong guess can be corrected in the first message. */
|
|
1146
|
-
await appendChatEntry(runtime.workspaceRoot, session, {
|
|
1147
|
-
kind: 'system',
|
|
1148
|
-
role: plan.firstRole,
|
|
1149
|
-
text: `${plan.kind === 'new-module' ? 'New module' : 'Change to'} ${plan.modules
|
|
1150
|
-
.map((module) => `${module.id} (modules/${module.directory})`)
|
|
1151
|
-
.join(
|
|
1152
|
-
', ',
|
|
1153
|
-
)}, classified by the ${plan.classifiedBy === 'agent' ? 'planner' : 'workspace rules'}. ${plan.rationale} Say so in your first message if this is the wrong module.`,
|
|
1154
|
-
});
|
|
1155
|
-
|
|
1156
|
-
for (const entry of planningEvents)
|
|
1157
|
-
await appendChatEntry(runtime.workspaceRoot, session, entry);
|
|
1158
|
-
const platform = actingPlatform(runtime, context);
|
|
1159
|
-
if (platform) {
|
|
1160
|
-
await platform
|
|
1161
|
-
.registerSession({
|
|
1162
|
-
sessionId: session.id,
|
|
1163
|
-
moduleId: session.moduleId,
|
|
1164
|
-
title: session.title,
|
|
1165
|
-
blueprint: session.blueprint,
|
|
1166
|
-
driver: session.driver,
|
|
1167
|
-
mode: configuration.mode,
|
|
1168
|
-
})
|
|
1169
|
-
.then(async () => {
|
|
1170
|
-
await updateSession(runtime.workspaceRoot, session.id, {
|
|
1171
|
-
registeredWithPlatform: true,
|
|
1172
|
-
});
|
|
1173
|
-
})
|
|
1174
|
-
.catch(() => undefined);
|
|
1175
|
-
}
|
|
1176
|
-
return json(
|
|
1177
|
-
{
|
|
1178
|
-
session: await readSession(runtime.workspaceRoot, session.id),
|
|
1179
|
-
plan,
|
|
1180
|
-
},
|
|
1181
|
-
201,
|
|
1182
|
-
);
|
|
1183
|
-
} catch (error) {
|
|
1184
|
-
return failure(error);
|
|
1185
|
-
}
|
|
1186
|
-
},
|
|
1187
|
-
});
|
|
1188
|
-
|
|
1189
|
-
const readSandboxSession = new ServerRoute({
|
|
1190
|
-
path: '/sandbox/api/sessions/:id',
|
|
1191
|
-
methods: ['GET'],
|
|
1192
|
-
handler: async (context) => {
|
|
1193
|
-
try {
|
|
1194
|
-
await authorize(runtime, context, options);
|
|
1195
|
-
const session = await readSession(
|
|
1196
|
-
runtime.workspaceRoot,
|
|
1197
|
-
sessionIdParam(context),
|
|
1198
|
-
);
|
|
1199
|
-
return json(
|
|
1200
|
-
await sessionView(runtime, session, running.has(session.id)),
|
|
1201
|
-
);
|
|
1202
|
-
} catch (error) {
|
|
1203
|
-
return failure(error);
|
|
1204
|
-
}
|
|
1205
|
-
},
|
|
1206
|
-
});
|
|
1207
|
-
|
|
1208
|
-
/* A session grows: an existing module of this workspace is materialized into
|
|
1209
|
-
the session, with its pristine base, and joins the modules the turns,
|
|
1210
|
-
gates, preview and delivery already iterate. The primary module never
|
|
1211
|
-
changes, so nothing that keyed on it moves. */
|
|
1212
|
-
const addSandboxModule = new ServerRoute({
|
|
1213
|
-
path: '/sandbox/api/sessions/:id/modules',
|
|
1214
|
-
methods: ['POST'],
|
|
1215
|
-
handler: async (context) => {
|
|
1216
|
-
try {
|
|
1217
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1218
|
-
const sessionId = sessionIdParam(context);
|
|
1219
|
-
const value = await body(context.request);
|
|
1220
|
-
const moduleId = text(value, 'moduleId', 120);
|
|
1221
|
-
const session = await readSession(runtime.workspaceRoot, sessionId);
|
|
1222
|
-
assertNotArchived(session);
|
|
1223
|
-
if (running.has(sessionId)) {
|
|
1224
|
-
throw new SandboxSetupError(
|
|
1225
|
-
'SESSION_RUNNING',
|
|
1226
|
-
'A turn is still running in this session. Wait for it or stop it before adding a module.',
|
|
1227
|
-
);
|
|
1228
|
-
}
|
|
1229
|
-
if (session.ejectedAt !== null) {
|
|
1230
|
-
throw new SandboxSetupError(
|
|
1231
|
-
'SESSION_DELIVERED',
|
|
1232
|
-
'This session was already delivered. Start a new session to work on another module.',
|
|
1233
|
-
);
|
|
1234
|
-
}
|
|
1235
|
-
const known = (await listWorkspaceModules(runtime.workspaceRoot)).find(
|
|
1236
|
-
(module) => module.id === moduleId,
|
|
1237
|
-
);
|
|
1238
|
-
if (!known) {
|
|
1239
|
-
throw new SandboxSetupError(
|
|
1240
|
-
'MODULE_NOT_FOUND',
|
|
1241
|
-
`No module ${moduleId} exists in this workspace.`,
|
|
1242
|
-
);
|
|
1243
|
-
}
|
|
1244
|
-
const updated = await addSessionModule(runtime.workspaceRoot, session, {
|
|
1245
|
-
id: known.id,
|
|
1246
|
-
directory: known.directory,
|
|
1247
|
-
kind: 'edit',
|
|
1248
|
-
});
|
|
1249
|
-
const install = await installSessionDependencies(
|
|
1250
|
-
runtime.workspaceRoot,
|
|
1251
|
-
updated,
|
|
1252
|
-
);
|
|
1253
|
-
await appendChatEntry(runtime.workspaceRoot, updated, {
|
|
1254
|
-
kind: 'system',
|
|
1255
|
-
role: updated.role,
|
|
1256
|
-
module: known.directory,
|
|
1257
|
-
text: `Added ${known.id} (modules/${known.directory}) to this session. Its working copy and its pristine base are in the session workspace.${
|
|
1258
|
-
install.ran && !install.ok
|
|
1259
|
-
? `\n\nThe session workspace could not install the declared dependencies:\n${install.output}`
|
|
1260
|
-
: ''
|
|
1261
|
-
}`,
|
|
1262
|
-
});
|
|
1263
|
-
forgetDiffs(sessionId);
|
|
1264
|
-
await preview.forget(sessionId);
|
|
1265
|
-
return json(
|
|
1266
|
-
await sessionView(
|
|
1267
|
-
runtime,
|
|
1268
|
-
await readSession(runtime.workspaceRoot, sessionId),
|
|
1269
|
-
false,
|
|
1270
|
-
),
|
|
1271
|
-
201,
|
|
1272
|
-
);
|
|
1273
|
-
} catch (error) {
|
|
1274
|
-
return failure(error);
|
|
1275
|
-
}
|
|
1276
|
-
},
|
|
1277
|
-
});
|
|
1278
|
-
|
|
1279
|
-
/* Attachments are copied into the session workspace at upload time, so the
|
|
1280
|
-
coding agent, which may only read inside the workspace, opens them by name
|
|
1281
|
-
from reference/attachments/. The bytes never leave this origin. */
|
|
1282
|
-
const addSandboxAttachment = new ServerRoute({
|
|
1283
|
-
path: '/sandbox/api/sessions/:id/attachments',
|
|
1284
|
-
methods: ['POST'],
|
|
1285
|
-
handler: async (context) => {
|
|
1286
|
-
try {
|
|
1287
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1288
|
-
const sessionId = sessionIdParam(context);
|
|
1289
|
-
const session = await readSession(runtime.workspaceRoot, sessionId);
|
|
1290
|
-
assertNotArchived(session);
|
|
1291
|
-
const value = await body(
|
|
1292
|
-
context.request,
|
|
1293
|
-
Math.ceil(MAX_ATTACHMENT_BYTES / 3) * 4 + 2_048,
|
|
1294
|
-
);
|
|
1295
|
-
const attachment = await addAttachment(runtime.workspaceRoot, session, {
|
|
1296
|
-
name: text(value, 'name', 256),
|
|
1297
|
-
bytes: base64Field(value, 'contentBase64', MAX_ATTACHMENT_BYTES),
|
|
1298
|
-
});
|
|
1299
|
-
return json({ attachment }, 201);
|
|
1300
|
-
} catch (error) {
|
|
1301
|
-
return failure(error);
|
|
1302
|
-
}
|
|
1303
|
-
},
|
|
1304
|
-
});
|
|
1305
|
-
|
|
1306
|
-
const removeSandboxAttachment = new ServerRoute({
|
|
1307
|
-
path: '/sandbox/api/sessions/:id/attachments/:attachmentId/delete',
|
|
1308
|
-
methods: ['POST'],
|
|
1309
|
-
handler: async (context) => {
|
|
1310
|
-
try {
|
|
1311
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1312
|
-
const session = await readSession(
|
|
1313
|
-
runtime.workspaceRoot,
|
|
1314
|
-
sessionIdParam(context),
|
|
1315
|
-
);
|
|
1316
|
-
await removeAttachment(
|
|
1317
|
-
runtime.workspaceRoot,
|
|
1318
|
-
session,
|
|
1319
|
-
attachmentIdParam(context),
|
|
1320
|
-
);
|
|
1321
|
-
return json({ deleted: true });
|
|
1322
|
-
} catch (error) {
|
|
1323
|
-
return failure(error);
|
|
1324
|
-
}
|
|
1325
|
-
},
|
|
1326
|
-
});
|
|
1327
|
-
|
|
1328
|
-
/* Serves the bytes for the composer thumbnail. A read, so it needs no
|
|
1329
|
-
mutation header; the same-origin cookie or loopback host still gates it. */
|
|
1330
|
-
const serveSandboxAttachment = new ServerRoute({
|
|
1331
|
-
path: '/sandbox/api/sessions/:id/attachments/:attachmentId',
|
|
1332
|
-
methods: ['GET'],
|
|
1333
|
-
handler: async (context) => {
|
|
1334
|
-
try {
|
|
1335
|
-
await authorize(runtime, context, options);
|
|
1336
|
-
const session = await readSession(
|
|
1337
|
-
runtime.workspaceRoot,
|
|
1338
|
-
sessionIdParam(context),
|
|
1339
|
-
);
|
|
1340
|
-
const attachment = await readAttachment(
|
|
1341
|
-
runtime.workspaceRoot,
|
|
1342
|
-
session,
|
|
1343
|
-
attachmentIdParam(context),
|
|
1344
|
-
);
|
|
1345
|
-
return new Response(new Uint8Array(attachment.bytes), {
|
|
1346
|
-
headers: {
|
|
1347
|
-
'content-type': attachment.contentType,
|
|
1348
|
-
'content-disposition': `inline; filename="${attachment.name}"`,
|
|
1349
|
-
'cache-control': 'private, no-store',
|
|
1350
|
-
'x-content-type-options': 'nosniff',
|
|
1351
|
-
},
|
|
1352
|
-
});
|
|
1353
|
-
} catch (error) {
|
|
1354
|
-
return failure(error);
|
|
1355
|
-
}
|
|
1356
|
-
},
|
|
1357
|
-
});
|
|
1358
|
-
|
|
1359
|
-
/* Lifecycle actions refuse a running session unless the caller says stop:
|
|
1360
|
-
a turn that is still writing must not lose its workspace under it. */
|
|
1361
|
-
const stopIfRequested = async (
|
|
1362
|
-
sessionId: string,
|
|
1363
|
-
value: Record<string, unknown>,
|
|
1364
|
-
): Promise<void> => {
|
|
1365
|
-
const channel = running.get(sessionId);
|
|
1366
|
-
if (!channel) return;
|
|
1367
|
-
if (value.stop !== true) {
|
|
1368
|
-
throw new SandboxSetupError(
|
|
1369
|
-
'SESSION_RUNNING',
|
|
1370
|
-
'A turn is still running in this session. Stop it first.',
|
|
1371
|
-
);
|
|
1372
|
-
}
|
|
1373
|
-
channel.controller.abort('stopped');
|
|
1374
|
-
await waitForTurn(channel);
|
|
1375
|
-
};
|
|
1376
|
-
|
|
1377
|
-
const notifyPlatform = async (
|
|
1378
|
-
session: SandboxSession,
|
|
1379
|
-
state: string,
|
|
1380
|
-
context: Context,
|
|
1381
|
-
): Promise<void> => {
|
|
1382
|
-
if (!session.registeredWithPlatform) return;
|
|
1383
|
-
await actingPlatform(runtime, context)
|
|
1384
|
-
?.updateSessionState(session.id, state)
|
|
1385
|
-
.catch(() => undefined);
|
|
1386
|
-
};
|
|
1387
|
-
|
|
1388
|
-
const archiveSandboxSession = new ServerRoute({
|
|
1389
|
-
path: '/sandbox/api/sessions/:id/archive',
|
|
1390
|
-
methods: ['POST'],
|
|
1391
|
-
handler: async (context) => {
|
|
1392
|
-
try {
|
|
1393
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1394
|
-
const sessionId = sessionIdParam(context);
|
|
1395
|
-
const value = await body(context.request);
|
|
1396
|
-
await readSession(runtime.workspaceRoot, sessionId);
|
|
1397
|
-
await stopIfRequested(sessionId, value);
|
|
1398
|
-
const session = await archiveSession(runtime.workspaceRoot, sessionId);
|
|
1399
|
-
await preview.forget(sessionId);
|
|
1400
|
-
await notifyPlatform(session, 'archived', context);
|
|
1401
|
-
return json({ session });
|
|
1402
|
-
} catch (error) {
|
|
1403
|
-
return failure(error);
|
|
1404
|
-
}
|
|
1405
|
-
},
|
|
1406
|
-
});
|
|
1407
|
-
|
|
1408
|
-
const restoreSandboxSession = new ServerRoute({
|
|
1409
|
-
path: '/sandbox/api/sessions/:id/restore',
|
|
1410
|
-
methods: ['POST'],
|
|
1411
|
-
handler: async (context) => {
|
|
1412
|
-
try {
|
|
1413
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1414
|
-
const sessionId = sessionIdParam(context);
|
|
1415
|
-
await body(context.request);
|
|
1416
|
-
const session = await restoreSession(runtime.workspaceRoot, sessionId);
|
|
1417
|
-
await notifyPlatform(session, session.state, context);
|
|
1418
|
-
return json({ session });
|
|
1419
|
-
} catch (error) {
|
|
1420
|
-
return failure(error);
|
|
1421
|
-
}
|
|
1422
|
-
},
|
|
1423
|
-
});
|
|
1424
|
-
|
|
1425
|
-
const rejectSandboxSession = new ServerRoute({
|
|
1426
|
-
path: '/sandbox/api/sessions/:id/reject',
|
|
1427
|
-
methods: ['POST'],
|
|
1428
|
-
handler: async (context) => {
|
|
1429
|
-
try {
|
|
1430
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1431
|
-
await body(context.request);
|
|
1432
|
-
const id = sessionIdParam(context);
|
|
1433
|
-
const current = await readSession(runtime.workspaceRoot, id);
|
|
1434
|
-
assertNotDelivered(current);
|
|
1435
|
-
await stopIfRequested(id, {});
|
|
1436
|
-
const at = current.rejectedAt ?? Date.now();
|
|
1437
|
-
const session = await updateSession(runtime.workspaceRoot, id, {
|
|
1438
|
-
rejectedAt: at,
|
|
1439
|
-
archivedAt: at,
|
|
1440
|
-
});
|
|
1441
|
-
await preview.forget(id);
|
|
1442
|
-
return json({ session });
|
|
1443
|
-
} catch (error) {
|
|
1444
|
-
return failure(error);
|
|
1445
|
-
}
|
|
1446
|
-
},
|
|
1447
|
-
});
|
|
1448
|
-
|
|
1449
|
-
/* Rolls the session's workspace back to a captured checkpoint. The path is
|
|
1450
|
-
distinct from /restore, which un-archives a session; this one replaces the
|
|
1451
|
-
draft files with an earlier snapshot and keeps the transcript. A delivered
|
|
1452
|
-
session refuses: its module already landed, so a new session should carry
|
|
1453
|
-
any further change. */
|
|
1454
|
-
const restoreSandboxCheckpoint = new ServerRoute({
|
|
1455
|
-
path: '/sandbox/api/sessions/:id/checkpoints/restore',
|
|
1456
|
-
methods: ['POST'],
|
|
1457
|
-
handler: async (context) => {
|
|
1458
|
-
try {
|
|
1459
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1460
|
-
const sessionId = sessionIdParam(context);
|
|
1461
|
-
const value = await body(context.request);
|
|
1462
|
-
const sequence = value.sequence;
|
|
1463
|
-
if (
|
|
1464
|
-
typeof sequence !== 'number' ||
|
|
1465
|
-
!Number.isInteger(sequence) ||
|
|
1466
|
-
sequence < 0
|
|
1467
|
-
) {
|
|
1468
|
-
throw new SandboxSetupError(
|
|
1469
|
-
'INVALID_INPUT',
|
|
1470
|
-
'sequence must be a non-negative integer.',
|
|
1471
|
-
);
|
|
1472
|
-
}
|
|
1473
|
-
const session = await readSession(runtime.workspaceRoot, sessionId);
|
|
1474
|
-
assertNotArchived(session);
|
|
1475
|
-
if (running.has(sessionId)) {
|
|
1476
|
-
throw new SandboxSetupError(
|
|
1477
|
-
'SESSION_RUNNING',
|
|
1478
|
-
'A turn is still running in this session. Stop it before restoring a checkpoint.',
|
|
1479
|
-
);
|
|
1480
|
-
}
|
|
1481
|
-
if (session.ejectedAt !== null) {
|
|
1482
|
-
throw new SandboxSetupError(
|
|
1483
|
-
'SESSION_DELIVERED',
|
|
1484
|
-
'This session was already delivered. Start a new session to change the module again.',
|
|
1485
|
-
);
|
|
1486
|
-
}
|
|
1487
|
-
const restored = await restoreCheckpoint(
|
|
1488
|
-
runtime.workspaceRoot,
|
|
1489
|
-
sessionId,
|
|
1490
|
-
sequence,
|
|
1491
|
-
);
|
|
1492
|
-
await preview.forget(sessionId);
|
|
1493
|
-
await notifyPlatform(restored, restored.state, context);
|
|
1494
|
-
return json(await sessionView(runtime, restored, false));
|
|
1495
|
-
} catch (error) {
|
|
1496
|
-
return failure(error);
|
|
1497
|
-
}
|
|
1498
|
-
},
|
|
1499
|
-
});
|
|
1500
|
-
|
|
1501
|
-
const removeSandboxSession = new ServerRoute({
|
|
1502
|
-
path: '/sandbox/api/sessions/:id/delete',
|
|
1503
|
-
methods: ['POST'],
|
|
1504
|
-
handler: async (context) => {
|
|
1505
|
-
try {
|
|
1506
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1507
|
-
const sessionId = sessionIdParam(context);
|
|
1508
|
-
const value = await body(context.request);
|
|
1509
|
-
const session = await readSession(runtime.workspaceRoot, sessionId);
|
|
1510
|
-
await stopIfRequested(sessionId, value);
|
|
1511
|
-
await preview.forget(sessionId);
|
|
1512
|
-
forgetDiffs(sessionId);
|
|
1513
|
-
await deleteSession(runtime.workspaceRoot, sessionId, {
|
|
1514
|
-
keepTranscript: value.keepTranscript !== false,
|
|
1515
|
-
});
|
|
1516
|
-
await notifyPlatform(session, 'deleted', context);
|
|
1517
|
-
return json({
|
|
1518
|
-
deleted: true,
|
|
1519
|
-
keptTranscript: value.keepTranscript !== false,
|
|
1520
|
-
});
|
|
1521
|
-
} catch (error) {
|
|
1522
|
-
return failure(error);
|
|
1523
|
-
}
|
|
1524
|
-
},
|
|
1525
|
-
});
|
|
1526
|
-
|
|
1527
|
-
const turn = new ServerRoute({
|
|
1528
|
-
path: '/sandbox/api/sessions/:id/turn',
|
|
1529
|
-
methods: ['POST'],
|
|
1530
|
-
handler: async (context) => {
|
|
1531
|
-
try {
|
|
1532
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1533
|
-
const sessionId = sessionIdParam(context);
|
|
1534
|
-
const value = await body(context.request);
|
|
1535
|
-
assertNotArchived(await readSession(runtime.workspaceRoot, sessionId));
|
|
1536
|
-
const channel = startTurn(
|
|
1537
|
-
sessionId,
|
|
1538
|
-
{
|
|
1539
|
-
message: text(value, 'message', 20_000),
|
|
1540
|
-
...(optionalText(value, 'role', 64)
|
|
1541
|
-
? { role: text(value, 'role', 64) }
|
|
1542
|
-
: {}),
|
|
1543
|
-
...(optionalText(value, 'module', 64)
|
|
1544
|
-
? { module: text(value, 'module', 64) }
|
|
1545
|
-
: {}),
|
|
1546
|
-
...(optionalText(value, 'driver', 64)
|
|
1547
|
-
? { driver: text(value, 'driver', 64) }
|
|
1548
|
-
: {}),
|
|
1549
|
-
},
|
|
1550
|
-
actingPlatform(runtime, context),
|
|
1551
|
-
);
|
|
1552
|
-
return streamChannel(channel);
|
|
1553
|
-
} catch (error) {
|
|
1554
|
-
return failure(error);
|
|
1555
|
-
}
|
|
1556
|
-
},
|
|
1557
|
-
});
|
|
1558
|
-
|
|
1559
|
-
/* Follow a turn another browser, or an earlier page load, started. */
|
|
1560
|
-
const followTurn = new ServerRoute({
|
|
1561
|
-
path: '/sandbox/api/sessions/:id/turn/stream',
|
|
1562
|
-
methods: ['GET'],
|
|
1563
|
-
handler: async (context) => {
|
|
1564
|
-
try {
|
|
1565
|
-
await authorize(runtime, context, options);
|
|
1566
|
-
const channel = running.get(sessionIdParam(context));
|
|
1567
|
-
if (!channel) return json({ running: false });
|
|
1568
|
-
return streamChannel(channel);
|
|
1569
|
-
} catch (error) {
|
|
1570
|
-
return failure(error);
|
|
1571
|
-
}
|
|
1572
|
-
},
|
|
1573
|
-
});
|
|
1574
|
-
|
|
1575
|
-
const stop = new ServerRoute({
|
|
1576
|
-
path: '/sandbox/api/sessions/:id/stop',
|
|
1577
|
-
methods: ['POST'],
|
|
1578
|
-
handler: async (context) => {
|
|
1579
|
-
try {
|
|
1580
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1581
|
-
const channel = running.get(sessionIdParam(context));
|
|
1582
|
-
channel?.controller.abort('stopped');
|
|
1583
|
-
return json({ stopped: channel !== undefined });
|
|
1584
|
-
} catch (error) {
|
|
1585
|
-
return failure(error);
|
|
1586
|
-
}
|
|
1587
|
-
},
|
|
1588
|
-
});
|
|
1589
|
-
|
|
1590
|
-
/* The operator decides whether a handoff runs on its own. The choice belongs
|
|
1591
|
-
to the session, so a long chain stays off for that session only. */
|
|
1592
|
-
const settings = new ServerRoute({
|
|
1593
|
-
path: '/sandbox/api/sessions/:id/settings',
|
|
1594
|
-
methods: ['POST'],
|
|
1595
|
-
handler: async (context) => {
|
|
1596
|
-
try {
|
|
1597
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1598
|
-
const value = await body(context.request);
|
|
1599
|
-
const session = await updateSession(
|
|
1600
|
-
runtime.workspaceRoot,
|
|
1601
|
-
sessionIdParam(context),
|
|
1602
|
-
{ autoContinue: value.autoContinue === true },
|
|
1603
|
-
);
|
|
1604
|
-
return json({ session });
|
|
1605
|
-
} catch (error) {
|
|
1606
|
-
return failure(error);
|
|
1607
|
-
}
|
|
1608
|
-
},
|
|
1609
|
-
});
|
|
1610
|
-
|
|
1611
|
-
/* Approving a specification is the operator's decision, so the sandbox makes
|
|
1612
|
-
it: the status line moves to approved, the approved text is recorded on
|
|
1613
|
-
the module, and its implementer can start. One module at a time. */
|
|
1614
|
-
const approve = new ServerRoute({
|
|
1615
|
-
path: '/sandbox/api/sessions/:id/approve',
|
|
1616
|
-
methods: ['POST'],
|
|
1617
|
-
handler: async (context) => {
|
|
1618
|
-
try {
|
|
1619
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1620
|
-
const session = await readSession(
|
|
1621
|
-
runtime.workspaceRoot,
|
|
1622
|
-
sessionIdParam(context),
|
|
1623
|
-
);
|
|
1624
|
-
assertNotArchived(session);
|
|
1625
|
-
assertNotDelivered(session);
|
|
1626
|
-
const value = await body(context.request);
|
|
1627
|
-
const module = findSessionModule(
|
|
1628
|
-
session,
|
|
1629
|
-
optionalText(value, 'module', 120),
|
|
1630
|
-
);
|
|
1631
|
-
const approved = await approveSpecification(
|
|
1632
|
-
runtime.workspaceRoot,
|
|
1633
|
-
session,
|
|
1634
|
-
module,
|
|
1635
|
-
);
|
|
1636
|
-
await appendChatEntry(runtime.workspaceRoot, approved.session, {
|
|
1637
|
-
decision: 'approved',
|
|
1638
|
-
kind: 'system',
|
|
1639
|
-
role: session.role,
|
|
1640
|
-
module: module.directory,
|
|
1641
|
-
text: `You approved the specification of ${module.id}. Implementation of modules/${module.directory} is unblocked until the specification changes again.`,
|
|
1642
|
-
});
|
|
1643
|
-
return json({
|
|
1644
|
-
session: approved.session,
|
|
1645
|
-
status: approved.status,
|
|
1646
|
-
module: approved.module,
|
|
1647
|
-
});
|
|
1648
|
-
} catch (error) {
|
|
1649
|
-
return failure(error);
|
|
1650
|
-
}
|
|
1651
|
-
},
|
|
1652
|
-
});
|
|
1653
|
-
|
|
1654
|
-
/* The second answer to a review: the operator says in words what the
|
|
1655
|
-
specification should say instead. The comment is the business manager's
|
|
1656
|
-
next turn, and the transcript records who asked for what. */
|
|
1657
|
-
const requestSpecChanges = new ServerRoute({
|
|
1658
|
-
path: '/sandbox/api/sessions/:id/spec/changes',
|
|
1659
|
-
methods: ['POST'],
|
|
1660
|
-
handler: async (context) => {
|
|
1661
|
-
try {
|
|
1662
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1663
|
-
const session = await readSession(
|
|
1664
|
-
runtime.workspaceRoot,
|
|
1665
|
-
sessionIdParam(context),
|
|
1666
|
-
);
|
|
1667
|
-
assertNotArchived(session);
|
|
1668
|
-
assertNotDelivered(session);
|
|
1669
|
-
const value = await body(context.request);
|
|
1670
|
-
const module = findSessionModule(
|
|
1671
|
-
session,
|
|
1672
|
-
optionalText(value, 'module', 120),
|
|
1673
|
-
);
|
|
1674
|
-
const comment = text(value, 'comment', MAX_SPEC_COMMENT);
|
|
1675
|
-
const handoff = {
|
|
1676
|
-
kind: 'continue' as const,
|
|
1677
|
-
role: SPEC_OWNER_ROLE,
|
|
1678
|
-
roleName:
|
|
1679
|
-
runtime.roles().find((role) => role.id === SPEC_OWNER_ROLE)?.name ??
|
|
1680
|
-
SPEC_OWNER_ROLE,
|
|
1681
|
-
reason: `You asked for changes to the specification of ${module.id}.`,
|
|
1682
|
-
prompt: [
|
|
1683
|
-
`The operator reviewed ${module.id} and asked for this change to the specification:`,
|
|
1684
|
-
comment,
|
|
1685
|
-
'Change spec/module.yaml to match, and nothing else. Leave status as it is: the operator approves. End with your handoff line.',
|
|
1686
|
-
].join('\n\n'),
|
|
1687
|
-
module: module.directory,
|
|
1688
|
-
};
|
|
1689
|
-
/* A review comment withdraws the prior decision immediately. The
|
|
1690
|
-
business manager can then revise the same document, but no stale
|
|
1691
|
-
approval can slip through an eject while that happens. */
|
|
1692
|
-
const awaitingRevision = await updateSession(
|
|
1693
|
-
runtime.workspaceRoot,
|
|
1694
|
-
session.id,
|
|
1695
|
-
{
|
|
1696
|
-
modules: session.modules.map((entry) =>
|
|
1697
|
-
entry.directory === module.directory
|
|
1698
|
-
? {
|
|
1699
|
-
id: entry.id,
|
|
1700
|
-
directory: entry.directory,
|
|
1701
|
-
kind: entry.kind,
|
|
1702
|
-
}
|
|
1703
|
-
: entry,
|
|
1704
|
-
),
|
|
1705
|
-
state: 'planned',
|
|
1706
|
-
},
|
|
1707
|
-
);
|
|
1708
|
-
await appendChatEntry(runtime.workspaceRoot, awaitingRevision, {
|
|
1709
|
-
decision: 'changes-requested',
|
|
1710
|
-
kind: 'user',
|
|
1711
|
-
role: SPEC_OWNER_ROLE,
|
|
1712
|
-
module: module.directory,
|
|
1713
|
-
text: `Requested changes to the specification of ${module.id}: ${comment}`,
|
|
1714
|
-
handoff,
|
|
1715
|
-
});
|
|
1716
|
-
return json({
|
|
1717
|
-
session: await readSession(runtime.workspaceRoot, session.id),
|
|
1718
|
-
handoff,
|
|
1719
|
-
});
|
|
1720
|
-
} catch (error) {
|
|
1721
|
-
return failure(error);
|
|
1722
|
-
}
|
|
1723
|
-
},
|
|
1724
|
-
});
|
|
1725
|
-
|
|
1726
|
-
/* The third answer: the operator edits the document. Reading it needs no
|
|
1727
|
-
mutation header; writing replaces the draft and says so in the transcript,
|
|
1728
|
-
which also re-opens the approval gate because the text changed. */
|
|
1729
|
-
const readSpecDocument = new ServerRoute({
|
|
1730
|
-
path: '/sandbox/api/sessions/:id/spec',
|
|
1731
|
-
methods: ['GET'],
|
|
1732
|
-
handler: async (context) => {
|
|
1733
|
-
try {
|
|
1734
|
-
await authorize(runtime, context, options);
|
|
1735
|
-
const session = await readSession(
|
|
1736
|
-
runtime.workspaceRoot,
|
|
1737
|
-
sessionIdParam(context),
|
|
1738
|
-
);
|
|
1739
|
-
const module = findSessionModule(
|
|
1740
|
-
session,
|
|
1741
|
-
context.url.searchParams.get('module'),
|
|
1742
|
-
);
|
|
1743
|
-
return json({
|
|
1744
|
-
module: module.directory,
|
|
1745
|
-
moduleId: module.id,
|
|
1746
|
-
path: `modules/${module.directory}/spec/module.yaml`,
|
|
1747
|
-
text: await readSpecFile(runtime.workspaceRoot, session, module),
|
|
1748
|
-
});
|
|
1749
|
-
} catch (error) {
|
|
1750
|
-
return failure(error);
|
|
1751
|
-
}
|
|
1752
|
-
},
|
|
1753
|
-
});
|
|
1754
|
-
|
|
1755
|
-
const writeSpecDocument = new ServerRoute({
|
|
1756
|
-
path: '/sandbox/api/sessions/:id/spec',
|
|
1757
|
-
methods: ['POST'],
|
|
1758
|
-
handler: async (context) => {
|
|
1759
|
-
try {
|
|
1760
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1761
|
-
const sessionId = sessionIdParam(context);
|
|
1762
|
-
const session = await readSession(runtime.workspaceRoot, sessionId);
|
|
1763
|
-
assertNotArchived(session);
|
|
1764
|
-
assertNotDelivered(session);
|
|
1765
|
-
if (running.has(sessionId)) {
|
|
1766
|
-
throw new SandboxSetupError(
|
|
1767
|
-
'SESSION_RUNNING',
|
|
1768
|
-
'A turn is still running in this session. Wait for it or stop it before editing the specification.',
|
|
1769
|
-
);
|
|
1770
|
-
}
|
|
1771
|
-
const value = await body(context.request, MAX_SPEC_TEXT * 2 + 2_048);
|
|
1772
|
-
const module = findSessionModule(
|
|
1773
|
-
session,
|
|
1774
|
-
optionalText(value, 'module', 120),
|
|
1775
|
-
);
|
|
1776
|
-
const draft = text(value, 'text', MAX_SPEC_TEXT);
|
|
1777
|
-
await writeSpecFile(
|
|
1778
|
-
runtime.workspaceRoot,
|
|
1779
|
-
session,
|
|
1780
|
-
module,
|
|
1781
|
-
`${draft.trimEnd()}\n`,
|
|
1782
|
-
);
|
|
1783
|
-
forgetDiffs(sessionId);
|
|
1784
|
-
await appendChatEntry(runtime.workspaceRoot, session, {
|
|
1785
|
-
kind: 'system',
|
|
1786
|
-
role: session.role,
|
|
1787
|
-
module: module.directory,
|
|
1788
|
-
text: `You edited the specification of ${module.id}. Approve it to let the implementer continue.`,
|
|
1789
|
-
});
|
|
1790
|
-
return json(
|
|
1791
|
-
await sessionView(
|
|
1792
|
-
runtime,
|
|
1793
|
-
await readSession(runtime.workspaceRoot, sessionId),
|
|
1794
|
-
false,
|
|
1795
|
-
),
|
|
1796
|
-
);
|
|
1797
|
-
} catch (error) {
|
|
1798
|
-
return failure(error);
|
|
1799
|
-
}
|
|
1800
|
-
},
|
|
1801
|
-
});
|
|
1802
|
-
|
|
1803
|
-
const gates = new ServerRoute({
|
|
1804
|
-
path: '/sandbox/api/sessions/:id/gates',
|
|
1805
|
-
methods: ['POST'],
|
|
1806
|
-
handler: async (context) => {
|
|
1807
|
-
try {
|
|
1808
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1809
|
-
const value = await body(context.request);
|
|
1810
|
-
const session = await readSession(
|
|
1811
|
-
runtime.workspaceRoot,
|
|
1812
|
-
sessionIdParam(context),
|
|
1813
|
-
);
|
|
1814
|
-
const requested = Array.isArray(value.gates)
|
|
1815
|
-
? (value.gates as readonly string[])
|
|
1816
|
-
: [
|
|
1817
|
-
'module-schema',
|
|
1818
|
-
'spec-schema',
|
|
1819
|
-
'dependencies',
|
|
1820
|
-
'typecheck',
|
|
1821
|
-
'tests',
|
|
1822
|
-
'format',
|
|
1823
|
-
];
|
|
1824
|
-
const results = await runSessionGates(
|
|
1825
|
-
turnContext(),
|
|
1826
|
-
session,
|
|
1827
|
-
requested,
|
|
1828
|
-
);
|
|
1829
|
-
return json({ gates: results });
|
|
1830
|
-
} catch (error) {
|
|
1831
|
-
return failure(error);
|
|
1832
|
-
}
|
|
1833
|
-
},
|
|
1834
|
-
});
|
|
1835
|
-
|
|
1836
|
-
const format = new ServerRoute({
|
|
1837
|
-
path: '/sandbox/api/sessions/:id/format',
|
|
1838
|
-
methods: ['POST'],
|
|
1839
|
-
handler: async (context) => {
|
|
1840
|
-
try {
|
|
1841
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1842
|
-
const session = await readSession(
|
|
1843
|
-
runtime.workspaceRoot,
|
|
1844
|
-
sessionIdParam(context),
|
|
1845
|
-
);
|
|
1846
|
-
const results = await formatSession({
|
|
1847
|
-
workspaceRoot: runtime.workspaceRoot,
|
|
1848
|
-
paths: sessionPaths(
|
|
1849
|
-
runtime.workspaceRoot,
|
|
1850
|
-
session.id,
|
|
1851
|
-
session.moduleSuffix,
|
|
1852
|
-
),
|
|
1853
|
-
session,
|
|
1854
|
-
});
|
|
1855
|
-
return json({ gate: results[0], gates: results });
|
|
1856
|
-
} catch (error) {
|
|
1857
|
-
return failure(error);
|
|
1858
|
-
}
|
|
1859
|
-
},
|
|
1860
|
-
});
|
|
1861
|
-
|
|
1862
|
-
/* Eject is watched, not awaited in silence: the plan, every gate, the copy,
|
|
1863
|
-
the install, and the enable arrive as they happen, so the operator sees
|
|
1864
|
-
what landed and what is still running. The session is marked delivered
|
|
1865
|
-
only when every step passed. */
|
|
1866
|
-
const eject = new ServerRoute({
|
|
1867
|
-
path: '/sandbox/api/sessions/:id/eject',
|
|
1868
|
-
methods: ['POST'],
|
|
1869
|
-
handler: async (context) => {
|
|
1870
|
-
try {
|
|
1871
|
-
await authorize(runtime, context, options, { mutation: true });
|
|
1872
|
-
const value = await body(context.request);
|
|
1873
|
-
const session = await readSession(
|
|
1874
|
-
runtime.workspaceRoot,
|
|
1875
|
-
sessionIdParam(context),
|
|
1876
|
-
);
|
|
1877
|
-
assertNotArchived(session);
|
|
1878
|
-
if (running.has(session.id)) {
|
|
1879
|
-
throw new SandboxSetupError(
|
|
1880
|
-
'SESSION_RUNNING',
|
|
1881
|
-
'A turn is still running in this session. Wait for it or stop it before delivering.',
|
|
1882
|
-
);
|
|
1883
|
-
}
|
|
1884
|
-
const projectDelivery = await readDeliveryConfiguration(
|
|
1885
|
-
runtime.workspaceRoot,
|
|
1886
|
-
);
|
|
1887
|
-
const requestedText = optionalText(value, 'target', 32);
|
|
1888
|
-
const requested =
|
|
1889
|
-
requestedText === null ? null : assertEjectTarget(requestedText);
|
|
1890
|
-
const configuration = runtime.configuration();
|
|
1891
|
-
const githubProviderDisabled =
|
|
1892
|
-
projectDelivery.git.provider === 'github' &&
|
|
1893
|
-
!configuration.github.enabled;
|
|
1894
|
-
if (
|
|
1895
|
-
requested !== null &&
|
|
1896
|
-
(!projectDelivery.targets.includes(requested) ||
|
|
1897
|
-
((requested === 'git-pr' || requested === 'official-modules') &&
|
|
1898
|
-
githubProviderDisabled))
|
|
1899
|
-
) {
|
|
1900
|
-
throw new SandboxSetupError(
|
|
1901
|
-
'EJECT_TARGET_DISABLED',
|
|
1902
|
-
(requested === 'git-pr' || requested === 'official-modules') &&
|
|
1903
|
-
githubProviderDisabled
|
|
1904
|
-
? 'GitHub pull request delivery is disabled in the sandbox configuration.'
|
|
1905
|
-
: `The ${requested} target is not enabled in flowdular.json sandbox.delivery.targets.`,
|
|
1906
|
-
);
|
|
1907
|
-
}
|
|
1908
|
-
const delivery =
|
|
1909
|
-
configuration.github.enabled && configuration.github.overridesProject
|
|
1910
|
-
? {
|
|
1911
|
-
...projectDelivery,
|
|
1912
|
-
git: {
|
|
1913
|
-
...projectDelivery.git,
|
|
1914
|
-
remote: configuration.github.remote,
|
|
1915
|
-
repository: configuration.github.repository,
|
|
1916
|
-
baseBranch: configuration.github.baseBranch,
|
|
1917
|
-
branchPrefix: configuration.github.branchPrefix,
|
|
1918
|
-
provider: projectDelivery.git.provider,
|
|
1919
|
-
mode: configuration.github.mode,
|
|
1920
|
-
forkOwner: configuration.github.forkOwner,
|
|
1921
|
-
reviewers: configuration.github.reviewers,
|
|
1922
|
-
},
|
|
1923
|
-
}
|
|
1924
|
-
: projectDelivery;
|
|
1925
|
-
const deliveryContext: DeliveryContext = {
|
|
1926
|
-
workspaceRoot: runtime.workspaceRoot,
|
|
1927
|
-
session,
|
|
1928
|
-
capabilities: actingCapabilities(runtime, context),
|
|
1929
|
-
platformUrl: configuration.platformUrl,
|
|
1930
|
-
build: value.build === true,
|
|
1931
|
-
delivery,
|
|
1932
|
-
gitProviderToken: () =>
|
|
1933
|
-
configuration.github.enabled &&
|
|
1934
|
-
delivery.git.provider === 'github' &&
|
|
1935
|
-
configuration.gitProviderToken
|
|
1936
|
-
? openSecret(
|
|
1937
|
-
runtime.workspaceRoot,
|
|
1938
|
-
configuration.gitProviderToken,
|
|
1939
|
-
)
|
|
1940
|
-
: Promise.resolve(null),
|
|
1941
|
-
runGates: (ids) => runSessionGates(turnContext(), session, ids),
|
|
1942
|
-
commands: spawnCommand,
|
|
1943
|
-
};
|
|
1944
|
-
const availableTargets = await Promise.all(
|
|
1945
|
-
delivery.targets.map(async (id) => ({
|
|
1946
|
-
id,
|
|
1947
|
-
...((id === 'git-pr' || id === 'official-modules') &&
|
|
1948
|
-
githubProviderDisabled
|
|
1949
|
-
? {
|
|
1950
|
-
available: false,
|
|
1951
|
-
reason:
|
|
1952
|
-
'GitHub pull request delivery is disabled in the sandbox configuration.',
|
|
1953
|
-
}
|
|
1954
|
-
: await resolveDeliveryTarget(id).available(deliveryContext)),
|
|
1955
|
-
})),
|
|
1956
|
-
);
|
|
1957
|
-
/* The configured default may be unusable here (no commits yet, no
|
|
1958
|
-
remote). A plan request without an explicit target then falls
|
|
1959
|
-
back to a usable one and says why; an explicit choice does not. */
|
|
1960
|
-
let chosen: EjectTarget = requested ?? delivery.default;
|
|
1961
|
-
const availability = availableTargets.find(
|
|
1962
|
-
(candidate) => candidate.id === chosen,
|
|
1963
|
-
);
|
|
1964
|
-
if (availability && !availability.available) {
|
|
1965
|
-
const fallback =
|
|
1966
|
-
requested === null && value.apply !== true
|
|
1967
|
-
? availableTargets.find((candidate) => candidate.available)
|
|
1968
|
-
: undefined;
|
|
1969
|
-
if (!fallback) {
|
|
1970
|
-
throw new SandboxSetupError(
|
|
1971
|
-
'EJECT_TARGET_UNAVAILABLE',
|
|
1972
|
-
availability.reason ?? 'The delivery target is not available.',
|
|
1973
|
-
);
|
|
1974
|
-
}
|
|
1975
|
-
chosen = fallback.id;
|
|
1976
|
-
}
|
|
1977
|
-
const target = resolveDeliveryTarget(chosen);
|
|
1978
|
-
const plan = await target.plan(deliveryContext);
|
|
1979
|
-
const paths = sessionPaths(
|
|
1980
|
-
runtime.workspaceRoot,
|
|
1981
|
-
session.id,
|
|
1982
|
-
session.moduleSuffix,
|
|
1983
|
-
);
|
|
1984
|
-
if (value.apply !== true) {
|
|
1985
|
-
return json({
|
|
1986
|
-
plan: {
|
|
1987
|
-
...plan,
|
|
1988
|
-
availableTargets,
|
|
1989
|
-
previous: await readDeliveryRecord(paths.root),
|
|
1990
|
-
},
|
|
1991
|
-
target: chosen,
|
|
1992
|
-
availableTargets,
|
|
1993
|
-
});
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
const encoder = new TextEncoder();
|
|
1997
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
1998
|
-
async start(controller) {
|
|
1999
|
-
let closed = false;
|
|
2000
|
-
const send = (event: string, payload: unknown) => {
|
|
2001
|
-
if (closed) return;
|
|
2002
|
-
try {
|
|
2003
|
-
controller.enqueue(
|
|
2004
|
-
encoder.encode(
|
|
2005
|
-
`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`,
|
|
2006
|
-
),
|
|
2007
|
-
);
|
|
2008
|
-
} catch {
|
|
2009
|
-
closed = true;
|
|
2010
|
-
}
|
|
2011
|
-
};
|
|
2012
|
-
try {
|
|
2013
|
-
send('plan', plan);
|
|
2014
|
-
const outcome = await target.apply(deliveryContext, plan, send);
|
|
2015
|
-
const ejectedAt = Date.now();
|
|
2016
|
-
await updateSession(runtime.workspaceRoot, session.id, {
|
|
2017
|
-
state: 'accepted',
|
|
2018
|
-
ejectedAt,
|
|
2019
|
-
});
|
|
2020
|
-
const record = await writeDeliveryRecord(paths.root, {
|
|
2021
|
-
target: plan.target,
|
|
2022
|
-
deliveredAt: ejectedAt,
|
|
2023
|
-
modules: plan.modules.map((module) => module.id),
|
|
2024
|
-
branch: outcome.branch ?? null,
|
|
2025
|
-
pullRequestUrl: outcome.pullRequestUrl ?? null,
|
|
2026
|
-
compareUrl: outcome.compareUrl ?? null,
|
|
2027
|
-
});
|
|
2028
|
-
const platform = actingPlatform(runtime, context);
|
|
2029
|
-
if (platform && session.registeredWithPlatform) {
|
|
2030
|
-
await platform
|
|
2031
|
-
.updateSessionState(session.id, 'accepted')
|
|
2032
|
-
.catch(() => undefined);
|
|
2033
|
-
await platform
|
|
2034
|
-
.recordEject(session.id, {
|
|
2035
|
-
target: plan.target,
|
|
2036
|
-
modules: plan.modules.map((module) => module.id).join(' '),
|
|
2037
|
-
files: outcome.files,
|
|
2038
|
-
removed: outcome.removed,
|
|
2039
|
-
enabled: outcome.enabled,
|
|
2040
|
-
gates: outcome.gates.length,
|
|
2041
|
-
...(record.branch ? { branch: record.branch } : {}),
|
|
2042
|
-
...(record.pullRequestUrl
|
|
2043
|
-
? { pullRequestUrl: record.pullRequestUrl }
|
|
2044
|
-
: {}),
|
|
2045
|
-
})
|
|
2046
|
-
.catch(() => undefined);
|
|
2047
|
-
}
|
|
2048
|
-
send('done', {
|
|
2049
|
-
target: plan.target,
|
|
2050
|
-
moduleId: outcome.moduleId,
|
|
2051
|
-
modules: plan.modules.map((module) => module.id),
|
|
2052
|
-
targetPath: outcome.targetPath,
|
|
2053
|
-
files: outcome.files,
|
|
2054
|
-
removed: outcome.removed,
|
|
2055
|
-
enabled: outcome.enabled,
|
|
2056
|
-
ejectedAt,
|
|
2057
|
-
gates: outcome.gates,
|
|
2058
|
-
restartRequired: outcome.restartRequired,
|
|
2059
|
-
platformLocal: plan.platformLocal,
|
|
2060
|
-
branch: record.branch,
|
|
2061
|
-
pullRequestUrl: record.pullRequestUrl,
|
|
2062
|
-
compareUrl: record.compareUrl,
|
|
2063
|
-
});
|
|
2064
|
-
} catch (error) {
|
|
2065
|
-
send('failed', {
|
|
2066
|
-
code:
|
|
2067
|
-
error instanceof SandboxSetupError
|
|
2068
|
-
? error.code
|
|
2069
|
-
: 'EJECT_FAILED',
|
|
2070
|
-
message:
|
|
2071
|
-
error instanceof Error ? error.message : 'The eject failed.',
|
|
2072
|
-
...(error instanceof DeliveryError
|
|
2073
|
-
? { output: error.output }
|
|
2074
|
-
: {}),
|
|
2075
|
-
});
|
|
2076
|
-
} finally {
|
|
2077
|
-
if (!closed) {
|
|
2078
|
-
closed = true;
|
|
2079
|
-
controller.close();
|
|
2080
|
-
}
|
|
2081
|
-
}
|
|
2082
|
-
},
|
|
2083
|
-
});
|
|
2084
|
-
return new Response(stream, {
|
|
2085
|
-
headers: {
|
|
2086
|
-
'content-type': 'text/event-stream; charset=utf-8',
|
|
2087
|
-
'cache-control': 'no-store',
|
|
2088
|
-
connection: 'keep-alive',
|
|
2089
|
-
},
|
|
2090
|
-
});
|
|
2091
|
-
} catch (error) {
|
|
2092
|
-
return failure(error);
|
|
2093
|
-
}
|
|
2094
|
-
},
|
|
2095
|
-
});
|
|
2096
|
-
|
|
2097
|
-
const previewInfo = new ServerRoute({
|
|
2098
|
-
path: '/sandbox/api/sessions/:id/preview',
|
|
2099
|
-
methods: ['GET'],
|
|
2100
|
-
handler: async (context) => {
|
|
2101
|
-
try {
|
|
2102
|
-
await authorize(runtime, context, options);
|
|
2103
|
-
const session = await readSession(
|
|
2104
|
-
runtime.workspaceRoot,
|
|
2105
|
-
sessionIdParam(context),
|
|
2106
|
-
);
|
|
2107
|
-
const composition = await preview.compose(session);
|
|
2108
|
-
return json({
|
|
2109
|
-
moduleId: composition.moduleId,
|
|
2110
|
-
modules: composition.modules,
|
|
2111
|
-
revision: composition.revision,
|
|
2112
|
-
scopes: composition.moduleScopes,
|
|
2113
|
-
credentials: composition.credentials,
|
|
2114
|
-
routes: composition.routes.length,
|
|
2115
|
-
error: composition.error,
|
|
2116
|
-
});
|
|
2117
|
-
} catch (error) {
|
|
2118
|
-
return failure(error);
|
|
2119
|
-
}
|
|
2120
|
-
},
|
|
2121
|
-
});
|
|
2122
|
-
|
|
2123
|
-
/* The composed preview API. The draft modules answer first with their own
|
|
2124
|
-
routes and the session's own ephemeral database, the session's
|
|
2125
|
-
authentication routes answer next, and only what is left falls through to
|
|
2126
|
-
the connected application. The preview page is the sandbox's own page, so
|
|
2127
|
-
it carries the sandbox session like every other request. */
|
|
2128
|
-
const previewApi = new ServerRoute({
|
|
2129
|
-
path: '/api/*path',
|
|
2130
|
-
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
2131
|
-
handler: async (context) => {
|
|
2132
|
-
try {
|
|
2133
|
-
await authorize(runtime, context, options);
|
|
2134
|
-
/* A draft module's own fetch carries no sandbox header, so the
|
|
2135
|
-
preview mutation boundary is the browser origin checked here.
|
|
2136
|
-
The worker hop below runs on its own loopback origin. */
|
|
2137
|
-
if (
|
|
2138
|
-
context.request.method !== 'GET' &&
|
|
2139
|
-
context.request.method !== 'HEAD'
|
|
2140
|
-
) {
|
|
2141
|
-
assertBrowserOrigin(context.request);
|
|
2142
|
-
}
|
|
2143
|
-
} catch (error) {
|
|
2144
|
-
return failure(error);
|
|
2145
|
-
}
|
|
2146
|
-
const sessionId = previewSessionId(context);
|
|
2147
|
-
let composition: PreviewComposition | null = null;
|
|
2148
|
-
if (sessionId) {
|
|
2149
|
-
try {
|
|
2150
|
-
composition = await preview.compose(
|
|
2151
|
-
await readSession(runtime.workspaceRoot, sessionId),
|
|
2152
|
-
);
|
|
2153
|
-
} catch {
|
|
2154
|
-
composition = null;
|
|
2155
|
-
}
|
|
2156
|
-
}
|
|
2157
|
-
if (composition) {
|
|
2158
|
-
/* Draft routes and their authentication runtime execute in a bounded
|
|
2159
|
-
loopback worker. A worker miss deliberately falls through to the
|
|
2160
|
-
read-only bridge, never to an import in this server process. */
|
|
2161
|
-
const response = await composition.request(context.request);
|
|
2162
|
-
if (response.headers.get('x-flowdular-preview-unmatched') !== '1') {
|
|
2163
|
-
return response;
|
|
2164
|
-
}
|
|
2165
|
-
}
|
|
2166
|
-
return bridgeRequest(context);
|
|
2167
|
-
},
|
|
2168
|
-
});
|
|
2169
|
-
|
|
2170
|
-
/* The read-only preview data bridge. Anything the draft modules do not
|
|
2171
|
-
serve is answered by the connected application under the acting
|
|
2172
|
-
principal's grant. */
|
|
2173
|
-
const bridgeRequest = async (context: Context): Promise<Response> => {
|
|
2174
|
-
const configuration = runtime.configuration();
|
|
2175
|
-
const platform = actingPlatform(runtime, context);
|
|
2176
|
-
if (configuration.previewData !== 'bridge' || !platform) {
|
|
2177
|
-
return json(
|
|
2178
|
-
{
|
|
2179
|
-
error: {
|
|
2180
|
-
code: 'BRIDGE_DISABLED',
|
|
2181
|
-
message:
|
|
2182
|
-
'This preview runs on fixtures. Switch preview data to bridge to read live platform data.',
|
|
2183
|
-
},
|
|
2184
|
-
},
|
|
2185
|
-
404,
|
|
2186
|
-
);
|
|
2187
|
-
}
|
|
2188
|
-
if (BRIDGE_DENIED.some((pattern) => pattern.test(context.url.pathname))) {
|
|
2189
|
-
return json(
|
|
2190
|
-
{
|
|
2191
|
-
error: {
|
|
2192
|
-
code: 'BRIDGE_PATH_DENIED',
|
|
2193
|
-
message:
|
|
2194
|
-
'The preview bridge does not forward authentication or sandbox records.',
|
|
2195
|
-
},
|
|
2196
|
-
},
|
|
2197
|
-
403,
|
|
2198
|
-
);
|
|
2199
|
-
}
|
|
2200
|
-
if (
|
|
2201
|
-
!actingCapabilities(runtime, context).includes('sandbox.preview.data')
|
|
2202
|
-
) {
|
|
2203
|
-
return json(
|
|
2204
|
-
{
|
|
2205
|
-
error: {
|
|
2206
|
-
code: 'BRIDGE_SCOPE_MISSING',
|
|
2207
|
-
message: 'The sandbox grant does not include sandbox.preview.data.',
|
|
2208
|
-
},
|
|
2209
|
-
},
|
|
2210
|
-
403,
|
|
2211
|
-
);
|
|
2212
|
-
}
|
|
2213
|
-
const response = await platform.bridge({
|
|
2214
|
-
method: context.request.method,
|
|
2215
|
-
path: context.url.pathname,
|
|
2216
|
-
search: context.url.search,
|
|
2217
|
-
accept: context.request.headers.get('accept'),
|
|
2218
|
-
});
|
|
2219
|
-
return new Response(response.body, {
|
|
2220
|
-
status: response.status,
|
|
2221
|
-
headers: {
|
|
2222
|
-
'content-type': response.contentType,
|
|
2223
|
-
'cache-control': 'no-store',
|
|
2224
|
-
'x-flowdular-bridge': 'platform',
|
|
2225
|
-
},
|
|
2226
|
-
});
|
|
2227
|
-
};
|
|
2228
|
-
|
|
2229
|
-
return [
|
|
2230
|
-
state,
|
|
2231
|
-
configure,
|
|
2232
|
-
connect,
|
|
2233
|
-
createSandboxSession,
|
|
2234
|
-
readSandboxSession,
|
|
2235
|
-
addSandboxModule,
|
|
2236
|
-
addSandboxAttachment,
|
|
2237
|
-
removeSandboxAttachment,
|
|
2238
|
-
serveSandboxAttachment,
|
|
2239
|
-
archiveSandboxSession,
|
|
2240
|
-
rejectSandboxSession,
|
|
2241
|
-
restoreSandboxSession,
|
|
2242
|
-
restoreSandboxCheckpoint,
|
|
2243
|
-
removeSandboxSession,
|
|
2244
|
-
turn,
|
|
2245
|
-
followTurn,
|
|
2246
|
-
stop,
|
|
2247
|
-
settings,
|
|
2248
|
-
approve,
|
|
2249
|
-
requestSpecChanges,
|
|
2250
|
-
readSpecDocument,
|
|
2251
|
-
writeSpecDocument,
|
|
2252
|
-
gates,
|
|
2253
|
-
format,
|
|
2254
|
-
eject,
|
|
2255
|
-
previewInfo,
|
|
2256
|
-
previewApi,
|
|
2257
|
-
];
|
|
2258
|
-
}
|