@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.
@@ -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
+ }