@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.
@@ -0,0 +1,1055 @@
1
+ import type { FileInfo, WorkspaceFsLike } from "@cloudflare/shell"
2
+ import { createHash } from "node:crypto"
3
+ import {
4
+ createFileContentCache,
5
+ FILE_CACHE_MAX_ENTRY_BYTES,
6
+ type CachedFileVersion,
7
+ type FileContentCache,
8
+ type FileContentCacheOptions,
9
+ } from "./file-content-cache"
10
+ import { inferMimeType } from "./fileinfo"
11
+ import { isReservedSpacePath, normalizeSpacePath, toBytes } from "./workspace-port"
12
+
13
+ export type SpaceFileOrigin = "user_upload" | "https_import" | "action" | "agent" | "userspace"
14
+ export type SpaceUnderstandingStatus = "not_requested" | "pending" | "ready" | "failed" | "unsupported"
15
+
16
+ export type SpaceFileEvidence = {
17
+ summary: string
18
+ body: string
19
+ warnings: string[]
20
+ truncated: boolean
21
+ }
22
+
23
+ export const SPACE_FILE_EVIDENCE_JSON_SCHEMA = {
24
+ type: "object",
25
+ properties: {
26
+ summary: { type: "string" },
27
+ body: { type: "string" },
28
+ warnings: { type: "array", items: { type: "string" } },
29
+ truncated: { type: "boolean" },
30
+ },
31
+ required: ["summary", "body", "warnings", "truncated"],
32
+ additionalProperties: false,
33
+ } as const
34
+
35
+ const EVIDENCE_MAX_BYTES = 32 * 1024
36
+ export const WORKSPACE_BUFFERED_READ_MAX_BYTES = 50 * 1024 * 1024
37
+
38
+ export function normalizeSpaceFileEvidence(input: unknown): SpaceFileEvidence {
39
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
40
+ throw new Error("Invalid Workspace file Evidence")
41
+ }
42
+ const value = input as Record<string, unknown>
43
+ const keys = Object.keys(value)
44
+ if (
45
+ keys.length !== 4 ||
46
+ keys.some((key) => !["summary", "body", "warnings", "truncated"].includes(key)) ||
47
+ typeof value.summary !== "string" || typeof value.body !== "string" ||
48
+ !Array.isArray(value.warnings) || !value.warnings.every((warning) => typeof warning === "string") ||
49
+ typeof value.truncated !== "boolean"
50
+ ) throw new Error("Invalid Workspace file Evidence")
51
+ let truncated = value.truncated
52
+ const clamp = (text: string, max: number) => {
53
+ const points = [...text]
54
+ if (points.length <= max) return text
55
+ truncated = true
56
+ return points.slice(0, max).join("")
57
+ }
58
+ const evidence: SpaceFileEvidence = {
59
+ summary: clamp(value.summary, 2_000),
60
+ body: value.body,
61
+ warnings: value.warnings.slice(0, 16).map((warning) => clamp(warning, 1_000)),
62
+ truncated,
63
+ }
64
+ if (evidence.warnings.length !== value.warnings.length) evidence.truncated = true
65
+ while (new TextEncoder().encode(JSON.stringify(evidence)).byteLength > EVIDENCE_MAX_BYTES) {
66
+ if (evidence.body) evidence.body = [...evidence.body].slice(0, Math.floor([...evidence.body].length / 2)).join("")
67
+ else if (evidence.warnings.length) evidence.warnings.pop()
68
+ else evidence.summary = ""
69
+ evidence.truncated = true
70
+ }
71
+ return evidence
72
+ }
73
+
74
+ export type RegisterExistingContentsInput = {
75
+ parent: string
76
+ ownerId: string
77
+ maxTotalBytes?: number
78
+ files: Array<{
79
+ name: string
80
+ contentKey: string
81
+ mediaType: string
82
+ origin: SpaceFileOrigin
83
+ }>
84
+ }
85
+
86
+ export type SpaceFileInspection = FileInfo & {
87
+ attachmentId?: string
88
+ contentVersion?: string
89
+ cdnUrl?: string
90
+ origin?: SpaceFileOrigin
91
+ understandingStatus?: SpaceUnderstandingStatus
92
+ evidence?: SpaceFileEvidence
93
+ }
94
+
95
+ export type WorkspaceFileStream = {
96
+ body: ReadableStream<Uint8Array>
97
+ size: number
98
+ mediaType: string
99
+ contentVersion: string
100
+ }
101
+
102
+ type FileRow = {
103
+ rowId: string
104
+ path: string
105
+ parentPath: string
106
+ name: string
107
+ type: "file" | "directory" | "symlink"
108
+ mimeType: string
109
+ size: number
110
+ target: string | null
111
+ createdAt: number
112
+ modifiedAt: number
113
+ attachmentId: string | null
114
+ contentVersion: string | null
115
+ contentKey: string | null
116
+ sha256: string | null
117
+ origin: SpaceFileOrigin | null
118
+ contentOwnership: "workspace_owned" | "referenced" | null
119
+ understandingStatus: SpaceUnderstandingStatus | null
120
+ evidenceJson: string | null
121
+ inlineContent: ArrayBuffer | null
122
+ }
123
+
124
+ function modifiedAtAfter(previous: FileRow | null): number {
125
+ return Math.max(Date.now(), (previous?.modifiedAt ?? 0) + 1_000)
126
+ }
127
+
128
+ const TABLE = "space_files"
129
+ const FILE_ROW_COLUMNS = `
130
+ row_id AS rowId, path, parent_path AS parentPath, name, type,
131
+ mime_type AS mimeType, size, target, created_at AS createdAt,
132
+ modified_at AS modifiedAt, attachment_id AS attachmentId,
133
+ content_version AS contentVersion, content_key AS contentKey, sha256,
134
+ origin, content_ownership AS contentOwnership,
135
+ understanding_status AS understandingStatus, evidence_json AS evidenceJson,
136
+ inline_content AS inlineContent
137
+ `
138
+ const encoder = new TextEncoder()
139
+ const decoder = new TextDecoder()
140
+
141
+ function id(prefix: "att" | "ver" | "row"): string {
142
+ return `${prefix}_${crypto.randomUUID()}`
143
+ }
144
+
145
+ function parentPath(path: string): string {
146
+ const index = path.lastIndexOf("/")
147
+ return index <= 0 ? "/" : path.slice(0, index)
148
+ }
149
+
150
+ function basename(path: string): string {
151
+ return path === "/" ? "" : path.slice(path.lastIndexOf("/") + 1)
152
+ }
153
+
154
+ // Internal filesystem calls from Git may contain dot segments. Public Space
155
+ // methods normalize and reject caller traversal before they reach this adapter.
156
+ function normalizeInternalPath(path: string): string {
157
+ const value = path.trim().replaceAll("\\", "/")
158
+ const parts: string[] = []
159
+ for (const part of value.split("/")) {
160
+ if (!part || part === ".") continue
161
+ if (part === "..") parts.pop()
162
+ else parts.push(part)
163
+ }
164
+ return `/${parts.join("/")}`
165
+ }
166
+
167
+ function writableFilePath(path: string): string {
168
+ const value = normalizeInternalPath(path)
169
+ if (value === "/") throw new Error("The Space root is not a file")
170
+ return value
171
+ }
172
+
173
+ function escapeRegex(value: string): string {
174
+ return value.replace(/[|\\{}()[\]^$+?.]/gu, "\\$&")
175
+ }
176
+
177
+ function globRegex(pattern: string): RegExp {
178
+ const normalized = normalizeInternalPath(pattern)
179
+ let source = ""
180
+ for (let index = 0; index < normalized.length; index++) {
181
+ const char = normalized[index]!
182
+ if (char !== "*") {
183
+ source += char === "?" ? "[^/]" : escapeRegex(char)
184
+ continue
185
+ }
186
+ if (normalized[index + 1] === "*") {
187
+ if (normalized[index + 2] === "/") {
188
+ source += "(?:.*/)?"
189
+ index += 2
190
+ } else {
191
+ source += ".*"
192
+ index++
193
+ }
194
+ } else {
195
+ source += "[^/]*"
196
+ }
197
+ }
198
+ return new RegExp(`^${source}$`, "u")
199
+ }
200
+
201
+ async function digest(bytes: Uint8Array): Promise<string> {
202
+ const value = await crypto.subtle.digest("SHA-256", bytes)
203
+ return [...new Uint8Array(value)].map((part) => part.toString(16).padStart(2, "0")).join("")
204
+ }
205
+
206
+ async function putStream(
207
+ bucket: R2Bucket,
208
+ key: string,
209
+ body: ReadableStream<Uint8Array>,
210
+ mediaType: string,
211
+ ): Promise<void> {
212
+ const upload = await bucket.createMultipartUpload(key, {
213
+ httpMetadata: { contentType: mediaType },
214
+ })
215
+ const reader = body.getReader()
216
+ const parts: R2UploadedPart[] = []
217
+ const chunkBytes = 5 * 1024 * 1024
218
+ let buffer = new Uint8Array(chunkBytes)
219
+ let buffered = 0
220
+ let partNumber = 1
221
+ const flush = async () => {
222
+ if (buffered === 0) return
223
+ parts.push(await upload.uploadPart(partNumber++, buffer.slice(0, buffered)))
224
+ buffer = new Uint8Array(chunkBytes)
225
+ buffered = 0
226
+ }
227
+ try {
228
+ while (true) {
229
+ const chunk = await reader.read()
230
+ if (chunk.done) break
231
+ let offset = 0
232
+ while (offset < chunk.value.byteLength) {
233
+ const length = Math.min(buffer.byteLength - buffered, chunk.value.byteLength - offset)
234
+ buffer.set(chunk.value.subarray(offset, offset + length), buffered)
235
+ buffered += length
236
+ offset += length
237
+ if (buffered === buffer.byteLength) await flush()
238
+ }
239
+ }
240
+ await flush()
241
+ if (parts.length === 0) {
242
+ await upload.abort()
243
+ await bucket.put(key, new Uint8Array(), { httpMetadata: { contentType: mediaType } })
244
+ return
245
+ }
246
+ await upload.complete(parts)
247
+ } catch (error) {
248
+ await upload.abort().catch(() => undefined)
249
+ throw error
250
+ } finally {
251
+ reader.releaseLock()
252
+ }
253
+ }
254
+
255
+ /** Space-owned file store: one SQLite file table and immutable R2 content. */
256
+ export class SpaceFiles implements WorkspaceFsLike {
257
+ private readonly cache: FileContentCache
258
+
259
+ constructor(
260
+ private readonly state: DurableObjectState,
261
+ private readonly bucket: R2Bucket,
262
+ private readonly spaceId: string,
263
+ private readonly publicOrigin: string,
264
+ cacheOptions?: FileContentCacheOptions,
265
+ ) {
266
+ this.cache = createFileContentCache(state.storage.sql, cacheOptions)
267
+ this.ensureSchema()
268
+ }
269
+
270
+ private ensureSchema(): void {
271
+ this.state.storage.sql.exec(`
272
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
273
+ row_id TEXT PRIMARY KEY,
274
+ path TEXT NOT NULL,
275
+ parent_path TEXT NOT NULL,
276
+ name TEXT NOT NULL,
277
+ type TEXT NOT NULL CHECK(type IN ('file','directory','symlink')),
278
+ mime_type TEXT NOT NULL,
279
+ size INTEGER NOT NULL DEFAULT 0,
280
+ target TEXT,
281
+ created_at INTEGER NOT NULL,
282
+ modified_at INTEGER NOT NULL,
283
+ attachment_id TEXT,
284
+ content_version TEXT,
285
+ content_key TEXT,
286
+ sha256 TEXT,
287
+ origin TEXT CHECK(origin IS NULL OR origin IN ('user_upload','https_import','action','agent','userspace')),
288
+ content_ownership TEXT CHECK(content_ownership IS NULL OR content_ownership IN ('workspace_owned','referenced')),
289
+ understanding_status TEXT CHECK(understanding_status IS NULL OR understanding_status IN ('not_requested','pending','ready','failed','unsupported')),
290
+ understanding_attempts INTEGER NOT NULL DEFAULT 0,
291
+ evidence_json TEXT,
292
+ inline_content BLOB,
293
+ write_state TEXT NOT NULL CHECK(write_state IN ('writing','ready')),
294
+ current INTEGER NOT NULL CHECK(current IN (0,1))
295
+ );
296
+ CREATE UNIQUE INDEX IF NOT EXISTS space_files_current_path
297
+ ON ${TABLE}(path) WHERE current = 1;
298
+ CREATE UNIQUE INDEX IF NOT EXISTS space_files_attachment_version
299
+ ON ${TABLE}(attachment_id, content_version)
300
+ WHERE attachment_id IS NOT NULL AND content_version IS NOT NULL;
301
+ CREATE INDEX IF NOT EXISTS space_files_parent
302
+ ON ${TABLE}(parent_path, current, write_state);
303
+ `)
304
+ const columns = new Set(
305
+ this.state.storage.sql.exec<{ name: string }>(`PRAGMA table_info(${TABLE})`)
306
+ .toArray().map(({ name }) => name),
307
+ )
308
+ if (!columns.has("inline_content")) {
309
+ this.state.storage.sql.exec(`ALTER TABLE ${TABLE} ADD COLUMN inline_content BLOB`)
310
+ }
311
+ if (!this.row("/")) {
312
+ const now = Date.now()
313
+ this.state.storage.sql.exec(
314
+ `INSERT INTO ${TABLE}
315
+ (row_id,path,parent_path,name,type,mime_type,size,created_at,modified_at,write_state,current)
316
+ VALUES (?, '/', '/', '', 'directory', 'inode/directory', 0, ?, ?, 'ready', 1)`,
317
+ id("row"), now, now,
318
+ )
319
+ }
320
+ }
321
+
322
+ async ready(): Promise<void> {
323
+ const legacy = this.state.storage.sql.exec<{ name: string }>(
324
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'cf_workspace_default'",
325
+ ).toArray()[0]
326
+ if (legacy) {
327
+ const rows = this.state.storage.sql.exec<{
328
+ path: string
329
+ type: "file" | "directory" | "symlink"
330
+ mimeType: string
331
+ storageBackend: "inline" | "r2"
332
+ r2Key: string | null
333
+ target: string | null
334
+ contentEncoding: "utf8" | "base64"
335
+ content: string | null
336
+ }>(`
337
+ SELECT path, type, mime_type AS mimeType, storage_backend AS storageBackend,
338
+ r2_key AS r2Key, target, content_encoding AS contentEncoding, content
339
+ FROM cf_workspace_default WHERE path <> '/' ORDER BY length(path), path
340
+ `).toArray()
341
+ for (const row of rows) {
342
+ if (row.type === "directory") {
343
+ await this.mkdir(row.path, { recursive: true })
344
+ continue
345
+ }
346
+ if (row.type === "symlink") {
347
+ await this.symlink(row.target ?? "", row.path)
348
+ continue
349
+ }
350
+ const bytes = row.storageBackend === "r2" && row.r2Key
351
+ ? await this.bucket.get(row.r2Key).then(async (object) =>
352
+ object ? new Uint8Array(await object.arrayBuffer()) : null)
353
+ : row.contentEncoding === "base64"
354
+ ? Uint8Array.from(atob(row.content ?? ""), (character) => character.charCodeAt(0))
355
+ : encoder.encode(row.content ?? "")
356
+ if (!bytes) throw new Error(`Legacy Workspace content is missing: ${row.path}`)
357
+ await this.commitBytes(row.path, bytes, row.mimeType, "agent")
358
+ }
359
+ this.state.storage.sql.exec("DROP TABLE cf_workspace_default")
360
+ }
361
+ await this.recoverWritingRows()
362
+ await this.inlineSystemContent()
363
+ }
364
+
365
+ async clearCache(): Promise<void> {
366
+ await this.cache.clear()
367
+ }
368
+
369
+ private async recoverWritingRows(): Promise<void> {
370
+ const rows = this.state.storage.sql.exec<{
371
+ rowId: string
372
+ path: string
373
+ attachmentId: string | null
374
+ contentVersion: string | null
375
+ contentKey: string | null
376
+ mediaType: string
377
+ size: number
378
+ sha256: string | null
379
+ }>(`
380
+ SELECT row_id AS rowId, path, attachment_id AS attachmentId,
381
+ content_version AS contentVersion, content_key AS contentKey,
382
+ mime_type AS mediaType, size, sha256
383
+ FROM ${TABLE}
384
+ WHERE write_state = 'writing'
385
+ ORDER BY modified_at, row_id
386
+ `).toArray()
387
+ for (const row of rows) {
388
+ const cached = row.attachmentId && row.contentVersion
389
+ ? { attachmentId: row.attachmentId, contentVersion: row.contentVersion, size: row.size }
390
+ : null
391
+ const object = row.contentKey ? await this.bucket.get(row.contentKey) : null
392
+ if (!object || object.httpMetadata?.contentType !== row.mediaType) {
393
+ if (row.contentKey && object) await this.bucket.delete(row.contentKey)
394
+ this.state.storage.sql.exec(`DELETE FROM ${TABLE} WHERE row_id = ?`, row.rowId)
395
+ await this.removeCached(cached)
396
+ continue
397
+ }
398
+ const bytes = new Uint8Array(await object.arrayBuffer())
399
+ const sha256 = await digest(bytes)
400
+ if (row.sha256 !== null && (row.size !== bytes.byteLength || row.sha256 !== sha256)) {
401
+ await this.bucket.delete(row.contentKey!)
402
+ this.state.storage.sql.exec(`DELETE FROM ${TABLE} WHERE row_id = ?`, row.rowId)
403
+ await this.removeCached(cached)
404
+ continue
405
+ }
406
+ this.state.storage.transactionSync(() => {
407
+ this.state.storage.sql.exec(
408
+ `UPDATE ${TABLE} SET current = 0 WHERE path = ? AND current = 1`,
409
+ row.path,
410
+ )
411
+ this.state.storage.sql.exec(
412
+ `UPDATE ${TABLE}
413
+ SET size = ?, sha256 = ?, write_state = 'ready', current = 1
414
+ WHERE row_id = ?`,
415
+ bytes.byteLength, sha256, row.rowId,
416
+ )
417
+ })
418
+ }
419
+ }
420
+
421
+ private async inlineSystemContent(): Promise<void> {
422
+ const rows = this.state.storage.sql.exec<{
423
+ rowId: string
424
+ contentKey: string
425
+ }>(`
426
+ SELECT row_id AS rowId, content_key AS contentKey
427
+ FROM ${TABLE}
428
+ WHERE current = 1 AND type = 'file' AND inline_content IS NULL
429
+ AND content_key IS NOT NULL
430
+ AND size <= ?
431
+ AND (path = '/.git' OR path LIKE '/.git/%' OR path = '/.afs' OR path LIKE '/.afs/%')
432
+ `, FILE_CACHE_MAX_ENTRY_BYTES).toArray()
433
+ for (let offset = 0; offset < rows.length; offset += 16) {
434
+ await Promise.all(rows.slice(offset, offset + 16).map(async ({ rowId, contentKey }) => {
435
+ const object = await this.bucket.get(contentKey)
436
+ if (!object) throw new Error(`Space system content is missing: ${contentKey}`)
437
+ const bytes = new Uint8Array(await object.arrayBuffer())
438
+ this.state.storage.sql.exec(
439
+ `UPDATE ${TABLE}
440
+ SET inline_content = ?, content_key = NULL, attachment_id = NULL,
441
+ content_version = NULL, sha256 = NULL, origin = NULL,
442
+ content_ownership = NULL, understanding_status = NULL,
443
+ understanding_attempts = 0, evidence_json = NULL
444
+ WHERE row_id = ?`,
445
+ bytes.buffer, rowId,
446
+ )
447
+ await this.bucket.delete(contentKey)
448
+ }))
449
+ }
450
+ }
451
+
452
+ private row(path: string): FileRow | null {
453
+ return this.state.storage.sql.exec<FileRow>(`
454
+ SELECT ${FILE_ROW_COLUMNS}
455
+ FROM ${TABLE}
456
+ WHERE path = ? AND current = 1 AND write_state = 'ready'
457
+ LIMIT 1
458
+ `, normalizeInternalPath(path)).toArray()[0] ?? null
459
+ }
460
+
461
+ private info(row: FileRow): SpaceFileInspection {
462
+ return {
463
+ path: row.path,
464
+ name: row.name,
465
+ type: row.type,
466
+ mimeType: row.mimeType,
467
+ size: row.size,
468
+ createdAt: row.createdAt,
469
+ updatedAt: row.modifiedAt,
470
+ ...(row.target ? { target: row.target } : {}),
471
+ ...(row.attachmentId ? { attachmentId: row.attachmentId } : {}),
472
+ ...(row.contentVersion ? { contentVersion: row.contentVersion } : {}),
473
+ ...(row.contentKey ? { cdnUrl: new URL(`/${row.contentKey}`, this.publicOrigin).href } : {}),
474
+ ...(row.origin ? { origin: row.origin } : {}),
475
+ ...(row.understandingStatus ? { understandingStatus: row.understandingStatus } : {}),
476
+ ...(row.evidenceJson ? { evidence: JSON.parse(row.evidenceJson) as SpaceFileEvidence } : {}),
477
+ }
478
+ }
479
+
480
+ private async ensureParent(path: string): Promise<void> {
481
+ await this.mkdir(parentPath(path), { recursive: true })
482
+ }
483
+
484
+ private contentKey(attachmentId: string, contentVersion: string): string {
485
+ return `spaces/v2/${encodeURIComponent(this.spaceId)}/files/${attachmentId}/${contentVersion}`
486
+ }
487
+
488
+ private cachedVersion(row: FileRow): CachedFileVersion | null {
489
+ return row.attachmentId && row.contentVersion
490
+ ? { attachmentId: row.attachmentId, contentVersion: row.contentVersion, size: row.size }
491
+ : null
492
+ }
493
+
494
+ private async cachedBytes(row: FileRow): Promise<Uint8Array | null> {
495
+ const file = this.cachedVersion(row)
496
+ if (!file) return null
497
+ try {
498
+ return await this.cache.get(file)
499
+ } catch (error) {
500
+ console.warn("Workspace file cache read failed; falling back to R2", error)
501
+ return null
502
+ }
503
+ }
504
+
505
+ private async cacheBytes(file: CachedFileVersion, bytes: Uint8Array): Promise<void> {
506
+ try {
507
+ await this.cache.put(file, bytes)
508
+ } catch (error) {
509
+ console.warn("Workspace file cache write failed; continuing with R2", error)
510
+ }
511
+ }
512
+
513
+ private async removeCached(file: CachedFileVersion | null): Promise<void> {
514
+ if (!file) return
515
+ try {
516
+ await this.cache.remove(file)
517
+ } catch (error) {
518
+ console.warn("Workspace file cache cleanup failed", error)
519
+ }
520
+ }
521
+
522
+ private async commitBytes(
523
+ path: string,
524
+ bytes: Uint8Array,
525
+ mediaType: string,
526
+ origin: SpaceFileOrigin,
527
+ ): Promise<void> {
528
+ const target = writableFilePath(path)
529
+ await this.ensureParent(target)
530
+ const previous = this.row(target)
531
+ if (previous?.type === "directory") throw new Error(`EISDIR: ${target} is a directory`)
532
+ if (isReservedSpacePath(target) && bytes.byteLength <= FILE_CACHE_MAX_ENTRY_BYTES) {
533
+ const now = modifiedAtAfter(previous)
534
+ this.state.storage.transactionSync(() => {
535
+ this.state.storage.sql.exec(
536
+ `UPDATE ${TABLE} SET current = 0 WHERE path = ? AND current = 1`,
537
+ target,
538
+ )
539
+ this.state.storage.sql.exec(
540
+ `INSERT INTO ${TABLE}
541
+ (row_id,path,parent_path,name,type,mime_type,size,created_at,modified_at,
542
+ inline_content,write_state,current)
543
+ VALUES (?,?,?,?, 'file', ?, ?, ?, ?, ?, 'ready', 1)`,
544
+ id("row"), target, parentPath(target), basename(target), mediaType,
545
+ bytes.byteLength, previous?.createdAt ?? now, now, bytes.slice().buffer,
546
+ )
547
+ })
548
+ return
549
+ }
550
+ const attachmentId = previous?.type === "file" && previous.attachmentId
551
+ ? previous.attachmentId
552
+ : id("att")
553
+ const contentVersion = id("ver")
554
+ const contentKey = this.contentKey(attachmentId, contentVersion)
555
+ const checksum = await digest(bytes)
556
+ const now = modifiedAtAfter(previous)
557
+ const rowId = id("row")
558
+ this.state.storage.sql.exec(
559
+ `INSERT INTO ${TABLE}
560
+ (row_id,path,parent_path,name,type,mime_type,size,created_at,modified_at,
561
+ attachment_id,content_version,content_key,sha256,origin,content_ownership,
562
+ understanding_status,write_state,current)
563
+ VALUES (?,?,?,?, 'file', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'workspace_owned',
564
+ 'not_requested', 'writing', 0)`,
565
+ rowId, target, parentPath(target), basename(target), mediaType, bytes.byteLength,
566
+ previous?.createdAt ?? now, now, attachmentId, contentVersion, contentKey,
567
+ checksum, origin,
568
+ )
569
+ const cached = { attachmentId, contentVersion, size: bytes.byteLength }
570
+ await this.cacheBytes(cached, bytes)
571
+ try {
572
+ await this.bucket.put(contentKey, bytes, {
573
+ httpMetadata: { contentType: mediaType },
574
+ customMetadata: { sha256: checksum, attachmentId, contentVersion },
575
+ })
576
+ this.state.storage.transactionSync(() => {
577
+ this.state.storage.sql.exec(
578
+ `UPDATE ${TABLE} SET current = 0 WHERE path = ? AND current = 1`,
579
+ target,
580
+ )
581
+ this.state.storage.sql.exec(
582
+ `UPDATE ${TABLE} SET write_state = 'ready', current = 1 WHERE row_id = ?`,
583
+ rowId,
584
+ )
585
+ })
586
+ await this.removeCached(previous ? this.cachedVersion(previous) : null)
587
+ } catch (error) {
588
+ this.state.storage.sql.exec(`DELETE FROM ${TABLE} WHERE row_id = ?`, rowId)
589
+ await this.removeCached(cached)
590
+ throw error
591
+ }
592
+ }
593
+
594
+ async registerExistingContents(
595
+ input: RegisterExistingContentsInput,
596
+ ): Promise<SpaceFileInspection[] | null> {
597
+ const parent = writableFilePath(input.parent)
598
+ if (input.maxTotalBytes !== undefined &&
599
+ (!Number.isSafeInteger(input.maxTotalBytes) || input.maxTotalBytes < 0)) return null
600
+ if (input.files.some(({ name }) =>
601
+ !name || name.includes("/") || name.includes("\\"))) return null
602
+ if (new Set(input.files.map(({ contentKey }) => contentKey)).size !== input.files.length) return null
603
+ const checked = await Promise.all(input.files.map(async (file) => ({
604
+ file,
605
+ object: await this.bucket.head(file.contentKey),
606
+ })))
607
+ let totalBytes = 0
608
+ for (const { file, object } of checked) {
609
+ const sha256 = object?.customMetadata?.sha256
610
+ if (!object || !sha256 || object.httpMetadata?.contentType !== file.mediaType ||
611
+ object.customMetadata?.owner !== input.ownerId ||
612
+ (file.origin === "user_upload" &&
613
+ object.customMetadata?.name !== encodeURIComponent(file.name))) return null
614
+ totalBytes += object.size
615
+ }
616
+ if (input.maxTotalBytes !== undefined && totalBytes > input.maxTotalBytes) return null
617
+ const paths: string[] = []
618
+ const pending: Array<{
619
+ path: string
620
+ attachmentId: string
621
+ contentVersion: string
622
+ file: RegisterExistingContentsInput["files"][number]
623
+ object: R2Object
624
+ }> = []
625
+ for (const { file, object } of checked) {
626
+ const existing = this.state.storage.sql.exec<FileRow>(`
627
+ SELECT ${FILE_ROW_COLUMNS}
628
+ FROM ${TABLE}
629
+ WHERE content_key = ? AND current = 1 AND write_state = 'ready'
630
+ LIMIT 1
631
+ `, file.contentKey).toArray()[0]
632
+ if (existing) {
633
+ paths.push(existing.path)
634
+ continue
635
+ }
636
+ const attachmentId = id("att")
637
+ const contentVersion = id("ver")
638
+ const path = `${parent}/${attachmentId}/${file.name}`
639
+ await this.ensureParent(path)
640
+ paths.push(path)
641
+ pending.push({
642
+ path,
643
+ attachmentId,
644
+ contentVersion,
645
+ file,
646
+ object: object!,
647
+ })
648
+ }
649
+ const now = Date.now()
650
+ this.state.storage.transactionSync(() => {
651
+ for (const registration of pending) {
652
+ this.state.storage.sql.exec(
653
+ `INSERT INTO ${TABLE}
654
+ (row_id,path,parent_path,name,type,mime_type,size,created_at,modified_at,
655
+ attachment_id,content_version,content_key,sha256,origin,content_ownership,
656
+ understanding_status,write_state,current)
657
+ VALUES (?,?,?,?, 'file', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'referenced',
658
+ 'pending', 'ready', 1)`,
659
+ id("row"),
660
+ registration.path,
661
+ parentPath(registration.path),
662
+ registration.file.name,
663
+ registration.file.mediaType,
664
+ registration.object.size,
665
+ now,
666
+ now,
667
+ registration.attachmentId,
668
+ registration.contentVersion,
669
+ registration.file.contentKey,
670
+ registration.object.customMetadata!.sha256!,
671
+ registration.file.origin,
672
+ )
673
+ }
674
+ })
675
+ return paths.map((path) => this.info(this.row(path)!))
676
+ }
677
+
678
+ async inspectFile(path: string): Promise<SpaceFileInspection | null> {
679
+ const row = this.row(path)
680
+ return row ? this.info(row) : null
681
+ }
682
+
683
+ async setUnderstanding(
684
+ path: string,
685
+ contentVersion: string,
686
+ status: SpaceUnderstandingStatus,
687
+ evidence?: SpaceFileEvidence,
688
+ ): Promise<SpaceFileInspection> {
689
+ const target = normalizeInternalPath(path)
690
+ const changed = this.state.storage.sql.exec<{ rowId: string }>(
691
+ `UPDATE ${TABLE}
692
+ SET understanding_status = ?, evidence_json = ?
693
+ WHERE path = ? AND content_version = ? AND current = 1 AND type = 'file'
694
+ RETURNING row_id AS rowId`,
695
+ status, evidence ? JSON.stringify(evidence) : null, target, contentVersion,
696
+ ).toArray()[0]
697
+ if (!changed) throw new Error("Workspace file not found")
698
+ return this.info(this.row(target)!)
699
+ }
700
+
701
+ beginUnderstanding(path: string, contentVersion: string, refresh = false): boolean {
702
+ return this.state.storage.sql.exec<{ rowId: string }>(
703
+ `UPDATE ${TABLE}
704
+ SET understanding_status = 'pending',
705
+ understanding_attempts = understanding_attempts + CASE WHEN ? = 1 THEN 0 ELSE 1 END,
706
+ evidence_json = NULL
707
+ WHERE path = ? AND content_version = ? AND current = 1 AND type = 'file'
708
+ AND ((? = 1 AND understanding_status IN ('ready', 'pending', 'failed'))
709
+ OR (? = 0 AND understanding_attempts < 2
710
+ AND understanding_status IN ('not_requested', 'pending', 'failed')))
711
+ RETURNING row_id AS rowId`,
712
+ refresh ? 1 : 0, normalizeInternalPath(path), contentVersion,
713
+ refresh ? 1 : 0, refresh ? 1 : 0,
714
+ ).toArray().length === 1
715
+ }
716
+
717
+ async readFile(path: string): Promise<string | null> {
718
+ const bytes = await this.readFileBytes(path)
719
+ return bytes ? decoder.decode(bytes) : null
720
+ }
721
+
722
+ async readFileBytes(path: string): Promise<Uint8Array | null> {
723
+ const row = this.row(path)
724
+ if (!row || row.type !== "file") return null
725
+ if (row.size > WORKSPACE_BUFFERED_READ_MAX_BYTES) {
726
+ throw new Error(`Workspace file exceeds the 50 MiB buffered read limit: ${row.path}`)
727
+ }
728
+ if (row.inlineContent) return new Uint8Array(row.inlineContent)
729
+ if (!row.contentKey) return null
730
+ const cached = await this.cachedBytes(row)
731
+ if (cached) return cached
732
+ const object = await this.bucket.get(row.contentKey)
733
+ if (!object) {
734
+ await this.removeCached(this.cachedVersion(row))
735
+ throw new Error(`Workspace content is missing: ${row.path}`)
736
+ }
737
+ const bytes = new Uint8Array(await object.arrayBuffer())
738
+ const version = this.cachedVersion(row)
739
+ if (version) await this.cacheBytes(version, bytes)
740
+ return bytes
741
+ }
742
+
743
+ async openFileStream(path: string): Promise<WorkspaceFileStream | null> {
744
+ const row = this.row(path)
745
+ if (!row || row.type !== "file") return null
746
+ if (!row.attachmentId || !row.contentVersion || !row.contentKey) return null
747
+ const cached = await this.cachedBytes(row)
748
+ if (cached) {
749
+ console.info("deployment_asset_source", { source: "sqlite_cache" })
750
+ return {
751
+ body: new Blob([cached]).stream(),
752
+ size: row.size,
753
+ mediaType: row.mimeType,
754
+ contentVersion: row.contentVersion,
755
+ }
756
+ }
757
+ const object = await this.bucket.get(row.contentKey)
758
+ if (!object) {
759
+ await this.removeCached(this.cachedVersion(row))
760
+ throw new Error(`Workspace content is missing: ${row.path}`)
761
+ }
762
+ console.info("deployment_asset_source", { source: "r2_stream" })
763
+ return {
764
+ body: object.body,
765
+ size: row.size,
766
+ mediaType: row.mimeType,
767
+ contentVersion: row.contentVersion,
768
+ }
769
+ }
770
+
771
+ async writeFile(path: string, content: string, mediaType?: string): Promise<void> {
772
+ await this.commitBytes(path, encoder.encode(content), mediaType ?? inferMimeType(path), "agent")
773
+ }
774
+
775
+ async writeFileBytes(path: string, content: Uint8Array | ArrayBuffer, mediaType?: string): Promise<void> {
776
+ await this.commitBytes(path, toBytes(content), mediaType ?? inferMimeType(path), "agent")
777
+ }
778
+
779
+ async writeFileStream(
780
+ path: string,
781
+ stream: ReadableStream<Uint8Array>,
782
+ mediaType: string,
783
+ origin: SpaceFileOrigin = "agent",
784
+ contentLength?: number,
785
+ ): Promise<number> {
786
+ const target = writableFilePath(path)
787
+ await this.ensureParent(target)
788
+ const previous = this.row(target)
789
+ if (previous?.type === "directory") throw new Error(`EISDIR: ${target} is a directory`)
790
+ const attachmentId = previous?.type === "file" && previous.attachmentId
791
+ ? previous.attachmentId
792
+ : id("att")
793
+ const contentVersion = id("ver")
794
+ const contentKey = this.contentKey(attachmentId, contentVersion)
795
+ const rowId = id("row")
796
+ const now = modifiedAtAfter(previous)
797
+ this.state.storage.sql.exec(
798
+ `INSERT INTO ${TABLE}
799
+ (row_id,path,parent_path,name,type,mime_type,size,created_at,modified_at,
800
+ attachment_id,content_version,content_key,origin,content_ownership,
801
+ understanding_status,write_state,current)
802
+ VALUES (?,?,?,?, 'file', ?, 0, ?, ?, ?, ?, ?, ?, 'workspace_owned',
803
+ 'not_requested', 'writing', 0)`,
804
+ rowId, target, parentPath(target), basename(target), mediaType,
805
+ previous?.createdAt ?? now, now, attachmentId, contentVersion, contentKey, origin,
806
+ )
807
+ let size = 0
808
+ const counted = stream.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
809
+ transform(chunk, controller) {
810
+ size += chunk.byteLength
811
+ if (contentLength !== undefined && contentLength <= FILE_CACHE_MAX_ENTRY_BYTES &&
812
+ size > FILE_CACHE_MAX_ENTRY_BYTES) {
813
+ throw new Error("Stream exceeded the Workspace file cache collection limit")
814
+ }
815
+ controller.enqueue(chunk)
816
+ },
817
+ }))
818
+ const [uploadBody, localBody] = counted.tee()
819
+ let digesting: Promise<ArrayBuffer>
820
+ let caching = Promise.resolve()
821
+ if (contentLength !== undefined && contentLength <= FILE_CACHE_MAX_ENTRY_BYTES) {
822
+ const collected = new Response(localBody).arrayBuffer()
823
+ .then((body) => new Uint8Array(body))
824
+ digesting = collected.then((bytes) => crypto.subtle.digest("SHA-256", bytes))
825
+ caching = collected.then((bytes) => this.cacheBytes(
826
+ { attachmentId, contentVersion, size: bytes.byteLength },
827
+ bytes,
828
+ ))
829
+ } else {
830
+ const hash = createHash("sha256")
831
+ digesting = localBody.pipeTo(new WritableStream<Uint8Array>({
832
+ write(chunk) { hash.update(chunk) },
833
+ })).then(() => Uint8Array.from(hash.digest()).buffer)
834
+ }
835
+ try {
836
+ const [, hash] = await Promise.all([
837
+ putStream(this.bucket, contentKey, uploadBody, mediaType),
838
+ digesting,
839
+ caching,
840
+ ])
841
+ const sha256 = [...new Uint8Array(hash)]
842
+ .map((part) => part.toString(16).padStart(2, "0"))
843
+ .join("")
844
+ this.state.storage.transactionSync(() => {
845
+ this.state.storage.sql.exec(
846
+ `UPDATE ${TABLE} SET current = 0 WHERE path = ? AND current = 1`,
847
+ target,
848
+ )
849
+ this.state.storage.sql.exec(
850
+ `UPDATE ${TABLE}
851
+ SET size = ?, sha256 = ?, write_state = 'ready', current = 1
852
+ WHERE row_id = ?`,
853
+ size, sha256, rowId,
854
+ )
855
+ })
856
+ await this.removeCached(previous ? this.cachedVersion(previous) : null)
857
+ return size
858
+ } catch (error) {
859
+ this.state.storage.sql.exec(`DELETE FROM ${TABLE} WHERE row_id = ?`, rowId)
860
+ await this.removeCached({ attachmentId, contentVersion, size })
861
+ throw error
862
+ }
863
+ }
864
+
865
+ async appendFile(path: string, content: string | Uint8Array): Promise<void> {
866
+ const current = await this.readFileBytes(path) ?? new Uint8Array()
867
+ const appended = typeof content === "string" ? encoder.encode(content) : content
868
+ const bytes = new Uint8Array(current.byteLength + appended.byteLength)
869
+ bytes.set(current)
870
+ bytes.set(appended, current.byteLength)
871
+ await this.commitBytes(path, bytes, this.row(path)?.mimeType ?? inferMimeType(path), "agent")
872
+ }
873
+
874
+ async exists(path: string): Promise<boolean> {
875
+ return this.row(path) !== null
876
+ }
877
+
878
+ async stat(path: string): Promise<SpaceFileInspection | null> {
879
+ const row = this.row(path)
880
+ return row ? this.info(row) : null
881
+ }
882
+
883
+ async lstat(path: string): Promise<SpaceFileInspection | null> {
884
+ return this.stat(path)
885
+ }
886
+
887
+ async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
888
+ const target = normalizeInternalPath(path)
889
+ if (target === "/" || this.row(target)) return
890
+ const parent = parentPath(target)
891
+ if (!this.row(parent)) {
892
+ if (!options?.recursive) throw new Error(`ENOENT: ${parent}`)
893
+ await this.mkdir(parent, { recursive: true })
894
+ }
895
+ const now = Date.now()
896
+ this.state.storage.sql.exec(
897
+ `INSERT INTO ${TABLE}
898
+ (row_id,path,parent_path,name,type,mime_type,size,created_at,modified_at,write_state,current)
899
+ VALUES (?,?,?,?, 'directory', 'inode/directory', 0, ?, ?, 'ready', 1)`,
900
+ id("row"), target, parent, basename(target), now, now,
901
+ )
902
+ }
903
+
904
+ async readDir(dir = "/", opts?: { limit?: number; offset?: number }): Promise<SpaceFileInspection[]> {
905
+ const target = normalizeInternalPath(dir)
906
+ const rows = this.state.storage.sql.exec<FileRow>(`
907
+ SELECT ${FILE_ROW_COLUMNS}
908
+ FROM ${TABLE}
909
+ WHERE parent_path = ? AND path <> ? AND current = 1 AND write_state = 'ready'
910
+ ORDER BY name LIMIT ? OFFSET ?
911
+ `, target, target, opts?.limit ?? 100_000, opts?.offset ?? 0).toArray()
912
+ return rows.map((row) => this.info(row))
913
+ }
914
+
915
+ async rm(path: string, opts?: { recursive?: boolean; force?: boolean }): Promise<void> {
916
+ const target = writableFilePath(path)
917
+ const row = this.row(target)
918
+ if (!row) {
919
+ if (opts?.force) return
920
+ throw new Error(`ENOENT: ${target}`)
921
+ }
922
+ if (row.type === "directory" && !opts?.recursive && (await this.readDir(target)).length > 0) {
923
+ throw new Error(`ENOTEMPTY: ${target}`)
924
+ }
925
+ const cachedFiles = this.state.storage.sql.exec<FileRow>(`
926
+ SELECT ${FILE_ROW_COLUMNS}
927
+ FROM ${TABLE}
928
+ WHERE current = 1 AND type = 'file'
929
+ AND (path = ? OR substr(path, 1, ?) = ?)
930
+ `, target, target.length + 1, `${target}/`).toArray()
931
+ this.state.storage.sql.exec(
932
+ `UPDATE ${TABLE} SET current = 0
933
+ WHERE current = 1 AND (path = ? OR substr(path, 1, ?) = ?)`,
934
+ target, target.length + 1, `${target}/`,
935
+ )
936
+ await Promise.all(cachedFiles.map((file) => this.removeCached(this.cachedVersion(file))))
937
+ }
938
+
939
+ async cp(src: string, dest: string, opts?: { recursive?: boolean }): Promise<void> {
940
+ const from = normalizeInternalPath(src)
941
+ const to = writableFilePath(dest)
942
+ const source = this.row(from)
943
+ if (!source) throw new Error(`ENOENT: ${from}`)
944
+ if (source.type === "directory" && !opts?.recursive) throw new Error("A directory copy requires recursive: true")
945
+ const rows = source.type === "directory"
946
+ ? this.state.storage.sql.exec<FileRow>(`
947
+ SELECT ${FILE_ROW_COLUMNS}
948
+ FROM ${TABLE} WHERE current = 1 AND write_state = 'ready'
949
+ AND (path = ? OR substr(path, 1, ?) = ?)
950
+ ORDER BY length(path)
951
+ `, from, from.length + 1, `${from}/`).toArray()
952
+ : [source]
953
+ await this.rm(to, { recursive: true, force: true })
954
+ for (const row of rows) {
955
+ const path = `${to}${row.path.slice(from.length)}`
956
+ if (row.type === "directory") {
957
+ await this.mkdir(path, { recursive: true })
958
+ continue
959
+ }
960
+ if (row.type === "symlink") {
961
+ await this.symlink(row.target ?? "", path)
962
+ continue
963
+ }
964
+ await this.ensureParent(path)
965
+ const now = Date.now()
966
+ this.state.storage.sql.exec(
967
+ `INSERT INTO ${TABLE}
968
+ (row_id,path,parent_path,name,type,mime_type,size,created_at,modified_at,
969
+ attachment_id,content_version,content_key,sha256,origin,content_ownership,
970
+ understanding_status,evidence_json,write_state,current)
971
+ VALUES (?,?,?,?, 'file', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'referenced', ?, ?, 'ready', 1)`,
972
+ id("row"), path, parentPath(path), basename(path), row.mimeType, row.size, now, now,
973
+ id("att"), id("ver"), row.contentKey, row.sha256, row.origin,
974
+ row.understandingStatus, row.evidenceJson,
975
+ )
976
+ }
977
+ }
978
+
979
+ async mv(src: string, dest: string): Promise<void> {
980
+ const from = writableFilePath(src)
981
+ const to = writableFilePath(dest)
982
+ if (!this.row(from)) throw new Error(`ENOENT: ${from}`)
983
+ await this.ensureParent(to)
984
+ await this.rm(to, { recursive: true, force: true })
985
+ this.state.storage.sql.exec(
986
+ `UPDATE ${TABLE}
987
+ SET path = ? || substr(path, ?),
988
+ parent_path = CASE
989
+ WHEN parent_path = ? THEN ?
990
+ ELSE ? || substr(parent_path, ?)
991
+ END,
992
+ name = CASE WHEN path = ? THEN ? ELSE name END,
993
+ modified_at = ?
994
+ WHERE path = ? OR substr(path, 1, ?) = ?`,
995
+ to, from.length + 1, parentPath(from), parentPath(to), to, from.length + 1,
996
+ from, basename(to), Date.now(), from, from.length + 1, `${from}/`,
997
+ )
998
+ }
999
+
1000
+ async symlink(target: string, linkPath: string): Promise<void> {
1001
+ const path = writableFilePath(linkPath)
1002
+ await this.ensureParent(path)
1003
+ await this.rm(path, { force: true, recursive: true })
1004
+ const now = Date.now()
1005
+ this.state.storage.sql.exec(
1006
+ `INSERT INTO ${TABLE}
1007
+ (row_id,path,parent_path,name,type,mime_type,size,target,created_at,modified_at,write_state,current)
1008
+ VALUES (?,?,?,?, 'symlink', 'inode/symlink', 0, ?, ?, ?, 'ready', 1)`,
1009
+ id("row"), path, parentPath(path), basename(path), target, now, now,
1010
+ )
1011
+ }
1012
+
1013
+ async readlink(path: string): Promise<string> {
1014
+ const row = this.row(path)
1015
+ if (!row || row.type !== "symlink" || !row.target) throw new Error(`EINVAL: ${path}`)
1016
+ return row.target
1017
+ }
1018
+
1019
+ async glob(pattern: string): Promise<SpaceFileInspection[]> {
1020
+ const matcher = globRegex(pattern)
1021
+ const rows = this.state.storage.sql.exec<FileRow>(`
1022
+ SELECT ${FILE_ROW_COLUMNS}
1023
+ FROM ${TABLE} WHERE current = 1 AND write_state = 'ready'
1024
+ `).toArray()
1025
+ return rows.filter((row) => matcher.test(row.path)).map((row) => this.info(row))
1026
+ }
1027
+
1028
+ async getWorkspaceInfo(): Promise<{ fileCount: number; directoryCount: number; totalBytes: number; r2FileCount: number }> {
1029
+ const result = this.state.storage.sql.exec<{
1030
+ fileCount: number
1031
+ directoryCount: number
1032
+ totalBytes: number
1033
+ }>(`
1034
+ SELECT
1035
+ SUM(CASE WHEN type = 'file' THEN 1 ELSE 0 END) AS fileCount,
1036
+ SUM(CASE WHEN type = 'directory' THEN 1 ELSE 0 END) AS directoryCount,
1037
+ SUM(CASE WHEN type = 'file' THEN size ELSE 0 END) AS totalBytes
1038
+ FROM ${TABLE} WHERE current = 1 AND write_state = 'ready'
1039
+ `).one()
1040
+ return { ...result, r2FileCount: result.fileCount }
1041
+ }
1042
+
1043
+ async _getAllPaths(): Promise<string[]> {
1044
+ return this.state.storage.sql.exec<{ path: string }>(
1045
+ `SELECT path FROM ${TABLE} WHERE current = 1 AND write_state = 'ready' ORDER BY path`,
1046
+ ).toArray().map(({ path }) => path)
1047
+ }
1048
+
1049
+ async _updateModifiedAt(path: string, mtime: Date): Promise<void> {
1050
+ this.state.storage.sql.exec(
1051
+ `UPDATE ${TABLE} SET modified_at = ? WHERE path = ? AND current = 1`,
1052
+ mtime.getTime(), normalizeInternalPath(path),
1053
+ )
1054
+ }
1055
+ }