From f0ea17323b8e90453b48c79cbf76372102fff869 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 01:21:21 +0800 Subject: [PATCH 01/35] fix(storage): ignore retired artifact metadata --- .../sqlite-artifact-metadata.test.ts | 42 +++++++++++++++++++ .../storage/src/artifact-metadata-codec.ts | 16 +++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/storage/src/__tests__/sqlite-artifact-metadata.test.ts b/packages/storage/src/__tests__/sqlite-artifact-metadata.test.ts index a9214898ab..3325f6e362 100644 --- a/packages/storage/src/__tests__/sqlite-artifact-metadata.test.ts +++ b/packages/storage/src/__tests__/sqlite-artifact-metadata.test.ts @@ -89,6 +89,48 @@ test('Artifact metadata changes only write changed rows', async () => { } }); +test('Artifact metadata recovery ignores records from unsupported sources', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-artifact-metadata-unsupported-')); + const repository = createSqliteArtifactMetadataRepository(root); + let inspector: DatabaseSync | undefined; + try { + const supported = artifactRecord('supported'); + repository.applyChanges({ upserts: [supported] }); + + const unsupported = { + ...artifactRecord('unsupported'), + source: 'retired_artifact_source', + }; + inspector = new DatabaseSync(join(root, OPERATIONAL_STATE_DATABASE_NAME)); + const insert = inspector.prepare(` + INSERT INTO artifact_records( + storage_key, + artifact_id, + session_id, + created_at, + status, + relative_path, + record_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `); + insert.run( + `${unsupported.id}-storage-key`, + unsupported.id, + unsupported.sessionId, + unsupported.createdAt, + unsupported.status, + unsupported.relativePath, + JSON.stringify(unsupported), + ); + + assert.deepEqual(repository.readAll(), [supported]); + } finally { + inspector?.close(); + repository.close(); + await rm(root, { recursive: true, force: true }); + } +}); + function artifactRecord(id: string): ArtifactRecord { return { id, diff --git a/packages/storage/src/artifact-metadata-codec.ts b/packages/storage/src/artifact-metadata-codec.ts index e5f9d05b2d..732689c8cc 100644 --- a/packages/storage/src/artifact-metadata-codec.ts +++ b/packages/storage/src/artifact-metadata-codec.ts @@ -56,7 +56,9 @@ export function decodeArtifactRecordJsons(values: readonly unknown[]): ArtifactR for (const [index, value] of values.entries()) { try { if (typeof value !== 'string') throw invalidMetadataRecord(index + 1); - const record = decodeArtifactRecord(JSON.parse(value), index + 1); + const parsed = JSON.parse(value); + if (!hasSupportedArtifactSource(parsed)) continue; + const record = decodeArtifactRecord(parsed, index + 1); if (ids.has(record.id)) throw invalidMetadataRecord(index + 1); ids.add(record.id); records.push(record); @@ -70,6 +72,14 @@ export function decodeArtifactRecordJsons(values: readonly unknown[]): ArtifactR return records; } +function hasSupportedArtifactSource(value: unknown): boolean { + return ( + isRecord(value) && + (value.source === undefined || + (typeof value.source === 'string' && ARTIFACT_SOURCE_SET.has(value.source as ArtifactSource))) + ); +} + export function isSafeRelativeArtifactPath(relativePath: string): boolean { if (!relativePath || isAbsolute(relativePath)) return false; if (relativePath.includes('\0')) return false; @@ -115,9 +125,7 @@ function decodeArtifactRecord(value: unknown, index: number): ArtifactRecord { !isOptionalNonEmptyString(value.mimeType) || !isOptionalNonEmptyString(value.summary) || (value.deepResearchRole !== undefined && !isDeepResearchArtifactRole(value.deepResearchRole)) || - (value.source !== undefined && - (typeof value.source !== 'string' || - !ARTIFACT_SOURCE_SET.has(value.source as ArtifactSource))) + (value.source !== undefined && typeof value.source !== 'string') ) { throw invalidMetadataRecord(index); } From 134c1972265161e60dc6f7b758de2bc393a5158f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 01:24:31 +0800 Subject: [PATCH 02/35] fix(runtime): return only child output artifacts --- packages/core/src/__tests__/artifacts.test.ts | 9 ++++++ packages/core/src/artifacts.ts | 11 +++++++ .../src/__tests__/session-manager.test.ts | 28 ++++++++++++++++ packages/runtime/src/session-manager.ts | 32 ++++++++++--------- 4 files changed, 65 insertions(+), 15 deletions(-) diff --git a/packages/core/src/__tests__/artifacts.test.ts b/packages/core/src/__tests__/artifacts.test.ts index 53f5d4b2fb..c471dec4d7 100644 --- a/packages/core/src/__tests__/artifacts.test.ts +++ b/packages/core/src/__tests__/artifacts.test.ts @@ -23,6 +23,7 @@ import { ARTIFACT_ENTITY_ID_MAX_CHARS, ARTIFACT_TURN_KEY_MAX_CHARS, canUserDeleteArtifact, + isArtifactChildResultOutput, isArtifactSharedSessionReadable, isArtifactUserVisible, isArtifactTurnKey, @@ -65,6 +66,14 @@ describe('Artifact user-delete policy', () => { }); describe('Artifact source policy', () => { + test('includes produced outputs in child results without leaking internal artifacts', () => { + assert.equal(isArtifactChildResultOutput({ source: 'tool_result' }), true); + assert.equal(isArtifactChildResultOutput({ source: 'subagent_writeback' }), true); + assert.equal(isArtifactChildResultOutput({ source: 'tool_result_archive' }), false); + assert.equal(isArtifactChildResultOutput({ source: 'user_upload' }), false); + assert.equal(isArtifactChildResultOutput({ source: undefined }), false); + }); + test('keeps projection artifacts internal, durable, and readable in shared sessions', () => { const projection = { source: 'tool_result_projection' as const }; diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts index 9fd6bcf5c2..8fa95d0271 100644 --- a/packages/core/src/artifacts.ts +++ b/packages/core/src/artifacts.ts @@ -178,6 +178,13 @@ const ARTIFACT_SOURCE_POLICIES = { fixture: { userDeletable: true, userVisible: true, sharedReadable: false }, } as const satisfies Record; +const CHILD_RESULT_OUTPUT_SOURCES = new Set([ + 'tool_result', + 'tool_result_projection', + 'subagent_writeback', + 'deep_research', +]); + export function canUserDeleteArtifact(record: Pick): boolean { return record.source === undefined || ARTIFACT_SOURCE_POLICIES[record.source].userDeletable; } @@ -190,6 +197,10 @@ export function isArtifactSharedSessionReadable(record: Pick): boolean { + return record.source !== undefined && CHILD_RESULT_OUTPUT_SOURCES.has(record.source); +} + export type ArtifactChangedReason = 'created' | 'deleted' | 'purged'; export interface ArtifactChangedEvent { diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 4aff33d521..4e6d6c11cc 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -1671,6 +1671,32 @@ describe('SessionManager claimed graph intent execution', () => { runtimeEventStore: runStore, backends, childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], + listArtifactsForTurn: async (sessionId, turnId) => [ + { + id: 'child-output', + sessionId, + turnId, + createdAt: 98, + name: 'answer.txt', + kind: 'file', + relativePath: `${sessionId}/child-output-answer.txt`, + sizeBytes: 6, + source: 'tool_result', + status: 'live', + }, + { + id: 'child-internal-archive', + sessionId, + turnId, + createdAt: 99, + name: 'tool-result.json', + kind: 'file', + relativePath: `${sessionId}/child-internal-archive-tool-result.json`, + sizeBytes: 12, + source: 'tool_result_archive', + status: 'live', + }, + ], newId: nextId(), now: nextNow(40), }); @@ -1705,6 +1731,7 @@ describe('SessionManager claimed graph intent execution', () => { status: 'completed', summary: 'ok', }); + assert.deepStrictEqual(result.artifactIds, ['child-output']); assert.deepStrictEqual(ready, [ { claimId: claim.claimId, @@ -9733,6 +9760,7 @@ describe('SessionManager permission mode updates', () => { kind: 'file', relativePath: 'artifacts/notes.md', sizeBytes: 12, + source: 'tool_result', status: 'live', }, ] diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 91bd2d1021..38ac4474e2 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -119,7 +119,7 @@ import type { } from '@maka/core/agent-graph-topology'; import type { AgentGraphScheduleUpdateSource } from '@maka/core/agent-graph-schedule'; import type { AgentRunEvent, AgentRunStore } from '@maka/core/agent-run'; -import type { ArtifactRecord } from '@maka/core/artifacts'; +import { isArtifactChildResultOutput, type ArtifactRecord } from '@maka/core/artifacts'; import { invocationMatchesClaimTarget } from '@maka/core/runtime-boundary'; import type { ContinuationClaimV1 } from '@maka/core/runtime-boundary'; import { @@ -1027,21 +1027,23 @@ export class SessionManager { ): Promise { const list = this.deps.listArtifactsForTurn; if (!list) return []; - const artifacts = await list(sessionId, turnId); - if (!isTerminalRunStatus(status) || !this.hasWorktreePatchWriteBack()) return artifacts; - const header = await this.deps.store.readHeader(sessionId); - if (!header.subagentWorkspace) return artifacts; - const existing = artifacts.find((artifact) => artifact.source === 'subagent_writeback'); - if (existing) return artifacts; - - await this.finalizeChildWorkspacePatches(sessionId); - const finalized = await list(sessionId, turnId); - if (!finalized.some((artifact) => artifact.source === 'subagent_writeback')) { - throw new Error( - `Child Session ${sessionId} cannot reconstruct the historical workspace patch for Turn ${turnId}`, - ); + let artifacts = await list(sessionId, turnId); + if (isTerminalRunStatus(status) && this.hasWorktreePatchWriteBack()) { + const header = await this.deps.store.readHeader(sessionId); + if ( + header.subagentWorkspace && + !artifacts.some((artifact) => artifact.source === 'subagent_writeback') + ) { + await this.finalizeChildWorkspacePatches(sessionId); + artifacts = await list(sessionId, turnId); + if (!artifacts.some((artifact) => artifact.source === 'subagent_writeback')) { + throw new Error( + `Child Session ${sessionId} cannot reconstruct the historical workspace patch for Turn ${turnId}`, + ); + } + } } - return finalized; + return artifacts.filter(isArtifactChildResultOutput); } /** From 352fa3294889cdbebc00f22a0a7ba003ac6377d9 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 01:26:42 +0800 Subject: [PATCH 03/35] refactor(core): remove unused artifact sources --- packages/core/src/artifacts.ts | 12 ------------ .../src/__tests__/artifact-protocol.test.ts | 2 +- .../src/__tests__/artifact-two-client-uds.test.ts | 10 +++++----- .../__tests__/session-catalog-two-client-uds.test.ts | 4 ++-- .../storage/src/__tests__/artifact-store.test.ts | 12 ++++++------ .../src/__tests__/operational-state-backup.test.ts | 4 ++-- .../src/__tests__/sqlite-artifact-metadata.test.ts | 2 +- 7 files changed, 17 insertions(+), 29 deletions(-) diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts index 8fa95d0271..117961894f 100644 --- a/packages/core/src/artifacts.ts +++ b/packages/core/src/artifacts.ts @@ -90,17 +90,11 @@ export const ARTIFACT_SOURCES = [ 'tool_result', 'tool_result_projection', 'tool_result_archive', - 'synthesis_cache_block', - 'history_compact_block', - 'history_compact_source', 'provider_request_capture', 'subagent_writeback', 'deep_research', 'user_upload', - 'export', - 'snapshot', 'session_effect', - 'fixture', ] as const; export type ArtifactSource = (typeof ARTIFACT_SOURCES)[number]; @@ -163,19 +157,13 @@ const ARTIFACT_SOURCE_POLICIES = { tool_result: { userDeletable: true, userVisible: false, sharedReadable: true }, tool_result_projection: { userDeletable: false, userVisible: false, sharedReadable: true }, tool_result_archive: { userDeletable: false, userVisible: false, sharedReadable: false }, - synthesis_cache_block: { userDeletable: true, userVisible: false, sharedReadable: false }, - history_compact_block: { userDeletable: true, userVisible: false, sharedReadable: false }, - history_compact_source: { userDeletable: true, userVisible: false, sharedReadable: false }, // Historical only: nothing produces these any more. The policy stays so the // records already on disk keep decoding and stay deletable. provider_request_capture: { userDeletable: true, userVisible: false, sharedReadable: false }, subagent_writeback: { userDeletable: false, userVisible: true, sharedReadable: false }, deep_research: { userDeletable: false, userVisible: true, sharedReadable: false }, user_upload: { userDeletable: true, userVisible: false, sharedReadable: true }, - export: { userDeletable: true, userVisible: true, sharedReadable: false }, - snapshot: { userDeletable: true, userVisible: true, sharedReadable: false }, session_effect: { userDeletable: false, userVisible: false, sharedReadable: false }, - fixture: { userDeletable: true, userVisible: true, sharedReadable: false }, } as const satisfies Record; const CHILD_RESULT_OUTPUT_SOURCES = new Set([ diff --git a/packages/runtime-host/src/__tests__/artifact-protocol.test.ts b/packages/runtime-host/src/__tests__/artifact-protocol.test.ts index aa4391e2b1..e8be5b1175 100644 --- a/packages/runtime-host/src/__tests__/artifact-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/artifact-protocol.test.ts @@ -413,7 +413,7 @@ function validArtifact() { kind: 'file' as const, sizeBytes: 4, mimeType: 'text/plain', - source: 'fixture' as const, + source: 'tool_result' as const, summary: 'bounded', status: 'live' as const, }; diff --git a/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts index bdabdb8598..cba5372baa 100644 --- a/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts @@ -318,7 +318,7 @@ async function seedExecutionRoot( kind: 'file', content: `content ${index}`, mimeType: 'text/plain', - source: 'fixture', + source: 'tool_result', now: 10_000 - index, }), ), @@ -330,7 +330,7 @@ async function seedExecutionRoot( kind: 'file', content: 'small text preview', mimeType: 'text/plain', - source: 'fixture', + source: 'tool_result', now: 20_000, }), openedArtifacts.create({ @@ -341,7 +341,7 @@ async function seedExecutionRoot( kind: 'image', content: tinyPng(), mimeType: 'image/png', - source: 'fixture', + source: 'tool_result', now: 19_999, }), openedArtifacts.create({ @@ -352,7 +352,7 @@ async function seedExecutionRoot( kind: 'file', content: Buffer.alloc(12 * 1024, 0xff), mimeType: 'text/plain', - source: 'fixture', + source: 'tool_result', now: 9_000, }), ...PROTECTED_ARTIFACTS.map((artifact, index) => @@ -378,7 +378,7 @@ async function seedExecutionRoot( kind: 'file', content: `bulk artifact payload ${index}`, mimeType: 'text/plain', - source: 'fixture', + source: 'tool_result', summary: BULK_ARTIFACT_SUMMARY, now: 19_000 - index, }); diff --git a/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts index 9f4415e2df..0bdf2b965c 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-two-client-uds.test.ts @@ -897,7 +897,7 @@ async function seedAuthority( kind: 'file', content: 'remove me', mimeType: 'text/plain', - source: 'fixture', + source: 'tool_result', now: 1, }), artifacts.create({ @@ -908,7 +908,7 @@ async function seedAuthority( kind: 'file', content: 'recover cleanup', mimeType: 'text/plain', - source: 'fixture', + source: 'tool_result', now: 2, }), todos.replaceAll(retirement.id, [{ content: 'Remove retirement task', status: 'pending' }]), diff --git a/packages/storage/src/__tests__/artifact-store.test.ts b/packages/storage/src/__tests__/artifact-store.test.ts index 15e6eb4334..5e16159679 100644 --- a/packages/storage/src/__tests__/artifact-store.test.ts +++ b/packages/storage/src/__tests__/artifact-store.test.ts @@ -347,7 +347,7 @@ describe('SQLite Artifact store', () => { assert.ok(copied.artifactIds.get(kept.id)); assert.deepEqual( (await store.list('session-copy', { includeDeleted: true })).map((record) => record.source), - ['fixture'], + ['tool_result'], ); // And the source keeps its own until the sweep takes them. assert.deepEqual(await store.purgeRetiredCaptures(8), { purged: 1, remaining: 0 }); @@ -389,7 +389,7 @@ describe('SQLite Artifact store', () => { assert.equal(copied.artifactIds.get('upload-capture'), undefined); assert.deepEqual( (await store.list('session-copy', { includeDeleted: true })).map((record) => record.source), - ['fixture'], + ['tool_result'], ); // A child result lists every Artifact its turn held, captures included, @@ -817,7 +817,7 @@ describe('SQLite Artifact store', () => { kind: 'file', content: bytes, mimeType: 'application/octet-stream', - source: 'fixture', + source: 'tool_result', summary: 'accepted summary', now: 7, }; @@ -848,7 +848,7 @@ describe('SQLite Artifact store', () => { relativePath: 'session-1/accepted-id-accepted.bin', sizeBytes: 4, mimeType: 'application/octet-stream', - source: 'fixture', + source: 'tool_result', summary: 'accepted summary', status: 'live', }); @@ -1657,7 +1657,7 @@ function artifactInput(id: string, content: string | Uint8Array, now: number) { name: `${id}.txt`, kind: 'file' as const, content, - source: 'fixture' as const, + source: 'tool_result' as const, now, }; } @@ -1692,7 +1692,7 @@ function canonicalRecord(input: { kind: 'file', relativePath: `${input.sessionId}/${input.id}-${input.name}`, sizeBytes: input.sizeBytes, - source: 'fixture', + source: 'tool_result', status: 'live', }; } diff --git a/packages/storage/src/__tests__/operational-state-backup.test.ts b/packages/storage/src/__tests__/operational-state-backup.test.ts index 9965b9e7c3..80bb513c50 100644 --- a/packages/storage/src/__tests__/operational-state-backup.test.ts +++ b/packages/storage/src/__tests__/operational-state-backup.test.ts @@ -78,7 +78,7 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => { name: 'note.txt', kind: 'file', content: 'artifact', - source: 'fixture', + source: 'tool_result', now: 2, }); artifacts.close?.(); @@ -131,7 +131,7 @@ test('rejects a backup whose SQLite Artifact metadata has no matching payload', name: 'note.txt', kind: 'file', content: 'artifact', - source: 'fixture', + source: 'tool_result', now: 2, }); artifacts.close?.(); diff --git a/packages/storage/src/__tests__/sqlite-artifact-metadata.test.ts b/packages/storage/src/__tests__/sqlite-artifact-metadata.test.ts index 3325f6e362..06c4f843d4 100644 --- a/packages/storage/src/__tests__/sqlite-artifact-metadata.test.ts +++ b/packages/storage/src/__tests__/sqlite-artifact-metadata.test.ts @@ -141,7 +141,7 @@ function artifactRecord(id: string): ArtifactRecord { kind: 'file', sizeBytes: id.length, relativePath: `session-1/${id}-${id}.txt`, - source: 'fixture', + source: 'tool_result', status: 'live', }; } From 203090770fab99e56c18fd2c7a4f1833dc046ec6 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 01:28:29 +0800 Subject: [PATCH 04/35] refactor(storage): remove retired capture sweep --- packages/core/src/artifacts.ts | 4 - .../__tests__/artifact-coordinator.test.ts | 2 +- .../src/server/execution-composition.ts | 22 +- .../session-revision-graph-references.ts | 3 +- .../src/__tests__/artifact-store.test.ts | 118 ----------- .../src/__tests__/artifact-stores.test.ts | 195 ------------------ packages/storage/src/artifact-store.ts | 54 +---- packages/storage/src/artifact-stores.ts | 83 -------- 8 files changed, 6 insertions(+), 475 deletions(-) diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts index 117961894f..2c2ad4c79c 100644 --- a/packages/core/src/artifacts.ts +++ b/packages/core/src/artifacts.ts @@ -90,7 +90,6 @@ export const ARTIFACT_SOURCES = [ 'tool_result', 'tool_result_projection', 'tool_result_archive', - 'provider_request_capture', 'subagent_writeback', 'deep_research', 'user_upload', @@ -157,9 +156,6 @@ const ARTIFACT_SOURCE_POLICIES = { tool_result: { userDeletable: true, userVisible: false, sharedReadable: true }, tool_result_projection: { userDeletable: false, userVisible: false, sharedReadable: true }, tool_result_archive: { userDeletable: false, userVisible: false, sharedReadable: false }, - // Historical only: nothing produces these any more. The policy stays so the - // records already on disk keep decoding and stay deletable. - provider_request_capture: { userDeletable: true, userVisible: false, sharedReadable: false }, subagent_writeback: { userDeletable: false, userVisible: true, sharedReadable: false }, deep_research: { userDeletable: false, userVisible: true, sharedReadable: false }, user_upload: { userDeletable: true, userVisible: false, sharedReadable: true }, diff --git a/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts b/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts index 409f0c6490..3649c6a054 100644 --- a/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts @@ -426,7 +426,7 @@ test('Session Guests can read only shared attachment Artifacts from their grante turnId: 'turn-1', name: 'private.txt', kind: 'file', - source: 'provider_request_capture', + source: 'session_effect', content: Buffer.from('private'), now: 2, }); diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 2e38c38f8d..bb9145e053 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -80,10 +80,7 @@ import { type MakaTool } from '@maka/runtime/tool-runtime'; import { type RuntimeHostedRootAuthority } from '@maka/runtime/message-authority'; import { isHostedExecutionTerminal } from './hosted-execution-authority.js'; import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; -import { - createArtifactAttachmentResourceReader, - startRetiredCaptureSweep, -} from '@maka/storage/artifact-stores'; +import { createArtifactAttachmentResourceReader } from '@maka/storage/artifact-stores'; import { createReadImageSnapshotStore } from '@maka/storage/read-image-snapshot-store'; import { isSessionNotFoundError } from '@maka/storage/execution-stores'; import { createExternalSessionAdapterRegistry } from '@maka/storage/external-sessions'; @@ -266,7 +263,6 @@ export async function createExecutionRuntimeHostComposition( `[runtime-host] optional context-offload Store could not be opened: ${generalizedErrorMessage(storage.contextOffloadUnavailable.cause)}`, ); } - let stopRetiredCaptureSweep: (() => void) | undefined; const stores = storage.execution; let graphControlStore: ReturnType | undefined; let graphClient: HostAgentGraphCoordinator | undefined; @@ -1763,21 +1759,6 @@ export async function createExecutionRuntimeHostComposition( state: async () => { await skills.recover(); await openedArtifactStore.recover(); - // Only now: a write authority refuses every mutation until it has - // recovered, and the sweep gives up on its first failure. - stopRetiredCaptureSweep = startRetiredCaptureSweep(storage.artifacts, { - onError: async (error) => { - console.error( - `[runtime-host] retired provider-request captures could not be reclaimed: ${generalizedErrorMessage(error)}`, - ); - // A purge that fails part way leaves the write authority - // refusing every mutation until something recovers it -- not - // just this sweep's, but the live turn's tool results and the - // user's uploads. Recovering here is what hands those back, - // and it replays the purge intent the failed batch left. - await openedArtifactStore.recover(); - }, - }); }, }, drain: [ @@ -1794,7 +1775,6 @@ export async function createExecutionRuntimeHostComposition( () => { unsubscribeTranscriptChanges?.(); unsubscribeUsageChanges?.(); - stopRetiredCaptureSweep?.(); }, ], releaseConnection: [(connectionId) => artifacts.releaseConnection(connectionId)], diff --git a/packages/runtime-host/src/server/session-revision-graph-references.ts b/packages/runtime-host/src/server/session-revision-graph-references.ts index 9e9385e462..64552389c7 100644 --- a/packages/runtime-host/src/server/session-revision-graph-references.ts +++ b/packages/runtime-host/src/server/session-revision-graph-references.ts @@ -244,8 +244,7 @@ export async function prepareAgentGraphRevisionReferences( // A child result names every Artifact its turn held, and the ledger that // records it can never be rewritten -- so an id in it outlives whatever it // named. What this checks is therefore that a reference does not reach - // outside its own child and lineage, not that its target survived: the - // retired provider-request captures are reclaimed on their own, and a user + // outside its own child and lineage, not that its target survived: a user // may delete a child's Artifact. A reference whose target is gone stays // admissible and simply resolves to nothing, while one that crosses a // Session or a lineage was never admissible and still fails. diff --git a/packages/storage/src/__tests__/artifact-store.test.ts b/packages/storage/src/__tests__/artifact-store.test.ts index 5e16159679..a712c0f9fc 100644 --- a/packages/storage/src/__tests__/artifact-store.test.ts +++ b/packages/storage/src/__tests__/artifact-store.test.ts @@ -322,124 +322,6 @@ describe('SQLite Artifact store', () => { }); }); - test('a conversation copy leaves retired captures behind rather than reintroducing them', async () => { - await withWorkspace(async (root) => { - const authority = createArtifactStoreWriteAuthority(root); - await authority.recover(); - const { store } = authority; - const kept = await store.create(artifactInput('kept-artifact', 'kept', 10)); - await store.create({ - ...artifactInput('capture-artifact', 'request', 11), - source: 'provider_request_capture', - }); - - const copied = await store.copyConversationArtifacts({ - sourceSessionId: 'session-1', - targetSessionId: 'session-copy', - turnIds: ['turn-1'], - }); - - // The sweep runs once and stops when nothing is left. A copy that - // carried captures would put them back afterwards, so every fork would - // hand the new Session a fresh set of bytes nobody reads and nothing - // will come back for. - assert.equal(copied.artifactIds.get('capture-artifact'), undefined); - assert.ok(copied.artifactIds.get(kept.id)); - assert.deepEqual( - (await store.list('session-copy', { includeDeleted: true })).map((record) => record.source), - ['tool_result'], - ); - // And the source keeps its own until the sweep takes them. - assert.deepEqual(await store.purgeRetiredCaptures(8), { purged: 1, remaining: 0 }); - }); - }); - - test('and leaves them behind on the linked and included paths too', async () => { - await withWorkspace(async (root) => { - const authority = createArtifactStoreWriteAuthority(root); - await authority.recover(); - const { store } = authority; - // A Side Conversation copy names artifacts three ways: by turn, by a - // linked child Session, and by an explicit include list for the refs - // that carry no conversation turn. All three reach the same records. - const linkedKept = await store.create({ - ...artifactInput('linked-kept', 'child result', 10), - sessionId: 'session-child', - }); - await store.create({ - ...artifactInput('linked-capture', 'child request', 11), - sessionId: 'session-child', - source: 'provider_request_capture', - }); - await store.create({ - ...artifactInput('upload-capture', 'uploaded request', 12), - turnId: 'upload-1', - source: 'provider_request_capture', - }); - - const copied = await store.copyConversationArtifacts({ - sourceSessionId: 'session-1', - targetSessionId: 'session-copy', - turnIds: ['turn-1'], - includeArtifactIds: ['upload-capture'], - linkedArtifacts: [{ sessionId: 'session-child', artifactIds: [linkedKept.id] }], - }); - - assert.ok(copied.artifactIds.get(linkedKept.id), 'ordinary linked artifacts still copy'); - assert.equal(copied.artifactIds.get('upload-capture'), undefined); - assert.deepEqual( - (await store.list('session-copy', { includeDeleted: true })).map((record) => record.source), - ['tool_result'], - ); - - // A child result lists every Artifact its turn held, captures included, - // and the ledger holding that list cannot be rewritten. So a request - // naming one is answered with what is still copyable, not refused: the - // alternative is a Session that can never be forked again. - const named = await store.copyConversationArtifacts({ - sourceSessionId: 'session-1', - targetSessionId: 'session-copy-2', - turnIds: ['turn-1'], - linkedArtifacts: [ - { sessionId: 'session-child', artifactIds: ['linked-capture', linkedKept.id] }, - ], - }); - assert.equal(named.artifactIds.get('linked-capture'), undefined); - assert.ok(named.artifactIds.get(linkedKept.id)); - }); - }); - - test('reclaims retired request captures in bounded batches and leaves everything else', async () => { - await withWorkspace(async (root) => { - const authority = createArtifactStoreWriteAuthority(root); - await authority.recover(); - const { store } = authority; - for (let index = 0; index < 3; index += 1) { - await store.create({ - ...artifactInput(`capture-${index}`, `request-${index}`, 10 + index), - source: 'provider_request_capture', - }); - } - await store.create(artifactInput('kept-artifact', 'kept', 20)); - - assert.deepEqual(await store.purgeRetiredCaptures(2), { purged: 2, remaining: 1 }); - assert.deepEqual(await store.purgeRetiredCaptures(2), { purged: 1, remaining: 0 }); - // The sweep stops on its own rather than spinning once the residue is gone. - assert.deepEqual(await store.purgeRetiredCaptures(2), { purged: 0, remaining: 0 }); - - assert.deepEqual( - (await store.list('session-1', { includeDeleted: true })).map((record) => record.id), - ['kept-artifact'], - ); - // Purge, not a tombstone: the bytes are what this reclaims. - const kept = await store.getInSession('session-1', 'kept-artifact'); - assert.ok(kept.record); - assert.deepEqual(await readdir(join(root, 'artifacts', 'session-1')), [ - basename(kept.record.relativePath), - ]); - }); - }); - test('excludes selected Artifacts from a conversation snapshot', async () => { await withWorkspace(async (root) => { const authority = createArtifactStoreWriteAuthority(root); diff --git a/packages/storage/src/__tests__/artifact-stores.test.ts b/packages/storage/src/__tests__/artifact-stores.test.ts index c50bdab1b5..8a137ec040 100644 --- a/packages/storage/src/__tests__/artifact-stores.test.ts +++ b/packages/storage/src/__tests__/artifact-stores.test.ts @@ -25,7 +25,6 @@ import { after, describe, test } from 'node:test'; import { authenticateInteractiveArtifactStoreWriter, openInteractiveArtifactStoreForWrite, - startRetiredCaptureSweep, type InteractiveArtifactStoreWriter, } from '../artifact-stores.js'; import { ARTIFACT_WRITER_LOCK_FILE } from '../artifact-storage-layout.js'; @@ -154,200 +153,6 @@ describe('interactive artifact store authority', () => { }); }); -describe('retired request capture sweep', () => { - test('drains a real writer, and cannot run before that writer has recovered', async () => { - await withInteractiveOwner(async (owner, _root, track) => { - const writer = track(await openInteractiveArtifactStoreForWrite(owner.lease)); - await writer.recover(); - for (let index = 0; index < 40; index += 1) { - await writer.create({ - ...artifactInput(`capture-${index}`, `request-${index}`), - source: 'provider_request_capture', - }); - } - await writer.create(artifactInput('kept', 'kept')); - - // Every other test here injects a fake, which is why the failure that - // actually shipped got through: wired ahead of recovery, each batch was - // refused, the sweep gave up on the first one, and it reclaimed nothing - // at all for anyone. Only the real writer and its real queue show that. - const errors: unknown[] = []; - startRetiredCaptureSweep(writer, { - onError: (error) => { - errors.push(error); - }, - }); - await settled( - async () => (await writer.listPage('session-1', { offset: 0, limit: 100 })).total === 1, - ); - assert.deepEqual(errors, []); - const remaining = await writer.listPage('session-1', { offset: 0, limit: 100 }); - assert.equal(remaining.records[0]?.id, 'kept'); - }); - }); - - test('keeps taking batches until the residue is gone, then stops', async () => { - const limits: number[] = []; - // A store that purges fewer than asked still has to be revisited. - let residue = 5; - startRetiredCaptureSweep({ - purgeRetiredCaptures: async (limit) => { - limits.push(limit); - const purged = Math.min(2, residue); - residue -= purged; - return { purged, remaining: residue }; - }, - }); - - await settled(() => residue === 0); - const passes = limits.length; - assert.equal(passes, 3, 'a batch that clears part of the residue is followed by another'); - - await idleLongerThanOnePause(); - assert.equal(limits.length, passes, 'an empty residue does not schedule another pass'); - }); - - test('waits longer after a batch that took longer', async () => { - const gaps: number[] = []; - let previousEnd = 0; - let residue = 3; - // A batch that holds the writer lock for 300 ms must not be followed - // straight away on a store large enough for that to happen. - startRetiredCaptureSweep({ - purgeRetiredCaptures: async () => { - if (previousEnd) gaps.push(Date.now() - previousEnd); - await new Promise((resolve) => { - setTimeout(resolve, 300); - }); - residue -= 1; - previousEnd = Date.now(); - return { purged: 1, remaining: residue }; - }, - }); - - await settled(() => residue === 0); - assert.ok(gaps.length >= 1, 'the sweep took more than one batch'); - assert.ok( - gaps.every((gap) => gap >= 600), - `a 300 ms batch must be followed by a pause of at least 600 ms, saw ${gaps.join(', ')}`, - ); - }); - - test('retries a failed batch, and lets onError repair what made it fail', async () => { - let calls = 0; - const errors: unknown[] = []; - let residue = 2; - // The first failure says nothing about the second: a write authority that - // another mutation left needing recovery refuses this batch too, until - // something recovers it. That something is onError. - let recovered = false; - startRetiredCaptureSweep( - { - purgeRetiredCaptures: async () => { - calls += 1; - if (!recovered) throw new Error('Artifact write recovery is required'); - residue -= 1; - return { purged: 1, remaining: residue }; - }, - }, - { - onError: async (error) => { - errors.push(error); - recovered = true; - }, - }, - ); - - await settled(() => residue === 0); - assert.equal(errors.length, 1); - assert.match(String(errors[0]), /recovery is required/); - assert.ok(calls > 1, 'the sweep came back after the failure'); - }); - - test('gives up once failures stop looking temporary', async () => { - let calls = 0; - const errors: unknown[] = []; - startRetiredCaptureSweep( - { - purgeRetiredCaptures: async () => { - calls += 1; - throw new Error('artifact store is unavailable'); - }, - }, - { - onError: (error) => { - errors.push(error); - }, - }, - ); - - await settled(() => errors.length === 5); - await idleLongerThanOnePause(); - assert.equal(calls, 5, 'a permanent failure does not retry forever'); - }); - - test('does not shrink a batch that cost a lot, because the cost is not the batch', async () => { - const limits: number[] = []; - let residue = 400; - // A batch costs what the whole store costs, not what its own size costs. - // Asking for less would pay that same toll again for fewer records, so an - // expensive batch is answered by waiting longer, not by taking less. - startRetiredCaptureSweep({ - purgeRetiredCaptures: async (limit) => { - limits.push(limit); - await new Promise((resolve) => { - setTimeout(resolve, 400); - }); - residue -= limit; - return { purged: limit, remaining: Math.max(0, residue) }; - }, - }); - - await settled(() => limits.length >= 2); - assert.deepEqual(limits.slice(0, 2), [256, 256]); - }); - - test('stop keeps the next batch from starting', async () => { - let calls = 0; - let release!: () => void; - const firstBatch = new Promise((resolve) => { - release = resolve; - }); - const stop = startRetiredCaptureSweep({ - purgeRetiredCaptures: async () => { - calls += 1; - await firstBatch; - return { purged: 1, remaining: 99 }; - }, - }); - - await settled(() => calls === 1); - stop(); - release(); - await idleLongerThanOnePause(); - assert.equal(calls, 1, 'a residue that remains is left for a later run'); - }); -}); - -/** Lets the sweep's own timers run until it reaches the state under test. */ -async function settled(done: () => boolean | Promise): Promise { - const deadline = Date.now() + 15_000; - while (Date.now() < deadline) { - if (await done()) return; - await new Promise((resolve) => { - setTimeout(resolve, 10); - }); - } - throw new Error('The capture sweep did not reach the expected state'); -} - -/** Long enough that a sweep which meant to continue would have called again. */ -async function idleLongerThanOnePause(): Promise { - await new Promise((resolve) => { - setTimeout(resolve, 400); - }); -} - function artifactInput(id: string, content: string | Uint8Array) { return { id, diff --git a/packages/storage/src/artifact-store.ts b/packages/storage/src/artifact-store.ts index 174d12a49e..9aaa1d4d18 100644 --- a/packages/storage/src/artifact-store.ts +++ b/packages/storage/src/artifact-store.ts @@ -85,25 +85,6 @@ const PURGE_INTENT_SCHEMA_VERSION = 1 as const; const MAX_PURGE_INTENT_BYTES = 64 * 1024 * 1024; const ARTIFACT_PURGE_RESOLVE_CONCURRENCY = 8; -/** - * The source of the artifacts the retired capture sink wrote. The value stays - * a valid source so the records still decode; nothing produces new ones. - */ -const RETIRED_CAPTURE_ARTIFACT_SOURCE: ArtifactSource = 'provider_request_capture'; - -/** - * A record on its way off disk, which no copy may carry anywhere. - * - * Copying one would hand the target Session bytes already condemned, and would - * put records back after the sweep finished and stopped looking. Leaving them - * out also keeps the two from racing: the sweep can no longer delete a record a - * copy is holding. That property belongs to the copy, not to one of its three - * selection passes, so every pass asks the same question here. - */ -function isRetiredCapture(record: ArtifactRecord): boolean { - return record.source === RETIRED_CAPTURE_ARTIFACT_SOURCE; -} - interface ArtifactSessionSnapshot { readonly records: readonly ArtifactRecord[]; readonly revision: ArtifactListRevision; @@ -237,8 +218,6 @@ export interface ArtifactAuthorityStore extends ArtifactStore { input: ConversationArtifactCopyInput, ): Promise; purgeSessionArtifacts(sessionId: string): Promise; - /** Drops up to `limit` retired prepared-request captures; reports what remains. */ - purgeRetiredCaptures(limit: number): Promise<{ purged: number; remaining: number }>; deleteUserArtifactInSession( sessionId: string, artifactId: string, @@ -441,8 +420,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { (record) => record.sessionId === input.sourceSessionId && turnIds.has(record.turnId) && - !excludedArtifactIds.has(record.id) && - !isRetiredCapture(record), + !excludedArtifactIds.has(record.id), ) .map((record) => ({ ...record })); for (const [sessionId, artifactIds] of requestedLinkedArtifactIds) { @@ -451,8 +429,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { (candidate) => candidate.sessionId === sessionId && candidate.id === artifactId && - candidate.status !== 'deleted' && - !isRetiredCapture(candidate), + candidate.status !== 'deleted', ); // A linked child result names every Artifact its turn held, and the // ledger naming them cannot be rewritten. One that is no longer @@ -468,8 +445,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { record.sessionId === input.sourceSessionId && includedArtifactIds.has(record.id) && !excludedArtifactIds.has(record.id) && - !selectedIds.has(record.id) && - !isRetiredCapture(record) + !selectedIds.has(record.id) ) { selected.push({ ...record }); selectedIds.add(record.id); @@ -913,30 +889,6 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { }); } - /** - * Drops a bounded batch of the prepared-request captures left behind by the - * retired capture sink, and reports what is still there. - * - * These are not the user's to clean up: they never appear in the UI, and the - * only thing that ever reclaimed one was purging its whole conversation. The - * store made them, so the store disposes of them. - * - * Bounded so a large residue cannot monopolise the mutation queue, and safe - * to stop at any point: purge publishes its intent before touching a file, - * and the next call reads whatever is left. - */ - async purgeRetiredCaptures(limit: number): Promise<{ purged: number; remaining: number }> { - let outcome = { purged: 0, remaining: 0 }; - await this.enqueueMutation(async () => { - await this.prepareMutationUnlocked({ kind: 'purge' }); - const retired = this.records.filter(isRetiredCapture); - const batch = retired.slice(0, limit); - await this.purgeRecordsUnlocked(batch); - outcome = { purged: batch.length, remaining: retired.length - batch.length }; - }); - return outcome; - } - private async purgeRecordsUnlocked(records: readonly ArtifactRecord[]): Promise { if (records.length === 0) return; const ids = new Set(records.map((record) => record.id)); diff --git a/packages/storage/src/artifact-stores.ts b/packages/storage/src/artifact-stores.ts index 2a8ad31455..cea55fc60a 100644 --- a/packages/storage/src/artifact-stores.ts +++ b/packages/storage/src/artifact-stores.ts @@ -87,7 +87,6 @@ export interface InteractiveArtifactStoreWriter extends DurableArtifactAttachmen input: ConversationArtifactCopyInput, ): Promise; purgeSessionArtifacts(sessionId: string): Promise; - purgeRetiredCaptures: ArtifactAuthorityStore['purgeRetiredCaptures']; listPage: ArtifactAuthorityStore['listPage']; listTurnArtifacts: ArtifactAuthorityStore['listTurnArtifacts']; getInSession: ArtifactAuthorityStore['getInSession']; @@ -211,7 +210,6 @@ function createWriterFacade( return run(() => store.copyConversationArtifacts(acceptedInput)); }, purgeSessionArtifacts: (sessionId) => run(() => store.purgeSessionArtifacts(sessionId)), - purgeRetiredCaptures: (limit) => run(() => store.purgeRetiredCaptures(limit)), deleteUserArtifactInSession: (sessionId, artifactId) => run(() => store.deleteUserArtifactInSession(sessionId, artifactId)), close: () => { @@ -222,87 +220,6 @@ function createWriterFacade( return Object.freeze(facade); } -/** - * How much one batch deletes -- as much as it may, not as little. - * - * A batch costs what the store costs, not what its own size costs: the purge - * guard resolves the path of every record it is NOT deleting, measured at - * roughly 0.04 ms per record held, so 9,000 records cost about 370 ms whether - * the batch deletes 256 of them or 16. That fixed cost is per batch, so a - * smaller batch cannot shorten the wait a live turn takes -- it only makes the - * residue take more batches, each paying the same toll again. - * - * The lever that does work is the pause below, which keeps the sweep out of the - * queue for three times as long as it was in it. - */ -const RETIRED_CAPTURE_SWEEP_BATCH = 256; -const RETIRED_CAPTURE_SWEEP_PAUSE_MS = 250; -/** Keeps the sweep to a quarter of the time, however long a batch takes. */ -const RETIRED_CAPTURE_SWEEP_DUTY_DIVISOR = 3; -/** - * How many batches may fail in a row before the sweep gives up. - * - * Most of what fails here is not permanent. Another mutation's failure makes - * the write authority refuse everything until something recovers it, and a - * full or briefly unavailable disk clears on its own -- so the first failure - * says nothing about the second. Giving up on it is how this sweep once - * reclaimed nothing at all, for every user, without saying so. - */ -const RETIRED_CAPTURE_SWEEP_MAX_CONSECUTIVE_FAILURES = 5; -const RETIRED_CAPTURE_SWEEP_RETRY_MS = 1_000; - -/** - * Drains the prepared-request captures the retired capture sink left behind. - * - * The sweep shares one mutation queue with live turns, so it takes bounded - * batches and waits between them for as long as the last one cost, rather than - * holding the queue for the whole residue. Stopping only means the next batch - * does not start: each batch is already durable on its own, and a later run - * continues from what is left. - * - * `onError` is where the decision to repair belongs -- the sweep knows a batch - * failed, not what would make the next one succeed. - */ -export function startRetiredCaptureSweep( - artifacts: Pick, - options: { readonly onError?: (error: unknown) => void | Promise } = {}, -): () => void { - let stopped = false; - void (async () => { - let failures = 0; - while (!stopped) { - let pauseMs: number; - try { - const startedAt = Date.now(); - const { remaining } = await artifacts.purgeRetiredCaptures(RETIRED_CAPTURE_SWEEP_BATCH); - const batchMs = Date.now() - startedAt; - if (remaining === 0) return; - failures = 0; - pauseMs = Math.max( - RETIRED_CAPTURE_SWEEP_PAUSE_MS, - batchMs * RETIRED_CAPTURE_SWEEP_DUTY_DIVISOR, - ); - } catch (error) { - failures += 1; - try { - await options.onError?.(error); - } catch { - // A repair that fails leaves the same state the batch did, and the - // failure below is already being counted. - } - if (failures >= RETIRED_CAPTURE_SWEEP_MAX_CONSECUTIVE_FAILURES) return; - pauseMs = RETIRED_CAPTURE_SWEEP_RETRY_MS; - } - await new Promise((resolve) => { - setTimeout(resolve, pauseMs).unref(); - }); - } - })(); - return () => { - stopped = true; - }; -} - function snapshotCreateInput(input: CreateArtifactInput): CreateArtifactInput { return Object.freeze({ ...input, From 7e6dbcebd85903d24c5a2cf3b2e62f7df3d94ebd Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 01:51:40 +0800 Subject: [PATCH 05/35] refactor(artifacts): replace tombstones with physical deletion --- apps/desktop/README.md | 2 +- .../runtime-host-artifacts-ipc-main.test.ts | 6 +- .../workbar-services-adapter.test.ts | 5 +- .../main/runtime-host-artifacts-ipc-main.ts | 32 +----- apps/desktop/src/main/runtime-host-boot.ts | 1 - apps/desktop/src/preload/bridge-contract.d.ts | 4 +- apps/desktop/src/preload/preload.ts | 13 +-- .../src/renderer/features/workbar/ports.ts | 9 +- .../src/renderer/features/workbar/testing.ts | 1 - .../workbar/tools/artifacts/artifact-pane.tsx | 27 +---- .../tools/artifacts/artifact-preview.tsx | 5 - .../src/renderer/locales/artifact-copy.ts | 25 ++--- .../desktop/create-workbar-services.ts | 3 +- .../src/renderer/styles/workbar/artifacts.css | 6 +- ...accessibility-runtime-surfaces.stories.tsx | 4 +- .../stories/session-workbar.stories.tsx | 3 - packages/core/src/__tests__/artifacts.test.ts | 11 --- packages/core/src/artifacts.ts | 47 ++------- .../src/__tests__/artifact-protocol.test.ts | 22 +++-- .../__tests__/artifact-two-client-uds.test.ts | 49 +++------- .../session-effect-coordinator.test.ts | 4 +- .../runtime-host/src/protocol/artifact.ts | 25 +---- packages/runtime-host/src/protocol/index.ts | 3 +- .../src/server/artifact-coordinator.ts | 22 +---- .../src/server/execution-artifacts.ts | 3 +- .../src/server/session-effect-coordinator.ts | 2 +- .../session-revision-graph-references.ts | 2 +- .../src/__tests__/deep-research-tools.test.ts | 10 +- .../src/__tests__/session-manager.test.ts | 4 - packages/runtime/src/deep-research-tools.ts | 11 +-- .../__tests__/artifact-attachments.test.ts | 16 +-- .../src/__tests__/artifact-store.test.ts | 98 +++++++------------ .../src/__tests__/artifact-stores.test.ts | 13 ++- .../sqlite-artifact-metadata.test.ts | 13 ++- packages/storage/src/artifact-attachments.ts | 2 +- .../storage/src/artifact-metadata-codec.ts | 9 +- packages/storage/src/artifact-store.ts | 86 ++++------------ .../storage/src/operational-state-backup.ts | 8 +- .../storage/src/sqlite-artifact-metadata.ts | 2 +- 39 files changed, 172 insertions(+), 436 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d611cb4285..93e60739b1 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -139,7 +139,7 @@ Sub-folders hold OS-facing implementations such as `browser/`, `computer-use/`, Three patterns, all rooted in preload's `maka` namespace. Channel names are `:`. - **Request/response** — `ipcRenderer.invoke(':', …args)` in preload ↔ `ipcMain.handle(':', …)`. Runtime domains are projected by `runtime-host-*-ipc-main.ts`; OS-facing client domains use a focused `*-ipc-main.ts` module. -- **Main→renderer push** — main sends through the safe-send guard (`safeSendToRenderer` via `mainWindowController.send`), not raw `webContents.send` (which throws when the window/`webContents` is destroyed); preload subscribes via `ipcRenderer.on` and returns an unsubscribe fn (e.g. `sessions:changed`, `scheduled-tasks:changed`, `artifacts:changed`). The guard checks both the `BrowserWindow` and its `webContents` before delivery. Route every new main-window push through it. +- **Main→renderer push** — main sends through the safe-send guard (`safeSendToRenderer` via `mainWindowController.send`), not raw `webContents.send` (which throws when the window/`webContents` is destroyed); preload subscribes via `ipcRenderer.on` and returns an unsubscribe fn (e.g. `sessions:changed`, `scheduled-tasks:changed`). The guard checks both the `BrowserWindow` and its `webContents` before delivery. Route every new main-window push through it. - **Renderer→main fire-and-forget** — `ipcRenderer.send(':', …)` in preload ↔ `ipcMain.on(':', …)`. Used when no response is needed (e.g. `browser:active-session`, `browser:setViewport`). Adding a new IPC surface: if extracting, write the `*-ipc-main.ts` exporting a `register*Ipc(...)`, import it in `main.ts`, and call it inside `registerIpc()`; add the matching method to the `maka` namespace in `preload.ts`; add the method to the `window.maka` type in `src/global.d.ts` (the renderer's typed bridge — without it, renderer calls get a TS error); keep the `:` channel naming. A handler file that isn't registered in `registerIpc()` compiles but never mounts. diff --git a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts index 6c452d5922..283d33f342 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts @@ -63,7 +63,6 @@ function attachmentReadHandler( streamArtifact, } as never, mainWindowController: {} as never, - sendToRenderer() {}, showItemInFolder() {}, }); const handler = handlers.get("attachments:readBytes"); @@ -78,7 +77,6 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports" const content = Buffer.alloc(70 * 1024, 5); const handlers = new Map(); const opened: string[] = []; - const events: unknown[] = []; const artifact = { id: "artifact-1", sessionId: "session-1", @@ -105,7 +103,7 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports" return { ok: false, reason: "unsupported_mime" }; }, async deleteArtifact() { - return { kind: "deleted", artifact: { ...artifact, status: "deleted" } }; + return { kind: "deleted" }; }, async streamArtifact( _sessionId: string, @@ -128,7 +126,6 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports" mainWindowController: { showSaveDialog: async () => ({ canceled: false, filePath: savedPath }), } as never, - sendToRenderer: (_channel, event) => events.push(event), showItemInFolder: (path) => opened.push(path), presentationRoot, }); @@ -159,7 +156,6 @@ test("Runtime Host Artifact IPC preserves previews and streams complete exports" assert.deepEqual(await readFile(opened[0]!), content); await handlers.get("artifacts:delete")?.({}, "session-1", "artifact-1"); - assert.equal((events[0] as { reason: string }).reason, "deleted"); } finally { await rm(root, { recursive: true, force: true }); } diff --git a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts index 05ba07dce8..6e92fabf11 100644 --- a/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/workbar-services-adapter.test.ts @@ -37,7 +37,6 @@ function createBridgeRecorder(): { 'browser.setViewport', 'browser.onState', 'browser.onLive', - 'artifacts.subscribeChanges', 'inspector.subscribeUsageChanges', ]); // Adapters that reshape a bridge answer need one to reshape. @@ -132,11 +131,10 @@ describe('createDesktopWorkbarServices', () => { services.browser.subscribeState(eventHandler)(); services.browser.subscribeLive(eventHandler)(); - await services.artifacts.list('s', { includeDeleted: true }); + await services.artifacts.list('s'); await services.artifacts.readText('s', 'a'); await services.artifacts.readBinary('s', 'a'); await services.artifacts.delete('s', 'a'); - services.artifacts.subscribeChanges(eventHandler)(); await services.artifacts.openPath('s', 'a'); await services.artifacts.saveAs('s', 'a'); @@ -207,7 +205,6 @@ describe('createDesktopWorkbarServices', () => { 'artifacts.readText', 'artifacts.readBinary', 'artifacts.delete', - 'artifacts.subscribeChanges', 'app.openArtifactPath', 'app.saveArtifactAs', 'inspector.trace', diff --git a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts index 4032293ef4..f5c3b7200b 100644 --- a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts @@ -39,7 +39,6 @@ interface RuntimeHostArtifactsIpcDeps { readonly ipcMain: ReconnectableReadIpcMain; readonly client: DesktopRuntimeHostClient; readonly mainWindowController: ReturnType; - readonly sendToRenderer: (channel: string, ...args: unknown[]) => void; readonly showItemInFolder: (path: string) => void; readonly presentationRoot?: string; } @@ -61,16 +60,7 @@ export function registerRuntimeHostArtifactsIpc( handleReconnectableRead( deps.ipcMain, "artifacts:list", - async ( - _event, - sessionId: string, - options?: { includeDeleted?: boolean }, - ) => { - const artifacts = await deps.client.listArtifacts(sessionId); - return options?.includeDeleted - ? artifacts - : artifacts.filter(({ status }) => status !== "deleted"); - }, + (_event, sessionId: string) => deps.client.listArtifacts(sessionId), ); handleReconnectableRead( deps.ipcMain, @@ -86,23 +76,15 @@ export function registerRuntimeHostArtifactsIpc( ); deps.ipcMain.handle( "artifacts:delete", - async (_event, sessionId: string, artifactId: string) => { - const result = await deps.client.deleteArtifact(sessionId, artifactId); - deps.sendToRenderer("artifacts:changed", { - reason: "deleted", - artifactId, - sessionId, - ts: Date.now(), - }); - return result; - }, + (_event, sessionId: string, artifactId: string) => + deps.client.deleteArtifact(sessionId, artifactId), ); registerRuntimeHostAttachmentPreviewIpc(deps); deps.ipcMain.handle( "app:openArtifactPath", async (_event, sessionId: string, artifactId: string) => { const artifact = await deps.client.getArtifact(sessionId, artifactId); - if (!artifact || artifact.status === "deleted") { + if (!artifact) { return { ok: false as const, reason: "missing" as const }; } try { @@ -128,7 +110,6 @@ export function registerRuntimeHostArtifactsIpc( ): Promise => { const artifact = await deps.client.getArtifact(sessionId, artifactId); if (!artifact) return { ok: false, reason: "not_found" }; - if (artifact.status === "deleted") return { ok: false, reason: "deleted" }; const result = await deps.mainWindowController.showSaveDialog({ title: `另存为 ${artifact.name}`, defaultPath: artifact.name, @@ -161,10 +142,7 @@ export function registerRuntimeHostAttachmentPreviewIpc( "attachments:readBytes", async (_event, sessionId: string, artifactId: string) => { const artifact = await deps.client.getArtifact(sessionId, artifactId); - if ( - !artifact || - artifact.status === "deleted" - ) { + if (!artifact) { return { ok: false as const, reason: "not_found" }; } const preview = resolveArtifactImagePreview(artifact); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 7caee1cf13..192e465f8f 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1499,7 +1499,6 @@ function registerHostClientIpc( ipcMain: scopedIpc, client, mainWindowController, - sendToRenderer, showItemInFolder: (path) => shell.showItemInFolder(path), }); registerRuntimeHostOAuthIpc({ diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 70b940e6b6..779c6651a6 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -76,7 +76,6 @@ import type { } from '@maka/core/git-review'; import type { ArtifactBinaryReadResult, - ArtifactChangedEvent, ArtifactDescriptor, ArtifactSaveResult, ArtifactTextReadResult, @@ -1810,11 +1809,10 @@ export interface MakaBridge { getState(): Promise; }; artifacts: { - list(sessionId: string, opts?: { includeDeleted?: boolean }): Promise; + list(sessionId: string): Promise; readText(sessionId: string, artifactId: string): Promise; readBinary(sessionId: string, artifactId: string): Promise; delete(sessionId: string, artifactId: string): Promise; - subscribeChanges(handler: (event: ArtifactChangedEvent) => void): () => void; }; skills: { list(host?: DesktopRuntimeHostRef): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index bcb2babbf9..e4b6a58c31 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -162,7 +162,6 @@ import type { } from '@maka/core/git-review'; import type { ArtifactBinaryReadResult, - ArtifactChangedEvent, ArtifactDescriptor, ArtifactSaveResult, ArtifactTextReadResult, @@ -3598,8 +3597,8 @@ const makaBridge = { }, }, artifacts: { - list(sessionId: string, opts?: { includeDeleted?: boolean }): Promise { - return invokeProjectedSessionRuntimeHost('artifacts:list', sessionId, opts); + list(sessionId: string): Promise { + return invokeProjectedSessionRuntimeHost('artifacts:list', sessionId); }, readText(sessionId: string, artifactId: string): Promise { return invokeSessionRuntimeHost('artifacts:readText', sessionId, artifactId); @@ -3610,14 +3609,6 @@ const makaBridge = { delete(sessionId: string, artifactId: string): Promise { return invokeSessionRuntimeHost('artifacts:delete', sessionId, artifactId); }, - subscribeChanges(handler: (event: ArtifactChangedEvent) => void): () => void { - return subscribeEveryRuntimeHostEvent('artifacts:changed', (scope, event: ArtifactChangedEvent) => - handler({ - ...event, - sessionId: recordRuntimeHostSessionScope(scope, event.sessionId), - }), - ); - }, }, skills: { list(host?: DesktopRuntimeHostRef): Promise { diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 34ac1d89e7..4ee8b1cd65 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -24,7 +24,6 @@ import type { } from '@maka/core/events'; import type { ArtifactBinaryReadResult, - ArtifactChangedEvent, ArtifactDescriptor, ArtifactSaveResult, ArtifactTextReadResult, @@ -126,10 +125,7 @@ export type WorkbarOpenArtifactResult = }; export interface WorkbarArtifactsService { - list( - sessionId: string, - options?: { includeDeleted?: boolean }, - ): Promise; + list(sessionId: string): Promise; readText( sessionId: string, artifactId: string, @@ -139,9 +135,6 @@ export interface WorkbarArtifactsService { artifactId: string, ): Promise; delete(sessionId: string, artifactId: string): Promise; - subscribeChanges( - handler: (event: ArtifactChangedEvent) => void, - ): WorkbarUnsubscribe; openPath( sessionId: string, artifactId: string, diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index 28604bef18..11c1e45a56 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -102,7 +102,6 @@ export function createFakeWorkbarServices( readText: async () => ({ ok: false, reason: 'not_found' }), readBinary: async () => ({ ok: false, reason: 'not_found' }), delete: async () => undefined, - subscribeChanges: noopSubscription, openPath: async () => ({ ok: false, reason: 'missing' }), saveAs: async () => ({ ok: false, reason: 'canceled' }), }, diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx index 89fd331441..227832e396 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx @@ -65,7 +65,6 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { formatRelativeTimestamp } from '@maka/core/relative-time'; import { generalizedErrorMessageForLocale, redactSecrets } from '@maka/core/redaction'; import { - Badge, Banner, Button, MoreMenu, @@ -143,9 +142,7 @@ export function ArtifactPane(props: { return; } try { - const next = await artifacts.list(sessionId, { - includeDeleted: true, - }); + const next = await artifacts.list(sessionId); if (artifactPaneMountedRef.current && requestSeq === artifactListRequestSeqRef.current) { recordsSessionIdRef.current = sessionId; setRecordsSessionId(sessionId); @@ -169,21 +166,10 @@ export function ArtifactPane(props: { useEffect(() => { void refresh(); - if (!sessionId) return; - // Keep the list in sync without polling. The - // backend emits `{ reason: 'created' | 'deleted' | 'purged' }` on the - // `artifacts:changed` channel; we just re-list since the list is bounded - // (one session's worth) and the metadata is already in memory on main. - const unsubscribe = artifacts.subscribeChanges((event) => { - if (event.sessionId === sessionId) { - void refresh(); - } - }); return () => { artifactListRequestSeqRef.current += 1; - unsubscribe(); }; - }, [artifacts, sessionId, refresh]); + }, [sessionId, refresh]); const activeRecords = useMemo( () => (recordsSessionId === sessionId ? filterUserVisibleArtifacts(records) : []), @@ -194,7 +180,6 @@ export function ArtifactPane(props: { props.onCountChange?.(activeRecords.length); }, [activeRecords.length, props.onCountChange]); - // 已删除墓碑记录保持可选,用于展示明确失败态;只有选中 id 彻底消失时才回退到最新 live artifact。 useEffect(() => { if (activeRecords.length === 0) { if (selectedId !== null) setSelectedId(null); @@ -494,7 +479,6 @@ export function ArtifactPane(props: { // ArrowUp/Down. tabIndex={-1} data-selected={record.id === selectedId ? 'true' : 'false'} - data-deleted={record.status === 'deleted' ? 'true' : 'false'} onClick={() => openPreview(record.id)} label={record.name} icon={( @@ -508,9 +492,6 @@ export function ArtifactPane(props: { {formatRelativeTimestamp(record.createdAt, Date.now(), locale)} - {record.status === 'deleted' && ( - - )} )} /> @@ -619,8 +600,6 @@ function saveArtifactFailureCopy(reason: string, copy: ArtifactCopy): string { return copy.pane.saveFailures.not_found; case 'not_allowed': return copy.pane.saveFailures.not_allowed; - case 'deleted': - return copy.pane.saveFailures.deleted; case 'write_failed': return copy.pane.saveFailures.write_failed; default: @@ -661,5 +640,5 @@ function KindIcon(props: { kind: ArtifactKind }) { formatter cache. Removed; we import the shared helper. */ function preferredArtifactSelectionId(records: readonly ArtifactDescriptor[]): string | null { - return (records.find((record) => record.status !== 'deleted') ?? records[0])?.id ?? null; + return records[0]?.id ?? null; } diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx index 7de1f6adcc..680236e8d4 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx @@ -387,11 +387,6 @@ function failureCopyText(record: ArtifactDescriptor, reason: TextFailureReason, status: 'info', ...copy.preview.tooLarge(record.sizeBytes), }; - case 'deleted': - return { - status: 'info', - ...copy.preview.deleted, - }; } } diff --git a/apps/desktop/src/renderer/locales/artifact-copy.ts b/apps/desktop/src/renderer/locales/artifact-copy.ts index ad73b11379..a718a7bb63 100644 --- a/apps/desktop/src/renderer/locales/artifact-copy.ts +++ b/apps/desktop/src/renderer/locales/artifact-copy.ts @@ -43,7 +43,6 @@ export type ArtifactCopy = { retrying: string; retry: string; listAria: string; - deletedBadge: string; previewNamed(name: string): string; empty: string; emptyHint: string; @@ -52,7 +51,7 @@ export type ArtifactCopy = { openInFinder: string; saveAs: string; copy: string; - saveFailures: Record<'not_found' | 'not_allowed' | 'deleted' | 'write_failed' | 'default', string>; + saveFailures: Record<'not_found' | 'not_allowed' | 'write_failed' | 'default', string>; actionFailed: string; }; preview: { @@ -72,7 +71,6 @@ export type ArtifactCopy = { readFailed: ReasonCopy; notAllowed: ReasonCopy; tooLarge(bytes: number): ReasonCopy; - deleted: ReasonCopy; unsupportedMime: ReasonCopy; }; registry: { @@ -96,13 +94,13 @@ const ARTIFACT_COPY = { pane: { refreshFailed: '刷新生成文件失败', openFailed: '无法在 Finder 中打开生成文件', copyFailed: '复制失败', readTextFailed: '无法读取生成文件文本内容。', copied: '已复制生成文件文本', saved: '已另存生成文件', saveFailed: '另存失败', - fallbackName: '生成文件', deleteTitle: (name) => `删除 "${name}"`, deleteDescription: '软删除:在记录中标记为已删除,文件保留 6 小时可恢复。', + fallbackName: '生成文件', deleteTitle: (name) => `删除 "${name}"`, deleteDescription: '永久删除此生成文件及其记录,无法恢复。', delete: '删除', deleteReadOnly: '删除(只读文件)', cancel: '取消', deleted: (name) => `已删除 ${name}`, deleteFailed: (name) => `删除 ${name} 失败`, panelAria: '生成文件预览面板', - listLoadFailed: '生成文件列表载入失败', retrying: '重试中…', retry: '重试', listAria: '生成文件列表', deletedBadge: '已删除', + listLoadFailed: '生成文件列表载入失败', retrying: '重试中…', retry: '重试', listAria: '生成文件列表', previewNamed: (name) => `预览 ${name}`, empty: '暂无生成文件', emptyHint: '助手生成文件后会显示在这里。', back: '返回生成文件列表', moreActions: (name) => `${name} 的更多操作`, openInFinder: '在 Finder 中打开', saveAs: '另存为', copy: '复制', - saveFailures: { not_found: '生成文件不存在。', not_allowed: '生成文件路径检查未通过。', deleted: '生成文件已删除,不能另存。', write_failed: '目标位置无法写入。', default: '无法保存生成文件。' }, + saveFailures: { not_found: '生成文件不存在。', not_allowed: '生成文件路径检查未通过。', write_failed: '目标位置无法写入。', default: '无法保存生成文件。' }, actionFailed: '生成文件操作失败,请稍后重试。', }, preview: { @@ -116,7 +114,6 @@ const ARTIFACT_COPY = { readFailed: { title: '无法读取生成文件', description: '路径可能已被外部删除。请通过更多菜单「在 Finder 中打开」检查文件位置。' }, notAllowed: { title: '无法读取生成文件', description: '路径检查未通过,文件已不在允许预览的生成文件目录内。' }, tooLarge: (bytes) => ({ title: '文件超出预览大小', description: `${bytes} 字节超过文本预览阈值,请通过更多菜单打开或另存完整内容。` }), - deleted: { title: '此生成文件已删除', description: '预览已停止。如需查看原文件请使用「在 Finder 中打开」。' }, unsupportedMime: { title: '不支持的文件类型', description: '该生成文件的 MIME 类型不在内联预览允许列表中。请使用工具栏「在 Finder 中打开」或「另存为」。' }, }, registry: { @@ -132,13 +129,13 @@ const ARTIFACT_COPY = { pane: { refreshFailed: '重新整理生成檔案失敗', openFailed: '無法在 Finder 中開啟生成檔案', copyFailed: '複製失敗', readTextFailed: '無法讀取生成檔案文本內容。', copied: '已複製生成檔案文本', saved: '已另存生成檔案', saveFailed: '另存失敗', - fallbackName: '生成檔案', deleteTitle: (name) => `刪除 "${name}"`, deleteDescription: '軟刪除:在記錄中標記為已刪除,檔案保留 6 小時可恢復。', + fallbackName: '生成檔案', deleteTitle: (name) => `刪除 "${name}"`, deleteDescription: '永久刪除此生成檔案及其記錄,無法復原。', delete: '刪除', deleteReadOnly: '刪除(只讀檔案)', cancel: '取消', deleted: (name) => `已刪除 ${name}`, deleteFailed: (name) => `刪除 ${name} 失敗`, panelAria: '生成檔案預覽面板', - listLoadFailed: '生成檔案列表載入失敗', retrying: '重試中…', retry: '重試', listAria: '生成檔案列表', deletedBadge: '已刪除', + listLoadFailed: '生成檔案列表載入失敗', retrying: '重試中…', retry: '重試', listAria: '生成檔案列表', previewNamed: (name) => `預覽 ${name}`, empty: '暫無生成檔案', emptyHint: '助手生成檔案後會顯示在這裡。', back: '返回生成檔案列表', moreActions: (name) => `${name} 的更多操作`, openInFinder: '在 Finder 中開啟', saveAs: '另存為', copy: '複製', - saveFailures: { not_found: '生成檔案不存在。', not_allowed: '生成檔案路徑檢查未透過。', deleted: '生成檔案已刪除,不能另存。', write_failed: '目標位置無法寫入。', default: '無法儲存生成檔案。' }, + saveFailures: { not_found: '生成檔案不存在。', not_allowed: '生成檔案路徑檢查未透過。', write_failed: '目標位置無法寫入。', default: '無法儲存生成檔案。' }, actionFailed: '生成檔案操作失敗,請稍後重試。', }, preview: { @@ -152,7 +149,6 @@ const ARTIFACT_COPY = { readFailed: { title: '無法讀取生成檔案', description: '路徑可能已被外部刪除。請透過更多選單「在 Finder 中開啟」檢查檔案位置。' }, notAllowed: { title: '無法讀取生成檔案', description: '路徑檢查未透過,檔案已不在允許預覽的生成檔案目錄內。' }, tooLarge: (bytes) => ({ title: '檔案超出預覽大小', description: `${bytes} 位元組超過文本預覽閾值,請透過更多選單開啟或另存完整內容。` }), - deleted: { title: '此生成檔案已刪除', description: '預覽已停止。如需檢視原檔案請使用「在 Finder 中開啟」。' }, unsupportedMime: { title: '不支援的檔案型別', description: '該生成檔案的 MIME 型別不在內聯預覽允許列表中。請使用工具欄「在 Finder 中開啟」或「另存為」。' }, }, registry: { @@ -168,13 +164,13 @@ const ARTIFACT_COPY = { pane: { refreshFailed: 'Failed to refresh generated files', openFailed: 'Could not show generated file in Finder', copyFailed: 'Copy failed', readTextFailed: 'Could not read the generated file as text.', copied: 'Generated file text copied', saved: 'Generated file saved as', saveFailed: 'Save as failed', - fallbackName: 'generated file', deleteTitle: (name) => `Delete "${name}"`, deleteDescription: 'Soft delete: mark this record as deleted and keep the file recoverable for 6 hours.', + fallbackName: 'generated file', deleteTitle: (name) => `Delete "${name}"`, deleteDescription: 'Permanently delete this generated file and its record. This cannot be undone.', delete: 'Delete', deleteReadOnly: 'Delete (read-only file)', cancel: 'Cancel', deleted: (name) => `Deleted ${name}`, deleteFailed: (name) => `Failed to delete ${name}`, panelAria: 'Generated file preview panel', - listLoadFailed: 'Failed to load generated files', retrying: 'Retrying…', retry: 'Retry', listAria: 'Generated files', deletedBadge: 'Deleted', + listLoadFailed: 'Failed to load generated files', retrying: 'Retrying…', retry: 'Retry', listAria: 'Generated files', previewNamed: (name) => `Preview ${name}`, empty: 'No generated files', emptyHint: 'Files generated by the assistant appear here.', back: 'Back to generated files', moreActions: (name) => `More actions for ${name}`, openInFinder: 'Show in Finder', saveAs: 'Save as', copy: 'Copy', - saveFailures: { not_found: 'The generated file does not exist.', not_allowed: 'The generated file failed the path safety check.', deleted: 'Deleted generated files cannot be saved.', write_failed: 'The destination is not writable.', default: 'Could not save the generated file.' }, + saveFailures: { not_found: 'The generated file does not exist.', not_allowed: 'The generated file failed the path safety check.', write_failed: 'The destination is not writable.', default: 'Could not save the generated file.' }, actionFailed: 'The generated file action failed. Try again later.', }, preview: { @@ -188,7 +184,6 @@ const ARTIFACT_COPY = { readFailed: { title: 'Could not read generated file', description: 'The file may have been deleted externally. Use “Show in Finder” in the More menu to check its location.' }, notAllowed: { title: 'Could not read generated file', description: 'The path safety check failed because the file is no longer inside the allowed generated-files directory.' }, tooLarge: (bytes) => ({ title: 'File exceeds preview size', description: `${bytes} bytes exceeds the text preview limit. Use the More menu to open or save the complete file.` }), - deleted: { title: 'This generated file was deleted', description: 'The preview has stopped. Use “Show in Finder” to inspect the original file.' }, unsupportedMime: { title: 'Unsupported file type', description: 'This generated file’s MIME type is not allowed for inline preview. Use “Show in Finder” or “Save as”.' }, }, registry: { diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 8313458a27..39a6e490c5 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -68,14 +68,13 @@ export function createDesktopWorkbarServices( subscribeLive: (handler) => bridge.browser.onLive(handler), }, artifacts: { - list: (sessionId, options) => bridge.artifacts.list(sessionId, options), + list: (sessionId) => bridge.artifacts.list(sessionId), readText: (sessionId, artifactId) => bridge.artifacts.readText(sessionId, artifactId), readBinary: (sessionId, artifactId) => bridge.artifacts.readBinary(sessionId, artifactId), delete: (sessionId, artifactId) => bridge.artifacts.delete(sessionId, artifactId), - subscribeChanges: (handler) => bridge.artifacts.subscribeChanges(handler), openPath: (sessionId, artifactId) => bridge.app.openArtifactPath(sessionId, artifactId), saveAs: (sessionId, artifactId) => diff --git a/apps/desktop/src/renderer/styles/workbar/artifacts.css b/apps/desktop/src/renderer/styles/workbar/artifacts.css index 949161aa8b..3b87bcffc8 100644 --- a/apps/desktop/src/renderer/styles/workbar/artifacts.css +++ b/apps/desktop/src/renderer/styles/workbar/artifacts.css @@ -77,7 +77,7 @@ box-shadow: inset 0 0 0 var(--focus-ring-width) var(--focus-ring); } -.maka-artifact-row:active:not([data-deleted="true"]) { +.maka-artifact-row:active { background: var(--state-selected-bg); } @@ -95,10 +95,6 @@ } } -.maka-artifact-row[data-deleted="true"] { - opacity: var(--opacity-muted); -} - .maka-artifact-row-icon { display: inline-flex; align-items: center; diff --git a/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx b/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx index ffc98ef4ed..e4982120d6 100644 --- a/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx +++ b/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx @@ -244,8 +244,7 @@ const htmlArtifact: ArtifactDescriptor = { kind: 'html', sizeBytes: 256, mimeType: 'text/html', - source: 'fixture', - status: 'live', + source: 'tool_result', }; function RemoteProjectDirectoryStory() { @@ -285,7 +284,6 @@ export const HtmlArtifact: Story = { }), readBinary: async () => ({ ok: false, reason: 'unsupported_mime' }), delete: async () => undefined, - subscribeChanges: unsubscribe, openPath: async () => ({ ok: false, reason: 'missing' }), saveAs: async () => ({ ok: false, reason: 'canceled' }), }, diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index f12f2f7989..168856188c 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -159,7 +159,6 @@ const artifacts: ArtifactRecord[] = [ sizeBytes: 4_812, mimeType: 'text/x-diff', source: 'tool_result', - status: 'live', }, { id: 'artifact-notes', @@ -172,7 +171,6 @@ const artifacts: ArtifactRecord[] = [ sizeBytes: 1_284, mimeType: 'text/markdown', source: 'tool_result', - status: 'live', }, ]; @@ -787,7 +785,6 @@ function bridge(options: { readText: async (_sessionId: string, id: string) => ({ ok: true, text: artifactText[id] ?? '' }), readBinary: async () => ({ ok: false, reason: 'unsupported_mime' }), delete: async () => undefined, - subscribeChanges: unsubscribe, openPath: async () => ({ ok: true, opened: 'artifact-patch' }), saveAs: async () => ({ ok: true, saved: 'slice-9-conversation.diff' }), }, diff --git a/packages/core/src/__tests__/artifacts.test.ts b/packages/core/src/__tests__/artifacts.test.ts index c471dec4d7..3965d44e22 100644 --- a/packages/core/src/__tests__/artifacts.test.ts +++ b/packages/core/src/__tests__/artifacts.test.ts @@ -22,7 +22,6 @@ import { describe, test } from 'node:test'; import { ARTIFACT_ENTITY_ID_MAX_CHARS, ARTIFACT_TURN_KEY_MAX_CHARS, - canUserDeleteArtifact, isArtifactChildResultOutput, isArtifactSharedSessionReadable, isArtifactUserVisible, @@ -57,14 +56,6 @@ describe('Artifact turn key', () => { }); }); -describe('Artifact user-delete policy', () => { - test('protects durable evidence while allowing ordinary and unattributed artifacts', () => { - assert.equal(canUserDeleteArtifact({ source: 'deep_research' }), false); - assert.equal(canUserDeleteArtifact({ source: 'user_upload' }), true); - assert.equal(canUserDeleteArtifact({ source: undefined }), true); - }); -}); - describe('Artifact source policy', () => { test('includes produced outputs in child results without leaking internal artifacts', () => { assert.equal(isArtifactChildResultOutput({ source: 'tool_result' }), true); @@ -77,7 +68,6 @@ describe('Artifact source policy', () => { test('keeps projection artifacts internal, durable, and readable in shared sessions', () => { const projection = { source: 'tool_result_projection' as const }; - assert.equal(canUserDeleteArtifact(projection), false); assert.equal(isArtifactUserVisible(projection), false); assert.equal(isArtifactSharedSessionReadable(projection), true); }); @@ -85,7 +75,6 @@ describe('Artifact source policy', () => { test('preserves unattributed artifact defaults', () => { const unattributed = { source: undefined }; - assert.equal(canUserDeleteArtifact(unattributed), true); assert.equal(isArtifactUserVisible(unattributed), true); assert.equal(isArtifactSharedSessionReadable(unattributed), false); }); diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts index 2c2ad4c79c..c9d1834c07 100644 --- a/packages/core/src/artifacts.ts +++ b/packages/core/src/artifacts.ts @@ -98,10 +98,6 @@ export const ARTIFACT_SOURCES = [ export type ArtifactSource = (typeof ARTIFACT_SOURCES)[number]; -export const ARTIFACT_STATUSES = ['live', 'deleted'] as const; - -export type ArtifactStatus = (typeof ARTIFACT_STATUSES)[number]; - export const ARTIFACT_ENTITY_ID_MAX_CHARS = 128; export const ARTIFACT_TURN_KEY_MAX_CHARS = 512; @@ -133,7 +129,6 @@ export interface ArtifactDescriptor { mimeType?: string; source?: ArtifactSource; summary?: string; - status: ArtifactStatus; } export interface ArtifactRecord extends ArtifactDescriptor { @@ -147,19 +142,18 @@ export interface ArtifactRecord extends ArtifactDescriptor { } interface ArtifactSourcePolicy { - readonly userDeletable: boolean; readonly userVisible: boolean; readonly sharedReadable: boolean; } const ARTIFACT_SOURCE_POLICIES = { - tool_result: { userDeletable: true, userVisible: false, sharedReadable: true }, - tool_result_projection: { userDeletable: false, userVisible: false, sharedReadable: true }, - tool_result_archive: { userDeletable: false, userVisible: false, sharedReadable: false }, - subagent_writeback: { userDeletable: false, userVisible: true, sharedReadable: false }, - deep_research: { userDeletable: false, userVisible: true, sharedReadable: false }, - user_upload: { userDeletable: true, userVisible: false, sharedReadable: true }, - session_effect: { userDeletable: false, userVisible: false, sharedReadable: false }, + tool_result: { userVisible: false, sharedReadable: true }, + tool_result_projection: { userVisible: false, sharedReadable: true }, + tool_result_archive: { userVisible: false, sharedReadable: false }, + subagent_writeback: { userVisible: true, sharedReadable: false }, + deep_research: { userVisible: true, sharedReadable: false }, + user_upload: { userVisible: false, sharedReadable: true }, + session_effect: { userVisible: false, sharedReadable: false }, } as const satisfies Record; const CHILD_RESULT_OUTPUT_SOURCES = new Set([ @@ -169,10 +163,6 @@ const CHILD_RESULT_OUTPUT_SOURCES = new Set([ 'deep_research', ]); -export function canUserDeleteArtifact(record: Pick): boolean { - return record.source === undefined || ARTIFACT_SOURCE_POLICIES[record.source].userDeletable; -} - export function isArtifactUserVisible(record: Pick): boolean { return record.source === undefined || ARTIFACT_SOURCE_POLICIES[record.source].userVisible; } @@ -185,21 +175,7 @@ export function isArtifactChildResultOutput(record: Pick { assert.doesNotThrow(() => request('artifact.delete', { sessionId: 'session-1', artifactId: 'artifact-1' }), ); + assert.doesNotThrow(() => response('artifact.delete', { kind: 'deleted' })); + assert.throws( + () => response('artifact.delete', { kind: 'deleted', artifact: validArtifact() }), + isInvalidFrame, + ); for (const input of [ { kind: 'list_start', sessionId: 'session-1', includeDeleted: false }, @@ -242,13 +247,6 @@ describe('Artifact protocol', () => { test('keeps operation failures closed and typed', () => { assert.doesNotThrow(() => failure('artifact.delete', 'not_found', 'Artifact was not found')); - assert.doesNotThrow(() => - failure( - 'artifact.delete', - 'operation_conflict', - 'Protected runtime evidence cannot be deleted through Runtime Host', - ), - ); assert.doesNotThrow(() => failure('artifact.query', 'persistence_failed', 'Artifact projection is unavailable'), ); @@ -257,6 +255,10 @@ describe('Artifact protocol', () => { () => failure('artifact.query', 'operation_conflict', 'Protected runtime evidence'), isInvalidFrame, ); + assert.throws( + () => failure('artifact.delete', 'operation_conflict', 'Protected runtime evidence'), + isInvalidFrame, + ); assert.throws(() => failure('artifact.delete', 'outcome_unknown', 'Unknown'), isInvalidFrame); }); @@ -415,7 +417,6 @@ function validArtifact() { mimeType: 'text/plain', source: 'tool_result' as const, summary: 'bounded', - status: 'live' as const, }; } @@ -426,7 +427,10 @@ function request( decodeClientFrame({ requestId: 'request', operation, input }); } -function response(operation: 'artifact.ingest' | 'artifact.query', result: unknown): void { +function response( + operation: 'artifact.ingest' | 'artifact.query' | 'artifact.delete', + result: unknown, +): void { decodeHostFrame({ requestId: 'response', operation, ok: true, result }); } diff --git a/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts index cba5372baa..0cf7714f17 100644 --- a/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts +++ b/packages/runtime-host/src/__tests__/artifact-two-client-uds.test.ts @@ -168,11 +168,11 @@ test('production Host recovers Artifact publication and preserves deletes across desktop.request('artifact.delete', { sessionId, artifactId: deleteA }), tui.request('artifact.delete', { sessionId, artifactId: deleteB }), ]); - assert.equal(deletedA.artifact.status, 'deleted'); - assert.equal(deletedB.artifact.status, 'deleted'); - assert.deepEqual( - await desktop.request('artifact.delete', { sessionId, artifactId: deleteA }), - deletedA, + assert.deepEqual(deletedA, { kind: 'deleted' }); + assert.deepEqual(deletedB, { kind: 'deleted' }); + await assert.rejects( + desktop.request('artifact.delete', { sessionId, artifactId: deleteA }), + operationError('not_found'), ); await assert.rejects( tui.request('artifact.delete', { @@ -182,20 +182,14 @@ test('production Host recovers Artifact publication and preserves deletes across operationError('not_found'), ); - const protectedBefore = await Promise.all( - PROTECTED_ARTIFACTS.map(({ id }) => getArtifact(desktop, sessionId, id)), - ); for (const [index, artifact] of PROTECTED_ARTIFACTS.entries()) { const client = index % 2 === 0 ? desktop : tui; - await assert.rejects( - client.request('artifact.delete', { sessionId, artifactId: artifact.id }), - operationError('operation_conflict'), - ); + const deleted = await client.request('artifact.delete', { + sessionId, + artifactId: artifact.id, + }); + assert.deepEqual(deleted, { kind: 'deleted' }); } - const protectedAfter = await Promise.all( - PROTECTED_ARTIFACTS.map(({ id }) => getArtifact(tui, sessionId, id)), - ); - assert.deepEqual(protectedAfter, protectedBefore); const stale = await tui.request('artifact.query', { kind: 'list_continue', @@ -226,34 +220,17 @@ test('production Host recovers Artifact publication and preserves deletes across successor = await startHost(root, capability.rootId); const observer = await connectClient(root); try { - for (const artifactId of [deleteA, deleteB]) { + for (const artifactId of [...PROTECTED_ARTIFACTS.map(({ id }) => id), deleteA, deleteB]) { const getResult = await getArtifact(observer, sessionId, artifactId); const readResult = await observer.request('artifact.query', { kind: 'read_text', sessionId, artifactId, }); - assert.equal(getResult.artifact?.status, 'deleted'); - assert.equal(readResult.kind, 'text'); - if (readResult.kind === 'text') { - assert.deepEqual(readResult.preview, { ok: false, reason: 'deleted' }); - } - } - for (const artifact of PROTECTED_ARTIFACTS) { - const getResult = await getArtifact(observer, sessionId, artifact.id); - assert.equal(getResult.artifact?.status, 'live'); - assert.equal(getResult.artifact?.source, artifact.source); - const readResult = await observer.request('artifact.query', { - kind: 'read_text', - sessionId, - artifactId: artifact.id, - }); + assert.equal(getResult.artifact, null); assert.equal(readResult.kind, 'text'); if (readResult.kind === 'text') { - assert.deepEqual(readResult.preview, { - ok: true, - text: `protected ${artifact.source}`, - }); + assert.deepEqual(readResult.preview, { ok: false, reason: 'not_found' }); } } } finally { diff --git a/packages/runtime-host/src/__tests__/session-effect-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-effect-coordinator.test.ts index 6c4be6855d..21fa17f4b2 100644 --- a/packages/runtime-host/src/__tests__/session-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-effect-coordinator.test.ts @@ -38,7 +38,7 @@ const connectionContext: ConnectionContext = { acquireResidency: () => ({ release: () => undefined }), }; -test('Session recap publishes one protected result and exact retries never repeat provider work', async () => { +test('Session recap publishes one result and exact retries never repeat provider work', async () => { await withHarness(async ({ store, coordinator, modelCalls }) => { const input = { sessionId: 'session-1', effectId: 'effect-1', reason: 'manual' as const }; const generated = { @@ -114,7 +114,7 @@ test('Session recap publishes one protected result and exact retries never repea assert.equal(record.source, 'session_effect'); assert.equal( (await store.deleteUserArtifactInSession('session-1', record.id)).kind, - 'protected', + 'deleted', ); } await successor.close(); diff --git a/packages/runtime-host/src/protocol/artifact.ts b/packages/runtime-host/src/protocol/artifact.ts index 9b9e2b06d6..5bb76853fa 100644 --- a/packages/runtime-host/src/protocol/artifact.ts +++ b/packages/runtime-host/src/protocol/artifact.ts @@ -20,13 +20,11 @@ import { ARTIFACT_KINDS, ARTIFACT_SOURCES, - ARTIFACT_STATUSES, type ArtifactRecord, type ArtifactBinaryReadFailureReason, type ArtifactKind, type ArtifactReadFailureReason, type ArtifactSource, - type ArtifactStatus, isArtifactTurnKey, isCanonicalArtifactEntityId, } from '@maka/core/artifacts'; @@ -56,7 +54,7 @@ const QUERY_ERRORS = [ 'not_found', 'persistence_failed', ] as const; -const DELETE_ERRORS = [...QUERY_ERRORS, 'operation_conflict'] as const; +const DELETE_ERRORS = QUERY_ERRORS; const ARTIFACT_REQUIRED_FIELDS = [ 'id', 'sessionId', @@ -65,7 +63,6 @@ const ARTIFACT_REQUIRED_FIELDS = [ 'name', 'kind', 'sizeBytes', - 'status', ] as const; const ARTIFACT_FIELDS = new Set([...ARTIFACT_REQUIRED_FIELDS, 'mimeType', 'source', 'summary']); @@ -82,7 +79,6 @@ export interface ArtifactProjection { readonly mimeType?: string; readonly source?: ArtifactSource; readonly summary?: string; - readonly status: ArtifactStatus; } export type ArtifactQueryInput = @@ -158,7 +154,6 @@ export interface ArtifactDeleteInput { export interface ArtifactDeleteResult { readonly kind: 'deleted'; - readonly artifact: ArtifactProjection; } export type ArtifactIngestInput = @@ -549,9 +544,9 @@ export function decodeArtifactQueryResult(value: unknown): ArtifactQueryResult { export const encodeArtifactQueryResult = decodeArtifactQueryResult; export function decodeArtifactDeleteResult(value: unknown): ArtifactDeleteResult { - const result = requireExactRecord(value, 'artifact delete result', ['kind', 'artifact']); + const result = requireExactRecord(value, 'artifact delete result', ['kind']); if (result.kind !== 'deleted') throw invalidProtocolFrame('Invalid artifact delete result kind'); - const decoded = { kind: 'deleted' as const, artifact: decodeArtifactProjection(result.artifact) }; + const decoded = { kind: 'deleted' as const }; assertResultSize(decoded); return decoded; } @@ -574,7 +569,6 @@ export function encodeArtifactProjection(record: ArtifactRecord): ArtifactProjec ...(record.summary === undefined ? {} : { summary: projectArtifactText(record.summary, ARTIFACT_SUMMARY_MAX_BYTES) }), - status: record.status, }; } @@ -594,7 +588,6 @@ function decodeArtifactProjection(value: unknown): ArtifactProjection { name: boundedText(record.name, 'artifact name', ARTIFACT_NAME_MAX_BYTES), kind: artifactKind(record.kind), sizeBytes: requireCount(record.sizeBytes, 'artifact sizeBytes'), - status: artifactStatus(record.status), ...(Object.hasOwn(record, 'mimeType') ? { mimeType: boundedText(record.mimeType, 'artifact mimeType', ARTIFACT_MIME_TYPE_MAX_BYTES), @@ -678,7 +671,7 @@ function boundedText(value: unknown, label: string, maxBytes: number, allowEmpty function boundedIngestText(value: unknown, label: string, maxBytes: number): string { const text = boundedText(value, label, maxBytes); // eslint-disable-next-line no-control-regex - if (/[-]/.test(text)) throw invalidProtocolFrame(`Invalid ${label}`); + if (/[\x00-\x1f\x7f]/.test(text)) throw invalidProtocolFrame(`Invalid ${label}`); return text; } @@ -728,20 +721,12 @@ function artifactSource(value: unknown): ArtifactSource { return value as ArtifactSource; } -function artifactStatus(value: unknown): ArtifactStatus { - if (typeof value !== 'string' || !ARTIFACT_STATUSES.includes(value as ArtifactStatus)) { - throw invalidProtocolFrame('Invalid artifact status'); - } - return value as ArtifactStatus; -} - function readFailureReason(value: unknown): ArtifactReadFailureReason { if ( value === 'not_found' || value === 'too_large' || value === 'read_failed' || - value === 'not_allowed' || - value === 'deleted' + value === 'not_allowed' ) { return value; } diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 57953277b6..8fe6f68a7e 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,8 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 113 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 114 as const; +// 114: Artifacts are physically deleted and no longer expose tombstone status. // 113: Client Capability tool schemas add `patternProperties` and draft-07 tuple // `additionalItems`; validation and projection share one per-keyword shape table. // Older peers reject these keywords and fail the handshake. diff --git a/packages/runtime-host/src/server/artifact-coordinator.ts b/packages/runtime-host/src/server/artifact-coordinator.ts index 3a278deb99..569346406a 100644 --- a/packages/runtime-host/src/server/artifact-coordinator.ts +++ b/packages/runtime-host/src/server/artifact-coordinator.ts @@ -118,7 +118,7 @@ export class HostArtifactCoordinator { } const entry = await this.#store.getInSession(sessionId, attachment.ref.relativePath); const record = entry.record; - if (!record || record.status !== 'live') return 'Attachment Artifact was not found'; + if (!record) return 'Attachment Artifact was not found'; if ( record.name !== attachment.name || record.mimeType !== attachment.mimeType || @@ -309,7 +309,7 @@ export class HostArtifactCoordinator { ); const record = entry.record; if (!record) return { kind: 'missing' }; - if (record.status !== 'live' || record.source !== 'user_upload' || record.turnId !== uploadId) { + if (record.source !== 'user_upload' || record.turnId !== uploadId) { return { kind: 'conflict' }; } return { kind: 'committed', record }; @@ -361,7 +361,7 @@ export class HostArtifactCoordinator { maxBytes: ARTIFACT_READ_CHUNK_MAX_BYTES, }); if (!chunk.ok) { - if (chunk.reason === 'not_found' || chunk.reason === 'deleted') { + if (chunk.reason === 'not_found') { return notFound('artifact.query', 'Artifact was not found'); } if (chunk.reason === 'out_of_range') { @@ -441,7 +441,7 @@ export class HostArtifactCoordinator { ); if (!grant) return; const entry = await this.#store.getInSession(input.sessionId, input.artifactId); - return entry.record?.status === 'live' && isArtifactSharedSessionReadable(entry.record) + return entry.record && isArtifactSharedSessionReadable(entry.record) ? grant.grantId : undefined; } @@ -479,21 +479,9 @@ export class HostArtifactCoordinator { error: { code: 'not_found', message: 'Artifact was not found' }, }; } - if (deleted.kind === 'protected') { - return { - ok: false, - error: { - code: 'operation_conflict', - message: 'Protected runtime evidence cannot be deleted through Runtime Host', - }, - }; - } return { ok: true, - result: encodeArtifactDeleteResult({ - kind: 'deleted', - artifact: encodeArtifactProjection(deleted.record), - }), + result: encodeArtifactDeleteResult({ kind: 'deleted' }), }; } catch { this.#requestDrain(); diff --git a/packages/runtime-host/src/server/execution-artifacts.ts b/packages/runtime-host/src/server/execution-artifacts.ts index 7ec86eb52d..c663470ab8 100644 --- a/packages/runtime-host/src/server/execution-artifacts.ts +++ b/packages/runtime-host/src/server/execution-artifacts.ts @@ -88,7 +88,7 @@ export function createHostExecutionArtifactServices(input: { runWrite(async () => { const artifactId = stableToolResultArchiveArtifactId(event); const existing = await input.artifacts.getInSession(event.sessionId, artifactId); - if (existing.record?.status === 'live') { + if (existing.record) { const read = await readArchive(input.artifacts, { artifactId, sessionId: event.sessionId, @@ -171,7 +171,6 @@ async function readArchive( const entry = await artifacts.getInSession(event.sessionId, event.artifactId); const record = entry.record; if (!record) return { ok: false, reason: 'not_found' }; - if (record.status === 'deleted') return { ok: false, reason: 'deleted' }; if (record.source !== 'tool_result_archive') return { ok: false, reason: 'source_mismatch' }; if (record.sizeBytes !== event.originalBytes) return { ok: false, reason: 'size_mismatch' }; const read = await artifacts.readTextInSession(event.sessionId, event.artifactId, { diff --git a/packages/runtime-host/src/server/session-effect-coordinator.ts b/packages/runtime-host/src/server/session-effect-coordinator.ts index b8fc3e2249..2514ee35b5 100644 --- a/packages/runtime-host/src/server/session-effect-coordinator.ts +++ b/packages/runtime-host/src/server/session-effect-coordinator.ts @@ -434,7 +434,7 @@ export class HostSessionEffectCoordinator { artifactId: string, ): Promise | undefined> { const entry = await this.#artifacts.getInSession(sessionId, artifactId); - if (!entry.record || entry.record.status !== 'live') return undefined; + if (!entry.record) return undefined; const read = await this.#artifacts.readTextInSession(sessionId, artifactId, { maxBytes: SESSION_EFFECT_ARTIFACT_MAX_BYTES, }); diff --git a/packages/runtime-host/src/server/session-revision-graph-references.ts b/packages/runtime-host/src/server/session-revision-graph-references.ts index 64552389c7..cd2cd79155 100644 --- a/packages/runtime-host/src/server/session-revision-graph-references.ts +++ b/packages/runtime-host/src/server/session-revision-graph-references.ts @@ -252,7 +252,7 @@ export async function prepareAgentGraphRevisionReferences( const artifact = await dependencies.artifacts .getInSession(childSessionId, artifactId) .catch(() => null); - if (!artifact?.record || artifact.record.status === 'deleted') continue; + if (!artifact?.record) continue; if ( artifact.record.sessionId !== childSessionId || !lineage.turnIds.has(artifact.record.turnId) diff --git a/packages/runtime/src/__tests__/deep-research-tools.test.ts b/packages/runtime/src/__tests__/deep-research-tools.test.ts index fd553a6053..11597329d0 100644 --- a/packages/runtime/src/__tests__/deep-research-tools.test.ts +++ b/packages/runtime/src/__tests__/deep-research-tools.test.ts @@ -63,7 +63,6 @@ class FakeArtifactStore implements DeepResearchArtifactStore { source: input.source, summary: input.summary, deepResearchRole: input.deepResearchRole, - status: 'live', }; this.records.push(record); this.contents.set(record.id, input.content); @@ -83,8 +82,9 @@ class FakeArtifactStore implements DeepResearchArtifactStore { async delete(artifactId: string): Promise { this.deleted.push(artifactId); - const record = this.records.find((item) => item.id === artifactId); - if (record) record.status = 'deleted'; + const index = this.records.findIndex((item) => item.id === artifactId); + if (index >= 0) this.records.splice(index, 1); + this.contents.delete(artifactId); } } @@ -395,13 +395,13 @@ describe('Deep Research runtime tools', () => { verification_commands: ['npm test'], }; const sourceRecord = artifactStore.records[0]!; - sourceRecord.status = 'deleted'; + artifactStore.records.splice(0, 1); await assert.rejects( () => execute(tools, DEEP_RESEARCH_COMPLETE_TOOL_NAME, completeInput, 'call-complete-deleted'), /missing or deleted/, ); - sourceRecord.status = 'live'; + artifactStore.records.unshift(sourceRecord); const sectionRecord = artifactStore.records[1]!; const sectionContent = artifactStore.contents.get(sectionRecord.id)!; diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 4e6d6c11cc..b059a7e8af 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -1121,7 +1121,6 @@ describe('SessionManager graph operator provisioning', () => { sizeBytes: patch.byteLength, mimeType: 'text/x-diff; charset=utf-8', source: 'subagent_writeback', - status: 'live', }; artifacts.set(`${sessionId}:${turnId}`, [record]); return record; @@ -1682,7 +1681,6 @@ describe('SessionManager claimed graph intent execution', () => { relativePath: `${sessionId}/child-output-answer.txt`, sizeBytes: 6, source: 'tool_result', - status: 'live', }, { id: 'child-internal-archive', @@ -1694,7 +1692,6 @@ describe('SessionManager claimed graph intent execution', () => { relativePath: `${sessionId}/child-internal-archive-tool-result.json`, sizeBytes: 12, source: 'tool_result_archive', - status: 'live', }, ], newId: nextId(), @@ -9761,7 +9758,6 @@ describe('SessionManager permission mode updates', () => { relativePath: 'artifacts/notes.md', sizeBytes: 12, source: 'tool_result', - status: 'live', }, ] : [], diff --git a/packages/runtime/src/deep-research-tools.ts b/packages/runtime/src/deep-research-tools.ts index 2a8c5a4883..4a94ed6f32 100644 --- a/packages/runtime/src/deep-research-tools.ts +++ b/packages/runtime/src/deep-research-tools.ts @@ -106,7 +106,7 @@ export interface DeepResearchArtifactStore { get(artifactId: string): Promise; readText( artifactId: string, - options?: { maxBytes?: number; includeDeleted?: boolean }, + options?: { maxBytes?: number }, ): Promise<{ ok: true; text: string } | { ok: false; reason: string }>; delete(artifactId: string): Promise; } @@ -219,12 +219,7 @@ function buildReadArtifactTool(deps: BuildDeepResearchToolsDeps): MakaTool< const ref = run.artifacts.find((artifact) => artifact.artifactId === input.artifact_id); if (!ref) throw new Error('Research artifact is not part of this session workspace'); const record = await deps.artifactStore.get(input.artifact_id); - if ( - !record || - record.sessionId !== ctx.sessionId || - record.source !== 'deep_research' || - record.status !== 'live' - ) { + if (!record || record.sessionId !== ctx.sessionId || record.source !== 'deep_research') { throw new Error('Research artifact is missing, deleted, or belongs to another session'); } const read = await deps.artifactStore.readText(input.artifact_id, { @@ -872,7 +867,7 @@ async function validateArtifactIntegrity( ref: DeepResearchArtifactRef, ): Promise { const record = await artifactStore.get(ref.artifactId); - if (!record || record.status !== 'live') { + if (!record) { throw new Error(`Deep Research artifact ${ref.artifactId} is missing or deleted`); } if (record.sessionId !== sessionId || record.source !== 'deep_research') { diff --git a/packages/storage/src/__tests__/artifact-attachments.test.ts b/packages/storage/src/__tests__/artifact-attachments.test.ts index d62ce889ca..311678fcc6 100644 --- a/packages/storage/src/__tests__/artifact-attachments.test.ts +++ b/packages/storage/src/__tests__/artifact-attachments.test.ts @@ -93,7 +93,7 @@ describe('artifact attachment authority', () => { await store.delete('image-1'); assert.deepEqual(await reader(sessionFileRef('image-1')), { ok: false, - reason: 'deleted', + reason: 'not_found', }); assert.deepEqual(await reader(sessionFileRef('image-1', 'other-session')), { ok: false, @@ -244,7 +244,7 @@ describe('artifact attachment authority', () => { }); }); - test('keeps a durable projection image live when a user requests deletion', async () => { + test('physically deletes a durable projection image when requested', async () => { await withStore(async (store) => { const ref = await createReadImageSnapshotter(store)({ sessionId: 'session-1', @@ -254,13 +254,13 @@ describe('artifact attachment authority', () => { mimeType: 'image/png', }); - assert.deepEqual(await store.deleteUserArtifactInSession('session-1', ref.relativePath), { - kind: 'protected', - }); + assert.equal( + (await store.deleteUserArtifactInSession('session-1', ref.relativePath)).kind, + 'deleted', + ); assert.deepEqual(await store.readBinary(ref.relativePath), { - ok: true, - base64: Buffer.from(png).toString('base64'), - mimeType: 'image/png', + ok: false, + reason: 'not_found', }); }); }); diff --git a/packages/storage/src/__tests__/artifact-store.test.ts b/packages/storage/src/__tests__/artifact-store.test.ts index a712c0f9fc..79df5775b5 100644 --- a/packages/storage/src/__tests__/artifact-store.test.ts +++ b/packages/storage/src/__tests__/artifact-store.test.ts @@ -245,8 +245,14 @@ describe('SQLite Artifact store', () => { assert.equal(deletedResult.kind, 'deleted'); const deleted = await store.listPage('session-1', { offset: 0, limit: 3 }); assert.notEqual(deleted.revision, created.revision); - assert.equal(deleted.records.find((record) => record.id === 'first')?.status, 'deleted'); - assert.equal((await store.deleteUserArtifactInSession('session-1', 'first')).kind, 'deleted'); + assert.equal( + deleted.records.find((record) => record.id === 'first'), + undefined, + ); + assert.equal( + (await store.deleteUserArtifactInSession('session-1', 'first')).kind, + 'not_found', + ); assert.equal( (await store.listPage('session-1', { offset: 0, limit: 3 })).revision, deleted.revision, @@ -255,7 +261,7 @@ describe('SQLite Artifact store', () => { await store.create(firstInput); const revived = await store.listPage('session-1', { offset: 0, limit: 3 }); assert.notEqual(revived.revision, deleted.revision); - assert.equal(revived.records.find((record) => record.id === 'first')?.status, 'live'); + assert.equal(revived.records.find((record) => record.id === 'first')?.id, 'first'); await store.purge(['first', 'second', 'revision-race']); const purged = await store.listPage('session-1', { offset: 0, limit: 2 }); @@ -292,7 +298,7 @@ describe('SQLite Artifact store', () => { const copiedId = copied.artifactIds.get(retained.id); const copiedDeletedId = copied.artifactIds.get(deleted.id); assert.ok(copiedId); - assert.ok(copiedDeletedId); + assert.equal(copiedDeletedId, undefined); assert.notEqual(copiedId, retained.id); const target = await store.list('session-copy'); assert.equal(target.length, 1); @@ -303,15 +309,7 @@ describe('SQLite Artifact store', () => { ok: true, text: 'retained', }); - const targetWithTombstones = await store.list('session-copy', { includeDeleted: true }); - assert.equal(targetWithTombstones.find((record) => record.id === copiedId)?.status, 'live'); - const copiedDeleted = targetWithTombstones.find((record) => record.id === copiedDeletedId); - assert.equal(copiedDeleted?.status, 'deleted'); - assert.equal(copied.relativePaths.get(deleted.relativePath), copiedDeleted?.relativePath); - assert.deepEqual(await store.readText(copiedDeletedId!, { includeDeleted: true }), { - ok: true, - text: 'deleted', - }); + assert.equal(copied.relativePaths.get(deleted.relativePath), undefined); await store.purgeSessionArtifacts('session-copy'); assert.deepEqual(await store.list('session-copy'), []); @@ -427,7 +425,7 @@ describe('SQLite Artifact store', () => { }); }); - test('user delete evaluates current-generation policy before tombstone state', async () => { + test('user delete physically removes every artifact source within its Session', async () => { await withWorkspace(async (root) => { const authority = createArtifactStoreWriteAuthority(root); await authority.recover(); @@ -435,23 +433,23 @@ describe('SQLite Artifact store', () => { const input = artifactInput('current-policy', 'replaceable', 1); await store.create(input); await store.purge([input.id]); - const protectedRecord = await store.create({ + await store.create({ ...input, content: 'protected replacement', source: 'deep_research', }); - assert.deepEqual(await store.deleteUserArtifactInSession(input.sessionId, input.id), { - kind: 'protected', - }); - assert.deepEqual(await store.get(input.id), protectedRecord); + assert.equal( + (await store.deleteUserArtifactInSession(input.sessionId, input.id)).kind, + 'deleted', + ); + assert.equal(await store.get(input.id), null); assert.deepEqual(await store.deleteUserArtifactInSession('different-session', input.id), { kind: 'not_found', }); - await store.delete(input.id); assert.deepEqual(await store.deleteUserArtifactInSession(input.sessionId, input.id), { - kind: 'protected', + kind: 'not_found', }); }); }); @@ -505,7 +503,7 @@ describe('SQLite Artifact store', () => { canonicalRecord({ id: 'invalid-name', sessionId: 'session-1', name, sizeBytes: 0 }), ]); await assert.rejects( - () => createArtifactStore(root).list('session-1', { includeDeleted: true }), + () => createArtifactStore(root).list('session-1'), /Invalid artifact metadata record 1/, ); }); @@ -614,31 +612,20 @@ describe('SQLite Artifact store', () => { }); }); - test('exact tombstone replay atomically revives only an unchanged canonical payload', async () => { + test('a stable id can be created again after physical deletion', async () => { await withWorkspace(async (root) => { const input = deepResearchArtifactInput('stable-revive', '# Revivable'); const store = createArtifactStore(root); const first = await store.create(input); await store.delete(first.id); - const deleted = await store.get(first.id); - assert.equal(deleted?.status, 'deleted'); + assert.equal(await store.get(first.id), null); - const revived = await createArtifactStore(root).create(input); - assert.deepEqual(revived, { ...first, status: 'live' }); + const recreated = await createArtifactStore(root).create(input); + assert.deepEqual({ ...recreated, createdAt: first.createdAt }, first); assert.deepEqual(await createArtifactStore(root).readText(first.id), { ok: true, text: '# Revivable', }); - - const reopened = createArtifactStore(root); - await reopened.delete(first.id); - await writeFile(join(root, 'artifacts', first.relativePath), '# Rewritten', 'utf8'); - assert.equal(Buffer.byteLength(input.content), Buffer.byteLength('# Rewritten')); - await assert.rejects( - () => createArtifactStore(root).create(input), - /already exists with different metadata or content/, - ); - assert.equal((await createArtifactStore(root).get(first.id))?.status, 'deleted'); }); }); @@ -649,7 +636,7 @@ describe('SQLite Artifact store', () => { await Promise.all(ids.map((id, index) => store.create(artifactInput(id, id, index + 1)))); const reopened = createArtifactStore(root); - const rows = await reopened.list('session-1', { includeDeleted: true }); + const rows = await reopened.list('session-1'); assert.deepEqual(rows.map((record) => record.id).sort(), ids.sort()); await assert.rejects(() => stat(join(root, 'artifacts', 'metadata.jsonl')), { code: 'ENOENT', @@ -666,7 +653,7 @@ describe('SQLite Artifact store', () => { second.create(artifactInput('independent-second', 'second', 2)), ]); - const rows = await createArtifactStore(root).list('session-1', { includeDeleted: true }); + const rows = await createArtifactStore(root).list('session-1'); assert.deepEqual(rows.map((record) => record.id).sort(), [ 'independent-first', 'independent-second', @@ -732,7 +719,6 @@ describe('SQLite Artifact store', () => { mimeType: 'application/octet-stream', source: 'tool_result', summary: 'accepted summary', - status: 'live', }); assert.deepEqual( await readFile(join(root, 'artifacts', record.relativePath)), @@ -782,7 +768,7 @@ describe('SQLite Artifact store', () => { await purgeLock.finished; await purged; assert.equal(await store.get('purge-me'), null); - assert.equal((await store.get('keep-me'))?.status, 'live'); + assert.equal((await store.get('keep-me'))?.id, 'keep-me'); }); }); @@ -900,7 +886,7 @@ describe('SQLite Artifact store', () => { assert.equal(created.id, 'target-only'); await store.delete('delete-me'); await store.purge(['purge-me']); - assert.equal((await store.get('delete-me'))?.status, 'deleted'); + assert.equal(await store.get('delete-me'), null); assert.equal(await store.get('purge-me'), null); await assert.rejects( @@ -1023,7 +1009,7 @@ describe('SQLite Artifact store', () => { await withWorkspace(async (root) => { await writeArtifactMetadata(root, [recordWithIdentity(field, value)]); await assert.rejects( - () => createArtifactStore(root).list('session-1', { includeDeleted: true }), + () => createArtifactStore(root).list('session-1'), /Invalid artifact metadata record 1/, ); }); @@ -1056,7 +1042,7 @@ describe('SQLite Artifact store', () => { } }); - test('soft delete tombstones bytes until idempotent purge', async () => { + test('delete physically removes metadata and bytes idempotently', async () => { await withWorkspace(async (root) => { const store = createArtifactStore(root); const record = await store.create(artifactInput('artifact-1', '

Report

', 1)); @@ -1064,19 +1050,6 @@ describe('SQLite Artifact store', () => { await store.delete(record.id); await store.delete(record.id); assert.deepEqual(await store.list('session-1'), []); - assert.equal((await store.get(record.id))?.status, 'deleted'); - assert.deepEqual(await store.readText(record.id), { ok: false, reason: 'deleted' }); - assert.deepEqual(await store.readText(record.id, { includeDeleted: true }), { - ok: true, - text: '

Report

', - }); - assert.equal( - await readFile(join(root, 'artifacts', record.relativePath), 'utf8'), - '

Report

', - ); - - await store.purge([record.id]); - await store.purge([record.id]); assert.equal(await store.get(record.id), null); await assert.rejects(() => stat(join(root, 'artifacts', record.relativePath)), { code: 'ENOENT', @@ -1216,7 +1189,7 @@ describe('SQLite Artifact store', () => { const store = createArtifactStore(root); await assert.rejects(() => store.purge([lower.id]), /path is still referenced/); assert.equal(await readFile(upperPath, 'utf8'), 'shared bytes'); - assert.equal((await store.get(upper.id))?.status, 'live'); + assert.equal((await store.get(upper.id))?.id, upper.id); }); }); @@ -1324,27 +1297,27 @@ describe('SQLite Artifact store', () => { }); }); - test('durable attachment reads authenticate the owning session and reject tombstoned bytes', async () => { + test('durable attachment reads report physically deleted bytes as missing', async () => { await withWorkspace(async (root) => { const store = createArtifactStore(root); const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); await store.create({ ...artifactInput('image', png, 1), name: 'image.png', kind: 'image' }); await store.delete('image'); - assert.deepEqual(await store.readBinary('image'), { ok: false, reason: 'deleted' }); + assert.deepEqual(await store.readBinary('image'), { ok: false, reason: 'not_found' }); assert.deepEqual( await store.readDurableAttachmentBinary({ artifactId: 'image', sessionId: 'other-session', }), - { ok: false, reason: 'session_mismatch' }, + { ok: false, reason: 'not_found' }, ); assert.deepEqual( await store.readDurableAttachmentBinary({ artifactId: 'image', sessionId: 'session-1', }), - { ok: false, reason: 'deleted' }, + { ok: false, reason: 'not_found' }, ); }); }); @@ -1575,7 +1548,6 @@ function canonicalRecord(input: { relativePath: `${input.sessionId}/${input.id}-${input.name}`, sizeBytes: input.sizeBytes, source: 'tool_result', - status: 'live', }; } diff --git a/packages/storage/src/__tests__/artifact-stores.test.ts b/packages/storage/src/__tests__/artifact-stores.test.ts index 8a137ec040..8de343b2d1 100644 --- a/packages/storage/src/__tests__/artifact-stores.test.ts +++ b/packages/storage/src/__tests__/artifact-stores.test.ts @@ -78,15 +78,14 @@ describe('interactive artifact store authority', () => { assert.strictEqual(await openInteractiveArtifactStoreForWrite(owner.lease), first); assert.equal(deleted.kind, 'deleted'); const page = await first.listPage('session-1', { offset: 0, limit: 1 }); - assert.equal(page.total, 1); - assert.equal(page.records[0]?.status, 'deleted'); + assert.equal(page.total, 0); assert.deepEqual(await first.getInSession('session-1', 'deleted'), { revision: page.revision, - record: page.records[0], + record: null, }); assert.deepEqual(await first.readTextInSession('session-1', 'deleted'), { ok: false, - reason: 'deleted', + reason: 'not_found', }); assert.deepEqual(await first.readTextInSession('other-session', 'deleted'), { ok: false, @@ -97,7 +96,7 @@ describe('interactive artifact store authority', () => { first.close(); const reopened = track(await openInteractiveArtifactStoreForWrite(owner.lease)); assert.notStrictEqual(reopened, first); - assert.equal((await reopened.getInSession('session-1', 'deleted')).record?.status, 'deleted'); + assert.equal((await reopened.getInSession('session-1', 'deleted')).record, null); }); }); @@ -146,9 +145,9 @@ describe('interactive artifact store authority', () => { assert.equal((await deleted).kind, 'deleted'); assert.equal( (await writer.deleteUserArtifactInSession('session-1', record.id)).kind, - 'deleted', + 'not_found', ); - assert.equal((await writer.getInSession('session-1', 'accepted')).record?.status, 'deleted'); + assert.equal((await writer.getInSession('session-1', 'accepted')).record, null); }); }); }); diff --git a/packages/storage/src/__tests__/sqlite-artifact-metadata.test.ts b/packages/storage/src/__tests__/sqlite-artifact-metadata.test.ts index 06c4f843d4..1167163ab8 100644 --- a/packages/storage/src/__tests__/sqlite-artifact-metadata.test.ts +++ b/packages/storage/src/__tests__/sqlite-artifact-metadata.test.ts @@ -49,7 +49,7 @@ test('Artifact metadata changes only write changed rows', async () => { `); repository.applyChanges({ - upserts: [unchanged, { ...updated, status: 'deleted' }, artifactRecord('added')], + upserts: [unchanged, { ...updated, summary: 'changed' }, artifactRecord('added')], deleteIds: [removed.id], }); @@ -67,12 +67,12 @@ test('Artifact metadata changes only write changed rows', async () => { assert.deepEqual( repository .readAll() - .map(({ id, status }) => ({ id, status })) + .map(({ id, summary }) => ({ id, summary })) .sort((left, right) => left.id.localeCompare(right.id)), [ - { id: 'added', status: 'live' }, - { id: 'unchanged', status: 'live' }, - { id: 'updated', status: 'deleted' }, + { id: 'added', summary: undefined }, + { id: 'unchanged', summary: undefined }, + { id: 'updated', summary: 'changed' }, ], ); @@ -118,7 +118,7 @@ test('Artifact metadata recovery ignores records from unsupported sources', asyn unsupported.id, unsupported.sessionId, unsupported.createdAt, - unsupported.status, + 'live', unsupported.relativePath, JSON.stringify(unsupported), ); @@ -142,6 +142,5 @@ function artifactRecord(id: string): ArtifactRecord { sizeBytes: id.length, relativePath: `session-1/${id}-${id}.txt`, source: 'tool_result', - status: 'live', }; } diff --git a/packages/storage/src/artifact-attachments.ts b/packages/storage/src/artifact-attachments.ts index 07d06c66c2..a5334a5039 100644 --- a/packages/storage/src/artifact-attachments.ts +++ b/packages/storage/src/artifact-attachments.ts @@ -63,7 +63,7 @@ export function createArtifactAttachmentResourceReader(input: { 'getInSession' in input.artifactStore ? (await input.artifactStore.getInSession(sessionId, artifactId)).record : await input.artifactStore.get(artifactId); - if (!record || record.status !== 'live' || record.source !== 'user_upload') { + if (!record || record.source !== 'user_upload') { throw new Error('Attachment was not found in this Session'); } if (record.sessionId !== sessionId) { diff --git a/packages/storage/src/artifact-metadata-codec.ts b/packages/storage/src/artifact-metadata-codec.ts index 732689c8cc..debb649d61 100644 --- a/packages/storage/src/artifact-metadata-codec.ts +++ b/packages/storage/src/artifact-metadata-codec.ts @@ -21,7 +21,6 @@ import { isAbsolute, basename } from 'node:path'; import { ARTIFACT_KINDS, ARTIFACT_SOURCES, - ARTIFACT_STATUSES, type ArtifactKind, type ArtifactRecord, type ArtifactSource, @@ -33,7 +32,6 @@ import { ARTIFACT_PUBLICATION_STAGING_PATTERN } from './artifact-storage-layout. const ARTIFACT_KIND_SET = new Set(ARTIFACT_KINDS); const ARTIFACT_SOURCE_SET = new Set(ARTIFACT_SOURCES); -const ARTIFACT_STATUS_SET = new Set(ARTIFACT_STATUSES); const ARTIFACT_RECORD_KEYS = new Set([ 'id', 'sessionId', @@ -58,6 +56,7 @@ export function decodeArtifactRecordJsons(values: readonly unknown[]): ArtifactR if (typeof value !== 'string') throw invalidMetadataRecord(index + 1); const parsed = JSON.parse(value); if (!hasSupportedArtifactSource(parsed)) continue; + if (isRecord(parsed) && parsed.status === 'deleted') continue; const record = decodeArtifactRecord(parsed, index + 1); if (ids.has(record.id)) throw invalidMetadataRecord(index + 1); ids.add(record.id); @@ -120,8 +119,7 @@ function decodeArtifactRecord(value: unknown, index: number): ArtifactRecord { typeof value.sizeBytes !== 'number' || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes < 0 || - typeof value.status !== 'string' || - !ARTIFACT_STATUS_SET.has(value.status) || + (value.status !== undefined && value.status !== 'live') || !isOptionalNonEmptyString(value.mimeType) || !isOptionalNonEmptyString(value.summary) || (value.deepResearchRole !== undefined && !isDeepResearchArtifactRole(value.deepResearchRole)) || @@ -135,7 +133,8 @@ function decodeArtifactRecord(value: unknown, index: number): ArtifactRecord { if (value.relativePath !== `${value.sessionId}/${value.id}-${value.name}`) { throw invalidMetadataRecord(index); } - return value as unknown as ArtifactRecord; + const { status: _legacyStatus, ...record } = value; + return record as unknown as ArtifactRecord; } function isCompatibleArtifactName(name: string): boolean { diff --git a/packages/storage/src/artifact-store.ts b/packages/storage/src/artifact-store.ts index 9aaa1d4d18..82bd82abd5 100644 --- a/packages/storage/src/artifact-store.ts +++ b/packages/storage/src/artifact-store.ts @@ -44,7 +44,6 @@ import { ArtifactRecord, ArtifactSource, ArtifactTextReadResult, - canUserDeleteArtifact, isArtifactTurnKey, isCanonicalArtifactEntityId, } from '@maka/core/artifacts'; @@ -92,7 +91,7 @@ interface ArtifactSessionSnapshot { type ArtifactReadFailure = { readonly ok: false; - readonly reason: 'not_found' | 'too_large' | 'read_failed' | 'not_allowed' | 'deleted'; + readonly reason: 'not_found' | 'too_large' | 'read_failed' | 'not_allowed'; }; interface PreparedArtifactRead { @@ -180,12 +179,9 @@ export interface ConversationArtifactCopyResult { } export interface ArtifactStoreReader { - list(sessionId: string, opts?: { includeDeleted?: boolean }): Promise; + list(sessionId: string): Promise; get(artifactId: string): Promise; - readText( - artifactId: string, - opts?: { maxBytes?: number; includeDeleted?: boolean }, - ): Promise; + readText(artifactId: string, opts?: { maxBytes?: number }): Promise; readBinary(artifactId: string, opts?: { maxBytes?: number }): Promise; } @@ -209,9 +205,8 @@ export interface ArtifactStore extends ArtifactStoreReader, DurableArtifactAttac } export type ArtifactUserDeleteResult = - | { readonly kind: 'deleted'; readonly record: ArtifactRecord } - | { readonly kind: 'not_found' } - | { readonly kind: 'protected' }; + | { readonly kind: 'deleted' } + | { readonly kind: 'not_found' }; export interface ArtifactAuthorityStore extends ArtifactStore { copyConversationArtifacts( @@ -380,7 +375,6 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { ...(acceptedInput.deepResearchRole ? { deepResearchRole: acceptedInput.deepResearchRole } : {}), - status: 'live', }, (tempPath) => writeFile(tempPath, acceptedInput.content, { flag: 'wx' }), ); @@ -426,10 +420,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { for (const [sessionId, artifactIds] of requestedLinkedArtifactIds) { for (const artifactId of artifactIds) { const record = this.records.find( - (candidate) => - candidate.sessionId === sessionId && - candidate.id === artifactId && - candidate.status !== 'deleted', + (candidate) => candidate.sessionId === sessionId && candidate.id === artifactId, ); // A linked child result names every Artifact its turn held, and the // ledger naming them cannot be rewritten. One that is no longer @@ -462,9 +453,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { input.targetSessionId, record.id, ); - const prepared = await this.enqueue(() => - this.prepareRecordRead(record, record.sizeBytes, true), - ); + const prepared = await this.enqueue(() => this.prepareRecordRead(record, record.sizeBytes)); if (!prepared.ok) { throw new Error(`Artifact ${record.id} could not be copied: ${prepared.reason}`); } @@ -590,7 +579,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { async purgeSessionArtifacts(sessionId: string): Promise { assertCanonicalArtifactEntityId(sessionId, 'sessionId'); - const records = await this.list(sessionId, { includeDeleted: true }); + const records = await this.list(sessionId); if (records.length > 0) await this.purge(records.map((record) => record.id)); } @@ -635,14 +624,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { throw artifactReplayConflict(canonical.id); } - if (existing.status === 'live') return { ...existing }; - const revived: ArtifactRecord = { ...existing, status: 'live' }; - const nextRecords = this.records.map((record) => - record.id === canonical.id ? revived : record, - ); - await this.writeMetadataUnlocked({ upserts: [revived] }); - this.records = nextRecords; - return { ...revived }; + return { ...existing }; } recoverForWriteWithAuthority(): Promise { @@ -655,17 +637,12 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { }); } - async list( - sessionId: string, - opts: { includeDeleted?: boolean } = {}, - ): Promise { - const includeDeleted = opts.includeDeleted ?? false; + async list(sessionId: string): Promise { return this.enqueue(async () => { await this.load(); return ( this.records .filter((record) => record.sessionId === sessionId) - .filter((record) => includeDeleted || record.status !== 'deleted') // Secondary `id` sort for determinism when fixture artifacts share // a frozen createdAt (PR108k-yj e2e-fixture determinism). .sort(compareArtifactRecords) @@ -707,7 +684,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { await this.load(); const snapshot = this.sessionSnapshot(sessionId); return snapshot.records - .filter((record) => record.turnId === turnId && record.status !== 'deleted') + .filter((record) => record.turnId === turnId) .map((record) => ({ ...record })); }); } @@ -726,12 +703,11 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { async readText( artifactId: string, - opts: { maxBytes?: number; includeDeleted?: boolean } = {}, + opts: { maxBytes?: number } = {}, ): Promise { const prepared = await this.prepareRead( artifactId, opts.maxBytes ?? ARTIFACT_TEXT_PREVIEW_LIMIT_BYTES, - opts.includeDeleted ?? false, ); return this.readPreparedText(prepared); } @@ -743,7 +719,6 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { const prepared = await this.prepareRead( artifactId, opts.maxBytes ?? ARTIFACT_BINARY_PREVIEW_LIMIT_BYTES, - false, ); return this.readPreparedBinary(prepared); } @@ -816,7 +791,6 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { const prepared = await this.prepareRecordRead( record, input.maxBytes ?? ARTIFACT_BINARY_PREVIEW_LIMIT_BYTES, - false, ); return this.readPreparedBinary(prepared); }); @@ -844,16 +818,8 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { async delete(artifactId: string): Promise { await this.enqueueMutation(async () => { await this.prepareMutationUnlocked({ kind: 'delete' }); - const existing = this.records.find( - (record) => record.id === artifactId && record.status !== 'deleted', - ); - if (!existing) return; - const tombstone: ArtifactRecord = { ...existing, status: 'deleted' }; - const nextRecords: ArtifactRecord[] = this.records.map((record) => - record.id === artifactId ? tombstone : record, - ); - await this.writeMetadataUnlocked({ upserts: [tombstone] }); - this.records = nextRecords; + const existing = this.records.find((record) => record.id === artifactId); + await this.purgeRecordsUnlocked(existing ? [existing] : []); }); } @@ -866,17 +832,8 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { const snapshot = this.sessionSnapshot(sessionId); const existing = snapshot.records.find((record) => record.id === artifactId); if (!existing) return { kind: 'not_found' }; - if (!canUserDeleteArtifact(existing)) return { kind: 'protected' }; - if (existing.status === 'deleted') { - return { kind: 'deleted', record: { ...existing } }; - } - const tombstone: ArtifactRecord = { ...existing, status: 'deleted' }; - const nextRecords = this.records.map((record) => - record.id === existing.id ? tombstone : record, - ); - await this.writeMetadataUnlocked({ upserts: [tombstone] }); - this.records = nextRecords; - return { kind: 'deleted', record: { ...tombstone } }; + await this.purgeRecordsUnlocked([existing]); + return { kind: 'deleted' }; }); } @@ -1007,11 +964,10 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { private async prepareRead( artifactId: string, maxBytes: number, - includeDeleted = false, ): Promise { const record = await this.get(artifactId); if (!record) return { ok: false, reason: 'not_found' }; - return this.prepareRecordRead(record, maxBytes, includeDeleted); + return this.prepareRecordRead(record, maxBytes); } private async prepareReadInSessionUnlocked( @@ -1023,15 +979,13 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { const snapshot = this.sessionSnapshot(sessionId); const record = snapshot.records.find((candidate) => candidate.id === artifactId); if (!record) return { ok: false, reason: 'not_found' }; - return this.prepareRecordRead(record, maxBytes, false); + return this.prepareRecordRead(record, maxBytes); } private async prepareRecordRead( record: ArtifactRecord, maxBytes: number, - includeDeleted: boolean, ): Promise { - if (record.status === 'deleted' && !includeDeleted) return { ok: false, reason: 'deleted' }; const resolved = await resolveArtifactPath({ artifactRoot: this.artifactRoot, relativePath: record.relativePath, @@ -1201,7 +1155,6 @@ class SqliteArtifactStore implements ArtifactAuthorityStore { ...(input.source ? { source: input.source } : {}), ...(input.summary ? { summary: input.summary } : {}), ...(input.deepResearchRole ? { deepResearchRole: input.deepResearchRole } : {}), - status: 'live', }; const nextRecords = [...this.records, record]; await this.writeMetadataUnlocked({ upserts: [record] }); @@ -1733,8 +1686,7 @@ function sameArtifactRecord(a: ArtifactRecord, b: ArtifactRecord): boolean { a.mimeType === b.mimeType && a.source === b.source && a.summary === b.summary && - a.deepResearchRole === b.deepResearchRole && - a.status === b.status + a.deepResearchRole === b.deepResearchRole ); } diff --git a/packages/storage/src/operational-state-backup.ts b/packages/storage/src/operational-state-backup.ts index cc64fabf1a..129ff85e65 100644 --- a/packages/storage/src/operational-state-backup.ts +++ b/packages/storage/src/operational-state-backup.ts @@ -377,15 +377,15 @@ function validateSqlite(path: string, files: readonly OperationalBackupFile[]): relative_path?: unknown; record_json?: unknown; }>; - const artifacts = decodeArtifactRecordJsons(artifactRows.map((row) => row.record_json)); const filesByPath = new Map(files.map((file) => [file.path, file])); - for (const [index, record] of artifacts.entries()) { - const row = artifactRows[index]; + for (const row of artifactRows) { + const [record] = decodeArtifactRecordJsons([row.record_json]); + if (!record) continue; if ( row?.artifact_id !== record.id || row.session_id !== record.sessionId || row.created_at !== record.createdAt || - row.status !== record.status || + row.status !== 'live' || row.relative_path !== record.relativePath ) { throw new Error(`artifact indexes do not match record: ${record.id}`); diff --git a/packages/storage/src/sqlite-artifact-metadata.ts b/packages/storage/src/sqlite-artifact-metadata.ts index c1678b6011..76f8860f5b 100644 --- a/packages/storage/src/sqlite-artifact-metadata.ts +++ b/packages/storage/src/sqlite-artifact-metadata.ts @@ -99,7 +99,7 @@ class SqliteArtifactMetadataRepository implements ArtifactMetadataRepository { record.id, record.sessionId, record.createdAt, - record.status, + 'live', record.relativePath, JSON.stringify(record), ); From a9adecb3d132015c6d44145c85f027dbd7639c93 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 5 Sep 2026 01:53:31 +0800 Subject: [PATCH 06/35] fix(desktop): allow deleting all artifacts --- .../features/workbar/tools/artifacts/artifact-pane.tsx | 9 +-------- apps/desktop/src/renderer/locales/artifact-copy.ts | 7 +++---- packages/storage/src/artifact-stores.ts | 6 ++---- 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx index 227832e396..b8f728b916 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx @@ -549,15 +549,8 @@ export function ArtifactPane(props: { : []), { type: 'divider' as const }, { - label: - previewRecord.source === 'deep_research' || - previewRecord.source === 'tool_result_archive' - ? copy.pane.deleteReadOnly - : copy.pane.delete, + label: copy.pane.delete, icon: