@springbrand/space 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +24 -0
- package/package.json +30 -0
- package/src/env.ts +30 -0
- package/src/index.ts +27 -0
- package/src/space/artifacts-fs.ts +516 -0
- package/src/space/artifacts-sync.ts +331 -0
- package/src/space/checkpoint.ts +101 -0
- package/src/space/deploy-engine.ts +363 -0
- package/src/space/durable-object.ts +1557 -0
- package/src/space/fileinfo.ts +117 -0
- package/src/space/fs-backend.ts +235 -0
- package/src/space/git-objects.ts +315 -0
- package/src/space/inspector-wrapper.ts +141 -0
- package/src/space/preview-headers.ts +38 -0
- package/src/space/workspace-port.ts +245 -0
- package/src/space/wrangler-config.ts +188 -0
|
@@ -0,0 +1,1557 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers"
|
|
2
|
+
import { type FileSystem, type FileInfo } from "@cloudflare/shell"
|
|
3
|
+
import { type Git, type GitLogEntry, type GitStatusEntry } from "@cloudflare/shell/git"
|
|
4
|
+
import type { Env } from "../env"
|
|
5
|
+
import {
|
|
6
|
+
buildBranchDeployment,
|
|
7
|
+
handleDeployCommand,
|
|
8
|
+
type BranchDeploymentBundle,
|
|
9
|
+
type DeployContext,
|
|
10
|
+
} from "./deploy-engine"
|
|
11
|
+
import { globInfos, readDirInfos, toFileInfo } from "./fileinfo"
|
|
12
|
+
import { handleAssetRequest, buildAssetManifest, createMemoryStorage, type AssetConfig } from "@cloudflare/worker-bundler"
|
|
13
|
+
import {
|
|
14
|
+
buildInspectorWrapperSource,
|
|
15
|
+
VIBE_APP_MODULE,
|
|
16
|
+
} from "./inspector-wrapper"
|
|
17
|
+
import { ArtifactsBackend, resolveSpaceFsBackendMode, SqlBackend, spaceR2Prefix, type SpaceFsBackend } from "./fs-backend"
|
|
18
|
+
import { createGitFs, stageWorkdir } from "./git-objects"
|
|
19
|
+
import { stripPreviewSecurityHeaders } from "./preview-headers"
|
|
20
|
+
import * as git from "isomorphic-git"
|
|
21
|
+
import {
|
|
22
|
+
defaultSpaceQuota,
|
|
23
|
+
isReservedSpacePath,
|
|
24
|
+
isUnversionedSpacePath,
|
|
25
|
+
normalizeSpacePath,
|
|
26
|
+
normalizeSpacePattern,
|
|
27
|
+
normalizeWritableSpacePath,
|
|
28
|
+
toBytes,
|
|
29
|
+
versionOf,
|
|
30
|
+
type SpaceCommit,
|
|
31
|
+
type SpaceCommitManifest,
|
|
32
|
+
type SpaceConditionalWriteResult,
|
|
33
|
+
type SpaceAppPort,
|
|
34
|
+
type SpaceControlPort,
|
|
35
|
+
type SpaceFileInfo,
|
|
36
|
+
type SpaceFileVersion,
|
|
37
|
+
type SpaceQuota,
|
|
38
|
+
type SpaceUsage,
|
|
39
|
+
type SpaceWorkspacePort,
|
|
40
|
+
} from "./workspace-port"
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
SPACE_RESERVED_PREFIXES,
|
|
44
|
+
SPACE_UNVERSIONED_PREFIXES,
|
|
45
|
+
} from "./workspace-port"
|
|
46
|
+
export type {
|
|
47
|
+
SpaceAppPort,
|
|
48
|
+
SpaceCommit,
|
|
49
|
+
SpaceCommitManifest,
|
|
50
|
+
SpaceConditionalWriteResult,
|
|
51
|
+
SpaceControlPort,
|
|
52
|
+
SpaceFileInfo,
|
|
53
|
+
SpaceFileVersion,
|
|
54
|
+
SpaceQuota,
|
|
55
|
+
SpaceUsage,
|
|
56
|
+
SpaceWorkspacePort,
|
|
57
|
+
} from "./workspace-port"
|
|
58
|
+
|
|
59
|
+
/** The complete capability a host holds over one Space. */
|
|
60
|
+
export type SpaceStub = SpaceWorkspacePort & SpaceControlPort & SpaceAppPort
|
|
61
|
+
|
|
62
|
+
// ─── Inspector result types ────────────────────────────────────────────────
|
|
63
|
+
// These mirror the shapes returned by the wrapper-subclass injected into
|
|
64
|
+
// the dynamic worker (see `inspector-wrapper.ts`). Re-exported at the
|
|
65
|
+
// package boundary so the host controller types match.
|
|
66
|
+
|
|
67
|
+
export interface AppDatabaseColumn {
|
|
68
|
+
name: string
|
|
69
|
+
type: string
|
|
70
|
+
notnull: number
|
|
71
|
+
pk: number
|
|
72
|
+
}
|
|
73
|
+
export interface AppDatabaseTable {
|
|
74
|
+
name: string
|
|
75
|
+
rowCount: number
|
|
76
|
+
columns: AppDatabaseColumn[]
|
|
77
|
+
}
|
|
78
|
+
export interface AppDatabaseReadResult {
|
|
79
|
+
columns: string[]
|
|
80
|
+
rows: Record<string, unknown>[]
|
|
81
|
+
totalCount: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface AppTableQueryOpts {
|
|
85
|
+
limit?: number
|
|
86
|
+
offset?: number
|
|
87
|
+
orderBy?: string
|
|
88
|
+
orderDir?: "asc" | "desc"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface DeploymentRow {
|
|
92
|
+
branch: string
|
|
93
|
+
commitHash: string
|
|
94
|
+
mainModule: string
|
|
95
|
+
modules: Record<string, string | Record<string, unknown>>
|
|
96
|
+
assets: Record<string, string>
|
|
97
|
+
assetConfig: AssetConfig
|
|
98
|
+
compatibilityDate: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Overlay-only paths that must never leak into a deploy, rollback tree, or any
|
|
102
|
+
// file listing: git's object store and the ArtifactsFileSystem bookkeeping dir.
|
|
103
|
+
const isReservedPath = isReservedSpacePath
|
|
104
|
+
|
|
105
|
+
// ─── SpaceDO ────────────────────────────────────────────────────────────────
|
|
106
|
+
// Agent space Durable Object backed by @cloudflare/shell.
|
|
107
|
+
//
|
|
108
|
+
// Each named instance provides an isolated filesystem + git repo.
|
|
109
|
+
// The host worker calls methods via DO RPC (same worker, no HTTP).
|
|
110
|
+
// Commits/deploys are mirrored to a per-app Cloudflare Artifacts repo
|
|
111
|
+
// (see artifacts-sync.ts), which is the durable source of truth for history.
|
|
112
|
+
|
|
113
|
+
// Built asset manifest + in-memory storage for a single deployment. Rebuilding
|
|
114
|
+
// these on every request is wasteful (CWE-770 amplification under a preview
|
|
115
|
+
// flood), so we cache them per `branch:commitHash` with an LRU + TTL bound.
|
|
116
|
+
type CachedAssets = {
|
|
117
|
+
manifest: Awaited<ReturnType<typeof buildAssetManifest>>
|
|
118
|
+
storage: ReturnType<typeof createMemoryStorage>
|
|
119
|
+
expiresAt: number
|
|
120
|
+
}
|
|
121
|
+
const ASSET_CACHE_MAX_ENTRIES = 8
|
|
122
|
+
const ASSET_CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
|
123
|
+
|
|
124
|
+
export class SpaceDO extends DurableObject<Env>
|
|
125
|
+
implements SpaceWorkspacePort, SpaceControlPort, SpaceAppPort {
|
|
126
|
+
private backend!: SpaceFsBackend
|
|
127
|
+
private initializationPromise: Promise<void> | null = null
|
|
128
|
+
private assetCache = new Map<string, CachedAssets>()
|
|
129
|
+
/**
|
|
130
|
+
* One lane for every operation that reads or rewrites the whole tree.
|
|
131
|
+
*
|
|
132
|
+
* A Durable Object's input gate closes across a single call, not across the
|
|
133
|
+
* awaits inside one — and a commit, a deploy and a restore each walk the
|
|
134
|
+
* entire working tree. Two of them interleaved would commit half of one
|
|
135
|
+
* caller's write, so they queue here instead.
|
|
136
|
+
*/
|
|
137
|
+
private treeLane: Promise<unknown> = Promise.resolve()
|
|
138
|
+
|
|
139
|
+
private get overlay(): FileSystem { return this.backend.overlay }
|
|
140
|
+
private get fs(): FileSystem { return this.backend.fs }
|
|
141
|
+
private get git(): Git { return this.backend.git }
|
|
142
|
+
private get stateBackend() { return this.backend.stateBackend }
|
|
143
|
+
|
|
144
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
145
|
+
super(ctx, env)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async flushCheckpoint(): Promise<void> {
|
|
149
|
+
await this.backend.flushCheckpoint()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── ArtifactsFileSystem hydration helpers ──
|
|
153
|
+
|
|
154
|
+
private async hydrate(path: string): Promise<void> {
|
|
155
|
+
await this.backend.hydrate(path)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async materializeAll(): Promise<void> {
|
|
159
|
+
await this.backend.materializeAll()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async ensureInit(): Promise<void> {
|
|
163
|
+
if (!this.initializationPromise) {
|
|
164
|
+
this.initializationPromise = this.initializeSpace().catch((error) => {
|
|
165
|
+
this.initializationPromise = null
|
|
166
|
+
throw error
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
await this.initializationPromise
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async initializeSpace(): Promise<void> {
|
|
173
|
+
await this.initializeBackend()
|
|
174
|
+
|
|
175
|
+
// Table needed by the deploy engine. (Git objects/refs live in the
|
|
176
|
+
// Workspace FS under `.git/`, managed by isomorphic-git — the old `refs`
|
|
177
|
+
// and `git_internal` tables from the retired smart-HTTP server are gone.)
|
|
178
|
+
this.ctx.storage.sql.exec(`
|
|
179
|
+
CREATE TABLE IF NOT EXISTS deployments (
|
|
180
|
+
branch TEXT PRIMARY KEY,
|
|
181
|
+
commit_hash TEXT NOT NULL,
|
|
182
|
+
main_module TEXT NOT NULL,
|
|
183
|
+
modules TEXT NOT NULL,
|
|
184
|
+
assets TEXT NOT NULL DEFAULT '{}',
|
|
185
|
+
asset_config TEXT NOT NULL DEFAULT '{}',
|
|
186
|
+
compatibility_date TEXT NOT NULL DEFAULT '',
|
|
187
|
+
deployed_at INTEGER NOT NULL
|
|
188
|
+
)
|
|
189
|
+
`)
|
|
190
|
+
|
|
191
|
+
// Migrate existing deployments tables that lack new columns
|
|
192
|
+
try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN assets TEXT NOT NULL DEFAULT '{}'`) } catch {}
|
|
193
|
+
try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN asset_config TEXT NOT NULL DEFAULT '{}'`) } catch {}
|
|
194
|
+
try { this.ctx.storage.sql.exec(`ALTER TABLE deployments ADD COLUMN compatibility_date TEXT NOT NULL DEFAULT ''`) } catch {}
|
|
195
|
+
|
|
196
|
+
// Initialize git repo if not already done
|
|
197
|
+
try {
|
|
198
|
+
await this.git.init({ defaultBranch: "main" })
|
|
199
|
+
} catch {
|
|
200
|
+
// Already initialized — ignore
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await this.backend.ready()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async initializeBackend(): Promise<void> {
|
|
207
|
+
const mode = await resolveSpaceFsBackendMode(this.ctx.storage, this.env)
|
|
208
|
+
const repoName = this.spaceName
|
|
209
|
+
this.backend = mode === "artifacts"
|
|
210
|
+
? new ArtifactsBackend(this.ctx, this.env, repoName)
|
|
211
|
+
: new SqlBackend(this.ctx, repoName, this.env.WORKSPACE_R2)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private get spaceName(): string {
|
|
215
|
+
return this.ctx.id.name ?? "space"
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Serialize whole-tree work: commits, restores, deploys and destruction. */
|
|
219
|
+
private lane<T>(operation: () => Promise<T>): Promise<T> {
|
|
220
|
+
const next = this.treeLane.then(operation, operation)
|
|
221
|
+
this.treeLane = next.then(() => undefined, () => undefined)
|
|
222
|
+
return next
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private async statInfo(path: string): Promise<SpaceFileInfo | null> {
|
|
226
|
+
try {
|
|
227
|
+
return toFileInfo(path, await this.overlay.stat(path))
|
|
228
|
+
} catch {
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async ensureParent(path: string): Promise<void> {
|
|
234
|
+
const parent = path.slice(0, path.lastIndexOf("/"))
|
|
235
|
+
if (!parent) return
|
|
236
|
+
try {
|
|
237
|
+
await this.fs.mkdir(parent, { recursive: true })
|
|
238
|
+
} catch {
|
|
239
|
+
// Already a directory, which is the only outcome that matters here.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Refuse a write that would take the Space past its limits.
|
|
245
|
+
*
|
|
246
|
+
* A Space is one Chat's storage inside one Durable Object, and a runaway
|
|
247
|
+
* loop writing a file per iteration will exhaust it. The check runs on the
|
|
248
|
+
* Space's own write boundary, not only in the host wrapper, because the
|
|
249
|
+
* wrapper is one caller among several.
|
|
250
|
+
*/
|
|
251
|
+
private async assertWritable(path: string, incomingBytes: number): Promise<void> {
|
|
252
|
+
const quota = defaultSpaceQuota()
|
|
253
|
+
if (quota.maxFileBytes !== null && incomingBytes > quota.maxFileBytes) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`Space file exceeds the ${quota.maxFileBytes}-byte limit: ${path}`,
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
if (quota.maxTotalBytes === null && quota.maxFiles === null) return
|
|
259
|
+
const existing = await this.statInfo(path)
|
|
260
|
+
const usage = await this.usageOf()
|
|
261
|
+
const projectedBytes = usage.totalBytes - (existing?.size ?? 0) + incomingBytes
|
|
262
|
+
if (quota.maxTotalBytes !== null && projectedBytes > quota.maxTotalBytes) {
|
|
263
|
+
throw new Error("Space storage quota exceeded")
|
|
264
|
+
}
|
|
265
|
+
const projectedFiles = usage.fileCount + (existing ? 0 : 1)
|
|
266
|
+
if (quota.maxFiles !== null && projectedFiles > quota.maxFiles) {
|
|
267
|
+
throw new Error("Space file count quota exceeded")
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private async usageOf(): Promise<SpaceUsage> {
|
|
272
|
+
let fileCount = 0
|
|
273
|
+
let directoryCount = 0
|
|
274
|
+
let totalBytes = 0
|
|
275
|
+
for (const info of await globInfos(this.overlay, "**/*")) {
|
|
276
|
+
if (isReservedPath(info.path)) continue
|
|
277
|
+
if (info.type === "file") {
|
|
278
|
+
fileCount++
|
|
279
|
+
totalBytes += info.size
|
|
280
|
+
} else if (info.type === "directory") {
|
|
281
|
+
directoryCount++
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { fileCount, directoryCount, totalBytes }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async getUsage(): Promise<SpaceUsage> {
|
|
288
|
+
await this.ensureInit()
|
|
289
|
+
await this.materializeAll()
|
|
290
|
+
return this.usageOf()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async getQuota(): Promise<SpaceQuota> {
|
|
294
|
+
return defaultSpaceQuota()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Space history ───────────────────────────────────────────────
|
|
298
|
+
//
|
|
299
|
+
// A Space has one Git history covering everything it holds. The host projects
|
|
300
|
+
// a slice of that history — today `/creations` — into the Workspace Revisions
|
|
301
|
+
// a person sees, so a commit that only touched a scratch script never shows
|
|
302
|
+
// up as a version of their work.
|
|
303
|
+
|
|
304
|
+
private gitFs(): git.FsClient {
|
|
305
|
+
return createGitFs(this.fs)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private async headOid(): Promise<string | null> {
|
|
309
|
+
try {
|
|
310
|
+
return await git.resolveRef({ fs: this.gitFs(), dir: "/", ref: "HEAD" })
|
|
311
|
+
} catch {
|
|
312
|
+
return null
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private async expandOid(revision: string): Promise<string> {
|
|
317
|
+
if (!/^[0-9a-f]{7,40}$/u.test(revision)) throw new SpaceCommitNotFoundError()
|
|
318
|
+
try {
|
|
319
|
+
return await git.expandOid({ fs: this.gitFs(), dir: "/", oid: revision })
|
|
320
|
+
} catch {
|
|
321
|
+
throw new SpaceCommitNotFoundError()
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The tree oid a commit records for `prefix`, or `null` when absent. */
|
|
326
|
+
private async subtreeOid(commitOid: string, prefix: string): Promise<string | null> {
|
|
327
|
+
const filepath = relativeTo(prefix)
|
|
328
|
+
try {
|
|
329
|
+
const result = filepath
|
|
330
|
+
? await git.readTree({ fs: this.gitFs(), dir: "/", oid: commitOid, filepath })
|
|
331
|
+
: await git.readTree({ fs: this.gitFs(), dir: "/", oid: commitOid })
|
|
332
|
+
return result.oid
|
|
333
|
+
} catch {
|
|
334
|
+
return null
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Every blob a commit holds under `prefix`, as absolute Space paths. */
|
|
339
|
+
private async filesInCommit(
|
|
340
|
+
commitOid: string,
|
|
341
|
+
prefix: string,
|
|
342
|
+
): Promise<Map<string, string>> {
|
|
343
|
+
const rel = relativeTo(prefix)
|
|
344
|
+
const gitFs = this.gitFs()
|
|
345
|
+
const found = new Map<string, string>()
|
|
346
|
+
const entries = (await git.walk({
|
|
347
|
+
fs: gitFs,
|
|
348
|
+
dir: "/",
|
|
349
|
+
trees: [git.TREE({ ref: commitOid })],
|
|
350
|
+
map: async (filepath, walkerEntries) => {
|
|
351
|
+
if (filepath === ".") return undefined
|
|
352
|
+
const entry = walkerEntries?.[0]
|
|
353
|
+
if (!entry) return undefined
|
|
354
|
+
if (rel && filepath !== rel && !filepath.startsWith(`${rel}/`)) return undefined
|
|
355
|
+
if ((await entry.type()) !== "blob") return undefined
|
|
356
|
+
return { path: `/${filepath}`, oid: await entry.oid() }
|
|
357
|
+
},
|
|
358
|
+
})) as Array<{ path: string; oid: string } | undefined>
|
|
359
|
+
for (const entry of entries) {
|
|
360
|
+
if (entry) found.set(entry.path, entry.oid)
|
|
361
|
+
}
|
|
362
|
+
return found
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private async commitEntry(commitOid: string, prefix: string): Promise<SpaceCommit | null> {
|
|
366
|
+
const treeHash = await this.subtreeOid(commitOid, prefix)
|
|
367
|
+
if (treeHash === null) return null
|
|
368
|
+
const { commit } = await git.readCommit({ fs: this.gitFs(), dir: "/", oid: commitOid })
|
|
369
|
+
return {
|
|
370
|
+
revision: commitOid,
|
|
371
|
+
treeHash,
|
|
372
|
+
reason: commit.message.trim(),
|
|
373
|
+
createdAt: commit.author.timestamp * 1000,
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Commit everything the Space holds that belongs in history.
|
|
379
|
+
*
|
|
380
|
+
* Runs inside the tree lane already held by the caller.
|
|
381
|
+
*/
|
|
382
|
+
private async commitNow(reason: string, prefix = "/"): Promise<{
|
|
383
|
+
revision: string | null
|
|
384
|
+
treeHash: string | null
|
|
385
|
+
changed: boolean
|
|
386
|
+
}> {
|
|
387
|
+
const message = reason.trim()
|
|
388
|
+
if (!message) throw new Error("A Space commit needs a reason")
|
|
389
|
+
await this.materializeAll()
|
|
390
|
+
await this.flushCheckpoint()
|
|
391
|
+
await stageWorkdir(this.fs, isUnversionedSpacePath)
|
|
392
|
+
|
|
393
|
+
const gitFs = this.gitFs()
|
|
394
|
+
const head = await this.headOid()
|
|
395
|
+
// Nothing moved between HEAD and the index. An empty commit would appear in
|
|
396
|
+
// the person's version list as a change they never made.
|
|
397
|
+
const matrix = await git.statusMatrix({ fs: gitFs, dir: "/" })
|
|
398
|
+
const staged = matrix.some(([filepath, headStatus, , stageStatus]) =>
|
|
399
|
+
!isUnversionedSpacePath(`/${filepath}`) && headStatus !== stageStatus
|
|
400
|
+
)
|
|
401
|
+
if (!staged) {
|
|
402
|
+
if (!head) return { revision: null, treeHash: null, changed: false }
|
|
403
|
+
return {
|
|
404
|
+
revision: head,
|
|
405
|
+
treeHash: await this.subtreeOid(head, prefix),
|
|
406
|
+
changed: false,
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const revision = await git.commit({
|
|
411
|
+
fs: gitFs,
|
|
412
|
+
dir: "/",
|
|
413
|
+
message,
|
|
414
|
+
author: SPACE_AUTHOR,
|
|
415
|
+
})
|
|
416
|
+
return {
|
|
417
|
+
revision,
|
|
418
|
+
treeHash: await this.subtreeOid(revision, prefix),
|
|
419
|
+
changed: true,
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Commit the whole Space, reporting the tree of `prefix`.
|
|
425
|
+
*
|
|
426
|
+
* Everything is committed — a checkpoint is only useful if all of it is
|
|
427
|
+
* recoverable — but the hash returned describes the slice the caller cares
|
|
428
|
+
* about, so it matches what `listCommits` reports for the same projection.
|
|
429
|
+
*/
|
|
430
|
+
async commit(reason: string, prefix = "/"): Promise<{
|
|
431
|
+
revision: string | null
|
|
432
|
+
treeHash: string | null
|
|
433
|
+
changed: boolean
|
|
434
|
+
}> {
|
|
435
|
+
await this.ensureInit()
|
|
436
|
+
return this.lane(() => this.commitNow(reason, prefix))
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The commits in which `prefix` actually changed, newest first.
|
|
441
|
+
*
|
|
442
|
+
* History is Space-wide, but a version list is about the person's work. A
|
|
443
|
+
* commit whose `/creations` tree is identical to its parent's is invisible
|
|
444
|
+
* here, so restoring "the previous version" restores a different set of
|
|
445
|
+
* files, not merely an earlier timestamp.
|
|
446
|
+
*/
|
|
447
|
+
async listCommits(prefix: string, limit?: number): Promise<readonly SpaceCommit[]> {
|
|
448
|
+
await this.ensureInit()
|
|
449
|
+
const cap = limit === undefined ? 100 : limit
|
|
450
|
+
if (!Number.isInteger(cap) || cap < 1 || cap > 100) {
|
|
451
|
+
throw new Error("Invalid Space commit limit")
|
|
452
|
+
}
|
|
453
|
+
const head = await this.headOid()
|
|
454
|
+
if (!head) return []
|
|
455
|
+
// Newest first, so the entry after each one is its parent. A commit counts
|
|
456
|
+
// as a version when its projected tree differs from that parent's; the
|
|
457
|
+
// oldest entry counts whenever it has a projected tree at all.
|
|
458
|
+
const log = await git.log({ fs: this.gitFs(), dir: "/", depth: 1000 })
|
|
459
|
+
const trees = await Promise.all(
|
|
460
|
+
log.map((entry) => this.subtreeOid(entry.oid, prefix)),
|
|
461
|
+
)
|
|
462
|
+
const commits: SpaceCommit[] = []
|
|
463
|
+
for (const [index, entry] of log.entries()) {
|
|
464
|
+
const treeHash = trees[index]
|
|
465
|
+
if (treeHash === null || treeHash === undefined) continue
|
|
466
|
+
if (index + 1 < trees.length && trees[index + 1] === treeHash) continue
|
|
467
|
+
commits.push({
|
|
468
|
+
revision: entry.oid,
|
|
469
|
+
treeHash,
|
|
470
|
+
reason: entry.commit.message.trim(),
|
|
471
|
+
createdAt: entry.commit.author.timestamp * 1000,
|
|
472
|
+
})
|
|
473
|
+
if (commits.length >= cap) break
|
|
474
|
+
}
|
|
475
|
+
return commits
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async currentCommit(prefix: string): Promise<SpaceCommit | null> {
|
|
479
|
+
await this.ensureInit()
|
|
480
|
+
const head = await this.headOid()
|
|
481
|
+
return head ? this.commitEntry(head, prefix) : null
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** The files a commit holds under `prefix`, named relative to it. */
|
|
485
|
+
async commitManifest(prefix: string, revision: string): Promise<SpaceCommitManifest> {
|
|
486
|
+
await this.ensureInit()
|
|
487
|
+
const oid = await this.expandOid(revision)
|
|
488
|
+
const treeHash = await this.subtreeOid(oid, prefix)
|
|
489
|
+
if (treeHash === null) throw new SpaceCommitNotFoundError()
|
|
490
|
+
const blobs = await this.filesInCommit(oid, prefix)
|
|
491
|
+
const root = `${normalizeSpacePath(prefix)}/`
|
|
492
|
+
const files: { path: string; size: number }[] = []
|
|
493
|
+
for (const [path, blobOid] of blobs) {
|
|
494
|
+
const { blob } = await git.readBlob({ fs: this.gitFs(), dir: "/", oid: blobOid })
|
|
495
|
+
files.push({ path: path.slice(root.length), size: blob.byteLength })
|
|
496
|
+
}
|
|
497
|
+
files.sort((left, right) => left.path.localeCompare(right.path))
|
|
498
|
+
return { revision: oid, treeHash, files }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async readCommitFile(prefix: string, revision: string, path: string): Promise<Uint8Array> {
|
|
502
|
+
await this.ensureInit()
|
|
503
|
+
const oid = await this.expandOid(revision)
|
|
504
|
+
const root = normalizeSpacePath(prefix)
|
|
505
|
+
const target = `${root}/${commitRelativePath(path)}`
|
|
506
|
+
const blobOid = (await this.filesInCommit(oid, prefix)).get(target)
|
|
507
|
+
if (!blobOid) throw new SpaceCommitNotFoundError()
|
|
508
|
+
const { blob } = await git.readBlob({ fs: this.gitFs(), dir: "/", oid: blobOid })
|
|
509
|
+
return blob
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Put `prefix` back the way a commit had it, and record that as a new commit.
|
|
514
|
+
*
|
|
515
|
+
* Restoring moves history forward rather than rewriting it, so the state
|
|
516
|
+
* being replaced stays recoverable. Only `prefix` is touched: a person
|
|
517
|
+
* restoring a document must not silently roll back the scripts and data
|
|
518
|
+
* beside it. Nothing is deployed — restoring files is not shipping a site.
|
|
519
|
+
*/
|
|
520
|
+
async restorePathsFromCommit(
|
|
521
|
+
prefix: string,
|
|
522
|
+
revision: string,
|
|
523
|
+
reason: string,
|
|
524
|
+
): Promise<{ revision: string; treeHash: string }> {
|
|
525
|
+
await this.ensureInit()
|
|
526
|
+
return this.lane(async () => {
|
|
527
|
+
await this.materializeAll()
|
|
528
|
+
const oid = await this.expandOid(revision)
|
|
529
|
+
const targetTree = await this.subtreeOid(oid, prefix)
|
|
530
|
+
if (targetTree === null) throw new SpaceCommitNotFoundError()
|
|
531
|
+
|
|
532
|
+
// Capture what is there now before overwriting it. Work done since the
|
|
533
|
+
// last checkpoint is still the person's work, and a restore that erased
|
|
534
|
+
// it would leave them nothing to go back to.
|
|
535
|
+
const preserved = await this.commitNow("Before restore", prefix)
|
|
536
|
+
if (preserved.revision && preserved.treeHash === targetTree) {
|
|
537
|
+
// Already exactly this. Committing again would add a version that
|
|
538
|
+
// records no change.
|
|
539
|
+
return { revision: preserved.revision, treeHash: preserved.treeHash }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const wanted = await this.filesInCommit(oid, prefix)
|
|
543
|
+
const root = normalizeSpacePath(prefix)
|
|
544
|
+
|
|
545
|
+
for (const info of await globInfos(this.overlay, `${root}/**/*`)) {
|
|
546
|
+
if (info.type !== "file" || isReservedPath(info.path)) continue
|
|
547
|
+
if (!wanted.has(info.path)) await this.fs.rm(info.path, { force: true })
|
|
548
|
+
}
|
|
549
|
+
for (const [path, blobOid] of wanted) {
|
|
550
|
+
const { blob } = await git.readBlob({ fs: this.gitFs(), dir: "/", oid: blobOid })
|
|
551
|
+
await this.ensureParent(path)
|
|
552
|
+
await this.fs.writeFileBytes(path, blob)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const committed = await this.commitNow(reason, prefix)
|
|
556
|
+
const head = committed.revision ?? (await this.headOid())
|
|
557
|
+
if (!head) throw new SpaceCommitNotFoundError()
|
|
558
|
+
return { revision: head, treeHash: committed.treeHash ?? EMPTY_TREE_OID }
|
|
559
|
+
})
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Erase this Space and everything it caused to exist.
|
|
564
|
+
*
|
|
565
|
+
* Called when the Chat that owns it is deleted. It is idempotent because the
|
|
566
|
+
* host retries: a cleanup that fails halfway must be safe to run again, and a
|
|
567
|
+
* Space that survives its Chat costs storage forever with nobody to notice.
|
|
568
|
+
*/
|
|
569
|
+
async destroySpace(): Promise<void> {
|
|
570
|
+
await this.lane(async () => {
|
|
571
|
+
for (const branch of this.deployedBranches()) {
|
|
572
|
+
try {
|
|
573
|
+
await this.ctx.facets.abort(
|
|
574
|
+
facetNameForApp(branch),
|
|
575
|
+
new Error("Space destroyed"),
|
|
576
|
+
)
|
|
577
|
+
} catch {
|
|
578
|
+
// Never started, or already gone.
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
await this.ctx.facets.delete(facetNameForApp(branch))
|
|
582
|
+
} catch {
|
|
583
|
+
// Already gone.
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
await this.deleteSpilledObjects()
|
|
587
|
+
this.assetCache.clear()
|
|
588
|
+
await this.ctx.storage.deleteAll()
|
|
589
|
+
this.initializationPromise = null
|
|
590
|
+
})
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private deployedBranches(): string[] {
|
|
594
|
+
try {
|
|
595
|
+
return this.ctx.storage.sql
|
|
596
|
+
.exec("SELECT branch FROM deployments")
|
|
597
|
+
.toArray()
|
|
598
|
+
.map((row) => row.branch as string)
|
|
599
|
+
} catch {
|
|
600
|
+
return []
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private async deleteSpilledObjects(): Promise<void> {
|
|
605
|
+
const bucket = this.env.WORKSPACE_R2
|
|
606
|
+
if (!bucket) return
|
|
607
|
+
const prefix = `${spaceR2Prefix(this.spaceName)}/`
|
|
608
|
+
let cursor: string | undefined
|
|
609
|
+
do {
|
|
610
|
+
const page = await bucket.list({ prefix, ...(cursor ? { cursor } : {}) })
|
|
611
|
+
const keys = page.objects.map((object) => object.key)
|
|
612
|
+
if (keys.length > 0) await bucket.delete(keys)
|
|
613
|
+
cursor = page.truncated ? page.cursor : undefined
|
|
614
|
+
} while (cursor)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ── Filesystem RPC methods ──────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Read a text file, or `null` when it is not there.
|
|
621
|
+
*
|
|
622
|
+
* Upstream threw for a missing path. The runtime contract asks for `null`
|
|
623
|
+
* instead, and the distinction matters: a caller has to be able to tell "no
|
|
624
|
+
* such file" from "the store is broken", and every tool that checks before
|
|
625
|
+
* writing would otherwise have to catch and re-classify an exception.
|
|
626
|
+
*/
|
|
627
|
+
async readFile(path: string, opts?: { offset?: number; limit?: number }): Promise<string | null> {
|
|
628
|
+
await this.ensureInit()
|
|
629
|
+
const target = normalizeSpacePath(path)
|
|
630
|
+
await this.hydrate(target)
|
|
631
|
+
// Read through the overlay FS: it hydrates the blob from the Artifacts base
|
|
632
|
+
// on demand and honours whiteouts (a deleted base file stays deleted).
|
|
633
|
+
let content: string
|
|
634
|
+
try {
|
|
635
|
+
content = await this.fs.readFile(target)
|
|
636
|
+
} catch {
|
|
637
|
+
return null
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (opts?.offset !== undefined || opts?.limit !== undefined) {
|
|
641
|
+
const lines = content.split("\n")
|
|
642
|
+
const start = (opts.offset ?? 1) - 1
|
|
643
|
+
const end = opts.limit !== undefined ? start + opts.limit : lines.length
|
|
644
|
+
return lines
|
|
645
|
+
.slice(start, end)
|
|
646
|
+
.map((line, i) => `${start + i + 1}\t${line}`)
|
|
647
|
+
.join("\n")
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return content
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async writeFile(path: string, content: string, mimeType?: string): Promise<void> {
|
|
654
|
+
void mimeType // MIME is derived from the path; the argument keeps the runtime contract.
|
|
655
|
+
await this.ensureInit()
|
|
656
|
+
const target = normalizeWritableSpacePath(path)
|
|
657
|
+
await this.assertWritable(target, byteLength(content))
|
|
658
|
+
await this.ensureParent(target)
|
|
659
|
+
// Route through the overlay FS so a write to a previously-deleted base path
|
|
660
|
+
// clears its whiteout tombstone and makes the file visible again.
|
|
661
|
+
await this.fs.writeFile(target, content)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async writeFileBytes(
|
|
665
|
+
path: string,
|
|
666
|
+
data: Uint8Array | ArrayBuffer,
|
|
667
|
+
mimeType?: string,
|
|
668
|
+
): Promise<void> {
|
|
669
|
+
void mimeType
|
|
670
|
+
await this.ensureInit()
|
|
671
|
+
const target = normalizeWritableSpacePath(path)
|
|
672
|
+
const bytes = toBytes(data)
|
|
673
|
+
await this.assertWritable(target, bytes.byteLength)
|
|
674
|
+
await this.ensureParent(target)
|
|
675
|
+
await this.fs.writeFileBytes(target, bytes)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async appendFile(path: string, content: string, mimeType?: string): Promise<void> {
|
|
679
|
+
void mimeType
|
|
680
|
+
await this.ensureInit()
|
|
681
|
+
const target = normalizeWritableSpacePath(path)
|
|
682
|
+
await this.hydrate(target)
|
|
683
|
+
const existing = await this.statInfo(target)
|
|
684
|
+
await this.assertWritable(target, (existing?.size ?? 0) + byteLength(content))
|
|
685
|
+
await this.ensureParent(target)
|
|
686
|
+
await this.fs.appendFile(target, content)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async exists(path: string): Promise<boolean> {
|
|
690
|
+
await this.ensureInit()
|
|
691
|
+
const target = normalizeSpacePath(path)
|
|
692
|
+
await this.hydrate(target)
|
|
693
|
+
return this.fs.exists(target)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Write only if the file is still exactly what the caller last saw.
|
|
698
|
+
*
|
|
699
|
+
* The Sandbox holds a copy of the Space and publishes back into it minutes
|
|
700
|
+
* later. Without this check a publish silently overwrites whatever the Agent
|
|
701
|
+
* or the person changed in between, and the loss is invisible — there is no
|
|
702
|
+
* error and no second copy to recover from.
|
|
703
|
+
*/
|
|
704
|
+
async writeFileBytesIfUnchanged(
|
|
705
|
+
path: string,
|
|
706
|
+
data: Uint8Array,
|
|
707
|
+
mimeType: string,
|
|
708
|
+
expected: SpaceFileVersion | null,
|
|
709
|
+
): Promise<SpaceConditionalWriteResult> {
|
|
710
|
+
await this.ensureInit()
|
|
711
|
+
const target = normalizeWritableSpacePath(path)
|
|
712
|
+
await this.hydrate(target)
|
|
713
|
+
const current = await this.statInfo(target)
|
|
714
|
+
const currentVersion = versionOf(current)
|
|
715
|
+
const matches = expected === null
|
|
716
|
+
? current === null
|
|
717
|
+
: current?.type === "file" &&
|
|
718
|
+
current.updatedAt === expected.updatedAt &&
|
|
719
|
+
current.size === expected.size
|
|
720
|
+
if (!matches) {
|
|
721
|
+
return { written: false, reason: "conflict", current: currentVersion }
|
|
722
|
+
}
|
|
723
|
+
await this.writeFileBytes(target, data, mimeType)
|
|
724
|
+
const written = await this.statInfo(target)
|
|
725
|
+
const version = versionOf(written)
|
|
726
|
+
if (!version) throw new Error("Published Space file could not be verified")
|
|
727
|
+
return { written: true, version }
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async editFile(path: string, oldString: string, newString: string): Promise<{ path: string; size: number }> {
|
|
731
|
+
await this.ensureInit()
|
|
732
|
+
await this.hydrate(path)
|
|
733
|
+
const result = await this.stateBackend.replaceInFile(path, oldString, newString)
|
|
734
|
+
if (result.replaced === 0) {
|
|
735
|
+
throw new Error(`old_string not found in ${path}`)
|
|
736
|
+
}
|
|
737
|
+
// Read back size
|
|
738
|
+
const content = await this.fs.readFile(path)
|
|
739
|
+
return { path, size: content.length }
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
async deleteFile(path: string): Promise<void> {
|
|
743
|
+
await this.ensureInit()
|
|
744
|
+
// Route through the overlay-aware FS so a base file is tombstoned
|
|
745
|
+
// (whiteout), not silently resurrected by later hydration.
|
|
746
|
+
await this.fs.rm(path, { force: true })
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Matching entries with their metadata.
|
|
751
|
+
*
|
|
752
|
+
* Upstream returned bare paths, which forced every caller to `stat` each
|
|
753
|
+
* match one at a time — a listing of a few hundred files became a few hundred
|
|
754
|
+
* extra round trips. The metadata is already in hand here, so it is returned.
|
|
755
|
+
*/
|
|
756
|
+
async glob(pattern: string): Promise<SpaceFileInfo[]> {
|
|
757
|
+
await this.ensureInit()
|
|
758
|
+
await this.materializeAll()
|
|
759
|
+
const files = await globInfos(this.overlay, normalizeSpacePattern(pattern))
|
|
760
|
+
return files
|
|
761
|
+
.filter((f: FileInfo) => f.type === "file" && !isReservedPath(f.path))
|
|
762
|
+
.sort((a: FileInfo, b: FileInfo) => b.updatedAt - a.updatedAt)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** The bare-path form upstream tooling expects. */
|
|
766
|
+
async globPaths(pattern: string): Promise<string[]> {
|
|
767
|
+
return (await this.glob(pattern)).map((f) => f.path)
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async grep(query: string, include?: string): Promise<Array<{ path: string; line: number; content: string }>> {
|
|
771
|
+
await this.ensureInit()
|
|
772
|
+
await this.materializeAll()
|
|
773
|
+
const results = await this.stateBackend.searchFiles(include ?? "**/*", query)
|
|
774
|
+
const matches: Array<{ path: string; line: number; content: string }> = []
|
|
775
|
+
for (const file of results) {
|
|
776
|
+
for (const match of file.matches) {
|
|
777
|
+
matches.push({
|
|
778
|
+
path: file.path,
|
|
779
|
+
line: match.line,
|
|
780
|
+
content: match.lineText,
|
|
781
|
+
})
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return matches
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async list(prefix?: string): Promise<Array<{ path: string; mtime: number }>> {
|
|
788
|
+
await this.ensureInit()
|
|
789
|
+
await this.materializeAll()
|
|
790
|
+
const pattern = prefix ? `${prefix.replace(/^\//, "")}/**/*` : "**/*"
|
|
791
|
+
const files = await globInfos(this.overlay, pattern)
|
|
792
|
+
return files
|
|
793
|
+
.filter((f: FileInfo) => f.type === "file" && !isReservedPath(f.path))
|
|
794
|
+
.map((f: FileInfo) => ({ path: f.path, mtime: f.updatedAt }))
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ── The WorkspacePort surface ──────────────────────────────────
|
|
798
|
+
// Method for method, this is the file capability `@springbrand/agent-runtime`
|
|
799
|
+
// consumes. There is deliberately no second set of file methods beside it:
|
|
800
|
+
// one Space, one way to read and write it.
|
|
801
|
+
|
|
802
|
+
async stat(path: string): Promise<SpaceFileInfo | null> {
|
|
803
|
+
await this.ensureInit()
|
|
804
|
+
const target = normalizeSpacePath(path)
|
|
805
|
+
await this.hydrate(target)
|
|
806
|
+
return this.statInfo(target)
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* The entry itself, without following a final symlink.
|
|
811
|
+
*
|
|
812
|
+
* Scope checks need this: resolving the link first is exactly how a link
|
|
813
|
+
* planted inside a Space becomes a way to read outside it.
|
|
814
|
+
*/
|
|
815
|
+
async lstat(path: string): Promise<SpaceFileInfo | null> {
|
|
816
|
+
await this.ensureInit()
|
|
817
|
+
const target = normalizeSpacePath(path)
|
|
818
|
+
await this.hydrate(target)
|
|
819
|
+
try {
|
|
820
|
+
return toFileInfo(target, await this.overlay.lstat(target))
|
|
821
|
+
} catch {
|
|
822
|
+
return null
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async readFileBytes(path: string): Promise<Uint8Array | null> {
|
|
827
|
+
await this.ensureInit()
|
|
828
|
+
const target = normalizeSpacePath(path)
|
|
829
|
+
await this.hydrate(target)
|
|
830
|
+
// Overlay FS hydrates from the base on demand and honours whiteouts; it
|
|
831
|
+
// throws ENOENT for a missing path, but this RPC's contract returns null.
|
|
832
|
+
try {
|
|
833
|
+
return await this.fs.readFileBytes(target)
|
|
834
|
+
} catch {
|
|
835
|
+
return null
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
async readDir(dir?: string, opts?: { limit?: number; offset?: number }): Promise<SpaceFileInfo[]> {
|
|
840
|
+
await this.ensureInit()
|
|
841
|
+
await this.materializeAll()
|
|
842
|
+
const base = dir === undefined ? "/" : normalizeSpacePath(dir)
|
|
843
|
+
const entries = await readDirInfos(this.overlay, base, opts)
|
|
844
|
+
return entries.filter((entry) => !isReservedPath(entry.path))
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async mkdir(path: string, opts?: { recursive?: boolean }): Promise<void> {
|
|
848
|
+
await this.ensureInit()
|
|
849
|
+
await this.fs.mkdir(normalizeWritableSpacePath(path), opts)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async rm(path: string, opts?: { recursive?: boolean; force?: boolean }): Promise<void> {
|
|
853
|
+
await this.ensureInit()
|
|
854
|
+
// Route through the overlay-aware FS so base files are tombstoned.
|
|
855
|
+
await this.fs.rm(normalizeWritableSpacePath(path), opts)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async cp(src: string, dest: string, opts?: { recursive?: boolean }): Promise<void> {
|
|
859
|
+
await this.ensureInit()
|
|
860
|
+
const from = normalizeSpacePath(src)
|
|
861
|
+
const to = normalizeWritableSpacePath(dest)
|
|
862
|
+
await this.hydrate(from)
|
|
863
|
+
if (opts?.recursive) await this.materializeAll()
|
|
864
|
+
const source = await this.statInfo(from)
|
|
865
|
+
await this.assertWritable(to, source?.size ?? 0)
|
|
866
|
+
await this.ensureParent(to)
|
|
867
|
+
await this.fs.cp(from, to, opts)
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async mv(src: string, dest: string, opts?: { recursive?: boolean }): Promise<void> {
|
|
871
|
+
void opts // `mv` moves whatever the source is; the FS needs no recursion flag.
|
|
872
|
+
await this.ensureInit()
|
|
873
|
+
const from = normalizeWritableSpacePath(src)
|
|
874
|
+
const to = normalizeWritableSpacePath(dest)
|
|
875
|
+
await this.materializeAll()
|
|
876
|
+
await this.ensureParent(to)
|
|
877
|
+
await this.fs.mv(from, to)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async symlink(target: string, linkPath: string): Promise<void> {
|
|
881
|
+
await this.ensureInit()
|
|
882
|
+
await this.fs.symlink(target, normalizeWritableSpacePath(linkPath))
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async readlink(path: string): Promise<string> {
|
|
886
|
+
await this.ensureInit()
|
|
887
|
+
return this.fs.readlink(normalizeSpacePath(path))
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
async patch(diff: string): Promise<{ applied: string[]; failed: string[] }> {
|
|
891
|
+
await this.ensureInit()
|
|
892
|
+
const edits = parseUnifiedDiffToEdits(diff)
|
|
893
|
+
const applied: string[] = []
|
|
894
|
+
const failed: string[] = []
|
|
895
|
+
|
|
896
|
+
for (const edit of edits) {
|
|
897
|
+
try {
|
|
898
|
+
await this.fs.writeFile(edit.path, edit.content)
|
|
899
|
+
applied.push(edit.path)
|
|
900
|
+
} catch {
|
|
901
|
+
failed.push(edit.path)
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return { applied, failed }
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** Resolve the branch HEAD currently points at (best-effort). */
|
|
909
|
+
private async currentBranch(): Promise<string | null> {
|
|
910
|
+
try {
|
|
911
|
+
const result = await this.git.branch({ list: true })
|
|
912
|
+
if ("current" in result && result.current) return result.current
|
|
913
|
+
} catch {
|
|
914
|
+
// ignore — treat as unknown
|
|
915
|
+
}
|
|
916
|
+
return null
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// ── Git RPC methods ─────────────────────────────────────────────
|
|
920
|
+
|
|
921
|
+
async gitCommitLocal(
|
|
922
|
+
message: string,
|
|
923
|
+
author?: { name: string; email: string }
|
|
924
|
+
): Promise<{ sha: string; message: string }> {
|
|
925
|
+
await this.ensureInit()
|
|
926
|
+
// Persist pending overlay writes before the commit captures the tree.
|
|
927
|
+
await this.flushCheckpoint()
|
|
928
|
+
// Stage the workdir (incl. deletions) but never reserved bookkeeping
|
|
929
|
+
// paths — a committed `.afs/state.json` would pollute the Artifacts base.
|
|
930
|
+
await stageWorkdir(this.fs, isUnversionedSpacePath)
|
|
931
|
+
const result = await this.git.commit({
|
|
932
|
+
message,
|
|
933
|
+
author: author ?? { name: "Agent", email: "agent@vibesdk.local" },
|
|
934
|
+
})
|
|
935
|
+
return { sha: result.oid, message: result.message }
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async gitCommit(
|
|
939
|
+
message: string,
|
|
940
|
+
author?: { name: string; email: string }
|
|
941
|
+
): Promise<{ sha: string; message: string }> {
|
|
942
|
+
await this.ensureInit()
|
|
943
|
+
// Staging walks the whole tree; ensure the Artifacts base is materialized
|
|
944
|
+
// so the commit captures every file, not just overlay writes. Reserved
|
|
945
|
+
// bookkeeping paths (`.afs`) are skipped so they never enter a commit.
|
|
946
|
+
await this.materializeAll()
|
|
947
|
+
// Persist pending overlay writes before the commit captures the tree.
|
|
948
|
+
await this.flushCheckpoint()
|
|
949
|
+
await stageWorkdir(this.fs, isUnversionedSpacePath)
|
|
950
|
+
const result = await this.git.commit({
|
|
951
|
+
message,
|
|
952
|
+
author: author ?? { name: "Agent", email: "agent@vibesdk.local" },
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
this.ctx.waitUntil(
|
|
956
|
+
(async () => {
|
|
957
|
+
const branch = await this.currentBranch()
|
|
958
|
+
if (branch) await this.backend.push(branch)
|
|
959
|
+
})(),
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
return { sha: result.oid, message: result.message }
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async gitLog(limit?: number): Promise<GitLogEntry[]> {
|
|
966
|
+
await this.ensureInit()
|
|
967
|
+
return this.git.log({ depth: limit })
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async gitStatus(): Promise<GitStatusEntry[]> {
|
|
971
|
+
await this.ensureInit()
|
|
972
|
+
return this.git.status()
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async gitCheckout(ref: string): Promise<void> {
|
|
976
|
+
await this.ensureInit()
|
|
977
|
+
await this.git.checkout({ ref })
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async gitBranch(opts?: { name?: string; list?: boolean; delete?: string }) {
|
|
981
|
+
await this.ensureInit()
|
|
982
|
+
return this.git.branch(opts)
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async gitDiff(): Promise<Array<{ filepath: string; status: string }>> {
|
|
986
|
+
await this.ensureInit()
|
|
987
|
+
return this.git.diff()
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Roll back `branch` to the tree of `commitHash` and redeploy. This is a
|
|
992
|
+
* forward restore (not a destructive reset): it captures the file tree at the
|
|
993
|
+
* target commit, reconciles the branch working tree to match it, then creates
|
|
994
|
+
* a new commit on `branch` and deploys — so history stays intact and the
|
|
995
|
+
* preview rebuilds from the restored files.
|
|
996
|
+
*/
|
|
997
|
+
async rollbackToCommit(branch: string, commitHash: string): Promise<unknown> {
|
|
998
|
+
await this.ensureInit()
|
|
999
|
+
// Reads/walks the whole working tree below, so the Artifacts base must be
|
|
1000
|
+
// fully materialized into the overlay first.
|
|
1001
|
+
await this.materializeAll()
|
|
1002
|
+
|
|
1003
|
+
await this.backend.fetch(branch)
|
|
1004
|
+
|
|
1005
|
+
// Verify the target commit exists on this branch's history.
|
|
1006
|
+
const history = await this.git.log({ ref: branch, depth: 1000 })
|
|
1007
|
+
const target = history.find(
|
|
1008
|
+
(entry) => entry.oid === commitHash || entry.oid.startsWith(commitHash),
|
|
1009
|
+
)
|
|
1010
|
+
if (!target) {
|
|
1011
|
+
return { error: `Commit ${commitHash} not found on branch "${branch}"` }
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Capture the file tree at the target commit as raw bytes. Reading and
|
|
1015
|
+
// rewriting via bytes (not strings) keeps binary assets — fonts, images,
|
|
1016
|
+
// favicons — byte-for-byte intact. Round-tripping them through UTF-8
|
|
1017
|
+
// corrupts them and inflates each invalid byte into a 3-byte replacement
|
|
1018
|
+
// char, which can push a file past the SQLite inline-value limit and throw
|
|
1019
|
+
// SQLITE_TOOBIG (only reproduced on rollback, since normal deploys never
|
|
1020
|
+
// rewrite these files).
|
|
1021
|
+
await this.git.checkout({ ref: target.oid })
|
|
1022
|
+
const targetFiles = new Map<string, Uint8Array>()
|
|
1023
|
+
for (const info of await globInfos(this.overlay, "**/*")) {
|
|
1024
|
+
if (info.type !== "file") continue
|
|
1025
|
+
if (isReservedPath(info.path)) continue
|
|
1026
|
+
const bytes = await this.overlay.readFileBytes(info.path)
|
|
1027
|
+
if (bytes) targetFiles.set(info.path, bytes)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Return to the branch HEAD and reconcile the working tree: remove files
|
|
1031
|
+
// that are absent from the target, then (over)write the captured files.
|
|
1032
|
+
await this.git.checkout({ ref: branch })
|
|
1033
|
+
for (const info of await globInfos(this.overlay, "**/*")) {
|
|
1034
|
+
if (info.type !== "file") continue
|
|
1035
|
+
if (isReservedPath(info.path)) continue
|
|
1036
|
+
if (!targetFiles.has(info.path)) {
|
|
1037
|
+
await this.overlay.rm(info.path, { force: true })
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
for (const [path, bytes] of targetFiles) {
|
|
1041
|
+
await this.overlay.writeFileBytes(path, bytes)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// Commit the restored tree (no-op if nothing changed) and redeploy.
|
|
1045
|
+
try {
|
|
1046
|
+
await this.gitCommit(`rollback: restore ${target.oid.slice(0, 8)}`)
|
|
1047
|
+
} catch {
|
|
1048
|
+
// Clean tree (already at target) — proceed to redeploy existing HEAD.
|
|
1049
|
+
}
|
|
1050
|
+
return this.deploy(branch)
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// ── Deploy RPC methods ──────────────────────────────────────────
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Build and publish `appRoot` as this branch's app.
|
|
1057
|
+
*
|
|
1058
|
+
* `appRoot` is what keeps a Space from being an app by default: a data
|
|
1059
|
+
* analysis Space with a spreadsheet and a script deploys nothing until
|
|
1060
|
+
* someone names the directory that holds a site.
|
|
1061
|
+
*/
|
|
1062
|
+
async deploy(branch: string, appRoot?: string): Promise<unknown> {
|
|
1063
|
+
await this.ensureInit()
|
|
1064
|
+
return this.lane(async () => {
|
|
1065
|
+
// The deploy engine reads the full branch tree, so ensure the Artifacts
|
|
1066
|
+
// base is materialized into the overlay first.
|
|
1067
|
+
await this.materializeAll()
|
|
1068
|
+
await this.flushCheckpoint()
|
|
1069
|
+
const fakeRequest = new Request("http://internal/?cmd=deploy", {
|
|
1070
|
+
method: "POST",
|
|
1071
|
+
headers: { "Content-Type": "application/json" },
|
|
1072
|
+
body: JSON.stringify({ branch, ...(appRoot ? { appRoot } : {}) }),
|
|
1073
|
+
})
|
|
1074
|
+
const ctx: DeployContext = {
|
|
1075
|
+
sql: this.ctx.storage.sql,
|
|
1076
|
+
git: this.git,
|
|
1077
|
+
fs: this.fs,
|
|
1078
|
+
}
|
|
1079
|
+
const res = await handleDeployCommand(ctx, "deploy", fakeRequest)
|
|
1080
|
+
const data = await res.json() as Record<string, unknown>
|
|
1081
|
+
data.preview_url = `/space/${this.spaceName}/preview/${encodeURIComponent(branch)}/`
|
|
1082
|
+
|
|
1083
|
+
if (!data.error) await this.backend.push(branch)
|
|
1084
|
+
|
|
1085
|
+
return data
|
|
1086
|
+
})
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async getDeploymentBundle(
|
|
1090
|
+
branch: string,
|
|
1091
|
+
appRoot?: string,
|
|
1092
|
+
): Promise<BranchDeploymentBundle> {
|
|
1093
|
+
await this.ensureInit()
|
|
1094
|
+
await this.materializeAll()
|
|
1095
|
+
return buildBranchDeployment(
|
|
1096
|
+
{
|
|
1097
|
+
sql: this.ctx.storage.sql,
|
|
1098
|
+
git: this.git,
|
|
1099
|
+
fs: this.fs,
|
|
1100
|
+
},
|
|
1101
|
+
branch,
|
|
1102
|
+
appRoot,
|
|
1103
|
+
)
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
async undeploy(branch: string): Promise<unknown> {
|
|
1107
|
+
await this.ensureInit()
|
|
1108
|
+
const fakeRequest = new Request("http://internal/?cmd=undeploy", {
|
|
1109
|
+
method: "POST",
|
|
1110
|
+
headers: { "Content-Type": "application/json" },
|
|
1111
|
+
body: JSON.stringify({ branch }),
|
|
1112
|
+
})
|
|
1113
|
+
const ctx: DeployContext = {
|
|
1114
|
+
sql: this.ctx.storage.sql,
|
|
1115
|
+
git: this.git,
|
|
1116
|
+
fs: this.overlay,
|
|
1117
|
+
}
|
|
1118
|
+
const res = await handleDeployCommand(ctx, "undeploy", fakeRequest)
|
|
1119
|
+
return res.json()
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
async listDeployments(): Promise<unknown> {
|
|
1123
|
+
await this.ensureInit()
|
|
1124
|
+
const fakeRequest = new Request("http://internal/?cmd=list_deployments")
|
|
1125
|
+
const ctx: DeployContext = {
|
|
1126
|
+
sql: this.ctx.storage.sql,
|
|
1127
|
+
git: this.git,
|
|
1128
|
+
fs: this.overlay,
|
|
1129
|
+
}
|
|
1130
|
+
const res = await handleDeployCommand(ctx, "list_deployments", fakeRequest)
|
|
1131
|
+
return res.json()
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
async getDeployment(branch: string): Promise<unknown> {
|
|
1135
|
+
await this.ensureInit()
|
|
1136
|
+
const fakeRequest = new Request(`http://internal/?cmd=get_deployment&branch=${encodeURIComponent(branch)}`)
|
|
1137
|
+
const ctx: DeployContext = {
|
|
1138
|
+
sql: this.ctx.storage.sql,
|
|
1139
|
+
git: this.git,
|
|
1140
|
+
fs: this.overlay,
|
|
1141
|
+
}
|
|
1142
|
+
const res = await handleDeployCommand(ctx, "get_deployment", fakeRequest)
|
|
1143
|
+
return res.json()
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// ── Space info ──────────────────────────────────────────────────
|
|
1147
|
+
|
|
1148
|
+
async getInfo(): Promise<{ fileCount: number; directoryCount: number; totalBytes: number }> {
|
|
1149
|
+
await this.ensureInit()
|
|
1150
|
+
await this.materializeAll()
|
|
1151
|
+
let fileCount = 0
|
|
1152
|
+
let directoryCount = 0
|
|
1153
|
+
let totalBytes = 0
|
|
1154
|
+
for (const info of await globInfos(this.overlay, "**/*")) {
|
|
1155
|
+
if (isReservedPath(info.path)) continue
|
|
1156
|
+
if (info.type === "file") {
|
|
1157
|
+
fileCount++
|
|
1158
|
+
totalBytes += info.size
|
|
1159
|
+
} else if (info.type === "directory") {
|
|
1160
|
+
directoryCount++
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return { fileCount, directoryCount, totalBytes }
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// ── Deployment row reader (shared by servePreview + DB-viewer) ──
|
|
1167
|
+
|
|
1168
|
+
private readDeployment(branch: string): DeploymentRow | null {
|
|
1169
|
+
const rows = this.ctx.storage.sql
|
|
1170
|
+
.exec(
|
|
1171
|
+
"SELECT branch, commit_hash, main_module, modules, assets, asset_config, compatibility_date FROM deployments WHERE branch = ?",
|
|
1172
|
+
branch,
|
|
1173
|
+
)
|
|
1174
|
+
.toArray()
|
|
1175
|
+
if (rows.length === 0) return null
|
|
1176
|
+
const r = rows[0]
|
|
1177
|
+
return {
|
|
1178
|
+
branch: r.branch as string,
|
|
1179
|
+
commitHash: r.commit_hash as string,
|
|
1180
|
+
mainModule: r.main_module as string,
|
|
1181
|
+
modules: JSON.parse(r.modules as string) as Record<string, string | Record<string, unknown>>,
|
|
1182
|
+
assets: JSON.parse((r.assets as string) || "{}") as Record<string, string>,
|
|
1183
|
+
assetConfig: JSON.parse((r.asset_config as string) || "{}") as AssetConfig,
|
|
1184
|
+
compatibilityDate: (r.compatibility_date as string) || FALLBACK_COMPATIBILITY_DATE,
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// ── Preview serving via Dynamic Workers ─────────────────────────
|
|
1189
|
+
//
|
|
1190
|
+
// Architecture (matches Cloudflare's Durable Object Facets docs example):
|
|
1191
|
+
//
|
|
1192
|
+
// - The LLM exports `class App extends DurableObject` from its main
|
|
1193
|
+
// module. `App.fetch(request)` is the entire backend (Hono /
|
|
1194
|
+
// itty-router / vanilla — the LLM decides).
|
|
1195
|
+
// - SpaceDO acts as the supervisor ("AppRunner" in the docs).
|
|
1196
|
+
// `servePreview` loads the user's worker via the Worker Loader,
|
|
1197
|
+
// extracts the App class, and hosts it as a Facet keyed
|
|
1198
|
+
// `app:<branch>`. Static assets are served host-side; everything
|
|
1199
|
+
// else (including WebSocket upgrades) is forwarded into the Facet.
|
|
1200
|
+
// - State is the Facet's own `ctx.storage` (SQLite + KV). No env.DB
|
|
1201
|
+
// binding is injected.
|
|
1202
|
+
// - To make the DB-viewer work without forcing the LLM to write
|
|
1203
|
+
// inspector boilerplate, we don't load the user's main directly.
|
|
1204
|
+
// We load a wrapper module (`inspector-wrapper.ts`) which imports
|
|
1205
|
+
// the user's `App`, re-exports everything, and exports a subclass
|
|
1206
|
+
// `App` that adds `__vibeInspectListTables` / `__vibeInspectRead`
|
|
1207
|
+
// / `__vibeWipe`. The subclass shares the same `ctx.storage`.
|
|
1208
|
+
|
|
1209
|
+
async servePreview(branch: string, request: Request): Promise<Response> {
|
|
1210
|
+
await this.ensureInit()
|
|
1211
|
+
|
|
1212
|
+
const dep = this.readDeployment(branch)
|
|
1213
|
+
if (!dep) {
|
|
1214
|
+
return new Response(`No deployment found for branch "${branch}"`, { status: 404 })
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// Serve static assets host-side before forwarding to the Facet. The built
|
|
1218
|
+
// manifest/storage are cached per deployment so repeat asset reads don't
|
|
1219
|
+
// re-spin the build on every request.
|
|
1220
|
+
if (Object.keys(dep.assets).length > 0) {
|
|
1221
|
+
const { manifest, storage } = await this.getCachedAssets(dep)
|
|
1222
|
+
const assetResponse = await handleAssetRequest(request, manifest, storage, dep.assetConfig)
|
|
1223
|
+
if (assetResponse) return assetResponse
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
let appClass: DurableObjectClass
|
|
1227
|
+
try {
|
|
1228
|
+
appClass = this.loadAppClass(dep)
|
|
1229
|
+
} catch (e) {
|
|
1230
|
+
return new Response(
|
|
1231
|
+
`Failed to load App class: ${e instanceof Error ? e.message : String(e)}`,
|
|
1232
|
+
{ status: 500 },
|
|
1233
|
+
)
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
const facet = this.ctx.facets.get(facetNameForApp(branch), () => ({ class: appClass }))
|
|
1237
|
+
return facet.fetch(request)
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Return the built asset manifest + in-memory storage for a deployment,
|
|
1242
|
+
* reusing a cached build when available. Keyed by `branch:commitHash` so a
|
|
1243
|
+
* redeploy (new commit) transparently rebuilds. Bounded by an LRU cap + TTL.
|
|
1244
|
+
*/
|
|
1245
|
+
private async getCachedAssets(dep: DeploymentRow): Promise<CachedAssets> {
|
|
1246
|
+
const key = `${dep.branch}:${dep.commitHash}`
|
|
1247
|
+
const now = Date.now()
|
|
1248
|
+
|
|
1249
|
+
const cached = this.assetCache.get(key)
|
|
1250
|
+
if (cached && cached.expiresAt > now) {
|
|
1251
|
+
// Refresh LRU recency.
|
|
1252
|
+
this.assetCache.delete(key)
|
|
1253
|
+
this.assetCache.set(key, cached)
|
|
1254
|
+
return cached
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const manifest = await buildAssetManifest(dep.assets)
|
|
1258
|
+
const storage = createMemoryStorage(dep.assets)
|
|
1259
|
+
const entry: CachedAssets = { manifest, storage, expiresAt: now + ASSET_CACHE_TTL_MS }
|
|
1260
|
+
|
|
1261
|
+
this.assetCache.set(key, entry)
|
|
1262
|
+
|
|
1263
|
+
// Evict expired / oldest entries to keep the cache bounded.
|
|
1264
|
+
for (const [k, v] of this.assetCache) {
|
|
1265
|
+
if (v.expiresAt <= now) this.assetCache.delete(k)
|
|
1266
|
+
}
|
|
1267
|
+
while (this.assetCache.size > ASSET_CACHE_MAX_ENTRIES) {
|
|
1268
|
+
const oldest = this.assetCache.keys().next().value
|
|
1269
|
+
if (oldest === undefined) break
|
|
1270
|
+
this.assetCache.delete(oldest)
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
return entry
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// ── App-class loader ────────────────────────────────────────────
|
|
1277
|
+
//
|
|
1278
|
+
// Loads the dynamic worker for `branch`'s latest deployment with the
|
|
1279
|
+
// inspector wrapper as the main module. The wrapper imports the
|
|
1280
|
+
// user's main and exports a subclass of `App` with `__vibeInspect*`
|
|
1281
|
+
// methods (see `inspector-wrapper.ts`).
|
|
1282
|
+
//
|
|
1283
|
+
// `LOADER.get(id, ...)` caches by id. We key on
|
|
1284
|
+
// `<spaceName>-<branch>-<commitHash>` so a redeploy invalidates the
|
|
1285
|
+
// worker (and any Facet still pinned to the old class is aborted
|
|
1286
|
+
// implicitly the next time `ctx.facets.get(...)` runs the callback).
|
|
1287
|
+
private loadAppClass(dep: DeploymentRow): DurableObjectClass {
|
|
1288
|
+
const spaceName = this.ctx.id.name ?? "space"
|
|
1289
|
+
const workerId = `${spaceName}-${dep.branch}-${dep.commitHash}`
|
|
1290
|
+
const wrappedModules: Record<string, string | Record<string, unknown>> = {
|
|
1291
|
+
...dep.modules,
|
|
1292
|
+
[VIBE_APP_MODULE]: buildInspectorWrapperSource(dep.mainModule),
|
|
1293
|
+
}
|
|
1294
|
+
const worker = this.env.LOADER.get(workerId, async () => ({
|
|
1295
|
+
mainModule: VIBE_APP_MODULE,
|
|
1296
|
+
modules: wrappedModules,
|
|
1297
|
+
// The date the app declared, not a date frozen into this file. A worker
|
|
1298
|
+
// built against one runtime and run under another is a bug the author
|
|
1299
|
+
// cannot see or fix.
|
|
1300
|
+
compatibilityDate: dep.compatibilityDate || FALLBACK_COMPATIBILITY_DATE,
|
|
1301
|
+
// Generated code gets no outbound network. Omitting this inherits the
|
|
1302
|
+
// parent Worker's full internet access, which would let an app the model
|
|
1303
|
+
// wrote reach anything this Worker can — including internal services.
|
|
1304
|
+
globalOutbound: null,
|
|
1305
|
+
}))
|
|
1306
|
+
return (
|
|
1307
|
+
worker as { getDurableObjectClass: (name: string) => DurableObjectClass }
|
|
1308
|
+
).getDurableObjectClass("App")
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Returns a Facet stub for the App of `branch`, starting it (loading
|
|
1313
|
+
* the dynamic worker, wrapping the App class) on first call. Used by
|
|
1314
|
+
* the DB-viewer inspector RPCs.
|
|
1315
|
+
*
|
|
1316
|
+
* Returns `null` if there is no deployment yet for the branch.
|
|
1317
|
+
*/
|
|
1318
|
+
private getAppFacet(branch: string): Fetcher | null {
|
|
1319
|
+
const dep = this.readDeployment(branch)
|
|
1320
|
+
if (!dep) return null
|
|
1321
|
+
const cls = this.loadAppClass(dep)
|
|
1322
|
+
return this.ctx.facets.get(facetNameForApp(branch), () => ({ class: cls })) as unknown as Fetcher
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// ── DB-viewer RPC methods ───────────────────────────────────────
|
|
1326
|
+
//
|
|
1327
|
+
// The App's storage lives inside the Facet. The inspector wrapper
|
|
1328
|
+
// (`inspector-wrapper.ts`) extends the user's App class with
|
|
1329
|
+
// `__vibeInspect*` methods, so we just RPC into the Facet stub.
|
|
1330
|
+
|
|
1331
|
+
async listAppTables(branch: string): Promise<AppDatabaseTable[]> {
|
|
1332
|
+
await this.ensureInit()
|
|
1333
|
+
const facet = this.getAppFacet(branch)
|
|
1334
|
+
if (!facet) return []
|
|
1335
|
+
return await (facet as unknown as {
|
|
1336
|
+
__vibeInspectListTables: () => Promise<AppDatabaseTable[]>
|
|
1337
|
+
}).__vibeInspectListTables()
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
async queryAppTable(
|
|
1341
|
+
branch: string,
|
|
1342
|
+
table: string,
|
|
1343
|
+
opts: AppTableQueryOpts = {},
|
|
1344
|
+
): Promise<AppDatabaseReadResult> {
|
|
1345
|
+
await this.ensureInit()
|
|
1346
|
+
const facet = this.getAppFacet(branch)
|
|
1347
|
+
if (!facet) {
|
|
1348
|
+
return { columns: [], rows: [], totalCount: 0 }
|
|
1349
|
+
}
|
|
1350
|
+
return await (facet as unknown as {
|
|
1351
|
+
__vibeInspectRead: (
|
|
1352
|
+
table: string,
|
|
1353
|
+
opts: AppTableQueryOpts,
|
|
1354
|
+
) => Promise<AppDatabaseReadResult>
|
|
1355
|
+
}).__vibeInspectRead(table, opts)
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* Drop every user table inside the App Facet's SQLite. We use a
|
|
1360
|
+
* targeted drop rather than `ctx.facets.delete(...)` because the
|
|
1361
|
+
* latter aborts the Facet immediately and breaks any active
|
|
1362
|
+
* WebSocket connections — the user is usually in the middle of
|
|
1363
|
+
* preview-iteration when they hit Reset.
|
|
1364
|
+
*/
|
|
1365
|
+
async wipeAppDatabase(branch: string): Promise<{ ok: true }> {
|
|
1366
|
+
await this.ensureInit()
|
|
1367
|
+
const facet = this.getAppFacet(branch)
|
|
1368
|
+
if (!facet) return { ok: true }
|
|
1369
|
+
await (facet as unknown as {
|
|
1370
|
+
__vibeWipe: () => Promise<{ ok: true }>
|
|
1371
|
+
}).__vibeWipe()
|
|
1372
|
+
return { ok: true }
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// ── HTTP handler: preview serving + internal deploy commands ────
|
|
1376
|
+
|
|
1377
|
+
async fetch(request: Request): Promise<Response> {
|
|
1378
|
+
await this.ensureInit()
|
|
1379
|
+
|
|
1380
|
+
const url = new URL(request.url)
|
|
1381
|
+
const path = url.pathname
|
|
1382
|
+
|
|
1383
|
+
// Preview routes: /space/:name/preview/:branch/*
|
|
1384
|
+
const previewMatch = path.match(/\/preview\/([^/]+)(\/.*)?$/)
|
|
1385
|
+
if (previewMatch) {
|
|
1386
|
+
const branch = decodeURIComponent(previewMatch[1])
|
|
1387
|
+
const spaceName = this.ctx.id.name ?? "space"
|
|
1388
|
+
const basePath = `/space/${spaceName}/preview/${encodeURIComponent(branch)}`
|
|
1389
|
+
|
|
1390
|
+
// Rewrite the URL so the dynamic worker sees a clean path
|
|
1391
|
+
const subPath = previewMatch[2] || "/"
|
|
1392
|
+
const previewUrl = new URL(subPath, url.origin)
|
|
1393
|
+
previewUrl.search = url.search
|
|
1394
|
+
const previewRequest = new Request(previewUrl.toString(), request)
|
|
1395
|
+
const response = await this.servePreview(branch, previewRequest)
|
|
1396
|
+
|
|
1397
|
+
// Strip headers a generated app must not be able to set on the shared
|
|
1398
|
+
// preview origin (e.g. Service-Worker-Allowed scope expansion) before
|
|
1399
|
+
// any further rewriting.
|
|
1400
|
+
const safeResponse = stripPreviewSecurityHeaders(response)
|
|
1401
|
+
|
|
1402
|
+
// Rewrite root-relative paths in HTML responses so they resolve
|
|
1403
|
+
// correctly when the preview is mounted on a sub-path
|
|
1404
|
+
return rewritePreviewResponse(safeResponse, basePath)
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// Deploy command routes
|
|
1408
|
+
const cmd = url.searchParams.get("cmd")
|
|
1409
|
+
if (cmd && ["deploy", "get_deployment", "list_deployments", "undeploy"].includes(cmd)) {
|
|
1410
|
+
const deployCtx: DeployContext = {
|
|
1411
|
+
sql: this.ctx.storage.sql,
|
|
1412
|
+
git: this.git,
|
|
1413
|
+
fs: this.overlay,
|
|
1414
|
+
}
|
|
1415
|
+
return handleDeployCommand(deployCtx, cmd, request)
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
return new Response("Not Found", { status: 404 })
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// ─── Preview Response Rewriting ──────────────────────────────────────────────
|
|
1423
|
+
// When a preview is served on /space/:name/preview/:branch/, root-relative
|
|
1424
|
+
// paths like /style.css in HTML would resolve to the domain root instead of
|
|
1425
|
+
// the preview path. We rewrite them so the browser fetches the correct URL.
|
|
1426
|
+
|
|
1427
|
+
function rewritePreviewResponse(response: Response, basePath: string): Response {
|
|
1428
|
+
// Rewrite Location header on redirects
|
|
1429
|
+
const location = response.headers.get("location")
|
|
1430
|
+
if (location?.startsWith("/")) {
|
|
1431
|
+
const rewritten = new Response(response.body, response)
|
|
1432
|
+
rewritten.headers.set("location", basePath + location)
|
|
1433
|
+
return rewritten
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// Only rewrite HTML responses
|
|
1437
|
+
const ct = response.headers.get("content-type") ?? ""
|
|
1438
|
+
if (!ct.includes("text/html")) return response
|
|
1439
|
+
|
|
1440
|
+
// Use HTMLRewriter to prefix root-relative src/href/action attributes
|
|
1441
|
+
return new HTMLRewriter()
|
|
1442
|
+
.on("[src],[href],[action]", {
|
|
1443
|
+
element(el) {
|
|
1444
|
+
for (const attr of ["src", "href", "action"] as const) {
|
|
1445
|
+
const val = el.getAttribute(attr)
|
|
1446
|
+
if (val?.startsWith("/") && !val.startsWith("//")) {
|
|
1447
|
+
el.setAttribute(attr, basePath + val)
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
},
|
|
1451
|
+
})
|
|
1452
|
+
.transform(response)
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// ─── Facet naming ───────────────────────────────────────────────────────────
|
|
1456
|
+
|
|
1457
|
+
function facetNameForApp(branch: string): string {
|
|
1458
|
+
return `app:${branch}`
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ─── Space history helpers ──────────────────────────────────────────────────
|
|
1462
|
+
|
|
1463
|
+
/** Git's oid for an empty tree — what a repository with no content writes. */
|
|
1464
|
+
const EMPTY_TREE_OID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
1465
|
+
|
|
1466
|
+
/** Used only for deployments recorded before the date was persisted. */
|
|
1467
|
+
const FALLBACK_COMPATIBILITY_DATE = "2025-04-01"
|
|
1468
|
+
|
|
1469
|
+
const SPACE_AUTHOR = Object.freeze({
|
|
1470
|
+
name: "Workspace",
|
|
1471
|
+
email: "workspace@springbrand.local",
|
|
1472
|
+
})
|
|
1473
|
+
|
|
1474
|
+
/** Raised when a caller names a commit this Space does not have. */
|
|
1475
|
+
export class SpaceCommitNotFoundError extends Error {
|
|
1476
|
+
constructor() {
|
|
1477
|
+
super("space_commit_not_found")
|
|
1478
|
+
this.name = "SpaceCommitNotFoundError"
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function relativeTo(prefix: string): string {
|
|
1483
|
+
return prefix.split("/").filter(Boolean).join("/")
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* A path naming one file inside a commit, or nothing.
|
|
1488
|
+
*
|
|
1489
|
+
* Stricter than a live Space path, and deliberately so: this string arrives
|
|
1490
|
+
* from a download URL, so anything but a plain relative POSIX path is a caller
|
|
1491
|
+
* trying to reach past the projection. Rejecting it as "no such file" is the
|
|
1492
|
+
* honest answer and reveals nothing about what else the commit holds.
|
|
1493
|
+
*/
|
|
1494
|
+
function commitRelativePath(path: string): string {
|
|
1495
|
+
if (typeof path !== "string" || !path || path.startsWith("/") || path.includes("\\")) {
|
|
1496
|
+
throw new SpaceCommitNotFoundError()
|
|
1497
|
+
}
|
|
1498
|
+
const parts = path.split("/")
|
|
1499
|
+
if (parts.some((part) => !part || part === "." || part === ".." || part === ".git")) {
|
|
1500
|
+
throw new SpaceCommitNotFoundError()
|
|
1501
|
+
}
|
|
1502
|
+
return parts.join("/")
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function byteLength(content: string): number {
|
|
1506
|
+
return new TextEncoder().encode(content).byteLength
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
1510
|
+
|
|
1511
|
+
interface PatchEdit {
|
|
1512
|
+
path: string
|
|
1513
|
+
content: string
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function parseUnifiedDiffToEdits(diff: string): PatchEdit[] {
|
|
1517
|
+
const edits: PatchEdit[] = []
|
|
1518
|
+
const lines = diff.split("\n")
|
|
1519
|
+
let i = 0
|
|
1520
|
+
|
|
1521
|
+
while (i < lines.length) {
|
|
1522
|
+
if (lines[i].startsWith("--- ")) {
|
|
1523
|
+
const oldPath = lines[i].slice(4).replace(/^[ab]\//, "")
|
|
1524
|
+
i++
|
|
1525
|
+
if (i < lines.length && lines[i].startsWith("+++ ")) {
|
|
1526
|
+
const newPath = lines[i].slice(4).replace(/^[ab]\//, "")
|
|
1527
|
+
i++
|
|
1528
|
+
const path = newPath === "/dev/null" ? oldPath : newPath
|
|
1529
|
+
const resultLines: string[] = []
|
|
1530
|
+
|
|
1531
|
+
while (i < lines.length && !lines[i].startsWith("--- ")) {
|
|
1532
|
+
const l = lines[i]
|
|
1533
|
+
if (l.startsWith("@@")) {
|
|
1534
|
+
i++
|
|
1535
|
+
continue
|
|
1536
|
+
}
|
|
1537
|
+
if (l.startsWith("+") && !l.startsWith("+++")) {
|
|
1538
|
+
resultLines.push(l.slice(1))
|
|
1539
|
+
} else if (l.startsWith("-") && !l.startsWith("---")) {
|
|
1540
|
+
// removed line — skip
|
|
1541
|
+
} else if (l.startsWith(" ") || l === "") {
|
|
1542
|
+
resultLines.push(l.slice(1))
|
|
1543
|
+
} else {
|
|
1544
|
+
break
|
|
1545
|
+
}
|
|
1546
|
+
i++
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
edits.push({ path, content: resultLines.join("\n") })
|
|
1550
|
+
continue
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
i++
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
return edits
|
|
1557
|
+
}
|