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