feat(web,hub): scratchlist v2.2 hub attachment storage (#921) (#1205)

* feat(hub,shared): scratchlist v2.2 hub attachment storage foundation (#921)

Hub stores scratchlist attachment bytes on filesystem; SQLite holds
AttachmentMetadata[] JSON via session_scratchlist.attachments (v11→v12).
Upstream ladder: v10→v11 text-only scratchlist table (#896), v11→v12
attachments column. Configurable limits via HAPI_SCRATCHLIST_* env vars.
Upload, serve, and limits REST routes; delete entry cleans hub files.

Web promote/rehydrate still TODO. Soup renumber branch follows.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): scratchlist v2.2 attachment UX (#921)

Route scratchlist-mode composer submits with attachments to hub storage,
show image thumbnails in the drawer, and rehydrate attachments on promote
to composer or queue (hub fetch → CLI upload for send).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): scratchlist attach submit, float thumbs, copy tooltip (#921)

Hub upload adapter now sets path on ready attachments so the composer send
button unlocks in scratchlist mode; routing label matches attachments too.
Entry thumbnails float left with text wrap; copy tooltip clarifies text-only.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): adapt scratchlist update tests to patch API (#921)

update() now takes { text?, attachments? }; v12 CRUD tests still passed a string.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub,web): harden scratchlist attachment ownership and orphan cleanup

Resolve claimed hub paths against the current session before persist,
count on-disk session bytes for upload caps, delete blobs dropped on
entry update, and DELETE pending uploads when composer remove runs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: drop accidental .cursor files from attachment PR

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): exit scratchlist mode before rehydrate; delete raced uploads

Promote-to-composer flushes mode exit so attachments use the chat adapter.
Cancel-during-upload deletes the hub blob once upload returns.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub,web): exact UUID delete match; stage hub paths on chat send

Reject partial attachment ids on disk delete, and restage scratchlist hub
attachments through uploadFile when sending after leaving scratchlist mode.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): skip text-only PUT resolve; cleanup session attachment dirs

Text-only edits keep existing attachment metadata after session-id transfer.
Require full UUID on resolve. Delete scratchlist attachment files when a
session is deleted.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub,web): scratchlist attach route, PUT bytes, orphan deletes

Park only hub-resident attachments; subtract removed blobs from the PUT
session cap; delete attachment files only when no other entry still
references them.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): canonicalize scratchlist attachment filenames

Resolve stores the on-disk sanitized name (not claimed.filename) and
hardens Content-Disposition against CR/LF/quote injection.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(hub): cover toxic filename canonicalize on resolve

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub,web): serialize scratchlist uploads; drop hub blobs after chat stage

Per-session upload lock keeps disk byte caps honest under concurrency.
After a successful toggle-off chat send, delete the staged hub copies so
they no longer count against the session attachment budget.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(shared,web): allow clearing scratchlist attachments; cleanup staged uploads

PUT may send attachments:[] without a text change. Staging to chat rolls
back partial normal-upload copies on failure.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): re-key scratchlist attachment files on session merge

Move hub blobs when scratchlist rows transfer between session ids so
quota and path ownership stay correct. Reject PUT that would leave an
empty textless entry after clearing attachments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): reuse restored scratchlist hub attachments without re-upload

Composer draft remount was re-uploading blobs that already had a
hapi-hub:scratchlist path, orphaning the originals against session quota.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-29 10:05:24 +08:00
committed by GitHub
co-authored by Cursor
parent 87e88743e5
commit 4c203f17cb
37 changed files with 2391 additions and 160 deletions
+181 -10
View File
@@ -149,6 +149,8 @@ export class SyncEngine {
private inactivityTimer: NodeJS.Timeout | null = null
/** Sessions that emitted `session-ready` (Cursor ACP load/newSession complete). */
private readonly sessionReadyIds = new Set<string>()
/** Serialize scratchlist uploads per session so disk-byte caps cannot race. */
private readonly scratchlistUploadTails = new Map<string, Promise<unknown>>()
constructor(
private readonly store: Store,
@@ -447,12 +449,14 @@ export class SyncEngine {
text: string
createdAt: number
updatedAt: number
attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[]
}> {
return this.store.scratchlist.list(sessionId).map((row) => ({
entryId: row.entryId,
text: row.text,
createdAt: row.createdAt,
updatedAt: row.updatedAt
updatedAt: row.updatedAt,
attachments: row.attachments,
}))
}
@@ -460,6 +464,10 @@ export class SyncEngine {
return this.store.scratchlist.count(sessionId)
}
sumScratchlistAttachmentBytes(sessionId: string): number {
return this.store.scratchlist.sumAttachmentBytes(sessionId)
}
/**
* Read a single entry by id. The route layer uses this to short-
* circuit duplicate POSTs (migration retry) BEFORE running the
@@ -470,14 +478,21 @@ export class SyncEngine {
getScratchlistEntry(
sessionId: string,
entryId: string
): { entryId: string; text: string; createdAt: number; updatedAt: number } | null {
): {
entryId: string
text: string
createdAt: number
updatedAt: number
attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[]
} | null {
const row = this.store.scratchlist.get(sessionId, entryId)
if (!row) return null
return {
entryId: row.entryId,
text: row.text,
createdAt: row.createdAt,
updatedAt: row.updatedAt
updatedAt: row.updatedAt,
attachments: row.attachments,
}
}
@@ -496,10 +511,20 @@ export class SyncEngine {
createScratchlistEntry(
sessionId: string,
text: string,
options?: { entryId?: string; createdAt?: number }
options?: {
entryId?: string
createdAt?: number
attachments?: import('@hapi/protocol').ScratchlistAttachmentMetadata[]
}
): {
outcome: 'created' | 'duplicate'
entry: { entryId: string; text: string; createdAt: number; updatedAt: number }
entry: {
entryId: string
text: string
createdAt: number
updatedAt: number
attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[]
}
} | { outcome: 'session-not-found' } {
const result = this.store.scratchlist.create(sessionId, text, options)
if (result.outcome === 'session-not-found') {
@@ -514,7 +539,8 @@ export class SyncEngine {
entryId: result.entry.entryId,
text: result.entry.text,
createdAt: result.entry.createdAt,
updatedAt: result.entry.updatedAt
updatedAt: result.entry.updatedAt,
attachments: result.entry.attachments,
}
}
}
@@ -522,27 +548,172 @@ export class SyncEngine {
updateScratchlistEntry(
sessionId: string,
entryId: string,
patch: {
text?: string
attachments?: import('@hapi/protocol').ScratchlistAttachmentMetadata[]
}
): {
entryId: string
text: string
): { entryId: string; text: string; createdAt: number; updatedAt: number } | null {
const updated = this.store.scratchlist.update(sessionId, entryId, text)
createdAt: number
updatedAt: number
attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[]
} | null {
const updated = this.store.scratchlist.update(sessionId, entryId, patch)
if (!updated) return null
this.sessionCache.emitScratchlistChanged(sessionId, updated.updatedAt)
return {
entryId: updated.entryId,
text: updated.text,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt
updatedAt: updated.updatedAt,
attachments: updated.attachments,
}
}
deleteScratchlistEntry(sessionId: string, entryId: string): boolean {
const existing = this.store.scratchlist.get(sessionId, entryId)
const removed = this.store.scratchlist.delete(sessionId, entryId)
if (removed) {
if (removed && existing) {
// Attachment ids may be shared across entries (direct REST).
// Only delete blobs that no remaining entry still references.
const remainingIds = new Set(
this.store.scratchlist
.list(sessionId)
.flatMap((entry) => entry.attachments.map((att) => att.id))
)
const orphaned = existing.attachments.filter((att) => !remainingIds.has(att.id))
if (orphaned.length > 0) {
void import('../scratchlistAttachments/storage').then(({ deleteScratchlistAttachmentFiles, getHapiHomeDir }) =>
deleteScratchlistAttachmentFiles(getHapiHomeDir(), orphaned)
)
}
this.sessionCache.emitScratchlistChanged(sessionId, Date.now())
}
return removed
}
private async withScratchlistUploadLock<T>(
namespace: string,
sessionId: string,
fn: () => Promise<T>
): Promise<T> {
const key = `${namespace}:${sessionId}`
const previous = this.scratchlistUploadTails.get(key) ?? Promise.resolve()
let release!: () => void
const gate = new Promise<void>((resolve) => {
release = resolve
})
const tail = previous.catch(() => undefined).then(() => gate)
this.scratchlistUploadTails.set(key, tail)
await previous.catch(() => undefined)
try {
return await fn()
} finally {
release()
if (this.scratchlistUploadTails.get(key) === tail) {
this.scratchlistUploadTails.delete(key)
}
}
}
async uploadScratchlistAttachment(
sessionId: string,
namespace: string,
filename: string,
contentBase64: string,
mimeType: string
): Promise<{ success: true; attachment: import('@hapi/protocol').ScratchlistAttachmentMetadata } | { success: false; error: string; code?: string }> {
const { loadScratchlistAttachmentLimitsFromEnv, isAllowedScratchlistMime } = await import('../config/scratchlistAttachmentLimits')
const {
estimateBase64Bytes,
writeScratchlistAttachmentFile,
getHapiHomeDir,
sumScratchlistAttachmentBytesOnDisk,
} = await import('../scratchlistAttachments/storage')
const { validateScratchlistAttachmentsForWrite } = await import('../scratchlistAttachments/validate')
const limits = loadScratchlistAttachmentLimitsFromEnv()
const estimated = estimateBase64Bytes(contentBase64)
if (estimated > limits.maxBytesPerFile) {
return { success: false, error: 'File too large', code: 'scratchlist_attachment_too_large' }
}
if (!isAllowedScratchlistMime(mimeType, limits)) {
return { success: false, error: 'Mime type not allowed', code: 'scratchlist_attachment_mime' }
}
const hapiHome = getHapiHomeDir()
const buffer = Buffer.from(contentBase64, 'base64')
return await this.withScratchlistUploadLock(namespace, sessionId, async () => {
const sessionBytes = await sumScratchlistAttachmentBytesOnDisk(hapiHome, namespace, sessionId)
const provisional = {
id: 'pending',
filename,
mimeType,
size: buffer.length,
path: 'pending',
}
const validation = validateScratchlistAttachmentsForWrite([provisional], limits, sessionBytes)
if (!validation.ok) {
return { success: false, error: validation.error, code: validation.code }
}
const attachment = await writeScratchlistAttachmentFile(
hapiHome,
namespace,
sessionId,
filename,
mimeType,
buffer
)
return { success: true, attachment }
})
}
async resolveScratchlistAttachmentsForSession(
sessionId: string,
namespace: string,
claimed: import('@hapi/protocol').ScratchlistAttachmentMetadata[]
): Promise<
| { ok: true; attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[] }
| { ok: false; error: string }
> {
const {
resolveScratchlistAttachmentsForSession: resolveAttachments,
getHapiHomeDir,
} = await import('../scratchlistAttachments/storage')
return resolveAttachments(getHapiHomeDir(), namespace, sessionId, claimed)
}
async sumScratchlistAttachmentBytesOnDisk(sessionId: string, namespace: string): Promise<number> {
const {
sumScratchlistAttachmentBytesOnDisk: sumOnDisk,
getHapiHomeDir,
} = await import('../scratchlistAttachments/storage')
return sumOnDisk(getHapiHomeDir(), namespace, sessionId)
}
async deleteScratchlistAttachmentById(
sessionId: string,
namespace: string,
attachmentId: string
): Promise<boolean> {
const {
deleteScratchlistAttachmentById: deleteById,
getHapiHomeDir,
} = await import('../scratchlistAttachments/storage')
return deleteById(getHapiHomeDir(), namespace, sessionId, attachmentId)
}
async readScratchlistAttachment(
hubPath: string
): Promise<{ buffer: Buffer; mimeType: string; filename: string } | null> {
const { readScratchlistAttachmentFile, getHapiHomeDir } = await import('../scratchlistAttachments/storage')
const read = await readScratchlistAttachmentFile(getHapiHomeDir(), hubPath)
if (!read) return null
return { buffer: read.buffer, mimeType: 'application/octet-stream', filename: 'attachment' }
}
handleMachineAlive(payload: { machineId: string; time: number; health?: unknown }): void {
this.machineCache.handleMachineAlive(payload)
}