Skip to content

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

Merged
tiann merged 16 commits into
tiann:mainfrom
heavygee:feat/scratchlist-attachments-v22
Jul 29, 2026
Merged

feat(web,hub): scratchlist v2.2 hub attachment storage (#921)#1205
tiann merged 16 commits into
tiann:mainfrom
heavygee:feat/scratchlist-attachments-v22

Conversation

@heavygee

@heavygee heavygee commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds scratchlist v2.2 image/file attachment support on top of hub-synced scratchlist (#896).

  • Hub stores attachment bytes under HAPI_HOME/scratchlist-attachments/; SQLite holds AttachmentMetadata JSON on session_scratchlist.attachments (schema V14 to V15; scratchlist table remains V11 to V12 from feat(web,hub): scratchlist v2 - hub sync via typed table + session-updated piggyback #896).
  • REST: upload, serve, and limits endpoints; entry create/update accept optional attachments; delete cleans hub files.
  • Web: composer scratchlist mode can park text + images; drawer shows float thumbnails; promote-to-composer rehydrates attachments; promote-to-queue stages hub bytes to CLI upload for send.
  • Copy action tooltip clarifies text-only (not images).

Closes #921.

Test plan

  • bun typecheck and bun run test green on this branch
  • Fresh hub DB reaches schema 15 with session_scratchlist.attachments
  • Existing V14 DB migrates and gains attachments without dropping scratchlist rows
  • Scratchlist mode: attach image, Send enabled, entry appears with thumbnail
  • Promote entry to composer rehydrates image preview
  • Promote to queue sends text + attachment into the session
  • Attachment-only entry (no text) allowed within configured limits
  • Oversize / disallowed MIME rejected with clear API/UI error
  • Cross-device: entry + attachment bytes available from a second client against the same hub

Notes

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Attachment metadata is trusted without proving session ownership — POST/PUT /scratchlist accepts client-supplied id/path/size and only checks size/MIME, then the download route authorizes the current session but reads match.path; a forged metadata row can point at another session/namespace's hub file if the path is known. Evidence hub/src/web/routes/sessions.ts:856.
  • [Major] Scratchlist upload quota ignores uncommitted files — upload writes bytes immediately, but the per-session limit is computed only from persisted scratchlist entries, so repeated upload calls that are never attached to an entry can fill disk outside maxBytesPerSession. Evidence hub/src/sync/syncEngine.ts:600.

Summary
Review mode: initial
Found two Major issues in the new hub-backed attachment storage path. Both need server-side fixes; tests should cover forged attachment metadata and abandoned uploads.

Testing
Not run (automation; avoided executing PR code). Suggested tests: forged cross-session/cross-namespace attachment metadata is rejected; repeated unattached uploads cannot exceed session quota and are cleaned up.

HAPI Bot

Comment thread hub/src/web/routes/sessions.ts Outdated
Comment thread hub/src/sync/syncEngine.ts Outdated
heavygee and others added 4 commits July 28, 2026 12:03
…iann#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 (tiann#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>
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>
…n#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>
update() now takes { text?, attachments? }; v12 CRUD tests still passed a string.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee
heavygee force-pushed the feat/scratchlist-attachments-v22 branch from b4ffded to 28c5777 Compare July 28, 2026 12:04

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review mode: follow-up after new commits. Found two attachment-cleanup regressions in the new scratchlist attachment path.

Comment thread hub/src/web/routes/sessions.ts
Comment thread web/src/lib/scratchlistAttachmentAdapter.ts
heavygee and others added 2 commits July 28, 2026 13:30
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>
Co-authored-by: Cursor <cursoragent@cursor.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Promote-to-composer rehydrates attachments while scratchlist mode is still active, so assistantApi.composer().addAttachment(file) runs against createScratchlistAttachmentAdapter(...) instead of the normal upload adapter. That leaves hapi-hub:scratchlist/... paths in the composer, and the later chat send will format them as @path references the agent cannot read. web/src/components/SessionChat.tsx:326
    Suggested fix:
    const handlePromoteToComposer = useCallback(async (entry: ScratchlistEntry) => {
        assistantApi.composer().setText(entry.text)
        props.onExitScratchlistMode()
    
        if (entry.attachments?.length) {
            window.requestAnimationFrame(() => {
                void rehydrateScratchlistAttachmentsToComposer(
                    props.api,
                    props.sessionId,
                    entry.attachments,
                    assistantApi.composer()
                )
            })
        }
    }, [assistantApi, props.api, props.onExitScratchlistMode, props.sessionId])
  • [Major] Canceling a scratchlist attachment while its hub upload is in flight leaves the newly written blob orphaned, because the post-upload cancel branch returns without deleting the server-side file. Those bytes stay in HAPI_HOME/scratchlist-attachments, count against the session quota, and nothing points back to them for cleanup. web/src/lib/scratchlistAttachmentAdapter.ts:60
    Suggested fix:
    const result = await api.uploadScratchlistAttachment(sessionId, file.name, content, contentType)
    if (cancelledAttachmentIds.has(id)) {
        if (result.success && result.attachment) {
            await api.deleteScratchlistAttachment(sessionId, result.attachment.id).catch(() => {})
        }
        return
    }

Summary
Review mode: follow-up after new commits
Two Major regressions remain in the new scratchlist attachment flow. The server-side trust/quota issues from the prior pass appear addressed; the remaining gaps are in the web-side composer handoff and cancel race.

Testing
Not run (automation). Suggested tests: promote-to-composer uses the normal upload adapter after leaving scratchlist mode; removing an attachment during upload deletes any successfully written hub blob.

Comment thread web/src/components/SessionChat.tsx
Comment thread web/src/lib/scratchlistAttachmentAdapter.ts
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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Partial attachment IDs can delete referenced files — scratchlist files are named ${uuid}-${filename}, but the delete helper removes the first filename that starts with ${attachmentId}-; because UUIDs contain hyphens, a request using only the first UUID segment bypasses the route's exact stillReferenced check and deletes a file still referenced by an entry. Evidence hub/src/scratchlistAttachments/storage.ts:255 and hub/src/web/routes/sessions.ts:984.
    Suggested fix:
    const SCRATCHLIST_ATTACHMENT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
    
    function isScratchlistAttachmentId(id: string): boolean {
        return SCRATCHLIST_ATTACHMENT_ID_RE.test(id)
    }
    
    if (!isScratchlistAttachmentId(claimed.id)) {
        return { ok: false, error: 'Invalid scratchlist attachment id' }
    }
    if (!isScratchlistAttachmentId(attachmentId)) return false
  • [Major] Hub-only scratchlist uploads can be sent to chat after toggling the drawer off — when scratchlistMode is on, the composer uploads attachments to hapi-hub:scratchlist/...; if the user turns scratchlist mode off before pressing Send, the normal chat path receives that hub-private path unchanged, and the CLI only formats attachment paths as local @path references. Evidence web/src/components/SessionChat.tsx:1191 and web/src/lib/scratchlistAttachmentAdapter.ts:130.
    Suggested fix:
    const prepareChatAttachments = async (items?: AttachmentMetadata[]) => {
        const hubItems = (items ?? []).filter((att) => isHubScratchlistAttachmentPath(att.path))
        if (hubItems.length === 0) return items
    
        const normalItems = (items ?? []).filter((att) => !isHubScratchlistAttachmentPath(att.path))
        const staged = await stageScratchlistAttachmentsForComposeSend(
            props.api,
            props.session.id,
            hubItems
        )
        return [...normalItems, ...staged]
    }
    
    return props.onSend(text, await prepareChatAttachments(attachments), scheduledAt)

Questions

  • None.

Summary
Review mode: follow-up after new commits
Found two Major issues in the current scratchlist attachment flow. The earlier bot concerns around cross-session metadata and abandoned-upload quota are mostly addressed, but the current ID matching still has an exactness bug, and hub-staged attachments need conversion before they enter the normal chat send path.

Testing
Not run (automation; avoided executing PR code). Suggested coverage: DELETE with a UUID prefix must not remove an in-use attachment; toggling scratchlist mode off after a hub upload must stage the file through normal upload before chat send.

HAPI Bot

Comment thread hub/src/scratchlistAttachments/storage.ts
Comment thread web/src/components/SessionChat.tsx
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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Text-only scratchlist edits can fail after a session-id transfer because existing attachment paths still point at the old session id.
  • [Major] Deleting a session removes the DB rows but leaves hub-side scratchlist attachment bytes on disk.
  • [Major] Attachment validation still accepts partial UUID ids, letting direct REST callers persist aliases that bypass the in-use check.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The previous bot findings were addressed, but the current attachment storage still has lifecycle and identity issues around session transfer, session deletion, and server-side metadata validation.

Testing

  • Not run (automation)

HAPI Bot

Comment thread hub/src/web/routes/sessions.ts Outdated
Comment thread hub/src/scratchlistAttachments/storage.ts
Comment thread hub/src/scratchlistAttachments/storage.ts
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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Attach-before-toggle files are routed to scratchlist with non-hub paths, so the hub rejects the send. Evidence web/src/components/SessionChat.tsx:531.
    Suggested fix:
    export function shouldRouteToScratchlist(
        scratchlistMode: boolean,
        attachments: AttachmentMetadata[] | undefined,
        scheduledAt: number | null | undefined,
    ): boolean {
        if (!scratchlistMode) return false
        if (scheduledAt != null) return false
        return (attachments ?? []).every((att) => isHubScratchlistAttachmentPath(att.path))
    }
  • [Major] Replacing attachments can falsely exceed the per-session byte cap because removed bytes are still counted during PUT validation. Evidence hub/src/web/routes/sessions.ts:937.
    Suggested fix:
    const removedAttachments = existing.attachments.filter(
        (old) => !nextAttachments.some((next) => next.id === old.id)
    )
    const removedBytes = removedAttachments.reduce((sum, att) => sum + att.size, 0)
    const diskBytesAfterRemoval = Math.max(0, diskBytes - removedBytes)
    const sessionBytesBefore = Math.max(0, diskBytesAfterRemoval - entryBytes)
  • [Major] Deleting one scratchlist entry can delete attachment bytes still referenced by another entry. Evidence hub/src/sync/syncEngine.ts:572.
    Suggested fix:
    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))
    void import('../scratchlistAttachments/storage').then(({ deleteScratchlistAttachmentFiles, getHapiHomeDir }) =>
        deleteScratchlistAttachmentFiles(getHapiHomeDir(), orphaned)
    )

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The prior ID/path validation fixes are present, but the current scratchlist attachment lifecycle still has routing, byte-accounting, and shared-reference deletion issues.

Testing

  • Not run (automation)

HAPI Bot

Comment thread web/src/components/SessionChat.tsx
Comment thread hub/src/web/routes/sessions.ts Outdated
Comment thread hub/src/sync/syncEngine.ts Outdated
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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Canonicalize attachment filenames before persisting/serving them — resolveScratchlistAttachmentForSession verifies the id/path/size, but then stores claimed.filename; the attachment GET route later interpolates that value into Content-Disposition at hub/src/web/routes/sessions.ts:789. A direct REST caller can therefore persist CR/LF/quote/control characters and make future attachment fetches fail or produce malformed headers. Evidence hub/src/scratchlistAttachments/storage.ts:216.
    Suggested fix:
    function sanitizeFilename(filename: string): string {
        const sanitized = filename
            .replace(/[-"\/]/g, '_')
            .replace(/\.\./g, '_')
            .replace(/\s+/g, '_')
            .slice(0, 255)
        return sanitized || 'upload'
    }
    
    const storedFilename = sanitizeFilename(fileName.slice(`${claimed.id}-`.length))
    
    return {
        ok: true,
        attachment: {
            id: claimed.id,
            filename: storedFilename,
            mimeType: claimed.mimeType,
            size: info.size,
            path: toHubScratchlistAttachmentPath(storageKey),
        },
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • One attachment metadata canonicalization issue remains; the previous bot findings appear addressed in the current diff.

Testing

  • Not run (automation)

HAPI Bot

Comment thread hub/src/scratchlistAttachments/storage.ts Outdated
heavygee and others added 2 commits July 28, 2026 15:01
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>
Co-authored-by: Cursor <cursoragent@cursor.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Serialize the scratchlist upload quota check — the upload path reads sessionBytes, validates against maxBytesPerSession, then awaits the file write. Two concurrent uploads for the same session can both observe the same pre-write total and both write, bypassing the configured session cap. Evidence hub/src/sync/syncEngine.ts:617.
    Suggested fix:
    private readonly scratchlistUploadLocks = new Map<string, Promise<void>>()
    
    private async withScratchlistUploadLock<T>(namespace: string, sessionId: string, fn: () => Promise<T>): Promise<T> {
        const key = `${namespace}\0${sessionId}`
        const previous = this.scratchlistUploadLocks.get(key) ?? Promise.resolve()
        let release!: () => void
        const current = new Promise<void>((resolve) => { release = resolve })
        const tail = previous.catch(() => undefined).then(() => current)
        this.scratchlistUploadLocks.set(key, tail)
        await previous.catch(() => undefined)
        try {
            return await fn()
        } finally {
            release()
            if (this.scratchlistUploadLocks.get(key) === tail) {
                this.scratchlistUploadLocks.delete(key)
            }
        }
    }
    
    return await this.withScratchlistUploadLock(namespace, sessionId, async () => {
        const sessionBytes = await sumScratchlistAttachmentBytesOnDisk(hapiHome, namespace, sessionId)
        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 }
    })
  • [Major] Delete hub blobs after successful chat fallback staging — when a user uploads while scratchlist mode is on, then toggles it off before sending, this code stages the hub-resident blobs into normal CLI uploads but leaves the original hub scratchlist files unreferenced. Those files still count in sumScratchlistAttachmentBytesOnDisk, so repeated fallback sends can exhaust the scratchlist session cap with no UI path to clear them. Evidence web/src/components/SessionChat.tsx:545.
    Suggested fix:
    if (hubItems.length > 0) {
        const normalItems = list.filter((att) => !isHubScratchlistAttachmentPath(att.path))
        const staged = await stageScratchlistAttachmentsForComposeSend(props.api, props.session.id, hubItems)
        const accepted = await props.onSend(text, [...normalItems, ...staged], scheduledAt)
        if (accepted) {
            await Promise.allSettled(
                hubItems.map((att) => props.api.deleteScratchlistAttachment(props.session.id, att.id))
            )
        }
        return accepted
    }
    return props.onSend(text, attachments, scheduledAt)

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The previous filename canonicalization finding appears addressed, but the latest full diff still has a quota race and an orphaned-blob path around scratchlist attachment uploads.

Testing

  • Not run (automation). Suggested coverage: concurrent scratchlist uploads at the session cap; scratchlist-mode upload toggled back to chat send deletes the original hub blob only after accepted send.

HAPI Bot

Comment thread hub/src/sync/syncEngine.ts Outdated
Comment thread web/src/components/SessionChat.tsx
…t 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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review mode: initial.

Two issues found: the scratchlist update schema rejects clearing the last attachment, even though the PUT handler is written to replace the attachment list; and the scratchlist-to-queue staging path does not roll back already-uploaded files if a later attachment fails, which can leave orphaned uploads.

Not run (review only).

Comment thread shared/src/apiTypes.ts
Comment thread web/src/lib/scratchlistAttachmentFlow.ts Outdated
…ed 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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Re-key scratchlist attachment files during session merges — attachment storage and validation are now scoped by namespace/sessionId, but existing merge paths only transfer session_scratchlist rows. After a dedup/resume merge, moved entries can still point at files under the old session directory, so sumScratchlistAttachmentBytesOnDisk(newSessionId) misses those bytes and future attachment PUT validation rejects the old paths as outside the new session. Evidence hub/src/scratchlistAttachments/storage.ts:196 with merge context hub/src/sync/sessionCache.ts:932.
    Suggested fix:
    const movedScratchlist = this.store.scratchlist.transfer(oldSessionId, newSessionId)
    if (movedScratchlist.moved > 0) {
        const { getHapiHomeDir, moveScratchlistAttachmentFilesForSession } = await import('../scratchlistAttachments/storage')
        for (const entry of this.store.scratchlist.list(newSessionId)) {
            const attachments = await moveScratchlistAttachmentFilesForSession(
                getHapiHomeDir(),
                namespace,
                oldSessionId,
                newSessionId,
                entry.attachments,
            )
            if (attachments.some((att, i) => att.path !== entry.attachments[i]?.path)) {
                this.store.scratchlist.update(newSessionId, entry.entryId, { attachments })
            }
        }
        this.emitScratchlistChanged(newSessionId)
    }
  • [Minor] Re-check the final PUT payload after attachment clearing — the schema now allows any attachments-only patch, so PUT { "attachments": [] } against an attachment-only entry with empty text passes validation and stores an empty scratchlist row. That breaks the create/update invariant that an entry has text or attachments. Evidence hub/src/web/routes/sessions.ts:933 and shared/src/apiTypes.ts:296.
    Suggested fix:
    if (nextText.trim().length === 0 && nextAttachments.length === 0) {
        return c.json({
            error: 'Scratchlist entry requires text or attachments',
            code: 'scratchlist_entry_empty',
        }, 400)
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The prior quota-race and fallback cleanup issues appear addressed, but the full diff still has a merge-path attachment storage bug and a direct-API empty-entry validation gap.

Testing

  • Not run (automation). Suggested coverage: merge/dedup scratchlist entries with attachments and verify paths/quota still use the new session id; PUT clearing the last attachment from an attachment-only entry returns 400.

HAPI Bot

Comment thread hub/src/scratchlistAttachments/storage.ts
Comment thread hub/src/web/routes/sessions.ts
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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Reuse restored scratchlist uploads instead of uploading again — composer drafts persist the already-uploaded attachment path for remount/reload recovery, but the new scratchlist adapter ignores restored upload metadata and always reads the file and calls the scratchlist upload endpoint again. The original hub blob is then orphaned and still counts toward the per-session disk cap; for a large attachment near the cap, the restored draft can fail to upload on reload even though the bytes are already present. Evidence web/src/lib/scratchlistAttachmentAdapter.ts:40, with draft persistence context web/src/components/AssistantChat/HappyComposer.tsx:309.
    Suggested fix:
    import { getRestoredUploadMetadata } from '@/lib/composer-attachment-drafts'
    import { parseHubScratchlistAttachmentPath } from '@hapi/protocol'
    
    const restored = getRestoredUploadMetadata(file)
    if (restored) {
        const key = parseHubScratchlistAttachmentPath(restored.path)
        const storedName = key?.split('/').pop()
        const hubId = storedName?.slice(0, 36)
        if (hubId) {
            const hubAttachment = {
                id: hubId,
                filename: file.name,
                mimeType: contentType,
                size: file.size,
                path: restored.path,
            }
            yield {
                id,
                type: 'file',
                name: file.name,
                contentType,
                file,
                status: { type: 'requires-action', reason: 'composer-send' },
                path: restored.path,
                hubAttachment,
                previewUrl: restored.previewUrl,
            } as PendingScratchlistAttachment
            return
        }
    }

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • The prior bot findings appear addressed. The remaining issue is a reload/remount path that can strand scratchlist attachment bytes and block restored drafts under quota pressure.

Testing

  • Not run: bun is not installed in this runner image (bun typecheck failed with command not found). Suggested coverage: restore a composer draft containing a hub scratchlist attachment and assert the adapter does not call uploadScratchlistAttachment, preserves metadata for send, and can delete the existing hub blob on removal.

HAPI Bot

Comment thread web/src/lib/scratchlistAttachmentAdapter.ts
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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • None.

Questions

  • None.

Summary

  • Review mode: follow-up after new commits
  • No issues found in the current diff. Prior bot feedback about restored scratchlist uploads appears addressed in this head. Residual risk: attachment queue/promotion flows are covered by focused unit tests, but I did not run the full suite in this automation pass.

Testing

  • Not run (automation).

HAPI Bot

@heavygee

Copy link
Copy Markdown
Collaborator Author

Proof: promote-to-queue (Send) on a scratchlist entry with a hub attachment disgorges the file into the chat/queue path.

921-scratchlist-attachment-disgorge.mp4: https://github.com/heavygee/hapi/releases/download/pr-attach-proof-1205/proof-2026-07-28T21-01-40-921-scratchlist-attachment-disgorge.mp4

asset: heavygee/hapi@pr-attach-proof-1205/proof-2026-07-28T21-01-40-921-scratchlist-attachment-disgorge.mp4 · delete release after merge if desired

@tiann
tiann merged commit 4c203f1 into tiann:main Jul 29, 2026
2 checks passed
@heavygee
heavygee deleted the feat/scratchlist-attachments-v22 branch July 29, 2026 04:42
heavygee added a commit to heavygee/hapi that referenced this pull request Jul 29, 2026
Commit unfinished meta/fork WIP that was stranding hapi-sync-fork-main:
DROPPED tiann#1205/tiann#1217 soup layers + rich-composer tip note, Meta remat
playwright.config policy docs, hapi-from-active PATH wrapper + ping-peer
delegation, and in-use soup-heals 60/61.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

scratchlist: entries cannot include image attachments — text-only data model blocks image-bearing user input

2 participants