From de67b57517a4ba6474aab02a423f21a68d6b8d4e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 10 Jul 2026 11:30:21 -0700 Subject: [PATCH] FN-7791: bridge image task attachments into the artifact registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image attachments added via TaskStore.addAttachment now surface as first-class image artifacts, reusing the existing artifact listing/SSE/media pipeline instead of duplicating bytes. - addAttachment() registers a URI-only "image" artifact (metadata.source: "attachment") pointing at the already-written attachments/ path whenever an image mimeType is attached; registration is best-effort and swallows the archived/soft-deleted-task rejection so addAttachment keeps its always-succeeds contract for valid images. - deleteAttachment() now removes any bridged artifact rows for a filename before deleting the attachment file, so /api/artifacts/:id/media can never point at a deleted attachment. - register-task-workflow-routes.ts's resolveArtifactMediaPath now accepts task-scoped attachments/ URIs (in addition to artifacts/) so the media route can stream bridged image-attachment artifacts; task-less artifacts remain restricted to .fusion/artifacts/. - docs/storage.md documents the attachment→artifact bridge behavior and the media route's accepted URI prefixes. - Added a changeset (@runfusion/fusion: minor) describing the user-facing Artifacts view change. - Extended store-attachments and artifacts-route-integration tests to cover the new bridging and deletion behavior. Files changed: .changeset/fn-7791-image-attachments-artifacts.md | 7 +++ docs/storage.md | 3 +- packages/core/src/__tests__/store-attachments.test.ts | 59 +++++++++++++++++++++- packages/core/src/store.ts | 58 ++++++++++++++++++++- packages/dashboard/src/routes/__tests__/artifacts-route-integration.test.ts | 40 +++++++++++++++ packages/dashboard/src/routes/register-task-workflow-routes.ts | 9 +++- 6 files changed, 172 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-7791 Fusion-Task-Lineage: 4df47880-6161-4a8b-933a-2f6fc2fed953 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7791-image-attachments-artifacts.md | 7 +++ docs/storage.md | 3 +- .../src/__tests__/store-attachments.test.ts | 59 ++++++++++++++++++- packages/core/src/store.ts | 58 +++++++++++++++++- .../artifacts-route-integration.test.ts | 40 +++++++++++++ .../routes/register-task-workflow-routes.ts | 9 ++- 6 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 .changeset/fn-7791-image-attachments-artifacts.md diff --git a/.changeset/fn-7791-image-attachments-artifacts.md b/.changeset/fn-7791-image-attachments-artifacts.md new file mode 100644 index 0000000000..cc2b46e5ed --- /dev/null +++ b/.changeset/fn-7791-image-attachments-artifacts.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Image attachments now appear in the Artifacts view as artifacts. +category: feature +dev: Bridges TaskStore.addAttachment image files into artifact rows with attachment-backed media URIs. diff --git a/docs/storage.md b/docs/storage.md index 0538c9bc3f..cd989b059a 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -76,7 +76,8 @@ - `artifacts` is the first-class metadata registry for generated or uploaded task artifacts. Rows store ID, `type` (`document`, `image`, `video`, `audio`, or `other`), title/description, MIME type, size, author identity/type, optional task linkage, metadata JSON, textual `content`, a relative `uri`, and timestamps; binary bytes are not stored in SQLite. - `TaskStore.registerArtifact()` writes task-scoped binary payloads under `/.fusion/tasks/{ID}/artifacts/` and task-less registry payloads under `/.fusion/artifacts/`, then records a relative `artifacts/` URI in SQLite. If the DB insert fails after a binary write, the store removes the orphaned file before surfacing the error. -- Inline text/document artifacts may store `content` directly in SQLite and therefore have no media file. The dashboard media route streams `GET /api/artifacts/:id/media` from disk when `uri` is present, or returns inline `content` with the persisted MIME type when no `uri` exists. +- Image task attachments (`image/png`, `image/jpeg`, `image/gif`, `image/webp`) are bridged into the artifact registry by `TaskStore.addAttachment()` as `image` rows with `metadata.source: "attachment"` and a relative `attachments/` URI. This keeps one copy of the bytes under `/.fusion/tasks/{ID}/attachments/` while making the image discoverable through artifact list APIs and the Documents/Task Artifacts galleries. Non-image attachments remain attachment-only. Deleting an attachment also deletes its bridged artifact row before removing the attachment file so `/api/artifacts/:id/media` does not point at a deleted attachment. +- Inline text/document artifacts may store `content` directly in SQLite and therefore have no media file. The dashboard media route streams `GET /api/artifacts/:id/media` from disk when `uri` is present, accepting task-scoped artifact URIs under `artifacts/` and bridged image-attachment URIs under `attachments/`, or returns inline `content` with the persisted MIME type when no `uri` exists. - `getArtifact(id)` returns metadata by ID, `getArtifacts(taskId)` returns active-task artifacts newest-first, and `listArtifacts(...)` is the cross-agent query path with type/author/task/search filters and pagination. List reads hide artifacts whose parent task is soft-deleted while preserving task-less artifacts. - Task-linked artifact registration requires an active, non-archived task. Archived tasks are read-only for artifact writes; soft-deleted or missing tasks are rejected. - Retention follows the existing task lifecycle rather than a separate artifact policy: soft-deleted parent tasks keep artifact rows/files for forensics but normal live-reader APIs hide them; hard deletion from the active `tasks` table cascades artifact metadata through the `taskId` foreign key, and archive cleanup removes the task directory that contains task-scoped artifact binaries. Task-less artifacts live under `/.fusion/artifacts/` and are not tied to task archival cleanup. diff --git a/packages/core/src/__tests__/store-attachments.test.ts b/packages/core/src/__tests__/store-attachments.test.ts index 988eec5763..9ee6b6bdcb 100644 --- a/packages/core/src/__tests__/store-attachments.test.ts +++ b/packages/core/src/__tests__/store-attachments.test.ts @@ -39,11 +39,66 @@ describe("TaskStore", () => { expect(content).toEqual(TINY_PNG); }); + /* + * FNXC:ArtifactRegistry 2026-07-10-00:00: + * FN-7791 pins the operator requirement that an image created through the task attachment path becomes exactly one task-associated image artifact, while non-image attachments remain attachment-only and do not pollute the image artifact gallery. + */ + it("bridges image attachments into task-associated image artifacts only", async () => { + const emptyTask = await harness.store().createTask({ title: "No attachment artifacts", description: "No attachment artifacts" }); + expect(await harness.store().listArtifacts({ taskId: emptyTask.id })).toEqual([]); + + const task = await harness.store().createTask({ title: "Mixed attachment artifacts", description: "Mixed attachment artifacts" }); + const first = await harness.store().addAttachment(task.id, "first.png", TINY_PNG, "image/png"); + const text = await harness.store().addAttachment(task.id, "notes.txt", Buffer.from("not an image"), "text/plain"); + const second = await harness.store().addAttachment(task.id, "second.webp", TINY_PNG, "image/webp"); + + const artifacts = await harness.store().listArtifacts({ taskId: task.id }); + expect(artifacts).toHaveLength(2); + expect(artifacts.map((artifact) => artifact.type)).toEqual(["image", "image"]); + expect(artifacts.map((artifact) => artifact.title).sort()).toEqual(["first.png", "second.webp"]); + expect(artifacts.map((artifact) => artifact.taskId)).toEqual([task.id, task.id]); + expect(artifacts.map((artifact) => artifact.taskTitle)).toEqual([task.title, task.title]); + expect(artifacts.find((artifact) => artifact.title === text.originalName)).toBeUndefined(); + expect(artifacts.find((artifact) => artifact.title === "first.png")).toMatchObject({ + mimeType: "image/png", + sizeBytes: first.size, + uri: `attachments/${first.filename}`, + authorId: "attachment", + authorType: "system", + metadata: { + source: "attachment", + attachmentFilename: first.filename, + originalName: "first.png", + }, + }); + expect(artifacts.find((artifact) => artifact.title === "second.webp")).toMatchObject({ + mimeType: "image/webp", + sizeBytes: second.size, + uri: `attachments/${second.filename}`, + }); + }); + + /* + * FNXC:ArtifactRegistry 2026-07-10-00:00: + * registerArtifact() rejects task-linked writes for archived tasks. addAttachment must not let that rejection propagate and undo/orphan the already-written attachment file + task.json entry: addAttachment's own contract (succeeds for a valid image regardless of task lifecycle state) predates this bridge and must be preserved, with only the artifact-gallery bridge skipped. + */ + it("still succeeds and returns the attachment when the task is archived (artifact bridge is best-effort)", async () => { + const task = await harness.createTestTask(); + await harness.store().archiveTask(task.id, false); + + const attachment = await harness.store().addAttachment(task.id, "archived-shot.png", TINY_PNG, "image/png"); + expect(attachment.originalName).toBe("archived-shot.png"); + + const updated = await harness.store().getTask(task.id, { includeDeleted: true }); + expect(updated.attachments).toHaveLength(1); + }); + it("accepts text/plain mime type", async () => { const task = await harness.createTestTask(); const attachment = await harness.store().addAttachment(task.id, "error.log", Buffer.from("log content"), "text/plain"); expect(attachment.originalName).toBe("error.log"); expect(attachment.mimeType).toBe("text/plain"); + expect(await harness.store().listArtifacts({ taskId: task.id })).toEqual([]); }); it("accepts application/json mime type", async () => { @@ -82,9 +137,10 @@ describe("TaskStore", () => { expect(result.path).toContain(attachment.filename); }); - it("deletes an attachment from disk and metadata", async () => { + it("deletes an attachment from disk, metadata, and its bridged artifact", async () => { const task = await harness.createTestTask(); const attachment = await harness.store().addAttachment(task.id, "del.png", TINY_PNG, "image/png"); + expect(await harness.store().listArtifacts({ taskId: task.id })).toHaveLength(1); const updated = await harness.store().deleteAttachment(task.id, attachment.filename); expect(updated.attachments).toBeUndefined(); @@ -92,6 +148,7 @@ describe("TaskStore", () => { // Verify file removed from disk const filePath = join(harness.rootDir(), ".fusion", "tasks", task.id, "attachments", attachment.filename); expect(existsSync(filePath)).toBe(false); + expect(await harness.store().listArtifacts({ taskId: task.id })).toEqual([]); }); it("throws ENOENT when getting non-existent attachment", async () => { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 5c2b1ee24f..415a000f95 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -13094,7 +13094,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} ); } - return this.withTaskLock(id, async () => { + const attachment = await this.withTaskLock(id, async () => { const dir = this.taskDir(id); const attachDir = join(dir, "attachments"); await mkdir(attachDir, { recursive: true }); @@ -13123,6 +13123,60 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return attachment; }); + + if (mimeType.startsWith("image/")) { + /* + * FNXC:ArtifactRegistry 2026-07-10-00:00: + * FN-7791 requires image task attachments created by agents, dashboard uploads, and route callers to surface as normal image artifacts. Register a URI-only artifact that points at the already-written attachment file so the proven artifact listing/SSE/media pipeline is reused without duplicating bytes or re-entering addAttachment. + * + * FNXC:ArtifactRegistry 2026-07-10-00:00: + * registerArtifact() enforces the artifact-registry active/non-archived task rule (see registerArtifact's ACTIVE_TASKS_WHERE check), but addAttachment has never enforced that rule for attachments themselves — attachments may be added to archived or soft-deleted tasks. Without this guard, attaching an image to an archived/soft-deleted task would throw here AFTER the attachment file and task.json were already written, so the caller would see addAttachment fail even though the attachment actually succeeded. Bridging into the artifact registry is best-effort: swallow the expected archived/not-found rejection so addAttachment keeps its existing always-succeeds-for-a-valid-image contract, and only the artifact-gallery bridge is skipped. + */ + try { + await this.registerArtifact({ + type: "image", + title: attachment.originalName, + description: "Image task attachment", + mimeType, + sizeBytes: attachment.size, + uri: `attachments/${attachment.filename}`, + authorId: "attachment", + authorType: "system", + taskId: id, + metadata: { + source: "attachment", + attachmentFilename: attachment.filename, + originalName: attachment.originalName, + }, + }); + } catch (err) { + console.warn( + `[fusion:store] Skipping artifact bridge for attachment ${attachment.filename} on task ${id}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + return attachment; + } + + private async deleteAttachmentArtifactRows(taskId: string, filename: string): Promise { + const rows = this.db + .prepare("SELECT * FROM artifacts WHERE taskId = ?") + .all(taskId) as unknown as ArtifactRow[]; + const linkedArtifactIds = rows + .map((row) => this.rowToArtifact(row)) + .filter((artifact) => artifact.metadata?.source === "attachment" && artifact.metadata.attachmentFilename === filename) + .map((artifact) => artifact.id); + + if (linkedArtifactIds.length === 0) { + return; + } + + const deleteArtifact = this.db.prepare("DELETE FROM artifacts WHERE id = ?"); + for (const artifactId of linkedArtifactIds) { + deleteArtifact.run(artifactId); + } + this.db.bumpLastModified(); } async getAttachment( @@ -13158,6 +13212,8 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} throw err; } + await this.deleteAttachmentArtifactRows(id, filename); + // Remove file from disk const filePath = join(dir, "attachments", filename); try { diff --git a/packages/dashboard/src/routes/__tests__/artifacts-route-integration.test.ts b/packages/dashboard/src/routes/__tests__/artifacts-route-integration.test.ts index 0c5719630b..da2b287cb6 100644 --- a/packages/dashboard/src/routes/__tests__/artifacts-route-integration.test.ts +++ b/packages/dashboard/src/routes/__tests__/artifacts-route-integration.test.ts @@ -175,6 +175,46 @@ describe("artifacts route integration", () => { expect(mediaRes.body).toEqual(PNG_IMAGE_BYTES); }); + /* + * FNXC:ArtifactRegistry 2026-07-10-00:00: + * FN-7791 pins the missing attachment-to-artifact bridge at the route/media boundary: a real PNG stored by TaskStore.addAttachment must list through GET /api/artifacts with task metadata and stream the original attachment bytes via /media. + */ + it("an attachment-sourced image artifact lists and streams through the default server scope", async () => { + const task = await store.createTask({ + title: "Task agent attachment", + description: "A task agent attached a screenshot", + }); + const attachment = await store.addAttachment(task.id, "agent-shot.png", PNG_IMAGE_BYTES, "image/png"); + await store.addAttachment(task.id, "agent-notes.txt", Buffer.from("not surfaced as an image artifact"), "text/plain"); + + const listRes = await REQUEST(app, "GET", "/api/artifacts"); + + expect(listRes.status).toBe(200); + const body = listRes.body as ArtifactWithTask[]; + expect(body).toHaveLength(1); + expect(body[0]).toMatchObject({ + type: "image", + title: "agent-shot.png", + mimeType: "image/png", + sizeBytes: PNG_IMAGE_BYTES.length, + uri: `attachments/${attachment.filename}`, + authorId: "attachment", + authorType: "system", + taskId: task.id, + taskTitle: "Task agent attachment", + }); + + const taskScopedRes = await REQUEST(app, "GET", `/api/artifacts?taskId=${encodeURIComponent(task.id)}&type=image`); + expect(taskScopedRes.status).toBe(200); + expect(taskScopedRes.body).toHaveLength(1); + expect((taskScopedRes.body as ArtifactWithTask[])[0].id).toBe(body[0].id); + + const mediaRes = await requestRawBuffer(app, `/api/artifacts/${body[0].id}/media`); + expect(mediaRes.status).toBe(200); + expect(mediaRes.headers["content-type"]).toBe("image/png"); + expect(mediaRes.body).toEqual(PNG_IMAGE_BYTES); + }); + it("a global image artifact still streams from the managed global artifacts directory", async () => { const imageBytes = PNG_IMAGE_BYTES; const artifact = await store.registerArtifact({ diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 3bdae6eece..731926e4df 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -109,8 +109,15 @@ function resolveArtifactMediaPath(scopedStore: TaskStore, artifact: { taskId?: s const anchorDir = artifact.taskId ? scopedStore.getTaskDir(artifact.taskId) : scopedStore.getFusionDir(); const expectedArtifactsDir = resolve(anchorDir, "artifacts"); + const expectedAttachmentsDir = artifact.taskId ? resolve(anchorDir, "attachments") : null; const mediaPath = resolve(anchorDir, artifact.uri); - if (mediaPath !== expectedArtifactsDir && !mediaPath.startsWith(`${expectedArtifactsDir}${sep}`)) { + const underArtifacts = mediaPath === expectedArtifactsDir || mediaPath.startsWith(`${expectedArtifactsDir}${sep}`); + const underAttachments = expectedAttachmentsDir !== null && (mediaPath === expectedAttachmentsDir || mediaPath.startsWith(`${expectedAttachmentsDir}${sep}`)); + /* + * FNXC:ArtifactRegistry 2026-07-10-00:00: + * Attachment-sourced image artifacts intentionally store `attachments/` URIs so /media streams the original task attachment bytes without a second artifact copy. Keep the resolver anchored to task-owned artifact/attachment directories only; task-less artifacts still resolve exclusively under `.fusion/artifacts/`. + */ + if (!underArtifacts && !underAttachments) { throw badRequest("Invalid artifact media path"); } return mediaPath;