@springbrand/space 0.2.0-alpha.15 → 0.2.0-alpha.17
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/package.json +3 -2
- package/src/env.ts +1 -0
- package/src/index.ts +12 -0
- package/src/space/artifacts-fs.ts +7 -0
- package/src/space/deploy-engine.ts +50 -17
- package/src/space/durable-object.ts +93 -22
- package/src/space/file-content-cache.ts +194 -0
- package/src/space/fs-backend.ts +64 -133
- package/src/space/git-objects.ts +11 -24
- package/src/space/space-files.ts +1055 -0
- package/src/space/workspace-port.ts +17 -0
- package/src/space/workspace-site.ts +116 -0
package/src/space/fs-backend.ts
CHANGED
|
@@ -2,7 +2,6 @@ import {
|
|
|
2
2
|
createWorkspaceStateBackend,
|
|
3
3
|
FileSystemStateBackend,
|
|
4
4
|
InMemoryFs,
|
|
5
|
-
Workspace,
|
|
6
5
|
WorkspaceFileSystem,
|
|
7
6
|
type FileSystem,
|
|
8
7
|
} from "@cloudflare/shell"
|
|
@@ -12,33 +11,16 @@ import { ArtifactsFileSystem } from "./artifacts-fs"
|
|
|
12
11
|
import { ArtifactsSync, type ArtifactsRemoteStore } from "./artifacts-sync"
|
|
13
12
|
import { CheckpointStore } from "./checkpoint"
|
|
14
13
|
import { createArtifactsBaseSource } from "./git-objects"
|
|
14
|
+
import { inferMimeType } from "./fileinfo"
|
|
15
|
+
import type { FileContentCacheOptions } from "./file-content-cache"
|
|
16
|
+
import { SpaceFiles } from "./space-files"
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
const ARTIFACTS_REMOTE_URL_KEY = "artifacts:remoteUrl"
|
|
19
|
+
const SPACE_FS_BACKEND_KEY = "space:fsBackend"
|
|
18
20
|
const ARTIFACTS_BASE_BRANCH = "main"
|
|
19
21
|
const ARTIFACTS_INIT_TIMEOUT_MS = 10_000
|
|
20
22
|
const CHECKPOINT_DEBOUNCE_MS = 2_000
|
|
21
23
|
|
|
22
|
-
export type SpaceFsBackendMode = "artifacts" | "sql"
|
|
23
|
-
|
|
24
|
-
export async function resolveSpaceFsBackendMode(
|
|
25
|
-
storage: Pick<DurableObjectStorage, "get" | "put" | "sql">,
|
|
26
|
-
env: Pick<Env, "ENABLE_ARTIFACTS" | "ARTIFACTS">,
|
|
27
|
-
): Promise<SpaceFsBackendMode> {
|
|
28
|
-
let mode = await storage.get<SpaceFsBackendMode>(SPACE_FS_BACKEND_KEY)
|
|
29
|
-
if (mode) return mode
|
|
30
|
-
|
|
31
|
-
const priorArtifacts = (await storage.get<string>(ARTIFACTS_REMOTE_URL_KEY)) !== undefined
|
|
32
|
-
let hasCheckpoint = false
|
|
33
|
-
try {
|
|
34
|
-
hasCheckpoint = storage.sql.exec("SELECT 1 FROM space_checkpoint LIMIT 1").toArray().length > 0
|
|
35
|
-
} catch {}
|
|
36
|
-
const artifactsEnabled = env.ENABLE_ARTIFACTS === "true" && !!env.ARTIFACTS
|
|
37
|
-
mode = priorArtifacts || hasCheckpoint || artifactsEnabled ? "artifacts" : "sql"
|
|
38
|
-
await storage.put(SPACE_FS_BACKEND_KEY, mode)
|
|
39
|
-
return mode
|
|
40
|
-
}
|
|
41
|
-
|
|
42
24
|
export interface SpaceFsBackend {
|
|
43
25
|
readonly fs: FileSystem
|
|
44
26
|
readonly overlay: FileSystem
|
|
@@ -47,7 +29,13 @@ export interface SpaceFsBackend {
|
|
|
47
29
|
ready(): Promise<void>
|
|
48
30
|
hydrate(path: string): Promise<void>
|
|
49
31
|
materializeAll(): Promise<void>
|
|
50
|
-
writeFileStream(
|
|
32
|
+
writeFileStream(
|
|
33
|
+
path: string,
|
|
34
|
+
content: ReadableStream<Uint8Array>,
|
|
35
|
+
mediaType: string,
|
|
36
|
+
origin?: import("./space-files").SpaceFileOrigin,
|
|
37
|
+
contentLength?: number,
|
|
38
|
+
): Promise<void>
|
|
51
39
|
flushCheckpoint(): Promise<void>
|
|
52
40
|
push(branch: string): Promise<boolean>
|
|
53
41
|
fetch(branch: string): Promise<void>
|
|
@@ -135,6 +123,10 @@ export class ArtifactsBackend implements SpaceFsBackend {
|
|
|
135
123
|
await (this.fs as ArtifactsFileSystem).whenFullyMaterialized()
|
|
136
124
|
}
|
|
137
125
|
|
|
126
|
+
async materializeForMigration(): Promise<void> {
|
|
127
|
+
await (this.fs as ArtifactsFileSystem).materializeForMigration()
|
|
128
|
+
}
|
|
129
|
+
|
|
138
130
|
async writeFileStream(): Promise<void> {
|
|
139
131
|
throw new Error("Streaming writes require the SQL Space backend")
|
|
140
132
|
}
|
|
@@ -204,63 +196,12 @@ export function spaceR2Prefix(spaceId: string): string {
|
|
|
204
196
|
return `spaces/v1/${spaceId}`
|
|
205
197
|
}
|
|
206
198
|
|
|
207
|
-
const R2_MULTIPART_CHUNK_BYTES = 5 * 1024 * 1024
|
|
208
|
-
|
|
209
|
-
async function putStream(
|
|
210
|
-
bucket: R2Bucket,
|
|
211
|
-
key: string,
|
|
212
|
-
content: ReadableStream<Uint8Array>,
|
|
213
|
-
mediaType: string,
|
|
214
|
-
): Promise<R2Object> {
|
|
215
|
-
const upload = await bucket.createMultipartUpload(key, {
|
|
216
|
-
httpMetadata: { contentType: mediaType },
|
|
217
|
-
})
|
|
218
|
-
const uploaded: R2UploadedPart[] = []
|
|
219
|
-
const reader = content.getReader()
|
|
220
|
-
let buffer = new Uint8Array(R2_MULTIPART_CHUNK_BYTES)
|
|
221
|
-
let buffered = 0
|
|
222
|
-
let partNumber = 1
|
|
223
|
-
const flush = async () => {
|
|
224
|
-
if (buffered === 0) return
|
|
225
|
-
uploaded.push(await upload.uploadPart(partNumber++, buffer.slice(0, buffered)))
|
|
226
|
-
buffer = new Uint8Array(R2_MULTIPART_CHUNK_BYTES)
|
|
227
|
-
buffered = 0
|
|
228
|
-
}
|
|
229
|
-
try {
|
|
230
|
-
while (true) {
|
|
231
|
-
const next = await reader.read()
|
|
232
|
-
if (next.done) break
|
|
233
|
-
let offset = 0
|
|
234
|
-
while (offset < next.value.byteLength) {
|
|
235
|
-
const copied = Math.min(buffer.byteLength - buffered, next.value.byteLength - offset)
|
|
236
|
-
buffer.set(next.value.subarray(offset, offset + copied), buffered)
|
|
237
|
-
buffered += copied
|
|
238
|
-
offset += copied
|
|
239
|
-
if (buffered === buffer.byteLength) await flush()
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
await flush()
|
|
243
|
-
if (uploaded.length === 0) {
|
|
244
|
-
await upload.abort()
|
|
245
|
-
return (await bucket.put(key, new Uint8Array(), {
|
|
246
|
-
httpMetadata: { contentType: mediaType },
|
|
247
|
-
}))
|
|
248
|
-
}
|
|
249
|
-
return upload.complete(uploaded)
|
|
250
|
-
} catch (error) {
|
|
251
|
-
await upload.abort().catch(() => undefined)
|
|
252
|
-
throw error
|
|
253
|
-
} finally {
|
|
254
|
-
reader.releaseLock()
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
199
|
export class SqlBackend implements SpaceFsBackend {
|
|
259
200
|
readonly overlay: FileSystem
|
|
260
201
|
readonly fs: FileSystem
|
|
261
202
|
readonly git: Git
|
|
262
203
|
readonly stateBackend: FileSystemStateBackend
|
|
263
|
-
readonly workspace:
|
|
204
|
+
readonly workspace: SpaceFiles
|
|
264
205
|
|
|
265
206
|
/**
|
|
266
207
|
* Large files spill to R2 rather than to the DO's own SQLite. A Space holds
|
|
@@ -272,12 +213,11 @@ export class SqlBackend implements SpaceFsBackend {
|
|
|
272
213
|
private readonly ctx: DurableObjectState,
|
|
273
214
|
private readonly repoName: string,
|
|
274
215
|
private readonly r2?: R2Bucket,
|
|
216
|
+
publicOrigin = "https://workspace.invalid",
|
|
217
|
+
fileCacheOptions?: FileContentCacheOptions,
|
|
275
218
|
) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
name: () => repoName,
|
|
279
|
-
...(r2 ? { r2, r2Prefix: spaceR2Prefix(repoName) } : {}),
|
|
280
|
-
})
|
|
219
|
+
if (!r2) throw new Error("Space files require WORKSPACE_R2")
|
|
220
|
+
const workspace = new SpaceFiles(ctx, r2, repoName, publicOrigin, fileCacheOptions)
|
|
281
221
|
this.workspace = workspace
|
|
282
222
|
const fs = new WorkspaceFileSystem(workspace)
|
|
283
223
|
this.overlay = fs
|
|
@@ -286,67 +226,58 @@ export class SqlBackend implements SpaceFsBackend {
|
|
|
286
226
|
this.stateBackend = createWorkspaceStateBackend(workspace)
|
|
287
227
|
}
|
|
288
228
|
|
|
289
|
-
async ready(): Promise<void> {}
|
|
229
|
+
async ready(): Promise<void> { await this.workspace.ready() }
|
|
290
230
|
async hydrate(_path: string): Promise<void> {}
|
|
291
231
|
async materializeAll(): Promise<void> {}
|
|
292
232
|
async writeFileStream(
|
|
293
233
|
path: string,
|
|
294
234
|
content: ReadableStream<Uint8Array>,
|
|
295
235
|
mediaType: string,
|
|
236
|
+
origin: import("./space-files").SpaceFileOrigin = "agent",
|
|
237
|
+
contentLength?: number,
|
|
296
238
|
): Promise<void> {
|
|
297
|
-
|
|
298
|
-
await this.workspace.exists("/")
|
|
299
|
-
const existing = await this.workspace.stat(path)
|
|
300
|
-
if (existing?.type === "directory") throw new Error(`EISDIR: ${path} is a directory`)
|
|
301
|
-
const parent = path.slice(0, path.lastIndexOf("/")) || "/"
|
|
302
|
-
await this.workspace.mkdir(parent, { recursive: true })
|
|
303
|
-
|
|
304
|
-
// @cloudflare/shell@0.4.3 owns this table. Its public writeFileStream buffers;
|
|
305
|
-
// this adapter keeps the same row contract while R2 consumes the body stream.
|
|
306
|
-
const table = "cf_workspace_default"
|
|
307
|
-
const previous = this.ctx.storage.sql.exec<{ r2Key: string | null }>(
|
|
308
|
-
`SELECT r2_key AS r2Key FROM ${table} WHERE path = ?`,
|
|
309
|
-
path,
|
|
310
|
-
).toArray()[0]
|
|
311
|
-
const key = `${spaceR2Prefix(this.repoName)}/streams/${crypto.randomUUID()}`
|
|
312
|
-
try {
|
|
313
|
-
const object = await putStream(this.r2, key, content, mediaType)
|
|
314
|
-
const name = path.split("/").at(-1)!
|
|
315
|
-
const now = Math.floor(Date.now() / 1_000)
|
|
316
|
-
this.ctx.storage.sql.exec(
|
|
317
|
-
`INSERT INTO ${table}
|
|
318
|
-
(path, parent_path, name, type, mime_type, size, storage_backend,
|
|
319
|
-
r2_key, content_encoding, content, created_at, modified_at)
|
|
320
|
-
VALUES (?, ?, ?, 'file', ?, ?, 'r2', ?, 'base64', NULL, ?, ?)
|
|
321
|
-
ON CONFLICT(path) DO UPDATE SET
|
|
322
|
-
parent_path = excluded.parent_path,
|
|
323
|
-
name = excluded.name,
|
|
324
|
-
type = 'file',
|
|
325
|
-
mime_type = excluded.mime_type,
|
|
326
|
-
size = excluded.size,
|
|
327
|
-
storage_backend = 'r2',
|
|
328
|
-
r2_key = excluded.r2_key,
|
|
329
|
-
content_encoding = 'base64',
|
|
330
|
-
content = NULL,
|
|
331
|
-
modified_at = excluded.modified_at`,
|
|
332
|
-
path,
|
|
333
|
-
parent,
|
|
334
|
-
name,
|
|
335
|
-
mediaType,
|
|
336
|
-
object.size,
|
|
337
|
-
key,
|
|
338
|
-
now,
|
|
339
|
-
now,
|
|
340
|
-
)
|
|
341
|
-
} catch (error) {
|
|
342
|
-
await this.r2.delete(key).catch(() => undefined)
|
|
343
|
-
throw error
|
|
344
|
-
}
|
|
345
|
-
if (previous?.r2Key && previous.r2Key !== key) {
|
|
346
|
-
await this.r2.delete(previous.r2Key).catch(() => undefined)
|
|
347
|
-
}
|
|
239
|
+
await this.workspace.writeFileStream(path, content, mediaType, origin, contentLength)
|
|
348
240
|
}
|
|
349
241
|
async flushCheckpoint(): Promise<void> {}
|
|
350
242
|
async push(_branch: string): Promise<boolean> { return false }
|
|
351
243
|
async fetch(_branch: string): Promise<void> {}
|
|
352
244
|
}
|
|
245
|
+
|
|
246
|
+
export async function migrateArtifactsFiles(
|
|
247
|
+
source: FileSystem,
|
|
248
|
+
target: SpaceFiles,
|
|
249
|
+
): Promise<void> {
|
|
250
|
+
const paths = (await source.glob("/**")).sort((left, right) =>
|
|
251
|
+
left.length - right.length || left.localeCompare(right))
|
|
252
|
+
for (const path of paths) {
|
|
253
|
+
if (path === "/") continue
|
|
254
|
+
const stat = await source.lstat(path)
|
|
255
|
+
if (stat.type === "directory") {
|
|
256
|
+
await target.mkdir(path, { recursive: true })
|
|
257
|
+
} else if (stat.type === "symlink") {
|
|
258
|
+
await target.symlink(await source.readlink(path), path)
|
|
259
|
+
} else {
|
|
260
|
+
await target.writeFileBytes(path, await source.readFileBytes(path), inferMimeType(path))
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function migrateArtifactsBackend(
|
|
266
|
+
ctx: DurableObjectState,
|
|
267
|
+
env: Env,
|
|
268
|
+
repoName: string,
|
|
269
|
+
target: SpaceFiles,
|
|
270
|
+
): Promise<void> {
|
|
271
|
+
if (await ctx.storage.get(SPACE_FS_BACKEND_KEY) === "sql") return
|
|
272
|
+
const hasRemote = await ctx.storage.get(ARTIFACTS_REMOTE_URL_KEY) !== undefined
|
|
273
|
+
let hasCheckpoint = false
|
|
274
|
+
try {
|
|
275
|
+
hasCheckpoint = ctx.storage.sql.exec("SELECT 1 FROM space_checkpoint LIMIT 1").toArray().length > 0
|
|
276
|
+
} catch {}
|
|
277
|
+
if (hasRemote || hasCheckpoint) {
|
|
278
|
+
const artifacts = new ArtifactsBackend(ctx, env, repoName)
|
|
279
|
+
await artifacts.materializeForMigration()
|
|
280
|
+
await migrateArtifactsFiles(artifacts.fs, target)
|
|
281
|
+
}
|
|
282
|
+
await ctx.storage.put(SPACE_FS_BACKEND_KEY, "sql")
|
|
283
|
+
}
|
package/src/space/git-objects.ts
CHANGED
|
@@ -272,27 +272,6 @@ export function createArtifactsBaseSource(opts: {
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
/**
|
|
276
|
-
* Drop the git index so the next status walk re-reads file contents.
|
|
277
|
-
*
|
|
278
|
-
* Git decides a file is unchanged when its size and modification time still
|
|
279
|
-
* match the index, and only hashes it otherwise. On a real filesystem that is
|
|
280
|
-
* a safe shortcut; here it is not. A Space's files live in memory or SQLite,
|
|
281
|
-
* where an agent can rewrite a file to different content of the same length
|
|
282
|
-
* within the same millisecond — and git would then see nothing to commit. The
|
|
283
|
-
* work would be in the Space but absent from every version of it.
|
|
284
|
-
*
|
|
285
|
-
* The index is pure cache: deleting it costs one re-hash of the tree and
|
|
286
|
-
* cannot lose anything.
|
|
287
|
-
*/
|
|
288
|
-
async function discardIndexStatCache(fs: FileSystem): Promise<void> {
|
|
289
|
-
try {
|
|
290
|
-
await fs.rm("/.git/index", { force: true })
|
|
291
|
-
} catch {
|
|
292
|
-
// No index yet, or the backend refused; the status walk still works.
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
275
|
/**
|
|
297
276
|
* Stage the whole working tree (like the shell's `git add .`, which also
|
|
298
277
|
* stages deletions via `statusMatrix`) while skipping reserved paths — the
|
|
@@ -302,14 +281,22 @@ async function discardIndexStatCache(fs: FileSystem): Promise<void> {
|
|
|
302
281
|
export async function stageWorkdir(
|
|
303
282
|
fs: FileSystem,
|
|
304
283
|
skip: (absPath: string) => boolean,
|
|
305
|
-
|
|
284
|
+
prefix = "/",
|
|
285
|
+
): Promise<boolean> {
|
|
306
286
|
const gitFs = createGitFs(fs)
|
|
307
|
-
|
|
308
|
-
const matrix = await git.statusMatrix({
|
|
287
|
+
const relativePrefix = prefix.replace(/^\/+|\/+$/gu, "")
|
|
288
|
+
const matrix = await git.statusMatrix({
|
|
289
|
+
fs: gitFs,
|
|
290
|
+
dir: "/",
|
|
291
|
+
...(relativePrefix ? { filepaths: [relativePrefix] } : {}),
|
|
292
|
+
})
|
|
293
|
+
let changed = false
|
|
309
294
|
for (const [filepath, head, workdir, stage] of matrix) {
|
|
310
295
|
if (skip(`/${filepath}`)) continue
|
|
296
|
+
if (head !== workdir) changed = true
|
|
311
297
|
if (`${head}${workdir}${stage}` === "111") continue
|
|
312
298
|
if (workdir === 0) await git.remove({ fs: gitFs, dir: "/", filepath })
|
|
313
299
|
else await git.add({ fs: gitFs, dir: "/", filepath })
|
|
314
300
|
}
|
|
301
|
+
return changed
|
|
315
302
|
}
|