dsh-edge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +183 -0
- package/README.zh.md +183 -0
- package/THIRD_PARTY_NOTICES.md +199 -0
- package/dist/_headers +3 -0
- package/dist/assets/fonts/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/fonts/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/fonts/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/fonts/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/fonts/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/fonts/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/fonts/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/fonts/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/fonts/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/fonts/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/fonts/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/fonts/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/fonts/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/fonts/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/fonts/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/fonts/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/fonts/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/fonts/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/fonts/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/fonts/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/fonts/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/fonts/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/fonts/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/fonts/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/fonts/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/fonts/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/index-C-1AiF3k.js +112 -0
- package/dist/assets/index-CSGf6Qzd.css +1 -0
- package/dist/assets/langs/c-BIGW1oBm.js +2 -0
- package/dist/assets/langs/cpp-DIPi6g--.js +2 -0
- package/dist/assets/langs/csharp-DSvCPggb.js +2 -0
- package/dist/assets/langs/css-CLj8gQPS.js +2 -0
- package/dist/assets/langs/go-C27-OAKa.js +2 -0
- package/dist/assets/langs/html-CfGypltT.js +2 -0
- package/dist/assets/langs/ini-BEwlwnbL.js +2 -0
- package/dist/assets/langs/java-CylS5w8V.js +2 -0
- package/dist/assets/langs/kotlin-BdnUsdx6.js +2 -0
- package/dist/assets/langs/less-B1dDrJ26.js +2 -0
- package/dist/assets/langs/lua-BaeVxFsk.js +2 -0
- package/dist/assets/langs/markdown-Cvjx9yec.js +2 -0
- package/dist/assets/langs/mdx-Cmh6b_Ma.js +2 -0
- package/dist/assets/langs/php-D-uCvoST.js +2 -0
- package/dist/assets/langs/python-B6aJPvgy.js +2 -0
- package/dist/assets/langs/ruby-DEItuUFs.js +2 -0
- package/dist/assets/langs/rust-B1yitclQ.js +2 -0
- package/dist/assets/langs/scss-D5BDwBP9.js +2 -0
- package/dist/assets/langs/sql-CRqJ_cUM.js +2 -0
- package/dist/assets/langs/swift-D82vCrfD.js +2 -0
- package/dist/assets/langs/toml-vGWfd6FD.js +2 -0
- package/dist/assets/langs/xml-sdJ4AIDG.js +2 -0
- package/dist/assets/langs/yaml-Buea-lGh.js +2 -0
- package/dist/assets/vendor-Cjbwl5VI.js +411 -0
- package/dist/assets/vendor-CjyC-hUb.css +1 -0
- package/dist/favicon.svg +8 -0
- package/dist/index.html +35 -0
- package/dist/manifest.webmanifest +16 -0
- package/dist/plugins/@deepseek-ai/dsh-api-gateway/client.js +420 -0
- package/dist/plugins/@deepseek-ai/dsh-api-remotes/client.js +5937 -0
- package/dist/plugins/@deepseek-ai/dsh-client-connection/client.js +10207 -0
- package/dist/plugins/@deepseek-ai/dsh-client-locale/client.js +1231 -0
- package/dist/plugins/@deepseek-ai/dsh-client-modules/client.js +259 -0
- package/dist/plugins/@deepseek-ai/dsh-client-runtime/client.js +10539 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-agent-preset/client.js +1725 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-commands/client.js +1116 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-conversation/client.js +9832 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-deliverables/client.js +373 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-edge/client.js +549 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-input-trigger/client.js +859 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-jobs/client.js +280 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-layout/client.js +456 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-model-selection/client.js +804 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-permission-presets/client.js +461 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-settings/client.js +254 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-settings-general/client.js +609 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-sidebar/client.js +292 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-skill/client.js +327 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-subagent/client.js +710 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-theme/client.js +1312 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-tool/client.js +1630 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-trajectory/client.js +7370 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-user-questions/client.js +700 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-workflow-run/client.js +474 -0
- package/dist/plugins/@deepseek-ai/dsh-client-ui-workspace/client.js +2426 -0
- package/dist/plugins/@deepseek-ai/dsh-typert-registry/client.js +1372 -0
- package/package.json +97 -0
- package/scripts/assemble-web.mjs +230 -0
- package/scripts/bundle-size.d.mts +2 -0
- package/scripts/bundle-size.mjs +26 -0
- package/scripts/bundle.mjs +53 -0
- package/scripts/cli.d.mts +39 -0
- package/scripts/cli.mjs +496 -0
- package/scripts/install.d.mts +149 -0
- package/scripts/install.mjs +989 -0
- package/scripts/legal-files.mjs +24 -0
- package/scripts/verify-packed.mjs +32 -0
- package/scripts/wrangler-config.d.mts +18 -0
- package/scripts/wrangler-config.mjs +84 -0
- package/src/agent.ts +119 -0
- package/src/auth.ts +352 -0
- package/src/deepseek.ts +231 -0
- package/src/deployment.ts +147 -0
- package/src/direct-shell-core-empty.ts +10 -0
- package/src/direct-shell-protocol.ts +5 -0
- package/src/direct-shell.ts +341 -0
- package/src/do-session-persistence.ts +1064 -0
- package/src/edge-api.ts +796 -0
- package/src/edge-credentials.ts +56 -0
- package/src/edge-remotes.ts +41 -0
- package/src/edge-workspace-store.ts +339 -0
- package/src/http.ts +212 -0
- package/src/index.ts +196 -0
- package/src/instance.ts +1007 -0
- package/src/isolated-direct-shell-unavailable.ts +11 -0
- package/src/protocol.ts +39 -0
- package/src/release.ts +9 -0
- package/src/session-store.ts +1110 -0
- package/src/sse.ts +86 -0
- package/src/web-search.ts +38 -0
- package/src/workspace.ts +270 -0
- package/wrangler.jsonc +56 -0
package/src/instance.ts
ADDED
|
@@ -0,0 +1,1007 @@
|
|
|
1
|
+
/** Workspace Durable Object with persistent sessions and streamed agent turns. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getWorkspace,
|
|
5
|
+
withWorkspace,
|
|
6
|
+
type DurableObjectStorageLike,
|
|
7
|
+
} from '@cloudflare/computer'
|
|
8
|
+
import {
|
|
9
|
+
WorkerShellBackend,
|
|
10
|
+
type WorkerShellLoader,
|
|
11
|
+
} from '@cloudflare/computer/backends/worker-shell'
|
|
12
|
+
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
|
|
13
|
+
import type {
|
|
14
|
+
HostFrame,
|
|
15
|
+
MuxFrame,
|
|
16
|
+
PromptContentPart,
|
|
17
|
+
QueueAction,
|
|
18
|
+
QueuedInboxItem,
|
|
19
|
+
ServerRequest,
|
|
20
|
+
SessionListMetadata,
|
|
21
|
+
WorkspaceId,
|
|
22
|
+
WorkspaceView,
|
|
23
|
+
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
|
24
|
+
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
|
25
|
+
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
|
26
|
+
import { freezeMessage, type MessageId } from '@deepseek-ai/dsh-llm'
|
|
27
|
+
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
|
28
|
+
import { normalizeSessionTitle } from '@deepseek-ai/dsh-session-title'
|
|
29
|
+
import { DurableObject } from 'cloudflare:workers'
|
|
30
|
+
import { OWNER_SESSION_EXPIRY_HEADER } from './auth.ts'
|
|
31
|
+
import { DirectShellBackend } from './direct-shell.ts'
|
|
32
|
+
import {
|
|
33
|
+
resolveEdgeModel,
|
|
34
|
+
type EdgeReasoningEffort,
|
|
35
|
+
} from './deepseek.ts'
|
|
36
|
+
import {
|
|
37
|
+
resolveEdgeDeploymentConfig,
|
|
38
|
+
resolveEdgeDeploymentProfile,
|
|
39
|
+
} from './deployment.ts'
|
|
40
|
+
import {
|
|
41
|
+
EdgeHttpError,
|
|
42
|
+
MAX_SESSION_CREATE_BODY_BYTES,
|
|
43
|
+
MAX_TURN_BODY_BYTES,
|
|
44
|
+
corsHeaders,
|
|
45
|
+
discardUnreadRequestBody,
|
|
46
|
+
errorResponse,
|
|
47
|
+
jsonResponse,
|
|
48
|
+
readJsonObject,
|
|
49
|
+
requireBoundedString,
|
|
50
|
+
requireBoundedUtf8String,
|
|
51
|
+
} from './http.ts'
|
|
52
|
+
import {
|
|
53
|
+
EdgeSessionStore,
|
|
54
|
+
EdgeSessionStoreError,
|
|
55
|
+
type EdgeAgentPromptAdmitter,
|
|
56
|
+
type EdgeMuxBaseline,
|
|
57
|
+
} from './session-store.ts'
|
|
58
|
+
import {
|
|
59
|
+
EdgeTurnId,
|
|
60
|
+
type CancelEdgeTurnResponse,
|
|
61
|
+
type EdgeSession,
|
|
62
|
+
} from './protocol.ts'
|
|
63
|
+
import {
|
|
64
|
+
createLiveSessionEventStream,
|
|
65
|
+
edgeEventStreamHeaders,
|
|
66
|
+
encodeSessionEvent,
|
|
67
|
+
} from './sse.ts'
|
|
68
|
+
import {
|
|
69
|
+
executeWorkspaceCommand,
|
|
70
|
+
requireCommand,
|
|
71
|
+
requireWorkspacePath,
|
|
72
|
+
type EdgeCommandTimeoutPolicy,
|
|
73
|
+
} from './workspace.ts'
|
|
74
|
+
import {
|
|
75
|
+
MAX_MESSAGE_TEXT_BYTES,
|
|
76
|
+
attachPublishedSession,
|
|
77
|
+
createEdgeApi,
|
|
78
|
+
messageTextByteLength,
|
|
79
|
+
} from './edge-api.ts'
|
|
80
|
+
import { handleEdgeRemote } from './edge-remotes.ts'
|
|
81
|
+
import type { EdgeApiSessionSummary } from './session-store.ts'
|
|
82
|
+
import { EDGE_WORKSPACE_ID, EdgeWorkspaceStore } from './edge-workspace-store.ts'
|
|
83
|
+
import { DSH_EDGE_VERSION } from './release.ts'
|
|
84
|
+
|
|
85
|
+
const MAX_SESSION_TITLE_LENGTH = 160
|
|
86
|
+
const MAX_SESSION_TITLE_BYTES = 640
|
|
87
|
+
const MAX_SESSION_ID_LENGTH = 128
|
|
88
|
+
const DEFAULT_SESSION_LIST_LIMIT = 50
|
|
89
|
+
const MAX_SESSION_LIST_LIMIT = 100
|
|
90
|
+
const DEFAULT_REPLAY_EVENT_LIMIT = 128
|
|
91
|
+
const MAX_REPLAY_EVENT_LIMIT = 256
|
|
92
|
+
const MAX_REPLAY_RESPONSE_BYTES = 1_048_576
|
|
93
|
+
const INITIAL_SESSION_LIST_METADATA: SessionListMetadata = { blank: true, lastPromptAt: null }
|
|
94
|
+
const OWNER_SESSION_EXPIRED_CLOSE_CODE = 1008
|
|
95
|
+
const OWNER_SESSION_EXPIRED_CLOSE_REASON = 'owner session expired'
|
|
96
|
+
|
|
97
|
+
interface DownlinkAttachment {
|
|
98
|
+
channel: 'mux' | 'host'
|
|
99
|
+
expiresAt: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Mirror the upstream session-list projection fold at the Edge transport seam. */
|
|
103
|
+
function applySessionListMetadata(
|
|
104
|
+
state: SessionListMetadata,
|
|
105
|
+
event: SessionEvent,
|
|
106
|
+
): SessionListMetadata {
|
|
107
|
+
const blank = state.blank && event.type !== 'turn/start'
|
|
108
|
+
const lastPromptAt = event.type === 'user/message' && event.data.source.kind === 'user'
|
|
109
|
+
? event.time
|
|
110
|
+
: state.lastPromptAt
|
|
111
|
+
return blank === state.blank && lastPromptAt === state.lastPromptAt
|
|
112
|
+
? state
|
|
113
|
+
: { blank, lastPromptAt }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Bindings shared by the entry Worker and each workspace Durable Object. */
|
|
117
|
+
export interface EdgeEnv {
|
|
118
|
+
DSH_EDGE_INSTANCE: DurableObjectNamespace<DshEdgeInstance>
|
|
119
|
+
ASSETS: Fetcher
|
|
120
|
+
LOADER?: WorkerShellLoader
|
|
121
|
+
DSH_EDGE_ACCESS_KEY?: string
|
|
122
|
+
DEEPSEEK_API_KEY?: string
|
|
123
|
+
DEEPSEEK_BASE_URL?: string
|
|
124
|
+
DEEPSEEK_MAX_OUTPUT_TOKENS?: string
|
|
125
|
+
DEEPSEEK_MODEL?: string
|
|
126
|
+
DEEPSEEK_REASONING_EFFORT?: string
|
|
127
|
+
DEEPSEEK_SEARCH_BASE_URL?: string
|
|
128
|
+
DEEPSEEK_STREAM_IDLE_TIMEOUT_MS?: string
|
|
129
|
+
DSH_EDGE_DEFAULT_COMMAND_TIMEOUT_MS?: string
|
|
130
|
+
DSH_EDGE_MAX_COMMAND_TIMEOUT_MS?: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class DshEdgeObjectBase extends DurableObject<EdgeEnv> {
|
|
134
|
+
workspaceOptions() {
|
|
135
|
+
const backend = this.env.LOADER === undefined
|
|
136
|
+
? new DirectShellBackend()
|
|
137
|
+
: new WorkerShellBackend({
|
|
138
|
+
loader: this.env.LOADER,
|
|
139
|
+
workspace: {
|
|
140
|
+
binding: 'DSH_EDGE_INSTANCE',
|
|
141
|
+
id: this.ctx.id.toString(),
|
|
142
|
+
},
|
|
143
|
+
ctx: this.ctx,
|
|
144
|
+
})
|
|
145
|
+
return {
|
|
146
|
+
// Computer's preview storage facade and Workers' generated SQL generic
|
|
147
|
+
// differ only in their type parameter; both expose the same runtime API.
|
|
148
|
+
storage: this.ctx.storage as unknown as DurableObjectStorageLike,
|
|
149
|
+
backends: [backend],
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const DshEdgeWorkspace = withWorkspace(
|
|
155
|
+
DshEdgeObjectBase,
|
|
156
|
+
self => self.workspaceOptions(),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
interface ActiveTurn {
|
|
160
|
+
turnId: EdgeTurnId
|
|
161
|
+
agent?: Agent
|
|
162
|
+
cancelRequested: boolean
|
|
163
|
+
accepting: boolean
|
|
164
|
+
wasAdmitted: boolean
|
|
165
|
+
admit?: EdgeAgentPromptAdmitter
|
|
166
|
+
admissionReady: Promise<void>
|
|
167
|
+
releaseComplete: Promise<void>
|
|
168
|
+
resolveAdmissionReady: () => void
|
|
169
|
+
resolveReleaseComplete: () => void
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** One isolated persistent workspace with its conversations and active agent turn. */
|
|
173
|
+
export class DshEdgeInstance extends DshEdgeWorkspace {
|
|
174
|
+
private readonly sessions = new EdgeSessionStore(
|
|
175
|
+
this.ctx.storage,
|
|
176
|
+
{
|
|
177
|
+
readDeepSeekApiKey: () => this.env.DEEPSEEK_API_KEY,
|
|
178
|
+
...this.env.DEEPSEEK_SEARCH_BASE_URL === undefined
|
|
179
|
+
? {}
|
|
180
|
+
: { searchBaseURL: this.env.DEEPSEEK_SEARCH_BASE_URL },
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
private readonly workspaces = new EdgeWorkspaceStore(this.ctx.storage)
|
|
184
|
+
private readonly model = resolveEdgeModel(this.env.DEEPSEEK_MODEL)
|
|
185
|
+
private readonly activeTurns = new Map<SessionId, ActiveTurn>()
|
|
186
|
+
private readonly sessionListMetadata = new Map<SessionId, SessionListMetadata>()
|
|
187
|
+
private readonly api = createEdgeApi({
|
|
188
|
+
sessions: this.sessions,
|
|
189
|
+
model: this.model,
|
|
190
|
+
version: DSH_EDGE_VERSION,
|
|
191
|
+
deploymentProfile: () => resolveEdgeDeploymentProfile(this.env),
|
|
192
|
+
describeCredential: ref => this.sessions.describeCredential(ref),
|
|
193
|
+
isRunning: sessionId => this.activeTurns.has(sessionId),
|
|
194
|
+
prompt: input => this.startApiPrompt(input),
|
|
195
|
+
updateQueue: (sessionId, itemId, action) => this.updateQueue(sessionId, itemId, action),
|
|
196
|
+
cancel: sessionId => this.requestTurnCancellation(sessionId),
|
|
197
|
+
workspaceList: sessions => this.workspaces.list(sessions),
|
|
198
|
+
workspaceCreate: path => this.createWorkspace(path),
|
|
199
|
+
workspaceRename: (workspaceId, title) => this.renameWorkspace(workspaceId, title),
|
|
200
|
+
workspaceDelete: workspaceId => this.deleteWorkspace(workspaceId),
|
|
201
|
+
workspaceInsertBefore: (workspaceId, beforeWorkspaceId) =>
|
|
202
|
+
this.workspaces.insertBefore(workspaceId, beforeWorkspaceId),
|
|
203
|
+
workspaceInsertSessionBefore: (workspaceId, sessionId, beforeSessionId) =>
|
|
204
|
+
this.insertSessionBefore(workspaceId, sessionId, beforeSessionId),
|
|
205
|
+
archiveSession: sessionId => this.archiveSession(sessionId),
|
|
206
|
+
sessionCreated: (session) => {
|
|
207
|
+
this.publishSessionCreated(session)
|
|
208
|
+
},
|
|
209
|
+
sessionAttached: (session, workspaceId) => this.publishSessionAttached(session, workspaceId),
|
|
210
|
+
workspaceForSession: sessionId => this.workspaceForSession(sessionId),
|
|
211
|
+
sessionEvent: (sessionId, event) => {
|
|
212
|
+
this.publishSessionEvent(sessionId, event)
|
|
213
|
+
},
|
|
214
|
+
})
|
|
215
|
+
private readonly apiFetch = toFetchHandler(this.api).fetch
|
|
216
|
+
|
|
217
|
+
/** Serve session routes forwarded by the entry Worker. */
|
|
218
|
+
override async fetch(request: Request): Promise<Response> {
|
|
219
|
+
try {
|
|
220
|
+
const url = new URL(request.url)
|
|
221
|
+
const edgeRemote = await handleEdgeRemote(request)
|
|
222
|
+
if (edgeRemote !== undefined) return edgeRemote
|
|
223
|
+
if (url.pathname === '/api/events.mux' || url.pathname === '/api/events.host') {
|
|
224
|
+
return await this.openDownlink(request, url.pathname === '/api/events.mux' ? 'mux' : 'host')
|
|
225
|
+
}
|
|
226
|
+
if (url.pathname.startsWith('/api/') && !url.pathname.startsWith('/api/sessions')) {
|
|
227
|
+
return await this.apiFetch(request)
|
|
228
|
+
}
|
|
229
|
+
const route = parseSessionRoute(url.pathname)
|
|
230
|
+
|
|
231
|
+
if (route.sessionId === undefined) {
|
|
232
|
+
if (request.method === 'POST') return await this.createSession(request)
|
|
233
|
+
if (request.method === 'GET') {
|
|
234
|
+
const after = resolveSessionListAfter(url)
|
|
235
|
+
const limit = resolveSessionListLimit(url)
|
|
236
|
+
const page = await this.sessions.listSessions(after, limit)
|
|
237
|
+
if (page === undefined) throw new EdgeHttpError(400, 'Unknown session list cursor.')
|
|
238
|
+
return jsonResponse({
|
|
239
|
+
sessions: page.sessions.map(session => this.presentSession(session)),
|
|
240
|
+
hasMore: page.hasMore,
|
|
241
|
+
...page.nextAfter === undefined ? {} : { nextAfter: page.nextAfter },
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
} else if (route.action === undefined) {
|
|
245
|
+
if (request.method === 'GET') {
|
|
246
|
+
const session = await this.sessions.getSession(route.sessionId)
|
|
247
|
+
if (session === undefined) throw new EdgeHttpError(404, 'Session not found.')
|
|
248
|
+
return jsonResponse({ session: this.presentSession(session) })
|
|
249
|
+
}
|
|
250
|
+
} else if (route.action === 'events' && request.method === 'GET') {
|
|
251
|
+
return await this.replayEvents(request, url, route.sessionId)
|
|
252
|
+
} else if (route.action === 'turn' && request.method === 'POST') {
|
|
253
|
+
return await this.startTurn(request, route.sessionId)
|
|
254
|
+
} else if (route.action === 'cancel' && request.method === 'POST') {
|
|
255
|
+
return this.cancelTurn(route.sessionId)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
throw new EdgeHttpError(404, 'Session route not found.')
|
|
259
|
+
} catch (error) {
|
|
260
|
+
await discardUnreadRequestBody(request)
|
|
261
|
+
return errorResponse(error)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Reject client messages because both upstream WebSockets are downlink-only. */
|
|
266
|
+
override webSocketMessage(socket: WebSocket, _message: string | ArrayBuffer): void {
|
|
267
|
+
if (this.closeExpiredDownlink(socket, Date.now())) return
|
|
268
|
+
socket.close(1008, 'downlink only')
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Close a broken downstream without affecting other subscribers. */
|
|
272
|
+
override webSocketError(socket: WebSocket, error: unknown): void {
|
|
273
|
+
console.error('dsh-edge downstream WebSocket failed.', error)
|
|
274
|
+
socket.close(1011, 'downstream failure')
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** End hibernating downlinks when the owner session used to open them expires. */
|
|
278
|
+
override async alarm(): Promise<void> {
|
|
279
|
+
const nextExpiry = this.closeExpiredDownlinks()
|
|
280
|
+
if (nextExpiry !== undefined) await this.ctx.storage.setAlarm(nextExpiry)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private async openDownlink(request: Request, channel: 'mux' | 'host'): Promise<Response> {
|
|
284
|
+
if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
|
|
285
|
+
throw new EdgeHttpError(426, 'This event endpoint requires a WebSocket upgrade.')
|
|
286
|
+
}
|
|
287
|
+
const expiresAt = requireOwnerSessionExpiry(request)
|
|
288
|
+
await this.scheduleDownlinkExpiry(expiresAt * 1_000)
|
|
289
|
+
const accept = (baseline?: EdgeMuxBaseline) => {
|
|
290
|
+
const pair = new WebSocketPair()
|
|
291
|
+
const client = pair[0]
|
|
292
|
+
const server = pair[1]
|
|
293
|
+
server.serializeAttachment({ channel, expiresAt } satisfies DownlinkAttachment)
|
|
294
|
+
this.ctx.acceptWebSocket(server, [channel])
|
|
295
|
+
if (baseline !== undefined) {
|
|
296
|
+
for (const session of baseline.sessions) {
|
|
297
|
+
this.rememberSessionListMetadata(session)
|
|
298
|
+
if (!this.sendFrame(server, {
|
|
299
|
+
type: 'session/subscribed',
|
|
300
|
+
sessionId: session.id,
|
|
301
|
+
lastSeq: session.lastSeq,
|
|
302
|
+
})) break
|
|
303
|
+
}
|
|
304
|
+
for (const queue of baseline.queues) {
|
|
305
|
+
if (!this.sendFrame(server, {
|
|
306
|
+
type: 'session/queue',
|
|
307
|
+
sessionId: queue.sessionId,
|
|
308
|
+
items: queue.items,
|
|
309
|
+
})) break
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return new Response(null, { status: 101, webSocket: client })
|
|
313
|
+
}
|
|
314
|
+
return channel === 'mux' ? this.sessions.withMuxBaseline(accept) : accept()
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private publishSessionCreated(session: EdgeApiSessionSummary): void {
|
|
318
|
+
this.rememberSessionListMetadata(session)
|
|
319
|
+
this.broadcast('mux', {
|
|
320
|
+
type: 'session/subscribed',
|
|
321
|
+
sessionId: session.id,
|
|
322
|
+
lastSeq: session.lastSeq,
|
|
323
|
+
})
|
|
324
|
+
this.broadcast('host', {
|
|
325
|
+
type: 'host/session-added',
|
|
326
|
+
sessionId: session.id,
|
|
327
|
+
blank: session.blank,
|
|
328
|
+
...session.parentSessionId === undefined
|
|
329
|
+
? {}
|
|
330
|
+
: { parentSessionId: session.parentSessionId },
|
|
331
|
+
...session.origin === undefined ? {} : { origin: session.origin },
|
|
332
|
+
...session.cwd === undefined ? {} : { cwd: session.cwd },
|
|
333
|
+
...session.agentPreset === undefined ? {} : { agentPreset: session.agentPreset },
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private async publishSessionAttached(
|
|
338
|
+
session: EdgeApiSessionSummary,
|
|
339
|
+
workspaceId: WorkspaceId,
|
|
340
|
+
): Promise<void> {
|
|
341
|
+
const workspace = await this.workspaces.attachSession(
|
|
342
|
+
workspaceId,
|
|
343
|
+
session.id,
|
|
344
|
+
await this.sessions.listApiSessions(),
|
|
345
|
+
)
|
|
346
|
+
this.broadcast('host', { type: 'host/workspace-changed', workspace })
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private async workspaceForSession(sessionId: SessionId): Promise<WorkspaceId | undefined> {
|
|
350
|
+
return await this.workspaces.workspaceForSession(
|
|
351
|
+
sessionId,
|
|
352
|
+
await this.sessions.listApiSessions(),
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private async createWorkspace(path: string): Promise<{ workspace: WorkspaceView; created: boolean }> {
|
|
357
|
+
const result = await this.workspaces.create(path, await this.sessions.listApiSessions())
|
|
358
|
+
if (result.created) {
|
|
359
|
+
this.broadcast('host', { type: 'host/workspace-changed', workspace: result.workspace })
|
|
360
|
+
}
|
|
361
|
+
return result
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private async renameWorkspace(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
|
|
365
|
+
const result = await this.workspaces.rename(
|
|
366
|
+
workspaceId,
|
|
367
|
+
title,
|
|
368
|
+
await this.sessions.listApiSessions(),
|
|
369
|
+
)
|
|
370
|
+
if (result.changed) {
|
|
371
|
+
this.broadcast('host', { type: 'host/workspace-changed', workspace: result.workspace })
|
|
372
|
+
}
|
|
373
|
+
return result.workspace
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private async deleteWorkspace(workspaceId: WorkspaceId): Promise<void> {
|
|
377
|
+
await this.workspaces.delete(workspaceId)
|
|
378
|
+
this.broadcast('host', { type: 'host/workspace-removed', workspaceId })
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private async insertSessionBefore(
|
|
382
|
+
workspaceId: WorkspaceId,
|
|
383
|
+
sessionId: SessionId,
|
|
384
|
+
beforeSessionId?: SessionId,
|
|
385
|
+
): Promise<WorkspaceView> {
|
|
386
|
+
const result = await this.workspaces.insertSessionBefore(
|
|
387
|
+
workspaceId,
|
|
388
|
+
sessionId,
|
|
389
|
+
beforeSessionId,
|
|
390
|
+
await this.sessions.listApiSessions(),
|
|
391
|
+
)
|
|
392
|
+
if (result.changed) {
|
|
393
|
+
this.broadcast('host', { type: 'host/workspace-changed', workspace: result.workspace })
|
|
394
|
+
}
|
|
395
|
+
return result.workspace
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Archive with the same registry-global snapshot and frame contract as upstream. */
|
|
399
|
+
private async archiveSession(sessionId: SessionId): Promise<SessionId[]> {
|
|
400
|
+
const result = await this.workspaces.archiveSession(
|
|
401
|
+
sessionId,
|
|
402
|
+
() => this.sessions.requireSession(sessionId),
|
|
403
|
+
)
|
|
404
|
+
if (result.changed) {
|
|
405
|
+
this.broadcast('host', {
|
|
406
|
+
type: 'host/archived-sessions-changed',
|
|
407
|
+
archivedSessionIds: result.archivedSessionIds,
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
return result.archivedSessionIds
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private publishSessionEvent(sessionId: SessionId, event: SessionEvent): void {
|
|
414
|
+
this.broadcast('mux', { type: 'session/event', sessionId, event })
|
|
415
|
+
if (event.type === 'session/title') {
|
|
416
|
+
this.broadcast('mux', {
|
|
417
|
+
type: 'session/projection',
|
|
418
|
+
sessionId,
|
|
419
|
+
key: 'title',
|
|
420
|
+
value: event.data.title,
|
|
421
|
+
seq: event.seq,
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
const previous = this.sessionListMetadata.get(sessionId) ?? INITIAL_SESSION_LIST_METADATA
|
|
425
|
+
const next = applySessionListMetadata(previous, event)
|
|
426
|
+
if (next === previous) return
|
|
427
|
+
this.sessionListMetadata.set(sessionId, next)
|
|
428
|
+
this.broadcast('mux', {
|
|
429
|
+
type: 'session/projection',
|
|
430
|
+
sessionId,
|
|
431
|
+
key: 'sessionListMetadata',
|
|
432
|
+
value: next,
|
|
433
|
+
seq: event.seq,
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private rememberSessionListMetadata(session: EdgeApiSessionSummary): void {
|
|
438
|
+
this.sessionListMetadata.set(session.id, {
|
|
439
|
+
blank: session.blank,
|
|
440
|
+
lastPromptAt: session.lastPromptAt,
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private publishSessionQueue(sessionId: SessionId, items: QueuedInboxItem[]): void {
|
|
445
|
+
this.broadcast('mux', { type: 'session/queue', sessionId, items })
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private publishRunning(sessionId: SessionId, running: boolean): void {
|
|
449
|
+
this.broadcast('host', { type: 'host/session-status', sessionId, running })
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private publishAgentError(sessionId: SessionId, error: unknown): void {
|
|
453
|
+
this.broadcast('host', {
|
|
454
|
+
type: 'host/agent-error',
|
|
455
|
+
sessionId,
|
|
456
|
+
message: error instanceof Error ? error.message : String(error),
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private broadcast(channel: 'mux', frame: MuxFrame): void
|
|
461
|
+
private broadcast(channel: 'host', frame: HostFrame): void
|
|
462
|
+
private broadcast(channel: 'mux' | 'host', frame: MuxFrame | HostFrame): void {
|
|
463
|
+
const now = Date.now()
|
|
464
|
+
for (const socket of this.ctx.getWebSockets(channel)) {
|
|
465
|
+
if (socket.readyState !== WebSocket.OPEN) continue
|
|
466
|
+
if (this.closeExpiredDownlink(socket, now)) continue
|
|
467
|
+
try {
|
|
468
|
+
this.sendFrame(socket, frame)
|
|
469
|
+
} catch (error) {
|
|
470
|
+
console.error(`dsh-edge failed to publish ${frame.type}.`, error)
|
|
471
|
+
socket.close(1011, 'downstream send failed')
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private sendFrame(socket: WebSocket, frame: MuxFrame | HostFrame): boolean {
|
|
477
|
+
if (this.closeExpiredDownlink(socket, Date.now())) return false
|
|
478
|
+
const message: ServerRequest = {
|
|
479
|
+
type: 'server-request',
|
|
480
|
+
rpcId: RpcId(crypto.randomUUID()),
|
|
481
|
+
method: frame.type,
|
|
482
|
+
payload: frame,
|
|
483
|
+
}
|
|
484
|
+
socket.send(JSON.stringify(message))
|
|
485
|
+
return true
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private closeExpiredDownlinks(now = Date.now()): number | undefined {
|
|
489
|
+
let nextExpiry: number | undefined
|
|
490
|
+
for (const socket of this.ctx.getWebSockets()) {
|
|
491
|
+
const attachment = readDownlinkAttachment(socket)
|
|
492
|
+
if (attachment === undefined || attachment.expiresAt * 1_000 <= now) {
|
|
493
|
+
socket.close(OWNER_SESSION_EXPIRED_CLOSE_CODE, OWNER_SESSION_EXPIRED_CLOSE_REASON)
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
496
|
+
const expiresAt = attachment.expiresAt * 1_000
|
|
497
|
+
nextExpiry = nextExpiry === undefined ? expiresAt : Math.min(nextExpiry, expiresAt)
|
|
498
|
+
}
|
|
499
|
+
return nextExpiry
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private closeExpiredDownlink(socket: WebSocket, now: number): boolean {
|
|
503
|
+
const attachment = readDownlinkAttachment(socket)
|
|
504
|
+
if (attachment !== undefined && attachment.expiresAt * 1_000 > now) return false
|
|
505
|
+
socket.close(OWNER_SESSION_EXPIRED_CLOSE_CODE, OWNER_SESSION_EXPIRED_CLOSE_REASON)
|
|
506
|
+
return true
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private async scheduleDownlinkExpiry(expiresAt: number): Promise<void> {
|
|
510
|
+
const scheduled = await this.ctx.storage.getAlarm()
|
|
511
|
+
if (scheduled === null || expiresAt < scheduled) {
|
|
512
|
+
await this.ctx.storage.setAlarm(expiresAt)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private async createSession(request: Request): Promise<Response> {
|
|
517
|
+
const body = await readJsonObject(request, MAX_SESSION_CREATE_BODY_BYTES)
|
|
518
|
+
const title = normalizeSessionTitle(
|
|
519
|
+
requireBoundedString(body.title, 'title', MAX_SESSION_TITLE_LENGTH),
|
|
520
|
+
MAX_SESSION_TITLE_BYTES,
|
|
521
|
+
)
|
|
522
|
+
if (title.length === 0) {
|
|
523
|
+
throw new EdgeHttpError(400, 'Session title must contain visible text.')
|
|
524
|
+
}
|
|
525
|
+
const session = await this.sessions.createSession({ title })
|
|
526
|
+
const presented = this.presentSession(session)
|
|
527
|
+
const attachmentError = await attachPublishedSession(
|
|
528
|
+
session.id,
|
|
529
|
+
EDGE_WORKSPACE_ID,
|
|
530
|
+
'created',
|
|
531
|
+
async () => {
|
|
532
|
+
const summary = (await this.sessions.listApiSessions())
|
|
533
|
+
.find(item => item.id === session.id)
|
|
534
|
+
if (summary === undefined) {
|
|
535
|
+
throw new Error(`Session "${session.id}" is missing from its post-creation summary.`)
|
|
536
|
+
}
|
|
537
|
+
this.publishSessionCreated(summary)
|
|
538
|
+
const available = await this.workspaces.list(await this.sessions.listApiSessions())
|
|
539
|
+
if (available.items.some(item => item.workspaceId === EDGE_WORKSPACE_ID)) {
|
|
540
|
+
await this.publishSessionAttached(summary, EDGE_WORKSPACE_ID)
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
)
|
|
544
|
+
if (attachmentError !== undefined) {
|
|
545
|
+
return jsonResponse({
|
|
546
|
+
ok: false,
|
|
547
|
+
error: attachmentError.message,
|
|
548
|
+
code: attachmentError.code,
|
|
549
|
+
details: attachmentError.details,
|
|
550
|
+
session: presented,
|
|
551
|
+
}, 500)
|
|
552
|
+
}
|
|
553
|
+
return jsonResponse({ session: presented }, 201)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private async replayEvents(request: Request, url: URL, sessionId: SessionId): Promise<Response> {
|
|
557
|
+
const after = resolveAfterSequence(request, url)
|
|
558
|
+
const limit = resolveReplayLimit(url)
|
|
559
|
+
const page = await this.sessions.readEventPage(
|
|
560
|
+
sessionId,
|
|
561
|
+
after + 1,
|
|
562
|
+
limit,
|
|
563
|
+
MAX_REPLAY_RESPONSE_BYTES,
|
|
564
|
+
)
|
|
565
|
+
const chunks: Uint8Array[] = []
|
|
566
|
+
let byteLength = 0
|
|
567
|
+
let hasMore = page.hasMore
|
|
568
|
+
if (page.events.length === 0 && page.hasMore) {
|
|
569
|
+
throw new EdgeHttpError(413, 'The next session event exceeds the replay response limit.')
|
|
570
|
+
}
|
|
571
|
+
for (const event of page.events) {
|
|
572
|
+
const chunk = encodeSessionEvent(event)
|
|
573
|
+
if (byteLength + chunk.byteLength > MAX_REPLAY_RESPONSE_BYTES) {
|
|
574
|
+
if (chunks.length === 0) {
|
|
575
|
+
throw new EdgeHttpError(413, 'The next session event exceeds the replay response limit.')
|
|
576
|
+
}
|
|
577
|
+
hasMore = true
|
|
578
|
+
break
|
|
579
|
+
}
|
|
580
|
+
chunks.push(chunk)
|
|
581
|
+
byteLength += chunk.byteLength
|
|
582
|
+
}
|
|
583
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
584
|
+
start(controller) {
|
|
585
|
+
for (const chunk of chunks) controller.enqueue(chunk)
|
|
586
|
+
controller.close()
|
|
587
|
+
},
|
|
588
|
+
})
|
|
589
|
+
const nextAfter = page.events[chunks.length - 1]?.seq ?? after
|
|
590
|
+
const headers = edgeEventStreamHeaders(corsHeaders())
|
|
591
|
+
headers.set('x-dsh-edge-has-more', String(hasMore))
|
|
592
|
+
headers.set('x-dsh-edge-next-after', String(nextAfter))
|
|
593
|
+
return new Response(stream, {
|
|
594
|
+
headers,
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private async startTurn(request: Request, sessionId: SessionId): Promise<Response> {
|
|
599
|
+
const body = await readJsonObject(request, MAX_TURN_BODY_BYTES)
|
|
600
|
+
const message = requireBoundedUtf8String(body.message, 'message', MAX_MESSAGE_TEXT_BYTES)
|
|
601
|
+
const {
|
|
602
|
+
baseURL,
|
|
603
|
+
maxTokens,
|
|
604
|
+
reasoningEffort,
|
|
605
|
+
commandTimeoutPolicy,
|
|
606
|
+
streamIdleTimeoutMs,
|
|
607
|
+
} = resolveEdgeDeploymentConfig(this.env)
|
|
608
|
+
const claimed = await this.claimTurn(sessionId)
|
|
609
|
+
|
|
610
|
+
const { stream, completion } = createLiveSessionEventStream(
|
|
611
|
+
publish => this.runClaimedTurn({
|
|
612
|
+
baseURL,
|
|
613
|
+
claimed,
|
|
614
|
+
commandTimeoutPolicy,
|
|
615
|
+
mode: 'queue',
|
|
616
|
+
content: [{ type: 'text', text: message }],
|
|
617
|
+
maxTokens,
|
|
618
|
+
reasoningEffort,
|
|
619
|
+
streamIdleTimeoutMs,
|
|
620
|
+
publish,
|
|
621
|
+
}),
|
|
622
|
+
(error) => {
|
|
623
|
+
console.error('dsh-edge turn transport failed.', error)
|
|
624
|
+
},
|
|
625
|
+
)
|
|
626
|
+
this.ctx.waitUntil(completion.catch(() => undefined))
|
|
627
|
+
|
|
628
|
+
return new Response(stream, {
|
|
629
|
+
status: 200,
|
|
630
|
+
headers: edgeEventStreamHeaders(corsHeaders()),
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private async startApiPrompt(input: {
|
|
635
|
+
sessionId: SessionId
|
|
636
|
+
mode: 'queue' | 'steer'
|
|
637
|
+
content: Extract<PromptContentPart, { type: 'text' }>[]
|
|
638
|
+
rpcId: RpcId
|
|
639
|
+
clientTimeZone?: string
|
|
640
|
+
}): Promise<void> {
|
|
641
|
+
if (messageTextByteLength(input.content) > MAX_MESSAGE_TEXT_BYTES) {
|
|
642
|
+
throw new EdgeHttpError(
|
|
643
|
+
413,
|
|
644
|
+
`Prompt text is limited to ${MAX_MESSAGE_TEXT_BYTES} UTF-8 bytes.`,
|
|
645
|
+
)
|
|
646
|
+
}
|
|
647
|
+
while (true) {
|
|
648
|
+
const active = this.activeTurns.get(input.sessionId)
|
|
649
|
+
if (active !== undefined) {
|
|
650
|
+
await active.admissionReady
|
|
651
|
+
if (this.activeTurns.get(input.sessionId) === active
|
|
652
|
+
&& active.accepting
|
|
653
|
+
&& active.admit !== undefined) {
|
|
654
|
+
await active.admit({
|
|
655
|
+
mode: input.mode,
|
|
656
|
+
content: input.content,
|
|
657
|
+
rpcId: input.rpcId,
|
|
658
|
+
...input.clientTimeZone === undefined
|
|
659
|
+
? {}
|
|
660
|
+
: { clientTimeZone: input.clientTimeZone },
|
|
661
|
+
})
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
await active.releaseComplete
|
|
665
|
+
continue
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const {
|
|
669
|
+
baseURL,
|
|
670
|
+
maxTokens,
|
|
671
|
+
reasoningEffort,
|
|
672
|
+
streamIdleTimeoutMs,
|
|
673
|
+
commandTimeoutPolicy,
|
|
674
|
+
} = resolveEdgeDeploymentConfig(this.env)
|
|
675
|
+
let claimed: Awaited<ReturnType<DshEdgeInstance['claimTurn']>>
|
|
676
|
+
try {
|
|
677
|
+
claimed = await this.claimTurn(input.sessionId)
|
|
678
|
+
} catch (error) {
|
|
679
|
+
if (error instanceof EdgeSessionStoreError && error.code === 'BUSY') continue
|
|
680
|
+
throw error
|
|
681
|
+
}
|
|
682
|
+
const running = this.runClaimedTurn({
|
|
683
|
+
claimed,
|
|
684
|
+
baseURL,
|
|
685
|
+
maxTokens,
|
|
686
|
+
reasoningEffort,
|
|
687
|
+
streamIdleTimeoutMs,
|
|
688
|
+
commandTimeoutPolicy,
|
|
689
|
+
mode: input.mode,
|
|
690
|
+
content: input.content,
|
|
691
|
+
rpcId: input.rpcId,
|
|
692
|
+
...input.clientTimeZone === undefined
|
|
693
|
+
? {}
|
|
694
|
+
: { clientTimeZone: input.clientTimeZone },
|
|
695
|
+
})
|
|
696
|
+
this.ctx.waitUntil(running.catch((error: unknown) => {
|
|
697
|
+
console.error('dsh-edge upstream protocol turn failed.', error)
|
|
698
|
+
}))
|
|
699
|
+
await claimed.turn.admissionReady
|
|
700
|
+
if (!claimed.turn.wasAdmitted) await running
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private updateQueue(
|
|
706
|
+
sessionId: SessionId,
|
|
707
|
+
itemId: MessageId,
|
|
708
|
+
action: QueueAction,
|
|
709
|
+
): 'accepted' | 'queue-item-not-found' | 'steer-unavailable' {
|
|
710
|
+
const active = this.activeTurns.get(sessionId)
|
|
711
|
+
const agent = active?.agent
|
|
712
|
+
if (agent === undefined) return 'queue-item-not-found'
|
|
713
|
+
const target = agent.inbox.nextTurn.some(message => message.id === itemId)
|
|
714
|
+
? 'next-turn'
|
|
715
|
+
: agent.inbox.nextStep.some(message => message.id === itemId) ? 'next-step' : undefined
|
|
716
|
+
const message = target === undefined
|
|
717
|
+
? undefined
|
|
718
|
+
: (target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep)
|
|
719
|
+
.find(candidate => candidate.id === itemId)
|
|
720
|
+
if (target === undefined || message === undefined) return 'queue-item-not-found'
|
|
721
|
+
if (action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
|
|
722
|
+
return 'steer-unavailable'
|
|
723
|
+
}
|
|
724
|
+
if (action.kind === 'edit') {
|
|
725
|
+
agent.inbox.replace(itemId, freezeMessage({ ...message, content: action.content }))
|
|
726
|
+
} else {
|
|
727
|
+
agent.inbox.remove(itemId)
|
|
728
|
+
if (action.kind === 'steer') agent.steer(message)
|
|
729
|
+
}
|
|
730
|
+
return 'accepted'
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
private async claimTurn(sessionId: SessionId): Promise<{
|
|
734
|
+
sessionId: SessionId
|
|
735
|
+
turn: ActiveTurn
|
|
736
|
+
handle: AgentHandle
|
|
737
|
+
}> {
|
|
738
|
+
const summary = await this.sessions.getApiSessionSummary(sessionId)
|
|
739
|
+
if (this.activeTurns.has(sessionId)) {
|
|
740
|
+
throw new EdgeSessionStoreError('BUSY', 'The session already has a running turn.')
|
|
741
|
+
}
|
|
742
|
+
this.rememberSessionListMetadata(summary)
|
|
743
|
+
const turn: ActiveTurn = {
|
|
744
|
+
turnId: EdgeTurnId(crypto.randomUUID()),
|
|
745
|
+
cancelRequested: false,
|
|
746
|
+
accepting: false,
|
|
747
|
+
wasAdmitted: false,
|
|
748
|
+
...activeTurnSignals(),
|
|
749
|
+
}
|
|
750
|
+
// Claim before opening the Agent so interleaved DO requests cannot own the same session.
|
|
751
|
+
this.activeTurns.set(sessionId, turn)
|
|
752
|
+
try {
|
|
753
|
+
const handle = await this.sessions.openAgentForTurn(sessionId, this.model)
|
|
754
|
+
turn.agent = handle.agent
|
|
755
|
+
return { sessionId, turn, handle }
|
|
756
|
+
} catch (error) {
|
|
757
|
+
this.activeTurns.delete(sessionId)
|
|
758
|
+
turn.resolveAdmissionReady()
|
|
759
|
+
turn.resolveReleaseComplete()
|
|
760
|
+
throw error
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private async runClaimedTurn(input: {
|
|
765
|
+
claimed: { sessionId: SessionId; turn: ActiveTurn; handle: AgentHandle }
|
|
766
|
+
baseURL: string
|
|
767
|
+
commandTimeoutPolicy: EdgeCommandTimeoutPolicy
|
|
768
|
+
maxTokens: number
|
|
769
|
+
content: Extract<PromptContentPart, { type: 'text' }>[]
|
|
770
|
+
reasoningEffort: EdgeReasoningEffort
|
|
771
|
+
streamIdleTimeoutMs: number
|
|
772
|
+
mode: 'queue' | 'steer'
|
|
773
|
+
rpcId?: RpcId
|
|
774
|
+
clientTimeZone?: string
|
|
775
|
+
publish?: (event: SessionEvent) => void | Promise<void>
|
|
776
|
+
}): Promise<void> {
|
|
777
|
+
const { sessionId, turn, handle } = input.claimed
|
|
778
|
+
try {
|
|
779
|
+
await this.runTurn({
|
|
780
|
+
...input,
|
|
781
|
+
agent: handle.agent,
|
|
782
|
+
turn,
|
|
783
|
+
onAdmitted: (admit) => {
|
|
784
|
+
turn.admit = admit
|
|
785
|
+
turn.accepting = true
|
|
786
|
+
turn.wasAdmitted = true
|
|
787
|
+
this.publishRunning(sessionId, true)
|
|
788
|
+
turn.resolveAdmissionReady()
|
|
789
|
+
},
|
|
790
|
+
onClosing: () => {
|
|
791
|
+
turn.accepting = false
|
|
792
|
+
},
|
|
793
|
+
publish: async (event) => {
|
|
794
|
+
this.publishSessionEvent(sessionId, event)
|
|
795
|
+
await input.publish?.(event)
|
|
796
|
+
},
|
|
797
|
+
publishQueue: (items) => {
|
|
798
|
+
this.publishSessionQueue(sessionId, items)
|
|
799
|
+
},
|
|
800
|
+
})
|
|
801
|
+
} catch (error) {
|
|
802
|
+
this.publishAgentError(sessionId, error)
|
|
803
|
+
throw error
|
|
804
|
+
} finally {
|
|
805
|
+
turn.accepting = false
|
|
806
|
+
turn.resolveAdmissionReady()
|
|
807
|
+
await handle.dispose().catch((error: unknown) => {
|
|
808
|
+
console.error('dsh-edge failed to release the turn agent.', error)
|
|
809
|
+
})
|
|
810
|
+
const active = this.activeTurns.get(sessionId)
|
|
811
|
+
if (active?.turnId === turn.turnId) this.activeTurns.delete(sessionId)
|
|
812
|
+
turn.resolveReleaseComplete()
|
|
813
|
+
this.publishRunning(sessionId, false)
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private async runTurn(input: {
|
|
818
|
+
agent: Agent
|
|
819
|
+
baseURL: string
|
|
820
|
+
commandTimeoutPolicy: EdgeCommandTimeoutPolicy
|
|
821
|
+
maxTokens: number
|
|
822
|
+
content: Extract<PromptContentPart, { type: 'text' }>[]
|
|
823
|
+
reasoningEffort: EdgeReasoningEffort
|
|
824
|
+
streamIdleTimeoutMs: number
|
|
825
|
+
mode: 'queue' | 'steer'
|
|
826
|
+
turn: ActiveTurn
|
|
827
|
+
rpcId?: RpcId
|
|
828
|
+
clientTimeZone?: string
|
|
829
|
+
publish: (event: SessionEvent) => void | Promise<void>
|
|
830
|
+
publishQueue: (items: QueuedInboxItem[]) => void | Promise<void>
|
|
831
|
+
onAdmitted?: (admit: EdgeAgentPromptAdmitter) => void
|
|
832
|
+
onClosing?: () => void
|
|
833
|
+
}): Promise<void> {
|
|
834
|
+
using workspace = await getWorkspace(this)
|
|
835
|
+
await this.sessions.runAgentTurn({
|
|
836
|
+
agent: input.agent,
|
|
837
|
+
baseURL: input.baseURL,
|
|
838
|
+
maxTokens: input.maxTokens,
|
|
839
|
+
reasoningEffort: input.reasoningEffort,
|
|
840
|
+
streamIdleTimeoutMs: input.streamIdleTimeoutMs,
|
|
841
|
+
mode: input.mode,
|
|
842
|
+
content: input.content,
|
|
843
|
+
...input.rpcId === undefined ? {} : { rpcId: input.rpcId },
|
|
844
|
+
...input.clientTimeZone === undefined
|
|
845
|
+
? {}
|
|
846
|
+
: { clientTimeZone: input.clientTimeZone },
|
|
847
|
+
shell: {
|
|
848
|
+
exec: async (command, options) => executeWorkspaceCommand(
|
|
849
|
+
workspace,
|
|
850
|
+
requireCommand(command),
|
|
851
|
+
requireWorkspacePath(options.cwd),
|
|
852
|
+
input.commandTimeoutPolicy,
|
|
853
|
+
options.timeoutMs,
|
|
854
|
+
options.signal,
|
|
855
|
+
),
|
|
856
|
+
},
|
|
857
|
+
afterFollowup: () => {
|
|
858
|
+
if (input.turn.cancelRequested) input.agent.cancel({ kind: 'user' })
|
|
859
|
+
},
|
|
860
|
+
...input.onAdmitted === undefined ? {} : { onAdmitted: input.onAdmitted },
|
|
861
|
+
...input.onClosing === undefined ? {} : { onClosing: input.onClosing },
|
|
862
|
+
publish: input.publish,
|
|
863
|
+
publishQueue: input.publishQueue,
|
|
864
|
+
})
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private cancelTurn(sessionId: SessionId): Response {
|
|
868
|
+
const active = this.activeTurns.get(sessionId)
|
|
869
|
+
if (active === undefined || !this.requestTurnCancellation(sessionId)) {
|
|
870
|
+
throw new EdgeHttpError(409, 'The session has no active turn in this Worker instance.')
|
|
871
|
+
}
|
|
872
|
+
const response: CancelEdgeTurnResponse = {
|
|
873
|
+
ok: true,
|
|
874
|
+
sessionId,
|
|
875
|
+
turnId: active.turnId,
|
|
876
|
+
}
|
|
877
|
+
return jsonResponse(response, 202)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private requestTurnCancellation(sessionId: SessionId): boolean {
|
|
881
|
+
const active = this.activeTurns.get(sessionId)
|
|
882
|
+
if (active === undefined) return false
|
|
883
|
+
active.cancelRequested = true
|
|
884
|
+
active.agent?.cancel({ kind: 'user' })
|
|
885
|
+
return true
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
private presentSession(session: EdgeSession) {
|
|
889
|
+
return {
|
|
890
|
+
...session,
|
|
891
|
+
status: this.activeTurns.has(session.id) ? 'running' as const : 'idle' as const,
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function requireOwnerSessionExpiry(request: Request): number {
|
|
897
|
+
const source = request.headers.get(OWNER_SESSION_EXPIRY_HEADER)
|
|
898
|
+
if (source === null || !/^\d{10}$/u.test(source)) {
|
|
899
|
+
throw new EdgeHttpError(401, 'Owner authentication required.')
|
|
900
|
+
}
|
|
901
|
+
const expiresAt = Number(source)
|
|
902
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt * 1_000 <= Date.now()) {
|
|
903
|
+
throw new EdgeHttpError(401, 'Owner authentication required.')
|
|
904
|
+
}
|
|
905
|
+
return expiresAt
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function readDownlinkAttachment(socket: WebSocket): DownlinkAttachment | undefined {
|
|
909
|
+
try {
|
|
910
|
+
const attachment: unknown = socket.deserializeAttachment()
|
|
911
|
+
if (typeof attachment !== 'object' || attachment === null) return undefined
|
|
912
|
+
const { channel, expiresAt } = attachment as Record<string, unknown>
|
|
913
|
+
if ((channel !== 'mux' && channel !== 'host')
|
|
914
|
+
|| typeof expiresAt !== 'number'
|
|
915
|
+
|| !Number.isSafeInteger(expiresAt)) {
|
|
916
|
+
return undefined
|
|
917
|
+
}
|
|
918
|
+
return { channel, expiresAt }
|
|
919
|
+
} catch {
|
|
920
|
+
return undefined
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function activeTurnSignals(): Pick<ActiveTurn,
|
|
925
|
+
'admissionReady' | 'releaseComplete' | 'resolveAdmissionReady' | 'resolveReleaseComplete'> {
|
|
926
|
+
const admission = Promise.withResolvers<void>()
|
|
927
|
+
const release = Promise.withResolvers<void>()
|
|
928
|
+
return {
|
|
929
|
+
admissionReady: admission.promise,
|
|
930
|
+
releaseComplete: release.promise,
|
|
931
|
+
resolveAdmissionReady: admission.resolve,
|
|
932
|
+
resolveReleaseComplete: release.resolve,
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
interface ParsedSessionRoute {
|
|
937
|
+
sessionId?: SessionId
|
|
938
|
+
action?: 'events' | 'turn' | 'cancel'
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function parseSessionRoute(pathname: string): ParsedSessionRoute {
|
|
942
|
+
const parts = pathname.split('/').filter(Boolean)
|
|
943
|
+
if (parts[0] !== 'api' || parts[1] !== 'sessions' || parts.length > 4) {
|
|
944
|
+
throw new EdgeHttpError(404, 'Session route not found.')
|
|
945
|
+
}
|
|
946
|
+
if (parts.length === 2) return {}
|
|
947
|
+
let sessionId: string
|
|
948
|
+
try {
|
|
949
|
+
sessionId = decodeURIComponent(parts[2] ?? '')
|
|
950
|
+
} catch {
|
|
951
|
+
throw new EdgeHttpError(400, 'Invalid session id.')
|
|
952
|
+
}
|
|
953
|
+
if (sessionId.length === 0 || sessionId.length > MAX_SESSION_ID_LENGTH) {
|
|
954
|
+
throw new EdgeHttpError(400, 'Invalid session id.')
|
|
955
|
+
}
|
|
956
|
+
const id = SessionId(sessionId)
|
|
957
|
+
if (parts.length === 3) return { sessionId: id }
|
|
958
|
+
const action = parts[3]
|
|
959
|
+
if (action !== 'events' && action !== 'turn' && action !== 'cancel') {
|
|
960
|
+
throw new EdgeHttpError(404, 'Session route not found.')
|
|
961
|
+
}
|
|
962
|
+
return { sessionId: id, action }
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function resolveAfterSequence(request: Request, url: URL): number {
|
|
966
|
+
const value = url.searchParams.get('after') ?? request.headers.get('last-event-id') ?? '-1'
|
|
967
|
+
if (!/^-?\d+$/.test(value)) throw new EdgeHttpError(400, 'after must be an integer.')
|
|
968
|
+
const sequence = Number(value)
|
|
969
|
+
if (!Number.isSafeInteger(sequence) || sequence < -1 || sequence >= Number.MAX_SAFE_INTEGER) {
|
|
970
|
+
throw new EdgeHttpError(400, 'after exceeds the supported integer range.')
|
|
971
|
+
}
|
|
972
|
+
return sequence
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function resolveSessionListAfter(url: URL): SessionId | undefined {
|
|
976
|
+
const raw = url.searchParams.get('after')
|
|
977
|
+
if (raw === null) return undefined
|
|
978
|
+
if (raw.length === 0 || raw.length > MAX_SESSION_ID_LENGTH) {
|
|
979
|
+
throw new EdgeHttpError(400, 'Invalid session list cursor.')
|
|
980
|
+
}
|
|
981
|
+
return SessionId(raw)
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function resolveSessionListLimit(url: URL): number {
|
|
985
|
+
const raw = url.searchParams.get('limit')
|
|
986
|
+
if (raw === null) return DEFAULT_SESSION_LIST_LIMIT
|
|
987
|
+
if (!/^\d+$/.test(raw)) throw new EdgeHttpError(400, 'limit must be a positive integer.')
|
|
988
|
+
const limit = Number(raw)
|
|
989
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_SESSION_LIST_LIMIT) {
|
|
990
|
+
throw new EdgeHttpError(400, `limit must be between 1 and ${MAX_SESSION_LIST_LIMIT}.`)
|
|
991
|
+
}
|
|
992
|
+
return limit
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function resolveReplayLimit(url: URL): number {
|
|
996
|
+
const raw = url.searchParams.get('limit')
|
|
997
|
+
if (raw === null) return DEFAULT_REPLAY_EVENT_LIMIT
|
|
998
|
+
if (!/^\d+$/.test(raw)) throw new EdgeHttpError(400, 'limit must be a positive integer.')
|
|
999
|
+
const limit = Number(raw)
|
|
1000
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_REPLAY_EVENT_LIMIT) {
|
|
1001
|
+
throw new EdgeHttpError(
|
|
1002
|
+
400,
|
|
1003
|
+
`limit must be between 1 and ${MAX_REPLAY_EVENT_LIMIT}.`,
|
|
1004
|
+
)
|
|
1005
|
+
}
|
|
1006
|
+
return limit
|
|
1007
|
+
}
|