Skip to content

feat(attachments): persist original attachments in Hub - #1684

Open
techotaku39 wants to merge 4 commits into
tiann:mainfrom
techotaku39:feat/durable-attachments-pr1
Open

feat(attachments): persist original attachments in Hub#1684
techotaku39 wants to merge 4 commits into
tiann:mainfrom
techotaku39:feat/durable-attachments-pr1

Conversation

@techotaku39

Copy link
Copy Markdown
Contributor

Summary

  • Add Hub-managed durable storage for original attachment bytes.
  • Return opaque attachmentId references for new uploads.
  • Keep original attachment bytes available for Agent access and downloads.
  • Preserve compatibility with legacy path and previewUrl metadata.
  • Add lifecycle handling for session merge, fork, OpenCode clear, Codex duplicate-session merge, deletion, and orphan cleanup.
  • Do not add thumbnail generation, thumbnail storage, thumbnail APIs, or thumbnail-first rendering.

Problem / Motivation

Normal chat attachments previously depended on CLI-local temporary files and could persist large image Data URLs inside message metadata.

This made historical attachments fragile across session lifecycle operations and increased message payload size. The new implementation stores original bytes under Hub-managed storage and keeps only compact attachment metadata in new messages.

Implementation

  • Added a Hub AttachmentStore for original attachment bytes.
  • Added the attachments database table through the V25 → V26 migration.
  • Added authenticated original attachment routes for Web and CLI clients.
  • Added atomic writes, filename sanitization, size validation, and SHA-256 integrity metadata.
  • Added CLI-side materialization into session-scoped temporary files before Agent delivery.
  • Added ownership checks for namespace and session access.
  • Added attachment transfer and byte cloning for merge, fork, redirect, and OpenCode clear flows.
  • Added orphan cleanup after session deletion and during Hub startup.
  • Updated shared, Web, Android, and iOS attachment metadata to support attachmentId.
  • Kept legacy path and previewUrl messages readable.
  • New Web durable attachments load the authenticated original directly; no thumbnail path is used.

Scope

This PR intentionally does not include:

  • thumbnail generation;
  • thumbnail storage;
  • thumbnail download endpoints;
  • thumbnail-first rendering;
  • SHA-256 preflight deduplication;
  • reference counting;
  • delayed garbage collection;
  • quota accounting.

Content-addressed deduplication and full attachment lifecycle management remain PR2 work.

Validation

  • bun typecheck
  • bun run test:web — 272 files, 2871 tests passed
  • bun run test:shared — 298 tests passed
  • ✅ Targeted Hub attachment, migration, route, lifecycle, and Codex merge tests — 162 tests passed
  • ✅ Targeted CLI attachment materialization and message tests — 50 tests passed
  • ✅ Targeted Web attachment upload and original-loading tests — 14 tests passed
  • bun run gen:fixtures
  • bun run build
  • terminal-wrap-fidelity.spec.ts — 2/2 passed
  • git diff --check
  • ⚠️ Android Gradle tests were not run locally because this environment only provides Java 8, while the Gradle build requires JDK 17+.
  • ⚠️ iOS Swift tests were not run locally because swift and xcodebuild are unavailable on Windows; these suites are covered by repository CI.

Related Issues

Refs #1681

AI Disclosure

Implemented with assistance from OpenAI Codex using GPT-5.6. All generated changes were reviewed and tested by the contributor.

@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] Make attachment row/blob deletion crash-safe — the file is unlinked before the SQLite row is removed. A process exit or database error between those operations leaves a live attachment row whose original permanently returns 404; startup cleanup only finds rows whose session is missing. The inverse create window can also leave untracked .original files. Evidence hub/src/store/attachments.ts:172.

    Suggested fix:

    const result = this.db.prepare(
        'DELETE FROM attachments WHERE id = ? AND namespace = ? AND session_id = ?'
    ).run(id, namespace, sessionId)
    if (Number(result.changes) === 0) return false
    await rm(attachment.originalPath, { force: true })
    return true

    Add startup reconciliation that removes blob files absent from the table, so a crash after the row-first delete is recoverable.

  • [Major] Preserve attachment-bearing duplicate turns without appending a second message — knownKeys deliberately ignores attachments, but this branch bypasses dedup for every source message that has any attachment. Two HAPI rows for the same Codex transcript therefore merge into repeated user prompts, and each additional duplicate adds another copy. Evidence hub/src/web/routes/codexDesktop.ts:1332.

    Suggested fix:

    const existing = knownMessages.get(comparableKey)
    if (existing) {
        const copied = await store.attachments.cloneMessageAttachments(
            namespace, source.sessionId, canonical.sessionId, message.content, clonedAttachments
        )
        store.messages.updateMessageContent(
            existing.id,
            mergeUserAttachments(existing.content, copied)
        )
        continue
    }

    Keep one canonical row and merge/replace its attachment metadata instead of copying the whole turn.

Summary

  • Review mode: initial
  • Two major issues found: crash windows can leave durable attachments permanently unreadable or leaked, and Codex duplicate-session merge deterministically duplicates attachment-bearing user turns.

Testing

  • Not run (automation; static review only).

HAPI Bot

Comment thread hub/src/store/attachments.ts Outdated
const attachment = this.getForSession(id, namespace, sessionId)
if (!attachment) return false

await rm(attachment.originalPath, { force: true })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] Make row/blob deletion crash-safe

This unlinks the only copy before the SQLite row is removed. If the Hub exits here, or the subsequent DELETE fails, the row survives under a valid session but every read returns 404 permanently; cleanupOrphanedAttachments() will not reclaim it because the owning session still exists. Creation has the inverse crash window (renamed blob before row insert), which leaks an untracked file.

Suggested fix:

const result = this.db.prepare(
    'DELETE FROM attachments WHERE id = ? AND namespace = ? AND session_id = ?'
).run(id, namespace, sessionId)
if (Number(result.changes) === 0) return false
await rm(attachment.originalPath, { force: true })
return true

Then reconcile the attachment directory against table paths at startup, deleting files with no row.

Comment thread hub/src/web/routes/codexDesktop.ts Outdated
// Attachment-bearing messages must be copied even when their text
// matches the canonical transcript. Otherwise deleting the source
// session can delete the only durable bytes for that message.
if (knownKeys.has(comparableKey) && !hasUserAttachments(message.content)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] Do not duplicate an already-known user turn just because it has attachments

comparableKey for user messages is based on text, so this condition appends a second copy of the same Codex turn whenever the source copy has attachments. With multiple duplicate HAPI sessions, the merged transcript gains one repeated prompt per source row. Preserve the bytes by merging cloned attachment metadata into the existing canonical message instead of copying the entire message.

Suggested fix:

const existing = knownMessages.get(comparableKey)
if (existing) {
    const copied = await store.attachments.cloneMessageAttachments(
        namespace, source.sessionId, canonical.sessionId, message.content, clonedAttachments
    )
    store.messages.updateMessageContent(
        existing.id,
        mergeUserAttachments(existing.content, copied)
    )
    continue
}

@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] Durable attachment images are decoded at full resolution on the main actor — GeneratedImageLoader is @MainActor, so the new Task inherits that actor and UIImage(data:) decodes an original of up to 50 MB without the downsampling already used by the preview path. A large-dimension compressed image can therefore freeze or terminate the app when its message scrolls into view. Evidence: ios/Hapi/Features/Chat/Blocks/ChatBlockCard.swift:89.

    Suggested fix:

    guard let cgImage = await Self.decodeAttachment(payload.data) else {
        return nil
    }
    return UIImage(cgImage: cgImage)
    
    private nonisolated static func decodeAttachment(_ bytes: Data) async -> CGImage? {
        AttachmentPreparer.decodeDownsampled(
            bytes,
            maxDimension: AttachmentPolicy.previewMaxDimension
        )
    }

Summary

  • Review mode: follow-up after new commits
  • One Major issue found in the latest full diff. The two findings from the prior HAPI Bot review appear addressed by the new commits.

Testing

  • Not run (automation; static review only). Add an iOS regression test using a small encoded file with very large pixel dimensions and verify the durable path returns a bounded-size image.

HAPI Bot

) else {
return nil
}
return UIImage(data: payload.data)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MAJOR] Downsample durable originals off the main actor

GeneratedImageLoader is @MainActor, so this Task inherits the main actor. The endpoint permits originals up to 50 MB, and UIImage(data:) decodes the full pixel buffer here. A highly compressed, large-dimension image can therefore freeze or terminate the app merely when its message scrolls into view. The existing AttachmentPreviewView path avoids this with AttachmentPreparer.decodeDownsampled.

Suggested fix:

guard let cgImage = await Self.decodeAttachment(payload.data) else {
    return nil
}
return UIImage(cgImage: cgImage)

private nonisolated static func decodeAttachment(_ bytes: Data) async -> CGImage? {
    AttachmentPreparer.decodeDownsampled(
        bytes,
        maxDimension: AttachmentPolicy.previewMaxDimension
    )
}

@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.

Summary

  • Review mode: follow-up after new commits
  • No new issues found in the latest full diff. The prior Major finding at ios/Hapi/Features/Chat/Blocks/ChatBlockCard.swift:90 is addressed by routing durable originals through the existing bounded ImageIO downsampling path off the main actor.
  • Residual risk: the native iOS test suite was not run in this review environment; the main CI test job was still pending when reviewed.

Testing

  • Not run (automation; static review only). app-build, build, integration, package-tests, and drift-gate were passing; main test was pending.

HAPI Bot

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.

1 participant