You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/05 17:19:37 UTC
(maka) branch main updated: refactor(artifacts): make child outputs exact and retire stale lifecycle state (#4808)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 02f97c16d7 refactor(artifacts): make child outputs exact and retire stale lifecycle state (#4808)
02f97c16d7 is described below
commit 02f97c16d76e644d5b565889701958293ff7b5fb
Author: AstroHan <le...@outlook.com>
AuthorDate: Sun Sep 6 01:19:30 2026 +0800
refactor(artifacts): make child outputs exact and retire stale lifecycle state (#4808)
* fix(storage): ignore retired artifact metadata
* fix(runtime): return only child output artifacts
* refactor(core): remove unused artifact sources
* refactor(storage): remove retired capture sweep
* refactor(artifacts): replace tombstones with physical deletion
* fix(desktop): allow deleting all artifacts
* refactor(storage): remove self-managed artifact writes
* refactor(artifacts): remove dead change notifications
* refactor(storage): retire artifact status metadata
* refactor(storage): remove global artifact deletion
* refactor(artifacts): require explicit source ownership
* refactor(storage): use artifact id as metadata key
* refactor(storage): remove artifact metadata readiness shim
* refactor(storage): remove artifact first-read lock state
* refactor(storage): remove artifact metadata interface
* refactor(storage): remove unscoped attachment reads
* refactor(storage): remove unscoped artifact reads
* refactor(storage): remove artifact purge journal
* refactor(storage): remove artifact store facade type
* fix(storage): discard invalid artifact metadata
* refactor(storage): remove artifact publication recovery
* refactor(storage): remove artifact recovery API
* refactor(storage): inline artifact lock layout
* test(artifacts): align disposable residue gates
* test(desktop): use visible artifact story fixture
* fix(storage): preserve durable artifacts across v1 upgrades
* fix(desktop): refresh visible artifact panes after publication
* fix(artifacts): protect workflow-owned evidence from user deletion
* fix(storage): retain artifact cleanup obligations until durable removal
* fix(runtime-host): fence artifact publication with session admission
* test(runtime-host): keep recap retries safe after rejected artifact deletion
* refactor(artifacts): let session cleanup own projection image reclamation
* fix(desktop): refresh artifact counts on background workbar tabs
* test(desktop): use supported tab for background artifact coverage
* test(desktop): decouple artifact count checks from tab name formatting
---
apps/desktop/README.md | 2 +-
.../runtime-host-artifacts-ipc-main.test.ts | 6 +-
.../__tests__/workbar-services-adapter.test.ts | 5 +-
.../src/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 +-
.../desktop/src/renderer/features/workbar/ports.ts | 9 +-
.../src/renderer/features/workbar/testing.ts | 1 -
.../workbar/tools/artifacts/artifact-pane.tsx | 62 +-
.../workbar/tools/artifacts/artifact-preview.tsx | 19 +-
.../features/workbar/ui/workbar-surface.tsx | 1 +
apps/desktop/src/renderer/locales/artifact-copy.ts | 32 +-
.../platform/desktop/create-workbar-services.ts | 3 +-
.../src/renderer/styles/workbar/artifacts.css | 6 +-
.../accessibility-runtime-surfaces.stories.tsx | 67 +-
apps/desktop/stories/session-workbar.stories.tsx | 3 -
docs/astryx-surface-file-inventory.md | 2 +-
packages/core/src/__tests__/artifacts.test.ts | 33 +-
packages/core/src/artifacts.ts | 64 +-
.../src/__tests__/artifact-coordinator.test.ts | 8 +-
.../src/__tests__/artifact-protocol.test.ts | 23 +-
.../src/__tests__/artifact-two-client-uds.test.ts | 69 +-
.../src/__tests__/execution-artifacts.test.ts | 142 ++-
.../__tests__/execution-model-composition.test.ts | 2 -
.../src/__tests__/fixtures/artifact-v1.ts | 48 +
.../src/__tests__/root-turn-coordinator.test.ts | 1 -
.../src/__tests__/session-admission-gate.test.ts | 7 +-
.../session-catalog-two-client-uds.test.ts | 6 +-
.../__tests__/session-effect-coordinator.test.ts | 36 +-
.../session-effect-two-client-uds.test.ts | 2 +-
.../session-retirement-coordinator.test.ts | 115 +++
.../session-revision-graph-references.test.ts | 6 +-
.../session-revision-two-client-uds.test.ts | 2 -
packages/runtime-host/src/protocol/artifact.ts | 32 +-
packages/runtime-host/src/protocol/index.ts | 5 +-
.../src/server/artifact-coordinator.ts | 15 +-
.../runtime-host/src/server/execution-artifacts.ts | 65 +-
.../src/server/execution-composition.ts | 49 +-
.../src/server/execution-model-composition.ts | 8 +-
.../src/server/session-admission-gate.ts | 12 +-
.../src/server/session-effect-coordinator.ts | 2 +-
.../server/session-revision-graph-references.ts | 5 +-
.../src/__tests__/deep-research-tools.test.ts | 15 +-
.../durable-tool-result-projection.test.ts | 31 +-
.../runtime/src/__tests__/session-manager.test.ts | 28 +-
packages/runtime/src/deep-research-tools.ts | 27 +-
.../runtime/src/durable-tool-result-projection.ts | 12 +-
packages/runtime/src/session-manager.ts | 32 +-
.../src/__tests__/artifact-attachments.test.ts | 91 +-
.../storage/src/__tests__/artifact-store.test.ts | 820 ++++++-----------
.../storage/src/__tests__/artifact-stores.test.ts | 307 +++----
.../src/__tests__/artifact-writer-lock.test.ts | 89 +-
.../fixtures/artifact-writer-lock-holder.ts | 14 +-
.../src/__tests__/operational-state-backup.test.ts | 16 +-
.../src/__tests__/operational-state-store.test.ts | 79 +-
.../src/__tests__/sqlite-artifact-metadata.test.ts | 51 +-
packages/storage/src/artifact-attachments.ts | 76 +-
packages/storage/src/artifact-metadata-codec.ts | 54 +-
.../storage/src/artifact-metadata-repository.ts | 32 -
packages/storage/src/artifact-storage-layout.ts | 35 -
packages/storage/src/artifact-store.ts | 965 ++-------------------
packages/storage/src/artifact-stores.ts | 122 +--
packages/storage/src/artifact-writer-lock.ts | 2 +-
packages/storage/src/operational-state-backup.ts | 12 +-
packages/storage/src/session-bundle-policy.ts | 2 +-
packages/storage/src/sqlite-artifact-metadata.ts | 45 +-
packages/storage/src/sqlite-artifact-schema.ts | 46 +-
68 files changed, 1429 insertions(+), 2599 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 `<domain>:<action>`.
- **Request/response** — `ipcRenderer.invoke('<domain>:<action>', …args)` in preload ↔ `ipcMain.handle('<domain>:<action>', …)`. 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('<domain>:<action>', …)` in preload ↔ `ipcMain.on('<domain>:<action>', …)`. 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 `<domain>:<action>` 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<string, Handler>();
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<typeof createMainWindowController>;
- 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<ArtifactSaveResult> => {
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<E2eFixtureState | null>;
};
artifacts: {
- list(sessionId: string, opts?: { includeDeleted?: boolean }): Promise<ArtifactDescriptor[]>;
+ list(sessionId: string): Promise<ArtifactDescriptor[]>;
readText(sessionId: string, artifactId: string): Promise<ArtifactTextReadResult>;
readBinary(sessionId: string, artifactId: string): Promise<ArtifactBinaryReadResult>;
delete(sessionId: string, artifactId: string): Promise<void>;
- subscribeChanges(handler: (event: ArtifactChangedEvent) => void): () => void;
};
skills: {
list(host?: DesktopRuntimeHostRef): Promise<SkillEntry[]>;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index e0f03716b4..7041eda546 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,
@@ -3603,8 +3602,8 @@ const makaBridge = {
},
},
artifacts: {
- list(sessionId: string, opts?: { includeDeleted?: boolean }): Promise<ArtifactDescriptor[]> {
- return invokeProjectedSessionRuntimeHost('artifacts:list', sessionId, opts);
+ list(sessionId: string): Promise<ArtifactDescriptor[]> {
+ return invokeProjectedSessionRuntimeHost('artifacts:list', sessionId);
},
readText(sessionId: string, artifactId: string): Promise<ArtifactTextReadResult> {
return invokeSessionRuntimeHost('artifacts:readText', sessionId, artifactId);
@@ -3615,14 +3614,6 @@ const makaBridge = {
delete(sessionId: string, artifactId: string): Promise<void> {
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<SkillEntry[]> {
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<ArtifactDescriptor[]>;
+ list(sessionId: string): Promise<ArtifactDescriptor[]>;
readText(
sessionId: string,
artifactId: string,
@@ -139,9 +135,6 @@ export interface WorkbarArtifactsService {
artifactId: string,
): Promise<ArtifactBinaryReadResult>;
delete(sessionId: string, artifactId: string): Promise<void>;
- 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..b7c27cb40c 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
@@ -60,12 +60,11 @@ import {
Copy,
Trash2,
} from '@maka/ui/icons';
-import type { ArtifactDescriptor, ArtifactKind } from '@maka/core/artifacts';
+import { canUserDeleteArtifact, type ArtifactDescriptor, type ArtifactKind } from '@maka/core/artifacts';
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,
@@ -84,6 +83,7 @@ import { useWorkbarServices } from '../../services-context.js';
export function ArtifactPane(props: {
sessionId: string;
+ refreshEnabled: boolean;
onCountChange?: (count: number) => void;
onDismiss?: () => void;
}) {
@@ -133,7 +133,7 @@ export function ArtifactPane(props: {
setSelectedId(null);
}, [sessionId]);
- const refresh = useCallback(async () => {
+ const refresh = useCallback(async (showFailureToast = true) => {
const requestSeq = ++artifactListRequestSeqRef.current;
if (!sessionId) {
recordsSessionIdRef.current = undefined;
@@ -143,9 +143,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);
@@ -160,7 +158,7 @@ export function ArtifactPane(props: {
recordsSessionIdRef.current = undefined;
setRecordsSessionId(undefined);
setRecords([]);
- } else {
+ } else if (showFailureToast) {
toast.error(copy.pane.refreshFailed, message, undefined, { sessionId });
}
}
@@ -168,22 +166,22 @@ export function ArtifactPane(props: {
}, [artifacts, copy, locale, sessionId, toast]);
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();
- }
- });
+ if (!props.refreshEnabled) return;
+ let stopped = false;
+ let timer: ReturnType<typeof setTimeout> | undefined;
+ // Writeback can commit after the terminal Session event. Read the existing
+ // catalog while the workbar is visible, including its background file tab's count.
+ const poll = async () => {
+ await refresh(false);
+ if (!stopped) timer = setTimeout(() => void poll(), 2_000);
+ };
+ void poll();
return () => {
+ stopped = true;
+ clearTimeout(timer);
artifactListRequestSeqRef.current += 1;
- unsubscribe();
};
- }, [artifacts, sessionId, refresh]);
+ }, [props.refreshEnabled, sessionId, refresh]);
const activeRecords = useMemo(
() => (recordsSessionId === sessionId ? filterUserVisibleArtifacts(records) : []),
@@ -194,7 +192,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);
@@ -356,6 +353,7 @@ export function ArtifactPane(props: {
async function deleteArtifact(artifactId: string) {
const actionSessionId = sessionId;
const record = activeRecords.find((entry) => entry.id === artifactId);
+ if (!record || !canUserDeleteArtifact(record)) return;
const name = record?.name ?? copy.pane.fallbackName;
const ok = await toast.confirm({
title: copy.pane.deleteTitle(name),
@@ -494,7 +492,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 +505,6 @@ export function ArtifactPane(props: {
<span className="maka-artifact-row-time">
{formatRelativeTimestamp(record.createdAt, Date.now(), locale)}
</span>
- {record.status === 'deleted' && (
- <Badge variant="error" className="maka-artifact-row-badge" label={copy.pane.deletedBadge} />
- )}
</span>
)}
/>
@@ -566,22 +560,14 @@ export function ArtifactPane(props: {
onClick: () => void runArtifactAction(`${previewRecord.id}:copy`, () => copyText(previewRecord.id)),
}]
: []),
- { type: 'divider' as const },
- {
- label:
- previewRecord.source === 'deep_research' ||
- previewRecord.source === 'tool_result_archive'
- ? copy.pane.deleteReadOnly
- : copy.pane.delete,
+ ...(canUserDeleteArtifact(previewRecord) ? [{ type: 'divider' as const }, {
+ label: copy.pane.delete,
icon: <Trash2 size={ICON_SIZE.control} aria-hidden="true" />,
- isDisabled:
- previewRecord.source === 'deep_research' ||
- previewRecord.source === 'tool_result_archive',
onClick: () => void runArtifactAction(
`${previewRecord.id}:delete`,
() => deleteArtifact(previewRecord.id),
),
- },
+ }] : []),
]}
/>
</header>
@@ -619,8 +605,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 +645,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..4cf547a0d9 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
@@ -104,11 +104,7 @@ function FilePreview(props: { record: ArtifactDescriptor; copy: ArtifactCopy })
const result = useTextRead(props.record.sessionId, props.record.id);
if (result.state === 'loading') return <PreviewLoading label={props.copy.preview.loadingFile} />;
if (!result.value.ok) return <TextFailureCard record={props.record} reason={result.value.reason} copy={props.copy} />;
- const text =
- props.record.source === 'tool_result_archive' && result.value.text.length <= TEXT_DISPLAY_LIMIT_BYTES
- ? prettyArchiveJson(result.value.text)
- : result.value.text;
- return <TextFilePreview name={props.record.name} text={text} copy={props.copy} />;
+ return <TextFilePreview name={props.record.name} text={result.value.text} copy={props.copy} />;
}
function TextFilePreview(props: { name: string; text: string; copy: ArtifactCopy }) {
@@ -156,14 +152,6 @@ function TextFilePreview(props: { name: string; text: string; copy: ArtifactCopy
);
}
-function prettyArchiveJson(text: string): string {
- try {
- return JSON.stringify(JSON.parse(text), null, 2);
- } catch {
- return text;
- }
-}
-
function DiffPreview(props: { record: ArtifactDescriptor; copy: ArtifactCopy }) {
const result = useTextRead(props.record.sessionId, props.record.id);
if (result.state === 'loading') return <PreviewLoading label={props.copy.preview.loadingDiff} />;
@@ -387,11 +375,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/features/workbar/ui/workbar-surface.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
index 1ab2a63fa6..e0077ba8ba 100644
--- a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
+++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
@@ -524,6 +524,7 @@ export function WorkbarSurface(props: {
<Suspense fallback={<WorkbarPanelLoading label={copy.files} />}>
<ArtifactPane
sessionId={props.sessionId}
+ refreshEnabled={!props.hidden && panelVisible}
onCountChange={setArtifactCount}
onDismiss={() => props.onDismissPanel(placement)}
/>
diff --git a/apps/desktop/src/renderer/locales/artifact-copy.ts b/apps/desktop/src/renderer/locales/artifact-copy.ts
index ad73b11379..7f83467fd7 100644
--- a/apps/desktop/src/renderer/locales/artifact-copy.ts
+++ b/apps/desktop/src/renderer/locales/artifact-copy.ts
@@ -34,7 +34,6 @@ export type ArtifactCopy = {
deleteTitle(name: string): string;
deleteDescription: string;
delete: string;
- deleteReadOnly: string;
cancel: string;
deleted(name: string): string;
deleteFailed(name: string): string;
@@ -43,7 +42,6 @@ export type ArtifactCopy = {
retrying: string;
retry: string;
listAria: string;
- deletedBadge: string;
previewNamed(name: string): string;
empty: string;
emptyHint: string;
@@ -52,7 +50,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 +70,6 @@ export type ArtifactCopy = {
readFailed: ReasonCopy;
notAllowed: ReasonCopy;
tooLarge(bytes: number): ReasonCopy;
- deleted: ReasonCopy;
unsupportedMime: ReasonCopy;
};
registry: {
@@ -96,13 +93,13 @@ const ARTIFACT_COPY = {
pane: {
refreshFailed: '刷新生成文件失败', openFailed: '无法在 Finder 中打开生成文件', copyFailed: '复制失败',
readTextFailed: '无法读取生成文件文本内容。', copied: '已复制生成文件文本', saved: '已另存生成文件', saveFailed: '另存失败',
- fallbackName: '生成文件', deleteTitle: (name) => `删除 "${name}"`, deleteDescription: '软删除:在记录中标记为已删除,文件保留 6 小时可恢复。',
- delete: '删除', deleteReadOnly: '删除(只读文件)', cancel: '取消', deleted: (name) => `已删除 ${name}`, deleteFailed: (name) => `删除 ${name} 失败`, panelAria: '生成文件预览面板',
- listLoadFailed: '生成文件列表载入失败', retrying: '重试中…', retry: '重试', listAria: '生成文件列表', deletedBadge: '已删除',
+ fallbackName: '生成文件', deleteTitle: (name) => `删除 "${name}"`, deleteDescription: '永久删除此生成文件及其记录,无法恢复。',
+ delete: '删除', cancel: '取消', deleted: (name) => `已删除 ${name}`, deleteFailed: (name) => `删除 ${name} 失败`, panelAria: '生成文件预览面板',
+ 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 +113,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 +128,13 @@ const ARTIFACT_COPY = {
pane: {
refreshFailed: '重新整理生成檔案失敗', openFailed: '無法在 Finder 中開啟生成檔案', copyFailed: '複製失敗',
readTextFailed: '無法讀取生成檔案文本內容。', copied: '已複製生成檔案文本', saved: '已另存生成檔案', saveFailed: '另存失敗',
- fallbackName: '生成檔案', deleteTitle: (name) => `刪除 "${name}"`, deleteDescription: '軟刪除:在記錄中標記為已刪除,檔案保留 6 小時可恢復。',
- delete: '刪除', deleteReadOnly: '刪除(只讀檔案)', cancel: '取消', deleted: (name) => `已刪除 ${name}`, deleteFailed: (name) => `刪除 ${name} 失敗`, panelAria: '生成檔案預覽面板',
- listLoadFailed: '生成檔案列表載入失敗', retrying: '重試中…', retry: '重試', listAria: '生成檔案列表', deletedBadge: '已刪除',
+ fallbackName: '生成檔案', deleteTitle: (name) => `刪除 "${name}"`, deleteDescription: '永久刪除此生成檔案及其記錄,無法復原。',
+ delete: '刪除', cancel: '取消', deleted: (name) => `已刪除 ${name}`, deleteFailed: (name) => `刪除 ${name} 失敗`, panelAria: '生成檔案預覽面板',
+ 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 +148,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 +163,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.',
- 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',
+ fallbackName: 'generated file', deleteTitle: (name) => `Delete "${name}"`, deleteDescription: 'Permanently delete this generated file and its record. This cannot be undone.',
+ delete: 'Delete', 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',
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 +183,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..199e8e5434 100644
--- a/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx
+++ b/apps/desktop/stories/accessibility-runtime-surfaces.stories.tsx
@@ -21,7 +21,7 @@ import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
import { useRef, useState } from 'react';
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
import type { ArtifactDescriptor } from '@maka/core/artifacts';
-import { ToastProvider } from '@maka/ui';
+import { Button, ToastProvider } from '@maka/ui';
import {
createFakeWorkbarServices,
createSessionWorkbarPanelsState,
@@ -65,7 +65,7 @@ function withWorkbarServices(overrides: Partial<WorkbarServices>): Decorator {
);
}
-function WorkbarToolSurface(props: { kind: 'terminal' | 'browser' | 'files' }) {
+function WorkbarToolSurface(props: { kind: 'terminal' | 'browser' | 'files'; hidden?: boolean; backgroundFiles?: boolean }) {
const terminalRef = 'pty:storybook';
const right = props.kind === 'terminal'
? createSessionWorkbarTabsState(
@@ -87,9 +87,9 @@ function WorkbarToolSurface(props: { kind: 'terminal' | 'browser' | 'files' }) {
<div className="mainColumn" />
<WorkbarSurface
sessionId="runtime-surface"
- hidden={false}
+ hidden={props.hidden ?? false}
onDismissPanel={noop}
- panelsState={createSessionWorkbarPanelsState(right)}
+ panelsState={createSessionWorkbarPanelsState(props.backgroundFiles ? openStaticSessionWorkbarTab(right, 'review') : right)}
rightCollapsed={false}
bottomOpen={false}
onActivateTab={noop}
@@ -244,8 +244,7 @@ const htmlArtifact: ArtifactDescriptor = {
kind: 'html',
sizeBytes: 256,
mimeType: 'text/html',
- source: 'fixture',
- status: 'live',
+ source: 'subagent_writeback',
};
function RemoteProjectDirectoryStory() {
@@ -268,11 +267,18 @@ function RemoteProjectDirectoryStory() {
);
}
+let htmlPublished = false;
+const listHtmlArtifacts = fn(async () => htmlPublished ? [htmlArtifact] : []);
+
export const HtmlArtifact: Story = {
+ beforeEach: () => {
+ htmlPublished = false;
+ listHtmlArtifacts.mockClear();
+ },
decorators: [
withWorkbarServices({
artifacts: {
- list: async () => [htmlArtifact],
+ list: listHtmlArtifacts,
readText: async () => ({
ok: true,
text: [
@@ -285,7 +291,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' }),
},
@@ -294,8 +299,11 @@ export const HtmlArtifact: Story = {
render: () => <WorkbarToolSurface kind="files" />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
+ await waitFor(() => expect(listHtmlArtifacts).toHaveBeenCalled());
+ await expect(canvas.queryByRole('option', { name: /agent-report\.html/ })).toBeNull();
+ htmlPublished = true;
await userEvent.click(
- await canvas.findByRole('option', { name: /agent-report\.html/ }),
+ await canvas.findByRole('option', { name: /agent-report\.html/ }, { timeout: 10_000 }),
);
const frame = await canvas.findByTitle('生成文件预览 · agent-report.html');
await expect(frame).toHaveAttribute('sandbox', 'allow-scripts');
@@ -303,6 +311,47 @@ export const HtmlArtifact: Story = {
},
};
+function HiddenArtifactSurface() {
+ const [hidden, setHidden] = useState(true);
+ return <>
+ <Button label={hidden ? 'Show files' : 'Hide files'} onClick={() => setHidden(!hidden)} />
+ <WorkbarToolSurface kind="files" hidden={hidden} />
+ </>;
+}
+
+export const HiddenArtifact: Story = {
+ decorators: HtmlArtifact.decorators,
+ beforeEach: HtmlArtifact.beforeEach,
+ render: () => <HiddenArtifactSurface />,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ htmlPublished = true;
+ await new Promise(resolve => setTimeout(resolve, 2_200));
+ await expect(listHtmlArtifacts).not.toHaveBeenCalled();
+ await userEvent.click(canvas.getByRole('button', { name: 'Show files' }));
+ await canvas.findByRole('option', { name: /agent-report\.html/ });
+ await userEvent.click(canvas.getByRole('button', { name: 'Hide files' }));
+ const reads = listHtmlArtifacts.mock.calls.length;
+ await new Promise(resolve => setTimeout(resolve, 2_200));
+ await expect(listHtmlArtifacts.mock.calls.length).toBe(reads);
+ },
+};
+
+export const BackgroundArtifactCount: Story = {
+ decorators: HtmlArtifact.decorators,
+ beforeEach: HtmlArtifact.beforeEach,
+ render: () => <WorkbarToolSurface kind="files" backgroundFiles />,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const tab = await canvas.findByRole('tab', { name: /^生成文件/ });
+ await expect(within(tab).getByText('0', { exact: true })).toBeVisible();
+ await expect(tab).toHaveAttribute('aria-selected', 'false');
+ htmlPublished = true;
+ await within(tab).findByText('1', { exact: true }, { timeout: 10_000 });
+ await expect(tab).toHaveAttribute('aria-selected', 'false');
+ },
+};
+
export const RemoteProjectDirectory: Story = {
decorators: [
withScopedMakaBridge({
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/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 274904ffde..8a09a3f13a 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -77,7 +77,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx` | other | Banner, Button, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList, TextInput, Tooltip | aligned — uses Astryx (Banner, Button, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList) | aligned |
| `apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx` | other | Card, EmptyState, Table | aligned — uses Astryx (Card, EmptyState, Table) | aligned |
| `apps/desktop/src/renderer/features/workbar/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
-| `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx` | shell-chrome-or-panel | Badge, Banner, Button, EmptyState, MoreMenu | aligned — uses Astryx (Badge, Banner, Button, EmptyState, MoreMenu) | aligned |
+| `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx` | shell-chrome-or-panel | Banner, Button, EmptyState, MoreMenu | aligned — uses Astryx (Banner, Button, EmptyState, MoreMenu) | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx` | shell-chrome-or-panel | Banner, Button, Spinner | aligned — uses Astryx (Banner, Button, Spinner) | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx` | shell-chrome-or-panel | Banner, Button, CodeBlock, Spinner | aligned — uses Astryx (Banner, Button, CodeBlock, Spinner) | aligned |
| `apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx` | shell-chrome-or-panel | EmptyState, IconButton, TextInput, Toolbar, Tooltip | aligned — uses Astryx (EmptyState, IconButton, TextInput, Toolbar, Tooltip) | aligned |
diff --git a/packages/core/src/__tests__/artifacts.test.ts b/packages/core/src/__tests__/artifacts.test.ts
index 53f5d4b2fb..93557757ce 100644
--- a/packages/core/src/__tests__/artifacts.test.ts
+++ b/packages/core/src/__tests__/artifacts.test.ts
@@ -22,7 +22,9 @@ import { describe, test } from 'node:test';
import {
ARTIFACT_ENTITY_ID_MAX_CHARS,
ARTIFACT_TURN_KEY_MAX_CHARS,
+ ARTIFACT_SOURCES,
canUserDeleteArtifact,
+ isArtifactChildResultOutput,
isArtifactSharedSessionReadable,
isArtifactUserVisible,
isArtifactTurnKey,
@@ -56,28 +58,27 @@ 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('keeps workflow-owned evidence out of independent user deletion', () => {
+ for (const source of ARTIFACT_SOURCES) {
+ assert.equal(
+ canUserDeleteArtifact({ source }),
+ source === 'tool_result' || source === 'user_upload',
+ source,
+ );
+ }
+ });
+ 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);
});
-});
-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);
});
-
- 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 9fd6bcf5c2..48c058ca76 100644
--- a/packages/core/src/artifacts.ts
+++ b/packages/core/src/artifacts.ts
@@ -90,25 +90,14 @@ 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];
-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;
@@ -138,9 +127,8 @@ export interface ArtifactDescriptor {
kind: ArtifactKind;
sizeBytes: number;
mimeType?: string;
- source?: ArtifactSource;
+ source: ArtifactSource;
summary?: string;
- status: ArtifactStatus;
}
export interface ArtifactRecord extends ArtifactDescriptor {
@@ -163,48 +151,37 @@ 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<ArtifactSource, ArtifactSourcePolicy>;
-export function canUserDeleteArtifact(record: Pick<ArtifactRecord, 'source'>): boolean {
- return record.source === undefined || ARTIFACT_SOURCE_POLICIES[record.source].userDeletable;
-}
+const CHILD_RESULT_OUTPUT_SOURCES = new Set<ArtifactSource>([
+ 'tool_result',
+ 'tool_result_projection',
+ 'subagent_writeback',
+ 'deep_research',
+]);
export function isArtifactUserVisible(record: Pick<ArtifactRecord, 'source'>): boolean {
- return record.source === undefined || ARTIFACT_SOURCE_POLICIES[record.source].userVisible;
+ return ARTIFACT_SOURCE_POLICIES[record.source].userVisible;
}
-export function isArtifactSharedSessionReadable(record: Pick<ArtifactRecord, 'source'>): boolean {
- return record.source !== undefined && ARTIFACT_SOURCE_POLICIES[record.source].sharedReadable;
+/** Visibility does not grant permission to destroy evidence owned by a runtime workflow. */
+export function canUserDeleteArtifact(record: Pick<ArtifactRecord, 'source'>): boolean {
+ return ARTIFACT_SOURCE_POLICIES[record.source].userDeletable;
}
-export type ArtifactChangedReason = 'created' | 'deleted' | 'purged';
+export function isArtifactSharedSessionReadable(record: Pick<ArtifactRecord, 'source'>): boolean {
+ return ARTIFACT_SOURCE_POLICIES[record.source].sharedReadable;
+}
-export interface ArtifactChangedEvent {
- reason: ArtifactChangedReason;
- artifactId: string;
- sessionId: string;
- ts: number;
+export function isArtifactChildResultOutput(record: Pick<ArtifactRecord, 'source'>): boolean {
+ return CHILD_RESULT_OUTPUT_SOURCES.has(record.source);
}
-export type ArtifactReadFailureReason =
- | 'not_found'
- | 'too_large'
- | 'read_failed'
- | 'not_allowed'
- | 'deleted';
+export type ArtifactReadFailureReason = 'not_found' | 'too_large' | 'read_failed' | 'not_allowed';
export type ArtifactBinaryReadFailureReason = ArtifactReadFailureReason | 'unsupported_mime';
@@ -216,12 +193,7 @@ export type ArtifactBinaryReadResult =
| { ok: true; base64: string; mimeType: string }
| { ok: false; reason: ArtifactBinaryReadFailureReason };
-export type ArtifactSaveFailureReason =
- | 'canceled'
- | 'not_found'
- | 'not_allowed'
- | 'deleted'
- | 'write_failed';
+export type ArtifactSaveFailureReason = 'canceled' | 'not_found' | 'not_allowed' | 'write_failed';
export type ArtifactSaveResult =
| { ok: true; saved: string }
diff --git a/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts b/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts
index 409f0c6490..8e0dd1e7a5 100644
--- a/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts
@@ -44,7 +44,6 @@ test('Artifact ingest is connection-bound, replay-safe, and commits one durable
assert.ok(owner);
try {
const store = await openInteractiveArtifactStoreForWrite(owner.lease);
- await store.recover();
let now = 0;
const coordinator = new HostArtifactCoordinator(
store,
@@ -271,7 +270,6 @@ test('Artifact mutation failure requests Host drain and fails closed', async ()
assert.ok(owner);
try {
const store = await openInteractiveArtifactStoreForWrite(owner.lease);
- await store.recover();
await store.create({
id: 'artifact-1',
sessionId: 'session-1',
@@ -279,6 +277,7 @@ test('Artifact mutation failure requests Host drain and fails closed', async ()
name: 'artifact.txt',
kind: 'file',
content: 'durable',
+ source: 'tool_result',
now: 1,
});
@@ -345,7 +344,6 @@ test('Artifact query streams complete content in bounded ordered chunks', async
assert.ok(owner);
try {
const store = await openInteractiveArtifactStoreForWrite(owner.lease);
- await store.recover();
const content = Buffer.alloc(ARTIFACT_READ_CHUNK_MAX_BYTES + 17, 7);
await store.create({
id: 'artifact-large',
@@ -354,6 +352,7 @@ test('Artifact query streams complete content in bounded ordered chunks', async
name: 'large.bin',
kind: 'file',
content,
+ source: 'tool_result',
now: 1,
});
const coordinator = new HostArtifactCoordinator(
@@ -408,7 +407,6 @@ test('Session Guests can read only shared attachment Artifacts from their grante
assert.ok(owner);
try {
const store = await openInteractiveArtifactStoreForWrite(owner.lease);
- await store.recover();
await store.create({
id: 'shared-image',
sessionId: 'session-1',
@@ -426,7 +424,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/__tests__/artifact-protocol.test.ts b/packages/runtime-host/src/__tests__/artifact-protocol.test.ts
index aa4391e2b1..906289e27f 100644
--- a/packages/runtime-host/src/__tests__/artifact-protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/artifact-protocol.test.ts
@@ -55,6 +55,11 @@ describe('Artifact protocol', () => {
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,9 @@ describe('Artifact protocol', () => {
() => failure('artifact.query', 'operation_conflict', 'Protected runtime evidence'),
isInvalidFrame,
);
+ assert.doesNotThrow(() =>
+ failure('artifact.delete', 'operation_conflict', 'Protected runtime evidence'),
+ );
assert.throws(() => failure('artifact.delete', 'outcome_unknown', 'Unknown'), isInvalidFrame);
});
@@ -413,9 +414,8 @@ 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,
};
}
@@ -426,7 +426,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 bdabdb8598..06b45444f4 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
@@ -21,7 +21,7 @@ import { withTimeout } from '@maka/core/test-only/async-primitives';
import assert from 'node:assert/strict';
import { fork, type ChildProcess } from 'node:child_process';
import { createHash } from 'node:crypto';
-import { link, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
+import { link, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import { test } from 'node:test';
@@ -55,20 +55,18 @@ const PROTECTED_ARTIFACTS = [
{ id: 'tool-result-archive', source: 'tool_result_archive' },
] as const;
-test('production Host recovers Artifact publication and preserves deletes across owner death', {
+test('production Host ignores Artifact publication residue and preserves deletes across owner death', {
timeout: 120_000,
}, async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-runtime-host-artifacts-'));
const root = join(base, 'root');
const capability = await resolveStorageRoot({ path: root, kind: 'interactive' });
const { sessionId, otherSessionId } = await seedExecutionRoot(capability, root);
- const residue = await createPublicationResidue(root, sessionId);
+ await createPublicationResidue(root, sessionId);
let firstHost: ExecutionHostHandle | undefined;
let successor: ExecutionHostHandle | undefined;
try {
firstHost = await startHost(root, capability.rootId);
- await assert.rejects(() => stat(residue.stagingPath), { code: 'ENOENT' });
- await assert.rejects(() => stat(residue.targetPath), { code: 'ENOENT' });
const desktop = await connectClient(root);
const tui = await connectClient(root);
@@ -168,11 +166,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,9 +180,6 @@ 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(
@@ -192,10 +187,6 @@ test('production Host recovers Artifact publication and preserves deletes across
operationError('operation_conflict'),
);
}
- 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 +217,27 @@ 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]) {
- const getResult = await getArtifact(observer, sessionId, artifactId);
- const readResult = await observer.request('artifact.query', {
+ for (const { id: artifactId } of PROTECTED_ARTIFACTS) {
+ assert.ok((await getArtifact(observer, sessionId, artifactId)).artifact);
+ const read = 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' });
- }
+ assert.equal(read.kind, 'text');
+ assert.ok(read.kind === 'text' && read.preview.ok);
}
- 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);
+ for (const artifactId of [deleteA, deleteB]) {
+ const getResult = await getArtifact(observer, sessionId, artifactId);
const readResult = await observer.request('artifact.query', {
kind: 'read_text',
sessionId,
- artifactId: artifact.id,
+ artifactId,
});
+ 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 {
@@ -307,7 +291,6 @@ async function seedExecutionRoot(
});
const openedArtifacts = await openInteractiveArtifactStoreForWrite(owner.lease);
artifacts = openedArtifacts;
- await openedArtifacts.recover();
await Promise.all([
...Array.from({ length: 130 }, (_, index) =>
openedArtifacts.create({
@@ -318,7 +301,7 @@ async function seedExecutionRoot(
kind: 'file',
content: `content ${index}`,
mimeType: 'text/plain',
- source: 'fixture',
+ source: 'tool_result',
now: 10_000 - index,
}),
),
@@ -330,7 +313,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 +324,7 @@ async function seedExecutionRoot(
kind: 'image',
content: tinyPng(),
mimeType: 'image/png',
- source: 'fixture',
+ source: 'tool_result',
now: 19_999,
}),
openedArtifacts.create({
@@ -352,7 +335,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 +361,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,
});
@@ -392,10 +375,7 @@ async function seedExecutionRoot(
}
}
-async function createPublicationResidue(
- root: string,
- sessionId: string,
-): Promise<{ stagingPath: string; targetPath: string }> {
+async function createPublicationResidue(root: string, sessionId: string): Promise<void> {
const sessionDirectory = join(root, 'artifacts', sessionId);
await mkdir(sessionDirectory, { recursive: true });
const targetPath = join(sessionDirectory, 'publication-residue-residue.txt');
@@ -406,7 +386,6 @@ async function createPublicationResidue(
);
await writeFile(stagingPath, 'uncommitted publication', { flag: 'wx' });
await link(stagingPath, targetPath);
- return { stagingPath, targetPath };
}
async function startHost(root: string, rootId: string): Promise<ExecutionHostHandle> {
diff --git a/packages/runtime-host/src/__tests__/execution-artifacts.test.ts b/packages/runtime-host/src/__tests__/execution-artifacts.test.ts
index b0fcbe59e1..1143f0c5de 100644
--- a/packages/runtime-host/src/__tests__/execution-artifacts.test.ts
+++ b/packages/runtime-host/src/__tests__/execution-artifacts.test.ts
@@ -19,14 +19,129 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
-import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises';
+import { mkdir, mkdtemp, rm, stat, truncate, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { MAX_ATTACHMENT_BYTES } from '@maka/core/attachments';
-import { openInteractiveArtifactStoreForWrite } from '@maka/storage/artifact-stores';
+import {
+ openInteractiveArtifactStoreForWrite,
+ createReadImageSnapshotPlanner,
+} from '@maka/storage/artifact-stores';
+import { encodeDurableToolResultOutputWithArtifacts } from '@maka/runtime/durable-tool-result-projection';
+import { deferred } from '@maka/core/test-only/async-primitives';
import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
import { createHostExecutionArtifactServices } from '../server/execution-artifacts.js';
+import { restoreArtifactV1Shape } from './fixtures/artifact-v1.js';
+import { SessionAdmissionGate } from '../server/session-admission-gate.js';
+
+test('a refused projection preserves a shared image until Session cleanup', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-shared-projection-'));
+ const owner = await tryAcquireInteractiveRootOwner(
+ await resolveStorageRoot({ path: root, kind: 'interactive' }),
+ );
+ assert.ok(owner);
+ const store = await openInteractiveArtifactStoreForWrite(owner.lease);
+ const entered = deferred();
+ const fail = deferred();
+ let rejected: PromiseLike<unknown> | undefined;
+ try {
+ const bytes = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1sAAAAASUVORK5CYII=',
+ 'base64',
+ );
+ const image = {
+ type: 'file' as const,
+ mediaType: 'image/png',
+ data: { type: 'data' as const, data: bytes.toString('base64') },
+ };
+ const plan = createReadImageSnapshotPlanner(store);
+ const shared = () =>
+ plan({
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ name: 'Tool Result image',
+ bytes,
+ mimeType: 'image/png',
+ });
+ let ordinal = 0;
+ rejected = Promise.resolve(
+ encodeDurableToolResultOutputWithArtifacts(
+ {
+ type: 'content',
+ value: [
+ image,
+ {
+ ...image,
+ data: {
+ type: 'data',
+ data: Buffer.concat([bytes, Buffer.from([0])]).toString('base64'),
+ },
+ },
+ ],
+ },
+ 'session-1',
+ () => {
+ if (++ordinal === 1) return shared();
+ return {
+ ref: {
+ kind: 'session_file' as const,
+ sessionId: 'session-1',
+ relativePath: 'failed-image',
+ },
+ persist: async () => {
+ entered.resolve();
+ await fail.promise;
+ throw new Error('injected publication failure');
+ },
+ };
+ },
+ ),
+ );
+ await entered.promise;
+ const accepted = await encodeDurableToolResultOutputWithArtifacts(
+ { type: 'content', value: [image] },
+ 'session-1',
+ shared,
+ );
+ assert.equal(accepted.kind, 'content');
+ fail.resolve();
+ assert.equal(((await rejected) as { kind: string }).kind, 'failure');
+ const ref = shared().ref;
+ assert.deepEqual(
+ await store.readDurableAttachmentBinary({
+ sessionId: ref.sessionId,
+ artifactId: ref.relativePath,
+ }),
+ {
+ ok: true,
+ base64: bytes.toString('base64'),
+ mimeType: 'image/png',
+ },
+ );
+ const record = (await store.getInSession(ref.sessionId, ref.relativePath)).record;
+ assert.ok(record);
+ await store.purgeSessionArtifacts('session-1');
+ await assert.rejects(stat(join(root, 'artifacts', record.relativePath)), { code: 'ENOENT' });
+ assert.equal((await store.listPage('session-1', { offset: 0, limit: 10 })).total, 0);
+ assert.deepEqual(
+ await store.readDurableAttachmentBinary({
+ sessionId: ref.sessionId,
+ artifactId: ref.relativePath,
+ }),
+ {
+ ok: false,
+ reason: 'not_found',
+ },
+ );
+ } finally {
+ fail.resolve();
+ await rejected;
+ store.close();
+ await owner.close();
+ await rm(root, { recursive: true, force: true });
+ }
+});
test('Hosted execution publishes contained Tool Artifacts and durable result archives', async () => {
const base = await mkdtemp(join(tmpdir(), 'maka-host-execution-artifacts-'));
@@ -42,9 +157,10 @@ test('Hosted execution publishes contained Tool Artifacts and durable result arc
assert.ok(owner);
try {
const store = await openInteractiveArtifactStoreForWrite(owner.lease);
- await store.recover();
const services = createHostExecutionArtifactServices({
artifacts: store,
+ sessionAdmission: new SessionAdmissionGate(),
+ sessions: { probeSessionRemoval: async () => ({ kind: 'present' }) },
requestDrain: () => assert.fail('successful Artifact writes must not request Host drain'),
});
await services.recordToolArtifacts({
@@ -98,6 +214,26 @@ test('Hosted execution publishes contained Tool Artifacts and durable result arc
{ ok: true, serializedResult },
);
await store.close();
+ restoreArtifactV1Shape(join(base, 'root'));
+ const upgraded = await openInteractiveArtifactStoreForWrite(owner.lease);
+ try {
+ const successor = createHostExecutionArtifactServices({
+ artifacts: upgraded,
+ sessionAdmission: new SessionAdmissionGate(),
+ sessions: { probeSessionRemoval: async () => ({ kind: 'present' }) },
+ requestDrain: () => assert.fail('reading an upgraded archive must not drain'),
+ });
+ assert.deepEqual(
+ await successor.toolResultArchive.services.readToolResultArchive({
+ ...archiveInput,
+ kind: 'maka.archived_tool_result',
+ artifactId: archived.artifactId,
+ }),
+ { ok: true, serializedResult },
+ );
+ } finally {
+ upgraded.close();
+ }
} finally {
await owner.close();
await rm(base, { recursive: true, force: true });
diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts
index b94f08d66a..9b18f5b261 100644
--- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts
+++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts
@@ -647,7 +647,6 @@ test('Host reopens one projected image from its ArtifactStore authority', async
let runtime = createSqliteRuntimeStore(runtimePath);
try {
artifacts = await openInteractiveArtifactStoreForWrite(owner.lease);
- await artifacts.recover();
await runtime.appendRuntimeEvent(sessionId, runId, head);
backend = await createHostAiSdkBackend(
backendCreationFixture({
@@ -737,7 +736,6 @@ test('Host reopens one projected image from its ArtifactStore authority', async
assert.ok(owner);
if (!owner) return;
artifacts = await openInteractiveArtifactStoreForWrite(owner.lease);
- await artifacts.recover();
runtime = createSqliteRuntimeStore(runtimePath);
const recoveredEvents = await runtime.readRuntimeEvents(sessionId, runId);
backend = await createHostAiSdkBackend(
diff --git a/packages/runtime-host/src/__tests__/fixtures/artifact-v1.ts b/packages/runtime-host/src/__tests__/fixtures/artifact-v1.ts
new file mode 100644
index 0000000000..713be8d50f
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/fixtures/artifact-v1.ts
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { DatabaseSync } from 'node:sqlite';
+import { join } from 'node:path';
+
+// Preserve production-written payloads while restoring the released v1 SQL/JSON shape.
+export function restoreArtifactV1Shape(root: string): void {
+ const db = new DatabaseSync(join(root, 'runtime.sqlite'));
+ try {
+ db.exec(`
+ ALTER TABLE artifact_records RENAME TO current_artifacts;
+ DROP INDEX artifact_records_session_order;
+ DROP INDEX artifact_records_relative_path;
+ CREATE TABLE artifact_records (
+ storage_key TEXT PRIMARY KEY, artifact_id TEXT NOT NULL,
+ session_id TEXT NOT NULL, created_at INTEGER NOT NULL CHECK(created_at >= 0),
+ status TEXT NOT NULL CHECK(status IN ('live', 'deleted')),
+ relative_path TEXT NOT NULL, record_json TEXT NOT NULL
+ );
+ INSERT INTO artifact_records
+ SELECT artifact_id, artifact_id, session_id, created_at, 'live', relative_path,
+ json_set(record_json, '$.status', 'live') FROM current_artifacts;
+ DROP TABLE current_artifacts;
+ CREATE INDEX artifact_records_session_order ON artifact_records(session_id, created_at, storage_key);
+ CREATE UNIQUE INDEX artifact_records_relative_path ON artifact_records(relative_path);
+ UPDATE operational_schema_migrations SET version = 1 WHERE scope = 'artifact';
+ `);
+ } finally {
+ db.close();
+ }
+}
diff --git a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
index 553d5d1d4b..7897dde2ea 100644
--- a/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/root-turn-coordinator.test.ts
@@ -5175,7 +5175,6 @@ async function createFailureFixture(options: {
const artifacts = options.withArtifacts
? await openInteractiveArtifactStoreForWrite(owner.lease)
: undefined;
- await artifacts?.recover();
const session = await stores.sessionStore.create({
cwd: capability.canonicalPath,
...(options.legacyConnectionIdentity
diff --git a/packages/runtime-host/src/__tests__/session-admission-gate.test.ts b/packages/runtime-host/src/__tests__/session-admission-gate.test.ts
index 84d9e743ed..f94d87a92f 100644
--- a/packages/runtime-host/src/__tests__/session-admission-gate.test.ts
+++ b/packages/runtime-host/src/__tests__/session-admission-gate.test.ts
@@ -90,15 +90,16 @@ test('serializes overlapping multi-Session admissions without lock-order deadloc
assert.deepEqual(order, ['first:start', 'third', 'first:end', 'second']);
});
-test('keeps the admission open until admitted child work settles', async () => {
+test('keeps the admission open until joined leaf work settles', async () => {
const gate = new SessionAdmissionGate();
const childEntered = deferred();
const releaseChild = deferred();
let outerSettled = false;
const outer = gate
- .run('session', (lease) => {
- void gate.runAdmitted('session', lease, async () => {
+ .run('session', () => {
+ assert.throws(() => gate.runOrJoin('other', () => undefined), /does not match/);
+ void gate.runOrJoin('session', async () => {
childEntered.resolve();
await releaseChild.promise;
});
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..560bf3a3f5 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
@@ -887,7 +887,6 @@ async function seedAuthority(
});
const artifacts = await openInteractiveArtifactStoreForWrite(owner.lease);
const todos = await openInteractiveSessionTodoStoreForWrite(owner.lease);
- await artifacts.recover();
await Promise.all([
artifacts.create({
id: 'retirement-artifact',
@@ -897,7 +896,7 @@ async function seedAuthority(
kind: 'file',
content: 'remove me',
mimeType: 'text/plain',
- source: 'fixture',
+ source: 'tool_result',
now: 1,
}),
artifacts.create({
@@ -908,7 +907,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' }]),
@@ -996,7 +995,6 @@ async function assertRetirementCleanup(
try {
const execution = await openInteractiveExecutionStoresForWrite(owner.lease);
const artifacts = await openInteractiveArtifactStoreForWrite(owner.lease);
- await artifacts.recover();
assert.deepEqual(await execution.sessionStore.listPendingSessionRetirementCleanupIds(), []);
for (const sessionId of sessionIds) {
assert.equal((await artifacts.listPage(sessionId, { offset: 0, limit: 1 })).total, 0);
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..eb0b0e28ff 100644
--- a/packages/runtime-host/src/__tests__/session-effect-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/session-effect-coordinator.test.ts
@@ -22,6 +22,7 @@ import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
+import { restoreArtifactV1Shape } from './fixtures/artifact-v1.js';
import type { SessionHeader } from '@maka/core/session';
import type { RuntimeReadModelSessionView } from '@maka/runtime/runtime-read-model';
import { openInteractiveArtifactStoreForWrite } from '@maka/storage/artifact-stores';
@@ -38,8 +39,8 @@ const connectionContext: ConnectionContext = {
acquireResidency: () => ({ release: () => undefined }),
};
-test('Session recap publishes one protected result and exact retries never repeat provider work', async () => {
- await withHarness(async ({ store, coordinator, modelCalls }) => {
+test('Session recap retries preserve the original result across a v1 upgrade', async () => {
+ await withHarness(async ({ coordinator, modelCalls, reopenFromV1 }) => {
const input = { sessionId: 'session-1', effectId: 'effect-1', reason: 'manual' as const };
const generated = {
ok: true as const,
@@ -65,15 +66,18 @@ test('Session recap publishes one protected result and exact retries never repea
);
assert.equal(modelCalls.count, 1);
+ await coordinator.close();
+ const upgraded = await reopenFromV1();
+ let archived = false;
const successor = createCoordinator(
- store,
+ upgraded,
{
generateTitle: async () => undefined,
generateRecap: async () => assert.fail('a durable exact retry must not call the model'),
},
{
readSessionHeader: async () =>
- ({ isArchived: true, status: 'active' }) as unknown as SessionHeader,
+ ({ isArchived: archived, status: 'active' }) as unknown as SessionHeader,
},
);
assert.deepEqual(
@@ -81,6 +85,7 @@ test('Session recap publishes one protected result and exact retries never repea
generated,
);
assert.equal(modelCalls.count, 1);
+ archived = true;
assert.deepEqual(
await successor.handlers['session.recap.generate'](
{ ...input, reason: 'idle' },
@@ -108,16 +113,21 @@ test('Session recap publishes one protected result and exact retries never repea
},
);
- const records = await store.listPage('session-1', { offset: 0, limit: 10 });
+ const records = await upgraded.listPage('session-1', { offset: 0, limit: 10 });
assert.equal(records.records.length, 2);
for (const record of records.records) {
assert.equal(record.source, 'session_effect');
assert.equal(
- (await store.deleteUserArtifactInSession('session-1', record.id)).kind,
+ (await upgraded.deleteUserArtifactInSession('session-1', record.id)).kind,
'protected',
);
}
+ assert.deepEqual(
+ await successor.handlers['session.recap.generate'](input, connectionContext),
+ generated,
+ );
await successor.close();
+ upgraded.close();
});
});
@@ -422,6 +432,7 @@ async function withHarness(
coordinator: HostSessionEffectCoordinator;
modelCalls: { count: number };
admission: SessionAdmissionGate;
+ reopenFromV1(): Promise<Awaited<ReturnType<typeof openInteractiveArtifactStoreForWrite>>>;
}) => Promise<void>,
overrideModel?: HostSessionEffectModel,
options?: CoordinatorOptions,
@@ -432,7 +443,6 @@ async function withHarness(
assert.ok(owner);
try {
const store = await openInteractiveArtifactStoreForWrite(owner.lease);
- await store.recover();
const modelCalls = { count: 0 };
const model: HostSessionEffectModel =
overrideModel ??
@@ -450,7 +460,17 @@ async function withHarness(
} satisfies HostSessionEffectModel);
const admission = options?.admission ?? new SessionAdmissionGate();
const coordinator = createCoordinator(store, model, { ...options, admission });
- await run({ store, coordinator, modelCalls, admission });
+ await run({
+ store,
+ coordinator,
+ modelCalls,
+ admission,
+ reopenFromV1: async () => {
+ store.close();
+ restoreArtifactV1Shape(root);
+ return openInteractiveArtifactStoreForWrite(owner.lease);
+ },
+ });
await coordinator.close();
} finally {
await owner.close();
diff --git a/packages/runtime-host/src/__tests__/session-effect-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-effect-two-client-uds.test.ts
index ad46b006b3..618c255cb4 100644
--- a/packages/runtime-host/src/__tests__/session-effect-two-client-uds.test.ts
+++ b/packages/runtime-host/src/__tests__/session-effect-two-client-uds.test.ts
@@ -88,7 +88,7 @@ test('two Clients share one durable Session recap effect', async () => {
...coordinator.handlers,
},
beginDrain: () => coordinator.beginDrain(),
- recover: () => artifacts.recover(),
+ recover: async () => {},
close: async () => {
await coordinator.close();
artifacts.close();
diff --git a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts
index c191cf0a0b..163e8fd10b 100644
--- a/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/session-retirement-coordinator.test.ts
@@ -22,6 +22,11 @@ import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
+import fsPromises from 'node:fs/promises';
+import { syncBuiltinESMExports } from 'node:module';
+import { openInteractiveArtifactStoreForWrite } from '@maka/storage/artifact-stores';
+import { resolveStorageRoot, tryAcquireInteractiveRootOwner } from '@maka/storage/root-authority';
+import { createHostExecutionArtifactServices } from '../server/execution-artifacts.js';
import { describe, test } from 'node:test';
import type { AgentGraphOperatorProvisionRequest } from '@maka/core/agent-graph-topology';
import type { CreateSessionInput } from '@maka/core/runtime-inputs';
@@ -52,6 +57,116 @@ const CONNECTION_CONTEXT: ConnectionContext = {
};
describe('Host Session retirement coordinator', () => {
+ test('retirement finalizes admitted patches and fences late artifact publication', async (t) => {
+ await withHarness(async (harness) => {
+ const capability = await resolveStorageRoot({
+ path: harness.workspaceRoot,
+ kind: 'interactive',
+ });
+ const owner = await tryAcquireInteractiveRootOwner(capability);
+ assert.ok(owner);
+ const artifacts = await openInteractiveArtifactStoreForWrite(owner.lease);
+ const source = join(harness.workspaceRoot, 'late.txt');
+ await fsPromises.writeFile(source, 'late payload');
+ let entered!: () => void;
+ let release!: () => void;
+ const started = new Promise<void>((resolve) => {
+ entered = resolve;
+ });
+ const blocked = new Promise<void>((resolve) => {
+ release = resolve;
+ });
+ const original = fsPromises.realpath;
+ const pause = t.mock.method(
+ fsPromises,
+ 'realpath',
+ async (...args: Parameters<typeof original>) => {
+ if (args[0] === source) {
+ entered();
+ await blocked;
+ }
+ return original(...args);
+ },
+ );
+ syncBuiltinESMExports();
+ let recording: Promise<void> | undefined;
+ try {
+ harness.purgeArtifact = (sessionId) => artifacts.purgeSessionArtifacts(sessionId);
+ const recorder = createHostExecutionArtifactServices({
+ artifacts,
+ requestDrain: () => assert.fail('unexpected drain'),
+ sessionAdmission: harness.admission,
+ sessions: harness.store,
+ });
+ const patch = {
+ turnId: 'turn-1',
+ patch: Buffer.from('workspace patch'),
+ binding: {
+ schemaVersion: 1 as const,
+ kind: 'git_worktree' as const,
+ leaseId: `subagent_worktree_${'a'.repeat(32)}`,
+ gitCommonDir: join(harness.workspaceRoot, '.git'),
+ worktreePath: harness.workspaceRoot,
+ branch: `maka/subagent/${'a'.repeat(32)}`,
+ baseCommit: 'b'.repeat(40),
+ },
+ };
+ harness.finalizeWorkspacePatches = async (sessionId) => {
+ const published = await recorder.publishChildWorkspacePatch({ ...patch, sessionId });
+ assert.equal(published.source, 'subagent_writeback');
+ assert.deepEqual(await artifacts.readTextInSession(sessionId, published.id), {
+ ok: true,
+ text: 'workspace patch',
+ });
+ };
+ recording = recorder.recordToolArtifacts({
+ sessionId: harness.revisionId,
+ turnId: 'turn-1',
+ toolUseId: 'tool-1',
+ toolName: 'Write',
+ args: {},
+ result: {},
+ cwd: harness.workspaceRoot,
+ candidates: [{ kind: 'file', name: 'late.txt', sourcePath: source }],
+ });
+ await started;
+ const target = await harness.store.readHeaderRecordSnapshot(harness.revisionId);
+ const removed = await harness.coordinator.handlers['session.remove'](
+ {
+ sessionId: harness.revisionId,
+ expectedRevision: target.revision,
+ },
+ CONNECTION_CONTEXT,
+ );
+ assert.equal(removed.ok, true);
+ await waitFor(
+ async () => (await harness.store.listPendingSessionRetirementCleanupIds()).length === 0,
+ 'retirement cleanup completes while source read is paused',
+ );
+ release();
+ await recording;
+ await assert.rejects(
+ recorder.publishChildWorkspacePatch({ ...patch, sessionId: harness.revisionId }),
+ /retired before patch publication/,
+ );
+ assert.deepEqual(
+ (await artifacts.listPage(harness.revisionId, { offset: 0, limit: 10 })).records,
+ [],
+ );
+ } finally {
+ release();
+ try {
+ await recording;
+ } finally {
+ pause.mock.restore();
+ syncBuiltinESMExports();
+ artifacts.close();
+ await owner.close();
+ }
+ }
+ });
+ });
+
test('retires context refs before draining every physical garbage batch', async () => {
const contextActions: string[] = [];
let garbageBatches = 0;
diff --git a/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts b/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts
index a963491729..e8fb45f78a 100644
--- a/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts
+++ b/packages/runtime-host/src/__tests__/session-revision-graph-references.test.ts
@@ -231,9 +231,6 @@ test('Agent Graph revision references outlive the Artifacts they name', async ()
// still fails on.
const reclaimed = await prepare({ artifactMissing: true });
assert.equal(reclaimed.ok, true);
-
- const userDeleted = await prepare({ artifactStatus: 'deleted' });
- assert.equal(userDeleted.ok, true);
});
test('Agent Graph revision references reject invalid ownership boundaries', async () => {
@@ -331,7 +328,6 @@ interface PrepareOverrides {
readonly sessionGraphState?: 'absent' | 'live' | 'terminal';
readonly graphState?: 'absent' | 'live' | 'terminal';
readonly artifactTurnId?: string;
- readonly artifactStatus?: 'live' | 'deleted';
readonly artifactMissing?: boolean;
readonly childActive?: boolean;
}
@@ -370,7 +366,7 @@ async function prepare(overrides: PrepareOverrides = {}) {
kind: 'file',
relativePath: 'result.txt',
sizeBytes: 1,
- status: overrides.artifactStatus ?? 'live',
+ source: 'tool_result',
},
}),
},
diff --git a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts
index efdc7f4895..8e039340ca 100644
--- a/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts
+++ b/packages/runtime-host/src/__tests__/session-revision-two-client-uds.test.ts
@@ -810,7 +810,6 @@ async function seedSource(
const execution = await openInteractiveExecutionStoresForWrite(owner.lease);
const artifacts = await openInteractiveArtifactStoreForWrite(owner.lease);
const todos = await openInteractiveSessionTodoStoreForWrite(owner.lease);
- await artifacts.recover();
const source = await execution.sessionStore.create({
cwd: root,
name: 'Source Session',
@@ -1689,7 +1688,6 @@ async function verifyDurableBranch(
const execution = await openInteractiveExecutionStoresForWrite(owner.lease);
const artifacts = await openInteractiveArtifactStoreForWrite(owner.lease);
const todos = await openInteractiveSessionTodoStoreForWrite(owner.lease);
- await artifacts.recover();
// Every copy kind that retains the upload turn must carry a rewritten,
// readable copy of the user-uploaded attachment (regression guard for the
// turn-scoped-only artifact selection that dropped user uploads).
diff --git a/packages/runtime-host/src/protocol/artifact.ts b/packages/runtime-host/src/protocol/artifact.ts
index 9b9e2b06d6..0c78f530ee 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';
@@ -65,9 +63,9 @@ const ARTIFACT_REQUIRED_FIELDS = [
'name',
'kind',
'sizeBytes',
- 'status',
+ 'source',
] as const;
-const ARTIFACT_FIELDS = new Set([...ARTIFACT_REQUIRED_FIELDS, 'mimeType', 'source', 'summary']);
+const ARTIFACT_FIELDS = new Set([...ARTIFACT_REQUIRED_FIELDS, 'mimeType', 'summary']);
export type ArtifactRevision = `sha256:${string}`;
@@ -80,9 +78,8 @@ export interface ArtifactProjection {
readonly kind: ArtifactKind;
readonly sizeBytes: number;
readonly mimeType?: string;
- readonly source?: ArtifactSource;
+ readonly source: ArtifactSource;
readonly summary?: string;
- readonly status: ArtifactStatus;
}
export type ArtifactQueryInput =
@@ -158,7 +155,6 @@ export interface ArtifactDeleteInput {
export interface ArtifactDeleteResult {
readonly kind: 'deleted';
- readonly artifact: ArtifactProjection;
}
export type ArtifactIngestInput =
@@ -549,9 +545,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;
}
@@ -570,11 +566,10 @@ export function encodeArtifactProjection(record: ArtifactRecord): ArtifactProjec
...(record.mimeType === undefined
? {}
: { mimeType: projectArtifactText(record.mimeType, ARTIFACT_MIME_TYPE_MAX_BYTES) }),
- ...(record.source === undefined ? {} : { source: record.source }),
+ source: record.source,
...(record.summary === undefined
? {}
: { summary: projectArtifactText(record.summary, ARTIFACT_SUMMARY_MAX_BYTES) }),
- status: record.status,
};
}
@@ -594,13 +589,12 @@ 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),
}
: {}),
- ...(Object.hasOwn(record, 'source') ? { source: artifactSource(record.source) } : {}),
+ source: artifactSource(record.source),
...(Object.hasOwn(record, 'summary')
? { summary: boundedText(record.summary, 'artifact summary', ARTIFACT_SUMMARY_MAX_BYTES) }
: {}),
@@ -678,7 +672,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 +722,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..b4223846eb 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -101,7 +101,10 @@ 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 = 116 as const;
+// 116: User deletion rejects workflow-owned Artifacts with operation_conflict.
+// 115: Artifact creation requires explicit source ownership.
+// 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..9b507ba550 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;
}
@@ -484,16 +484,13 @@ export class HostArtifactCoordinator {
ok: false,
error: {
code: 'operation_conflict',
- message: 'Protected runtime evidence cannot be deleted through Runtime Host',
+ message: 'Runtime-owned evidence cannot be deleted independently of its workflow',
},
};
}
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..43186e196f 100644
--- a/packages/runtime-host/src/server/execution-artifacts.ts
+++ b/packages/runtime-host/src/server/execution-artifacts.ts
@@ -35,9 +35,13 @@ import {
} from '@maka/runtime/context-budget';
import { type ToolResultArchiveResourceReadInput } from '@maka/runtime/tool-result-archive-resource';
import type { InteractiveArtifactStoreWriter } from '@maka/storage/artifact-stores';
+import type { SessionManagerDeps } from '@maka/runtime/session-manager';
+import type { SessionAdmissionGate } from './session-admission-gate.js';
+import type { SessionPresenceReader } from './session-presence.js';
export interface HostExecutionArtifactServices {
recordToolArtifacts(event: ToolArtifactRecorderInput): Promise<void>;
+ publishChildWorkspacePatch: NonNullable<SessionManagerDeps['publishChildWorkspacePatch']>;
/**
* One archive authority over the session artifact store (#2026). All three
* reads and the writer address the same store, so the host has no way to hand
@@ -49,6 +53,8 @@ export interface HostExecutionArtifactServices {
export function createHostExecutionArtifactServices(input: {
artifacts: InteractiveArtifactStoreWriter;
requestDrain: () => void;
+ sessionAdmission: SessionAdmissionGate;
+ sessions: SessionPresenceReader;
}): HostExecutionArtifactServices {
const runWrite = async <T>(operation: () => Promise<T>): Promise<T> => {
try {
@@ -59,6 +65,13 @@ export function createHostExecutionArtifactServices(input: {
}
};
+ const publish = (artifact: Parameters<InteractiveArtifactStoreWriter['create']>[0]) =>
+ input.sessionAdmission.runOrJoin(artifact.sessionId, async () => {
+ if ((await input.sessions.probeSessionRemoval(artifact.sessionId)).kind !== 'present')
+ return null;
+ return runWrite(() => input.artifacts.create(artifact));
+ });
+
const recordToolArtifacts = async (event: ToolArtifactRecorderInput): Promise<void> => {
for (const candidate of event.candidates) {
let content = candidate.content;
@@ -66,29 +79,43 @@ export function createHostExecutionArtifactServices(input: {
content = (await readBoundedSourceFile(event.cwd, candidate.sourcePath)) ?? undefined;
}
if (content === undefined || contentBytes(content) > MAX_ATTACHMENT_BYTES) continue;
- await runWrite(() =>
- input.artifacts.create({
- sessionId: event.sessionId,
- turnId: event.turnId,
- name: candidate.name,
- kind: candidate.kind,
- content,
- ...(candidate.mimeType ? { mimeType: candidate.mimeType } : {}),
- source: candidate.source ?? 'tool_result',
- ...(candidate.summary ? { summary: candidate.summary } : {}),
- }),
- );
+ await publish({
+ sessionId: event.sessionId,
+ turnId: event.turnId,
+ name: candidate.name,
+ kind: candidate.kind,
+ content,
+ ...(candidate.mimeType ? { mimeType: candidate.mimeType } : {}),
+ source: candidate.source ?? 'tool_result',
+ ...(candidate.summary ? { summary: candidate.summary } : {}),
+ });
}
};
const services: HostExecutionArtifactServices = {
recordToolArtifacts,
+ publishChildWorkspacePatch: async ({ sessionId, turnId, binding, patch }) => {
+ const artifact = await publish({
+ id: subagentWritebackArtifactId(sessionId, turnId),
+ sessionId,
+ turnId,
+ name: 'workspace.patch',
+ kind: 'diff',
+ content: patch,
+ mimeType: 'text/x-diff; charset=utf-8',
+ source: 'subagent_writeback',
+ summary: `Workspace changes relative to ${binding.baseCommit}.`,
+ });
+ if (!artifact)
+ throw new Error(`Child Session ${sessionId} was retired before patch publication`);
+ return artifact;
+ },
toolResultArchive: createToolResultArchiveCapability({
archiveToolResult: (event: ToolResultArchiveRecorderInput) =>
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,
@@ -123,6 +150,17 @@ export function createHostExecutionArtifactServices(input: {
return Object.freeze(services);
}
+function subagentWritebackArtifactId(sessionId: string, turnId: string): string {
+ const digest = createHash('sha256')
+ .update('maka-subagent-writeback-v1\0')
+ .update(sessionId)
+ .update('\0')
+ .update(turnId)
+ .digest('hex')
+ .slice(0, 32);
+ return `subagent_writeback_${digest}`;
+}
+
async function readBoundedSourceFile(cwd: string, sourcePath: string): Promise<Buffer | null> {
const candidate = isAbsolute(sourcePath) ? sourcePath : resolve(cwd, sourcePath);
let root: string;
@@ -171,7 +209,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/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts
index 2e38c38f8d..d6fad15d56 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<typeof createAgentGraphControlStore> | undefined;
let graphClient: HostAgentGraphCoordinator | undefined;
@@ -419,6 +415,8 @@ export async function createExecutionRuntimeHostComposition(
const executionArtifacts = createHostExecutionArtifactServices({
artifacts: openedArtifactStore,
requestDrain: context.requestDrain,
+ sessionAdmission,
+ sessions: stores.sessionStore,
});
const builtinTools = {
shellRuns: runtimeResources,
@@ -1064,18 +1062,7 @@ export async function createExecutionRuntimeHostComposition(
worktreeChildExecutor,
listArtifactsForTurn: (sessionId, turnId) =>
openedArtifactStore.listTurnArtifacts(sessionId, turnId),
- publishChildWorkspacePatch: ({ sessionId, turnId, binding, patch }) =>
- openedArtifactStore.create({
- id: subagentWritebackArtifactId(sessionId, turnId),
- sessionId,
- turnId,
- name: 'workspace.patch',
- kind: 'diff',
- content: patch,
- mimeType: 'text/x-diff; charset=utf-8',
- source: 'subagent_writeback',
- summary: `Workspace changes relative to ${binding.baseCommit}.`,
- }),
+ publishChildWorkspacePatch: executionArtifacts.publishChildWorkspacePatch,
assertChildWorkspaceQuiescent: async (sessionId) => {
if (await runtimeResources!.hasLiveSessionResources(sessionId)) {
throw new Error(
@@ -1762,22 +1749,6 @@ export async function createExecutionRuntimeHostComposition(
recovery: {
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 +1765,6 @@ export async function createExecutionRuntimeHostComposition(
() => {
unsubscribeTranscriptChanges?.();
unsubscribeUsageChanges?.();
- stopRetiredCaptureSweep?.();
},
],
releaseConnection: [(connectionId) => artifacts.releaseConnection(connectionId)],
@@ -2158,17 +2128,6 @@ function adaptWorkspaceFilesystemWorker(
};
}
-function subagentWritebackArtifactId(sessionId: string, turnId: string): string {
- const digest = createHash('sha256')
- .update('maka-subagent-writeback-v1\0')
- .update(sessionId)
- .update('\0')
- .update(turnId)
- .digest('hex')
- .slice(0, 32);
- return `subagent_writeback_${digest}`;
-}
-
function requireContinuity(
continuity: SessionContinuityCoordinator | undefined,
): SessionContinuityCoordinator {
diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts
index cc495950e2..f06cb71872 100644
--- a/packages/runtime-host/src/server/execution-model-composition.ts
+++ b/packages/runtime-host/src/server/execution-model-composition.ts
@@ -101,7 +101,7 @@ type HostExecutionRuntimePolicyAuthority = {
type HostExecutionArtifactAuthority = Pick<
InteractiveArtifactStoreWriter,
- 'create' | 'createOwned' | 'readDurableAttachmentBinary' | 'deleteOwnedArtifactInSession'
+ 'create' | 'readDurableAttachmentBinary'
>;
type HostExecutionUsageAuthority = {
@@ -329,11 +329,7 @@ async function buildHostAiSdkBackend(
);
}
: undefined;
- const planProjectionImage = createReadImageSnapshotPlanner(
- input.artifacts,
- (sessionId, artifactId) =>
- input.artifacts.deleteOwnedArtifactInSession(sessionId, artifactId, 'tool_result_projection'),
- );
+ const planProjectionImage = createReadImageSnapshotPlanner(input.artifacts);
try {
return new HostAiSdkBackend(
diff --git a/packages/runtime-host/src/server/session-admission-gate.ts b/packages/runtime-host/src/server/session-admission-gate.ts
index 69576581d3..162c2f7b3a 100644
--- a/packages/runtime-host/src/server/session-admission-gate.ts
+++ b/packages/runtime-host/src/server/session-admission-gate.ts
@@ -27,6 +27,7 @@ export interface SessionAdmissionLease {
interface SessionAdmissionContext {
readonly sessionIds: ReadonlySet<string>;
+ readonly lease: SessionAdmissionLease;
active: boolean;
}
@@ -74,6 +75,15 @@ export class SessionAdmissionGate {
return this.#runQueued(sessionIds, operation);
}
+ /** Leaf work may join an existing admission, but never acquire a second Session inside it. */
+ runOrJoin<T>(sessionId: string, operation: () => Promise<T> | T): Promise<T> {
+ const inherited = this.#context.getStore();
+ if (inherited?.active) {
+ return this.runAdmitted(sessionId, inherited.lease, operation);
+ }
+ return this.run(sessionId, operation);
+ }
+
enqueueDetached(
sessionId: string,
operation: (lease: SessionAdmissionLease) => Promise<void> | void,
@@ -150,10 +160,10 @@ export class SessionAdmissionGate {
}
const ownedSessionIds = new Set(sessionIds);
- const context: SessionAdmissionContext = { sessionIds: ownedSessionIds, active: true };
const lease: SessionAdmissionLease = Object.freeze({
[sessionAdmissionLeaseBrand]: true as const,
});
+ const context: SessionAdmissionContext = { sessionIds: ownedSessionIds, lease, active: true };
const state: SessionAdmissionLeaseState = {
sessionIds: ownedSessionIds,
context,
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<Record<string, unknown> | 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 9e9385e462..cd2cd79155 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.
@@ -253,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..7be150d75d 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<void> {
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);
}
}
@@ -148,14 +148,10 @@ describe('Deep Research runtime tools', () => {
it('runs the source-checkpoint-report lifecycle and makes artifact retries idempotent', async () => {
await withTempRoot(async (root) => {
const artifactStore = new FakeArtifactStore();
- const notifications: string[] = [];
const store = createSqliteDeepResearchStore(root);
const tools = buildDeepResearchTools({
store,
artifactStore,
- onArtifactCreated: (event) => {
- notifications.push(event.artifactId);
- },
});
await execute(
@@ -395,13 +391,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)!;
@@ -462,7 +458,6 @@ describe('Deep Research runtime tools', () => {
const status = await execute(tools, DEEP_RESEARCH_STATUS_TOOL_NAME, {}, 'call-status');
assert.match(status, new RegExp(`Final report: ${reportId}`));
assert.match(status, new RegExp(`Handoff artifact: ${handoffId}`));
- assert.equal(notifications.length, 8);
assert.equal((await store.readEvents(SESSION_ID)).length, 16);
});
});
diff --git a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts
index 7a92f11878..a1094c8e1e 100644
--- a/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts
+++ b/packages/runtime/src/__tests__/durable-tool-result-projection.test.ts
@@ -275,9 +275,8 @@ describe('durable Tool Result projection codec', () => {
assert.equal(writes, 0);
});
- it('retracts already-published artifacts when a later one cannot be published', async () => {
+ it('refuses the whole projection when a later artifact cannot be published', async () => {
const published: string[] = [];
- const retracted: string[] = [];
let nextId = 0;
const projection = await encodeDurableToolResultOutputWithArtifacts(
twoInlineImages(),
@@ -290,40 +289,12 @@ describe('durable Tool Result projection codec', () => {
if (relativePath === 'artifact-2') throw new Error('artifact store is unavailable');
published.push(relativePath);
},
- retract: async () => {
- retracted.push(relativePath);
- },
};
},
);
assert.equal(projection.kind, 'failure');
assert.deepEqual(published, ['artifact-1']);
- assert.deepEqual(retracted, ['artifact-1']);
- });
-
- it('still refuses the projection when the retraction itself fails', async () => {
- let nextId = 0;
- const projection = await encodeDurableToolResultOutputWithArtifacts(
- twoInlineImages(),
- 'session-1',
- () => {
- const relativePath = `artifact-${++nextId}`;
- return {
- ref: { kind: 'session_file' as const, sessionId: 'session-1', relativePath },
- persist: async () => {
- if (relativePath === 'artifact-2') throw new Error('artifact store is unavailable');
- },
- // Reclamation may be delayed; admitting a partially published
- // projection may not happen at all.
- retract: async () => {
- throw new Error('cleanup is unavailable too');
- },
- };
- },
- );
-
- assert.equal(projection.kind, 'failure');
});
it('validates default image refs through the same closed schema', () => {
diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts
index 4aff33d521..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;
@@ -1671,6 +1670,30 @@ 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',
+ },
+ {
+ 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',
+ },
+ ],
newId: nextId(),
now: nextNow(40),
});
@@ -1705,6 +1728,7 @@ describe('SessionManager claimed graph intent execution', () => {
status: 'completed',
summary: 'ok',
});
+ assert.deepStrictEqual(result.artifactIds, ['child-output']);
assert.deepStrictEqual(ready, [
{
claimId: claim.claimId,
@@ -9733,7 +9757,7 @@ describe('SessionManager permission mode updates', () => {
kind: 'file',
relativePath: 'artifacts/notes.md',
sizeBytes: 12,
- status: 'live',
+ source: 'tool_result',
},
]
: [],
diff --git a/packages/runtime/src/deep-research-tools.ts b/packages/runtime/src/deep-research-tools.ts
index 2a8c5a4883..f544c30591 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<ArtifactRecord | null>;
readText(
artifactId: string,
- options?: { maxBytes?: number; includeDeleted?: boolean },
+ options?: { maxBytes?: number },
): Promise<{ ok: true; text: string } | { ok: false; reason: string }>;
delete(artifactId: string): Promise<void>;
}
@@ -114,12 +114,6 @@ export interface DeepResearchArtifactStore {
export interface BuildDeepResearchToolsDeps {
store: DeepResearchStore;
artifactStore: DeepResearchArtifactStore;
- onArtifactCreated?: (event: {
- reason: 'created';
- artifactId: string;
- sessionId: string;
- ts: number;
- }) => void | Promise<void>;
}
export function buildDeepResearchTools(deps: BuildDeepResearchToolsDeps): MakaTool[] {
@@ -219,12 +213,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, {
@@ -433,16 +422,6 @@ function buildSaveArtifactTool(deps: BuildDeepResearchToolsDeps): MakaTool<
await deps.artifactStore.delete(artifactId).catch(() => undefined);
throw error;
}
- try {
- await deps.onArtifactCreated?.({
- reason: 'created',
- artifactId,
- sessionId: ctx.sessionId,
- ts: artifact.createdAt,
- });
- } catch {
- // Renderer notification is best effort; both durable authorities already committed.
- }
return `Saved ${input.role} artifact ${artifactId}.\n${renderRunStatus(run)}`;
},
};
@@ -872,7 +851,7 @@ async function validateArtifactIntegrity(
ref: DeepResearchArtifactRef,
): Promise<void> {
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/runtime/src/durable-tool-result-projection.ts b/packages/runtime/src/durable-tool-result-projection.ts
index 376d374e65..f392c0dae6 100644
--- a/packages/runtime/src/durable-tool-result-projection.ts
+++ b/packages/runtime/src/durable-tool-result-projection.ts
@@ -70,8 +70,6 @@ export function encodeDurableToolResultOutput(
interface DurableProjectionArtifactPlan {
ref: Extract<DurableProjectionArtifactRef, { kind: 'session_file' }>;
persist(): Promise<void>;
- /** Undo a publication whose projection is not going to be admitted. */
- retract?(): Promise<void>;
}
type DurableProjectionArtifactPlanner = (input: {
@@ -88,7 +86,6 @@ export function encodeDurableToolResultOutputWithArtifacts(
return encodeDurableToolResultOutput(output, sessionId);
}
return (async () => {
- const publishedPlans: DurableProjectionArtifactPlan[] = [];
try {
const prepared = prepareContentProjection(output, sessionId, planArtifact);
const persisted = new Set<string>();
@@ -96,16 +93,11 @@ export function encodeDurableToolResultOutputWithArtifacts(
if (persisted.has(artifact.ref.relativePath)) continue;
await artifact.persist();
persisted.add(artifact.ref.relativePath);
- publishedPlans.push(artifact);
}
return prepared.projection;
} catch {
- // Publication is all-or-nothing: a projection this codec refuses must not
- // leave images behind that no durable record will ever reference (#4283).
- // Retraction is best effort — a failed retraction only delays reclamation.
- for (const artifact of publishedPlans) {
- await artifact.retract?.().catch(() => undefined);
- }
+ // Refuse partial projections, but never retract shared image bytes:
+ // another projection may already reference them. Session cleanup owns reclamation.
return DURABLE_TOOL_RESULT_PROJECTION_FAILURE;
}
})();
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<ArtifactRecord[]> {
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);
}
/**
diff --git a/packages/storage/src/__tests__/artifact-attachments.test.ts b/packages/storage/src/__tests__/artifact-attachments.test.ts
index d62ce889ca..b9f26a35d8 100644
--- a/packages/storage/src/__tests__/artifact-attachments.test.ts
+++ b/packages/storage/src/__tests__/artifact-attachments.test.ts
@@ -38,6 +38,14 @@ import {
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+async function listArtifacts(store: ArtifactAuthorityStore, sessionId: string) {
+ return (await store.listPage(sessionId, { offset: 0, limit: Number.MAX_SAFE_INTEGER })).records;
+}
+
+function readArtifactBinary(store: ArtifactAuthorityStore, artifactId: string) {
+ return store.readBinaryInSession('session-1', artifactId);
+}
+
describe('artifact attachment authority', () => {
test('reads only live user-uploaded text within the invoking Session', async () => {
await withStore(async (store) => {
@@ -63,7 +71,7 @@ describe('artifact attachment authority', () => {
reader.readAttachmentResource('session-2', 'notes-1', signal),
/not found in this Session/,
);
- await store.delete('notes-1');
+ await store.deleteUserArtifactInSession('session-1', 'notes-1');
await assert.rejects(
reader.readAttachmentResource('session-1', 'notes-1', signal),
/not found in this Session/,
@@ -80,6 +88,7 @@ describe('artifact attachment authority', () => {
name: 'image.png',
kind: 'image',
content: png,
+ source: 'tool_result',
now: 1,
});
const reader = createAttachmentByteReader({
@@ -90,10 +99,10 @@ describe('artifact attachment authority', () => {
ok: true,
bytes: Buffer.from(png),
});
- await store.delete('image-1');
+ await store.deleteUserArtifactInSession('session-1', '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,
@@ -111,6 +120,7 @@ describe('artifact attachment authority', () => {
name: 'large.png',
kind: 'image',
content: new Uint8Array(MAX_ATTACHMENT_BYTES + 1).fill(0x89),
+ source: 'tool_result',
now: 1,
});
const reader = createAttachmentByteReader({
@@ -200,6 +210,7 @@ describe('artifact attachment authority', () => {
name: 'unknown.bin',
kind: 'file',
content: Uint8Array.from([0, 1, 2, 3]),
+ source: 'tool_result',
now: 1,
});
assert.deepEqual(await reader(sessionFileRef('unknown-binary')), {
@@ -221,7 +232,7 @@ describe('artifact attachment authority', () => {
}),
/Image exceeds the 5MB model input limit/,
);
- assert.deepEqual(await store.list('session-1'), []);
+ assert.deepEqual(await listArtifacts(store, 'session-1'), []);
});
});
@@ -240,11 +251,11 @@ describe('artifact attachment authority', () => {
const repeated = await snapshot(input);
assert.deepEqual(repeated, first);
- assert.equal((await store.list('session-1')).length, 1);
+ assert.equal((await listArtifacts(store, 'session-1')).length, 1);
});
});
- test('keeps a durable projection image live when a user requests deletion', async () => {
+ test('protects a durable projection image until its Session is purged', async () => {
await withStore(async (store) => {
const ref = await createReadImageSnapshotter(store)({
sessionId: 'session-1',
@@ -254,13 +265,15 @@ describe('artifact attachment authority', () => {
mimeType: 'image/png',
});
- assert.deepEqual(await store.deleteUserArtifactInSession('session-1', ref.relativePath), {
- kind: 'protected',
- });
- assert.deepEqual(await store.readBinary(ref.relativePath), {
- ok: true,
- base64: Buffer.from(png).toString('base64'),
- mimeType: 'image/png',
+ assert.equal(
+ (await store.deleteUserArtifactInSession('session-1', ref.relativePath)).kind,
+ 'protected',
+ );
+ assert.equal((await readArtifactBinary(store, ref.relativePath)).ok, true);
+ await store.purgeSessionArtifacts('session-1');
+ assert.deepEqual(await readArtifactBinary(store, ref.relativePath), {
+ ok: false,
+ reason: 'not_found',
});
});
});
@@ -277,68 +290,23 @@ describe('artifact attachment authority', () => {
};
const plan = createReadImageSnapshotPlanner(store)(input);
- assert.deepEqual(await store.list('session-1'), []);
+ assert.deepEqual(await listArtifacts(store, 'session-1'), []);
bytes[0] = 0;
input.name = 'mutated after prepare';
await Promise.all([plan.persist(), plan.persist()]);
- const published = await store.list('session-1');
+ const published = await listArtifacts(store, 'session-1');
assert.deepEqual(
published.map((artifact) => artifact.id),
[plan.ref.relativePath],
);
assert.equal(published[0]?.name, 'Tool Result image');
- assert.deepEqual(await store.readBinary(plan.ref.relativePath), {
+ assert.deepEqual(await readArtifactBinary(store, plan.ref.relativePath), {
ok: true,
base64: Buffer.from(png).toString('base64'),
mimeType: 'image/png',
});
});
});
- test('retraction reclaims only what the retracting plan published', async () => {
- await withStore(async (store) => {
- const input = {
- sessionId: 'session-1',
- turnId: 'turn-1',
- name: 'Tool Result image',
- bytes: png.slice(),
- mimeType: 'image/png',
- };
- const deleted: string[] = [];
- // The id is derived from the bytes, so a second projection for the same
- // image in the same Turn gets the first one's record back. Retracting the
- // second must not delete the artifact the first one committed.
- const owned = {
- create: (createInput: Parameters<typeof store.create>[0]) => store.create(createInput),
- createOwned: async (createInput: Parameters<typeof store.create>[0]) => {
- const existing = createInput.id
- ? await store.getInSession(createInput.sessionId, createInput.id)
- : undefined;
- const record = await store.create(createInput);
- return { record, publishedByThisCall: !existing?.record };
- },
- };
- const planner = createReadImageSnapshotPlanner(owned, async (_sessionId, artifactId) => {
- deleted.push(artifactId);
- await store.delete(artifactId);
- });
-
- const first = planner(input);
- await first.persist();
- const second = planner(input);
- await second.persist();
- assert.equal(second.ref.relativePath, first.ref.relativePath);
-
- await second.retract();
-
- assert.deepEqual(deleted, []);
- assert.equal((await store.readBinary(first.ref.relativePath)).ok, true);
-
- await first.retract();
-
- assert.deepEqual(deleted, [first.ref.relativePath]);
- assert.equal((await store.readBinary(first.ref.relativePath)).ok, false);
- });
- });
});
function sessionFileRef(relativePath: string, sessionId = 'session-1'): StorageRef {
@@ -353,7 +321,6 @@ async function withStore(run: (store: ArtifactAuthorityStore) => Promise<void>):
const root = await mkdtemp(join(tmpdir(), 'maka-artifact-attachment-'));
const authority = createSqliteArtifactStoreWriteAuthority(root);
try {
- await authority.recover();
const { store } = authority;
await run(store);
} finally {
diff --git a/packages/storage/src/__tests__/artifact-store.test.ts b/packages/storage/src/__tests__/artifact-store.test.ts
index 15e6eb4334..6119b08a40 100644
--- a/packages/storage/src/__tests__/artifact-store.test.ts
+++ b/packages/storage/src/__tests__/artifact-store.test.ts
@@ -18,6 +18,8 @@
*/
import assert from 'node:assert/strict';
+import fsPromises from 'node:fs/promises';
+import { syncBuiltinESMExports } from 'node:module';
import { createHash } from 'node:crypto';
import {
link,
@@ -41,10 +43,9 @@ import {
} from '@maka/core/artifacts';
import {
ARTIFACT_TEXT_PREVIEW_LIMIT_BYTES,
- type ArtifactStore,
+ type ArtifactAuthorityStore,
type ArtifactStoreWriteAuthority,
type CreateArtifactInput,
- createSqliteArtifactStore,
createSqliteArtifactStoreWriteAuthority,
isSafeRelativeArtifactPath,
resolveArtifactPath,
@@ -61,10 +62,38 @@ function trackArtifactStoreCloser(root: string, close: () => void): void {
artifactStoreClosersByRoot.set(root, closers);
}
-function createArtifactStore(root: string): ArtifactStore {
- const store = createSqliteArtifactStore(root);
- trackArtifactStoreCloser(root, () => store.close?.());
- return store;
+function createArtifactStore(root: string): ArtifactAuthorityStore {
+ const authority = createSqliteArtifactStoreWriteAuthority(root);
+ trackArtifactStoreCloser(root, () => authority.close());
+ return authority.store;
+}
+
+async function listArtifacts(store: ArtifactAuthorityStore, sessionId: string) {
+ return (await store.listPage(sessionId, { offset: 0, limit: Number.MAX_SAFE_INTEGER })).records;
+}
+
+async function getArtifact(
+ store: ArtifactAuthorityStore,
+ artifactId: string,
+ sessionId = 'session-1',
+) {
+ return (await store.getInSession(sessionId, artifactId)).record;
+}
+
+function readArtifactText(
+ store: ArtifactAuthorityStore,
+ artifactId: string,
+ sessionId = 'session-1',
+) {
+ return store.readTextInSession(sessionId, artifactId);
+}
+
+function readArtifactBinary(
+ store: ArtifactAuthorityStore,
+ artifactId: string,
+ sessionId = 'session-1',
+) {
+ return store.readBinaryInSession(sessionId, artifactId);
}
function createArtifactStoreWriteAuthority(root: string): ArtifactStoreWriteAuthority {
@@ -92,7 +121,7 @@ describe('SQLite Artifact store', () => {
assert.equal((await stat(root)).isDirectory(), true);
assert.equal(created.id, 'missing-root');
assert.deepEqual(
- (await createArtifactStore(root).list('session-1')).map((record) => record.id),
+ (await listArtifacts(createArtifactStore(root), 'session-1')).map((record) => record.id),
['missing-root'],
);
} finally {
@@ -115,25 +144,27 @@ describe('SQLite Artifact store', () => {
assert.equal(first.relativePath, 'session-1/artifact-1-artifact-1.txt');
assert.equal(first.sizeBytes, 7);
assert.deepEqual(
- (await store.list('session-1')).map((record) => record.id),
+ (await listArtifacts(store, 'session-1')).map((record) => record.id),
['artifact-2', 'artifact-1'],
);
- assert.deepEqual(await store.readText(first.id), { ok: true, text: '# Notes' });
+ assert.deepEqual(await readArtifactText(store, first.id), { ok: true, text: '# Notes' });
const reopened = createArtifactStore(root);
- assert.deepEqual(await reopened.get(second.id), second);
+ assert.deepEqual(await getArtifact(reopened, second.id), second);
await assert.rejects(
() => reopened.create(artifactInput('artifact-1', 'replacement', 300)),
/Artifact artifact-1 already exists/,
);
- assert.deepEqual(await reopened.readText('artifact-1'), { ok: true, text: '# Notes' });
+ assert.deepEqual(await readArtifactText(reopened, 'artifact-1'), {
+ ok: true,
+ text: '# Notes',
+ });
});
});
test('lists only live Artifacts committed by one exact Turn', async () => {
await withWorkspace(async (root) => {
const authority = createArtifactStoreWriteAuthority(root);
- await authority.recover();
const { store } = authority;
await store.create({ ...artifactInput('turn-a-new', 'new', 30), turnId: 'turn-a' });
await store.create({ ...artifactInput('turn-b', 'other turn', 20), turnId: 'turn-b' });
@@ -168,7 +199,10 @@ describe('SQLite Artifact store', () => {
const input = { ...artifactInput(id, 'compacted', 1), turnId };
const created = await createArtifactStore(root).create(input);
assert.equal(created.turnId, input.turnId);
- assert.equal((await createArtifactStore(root).get(created.id))?.turnId, input.turnId);
+ assert.equal(
+ (await getArtifact(createArtifactStore(root), created.id))?.turnId,
+ input.turnId,
+ );
}
for (const turnId of [
@@ -192,7 +226,6 @@ describe('SQLite Artifact store', () => {
test('owns stable session revisions across paging, reopen, mutations, and no-ops', async () => {
await withWorkspace(async (root) => {
const authority = createArtifactStoreWriteAuthority(root);
- await authority.recover();
const { store } = authority;
const empty = await store.listPage('session-1', { offset: 0, limit: 2 });
assert.equal(empty.total, 0);
@@ -215,7 +248,6 @@ describe('SQLite Artifact store', () => {
assert.equal((await store.getInSession('session-1', 'first')).revision, created.revision);
const reopenedAuthority = createArtifactStoreWriteAuthority(root);
- await reopenedAuthority.recover();
const reopenedPage = await reopenedAuthority.store.listPage('session-1', {
offset: 0,
limit: 2,
@@ -245,8 +277,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,9 +293,9 @@ 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']);
+ await store.purgeSessionArtifacts('session-1');
const purged = await store.listPage('session-1', { offset: 0, limit: 2 });
assert.equal(purged.revision, empty.revision);
assert.equal(purged.total, 0);
@@ -267,7 +305,6 @@ describe('SQLite Artifact store', () => {
test('copies an exact turn-scoped Artifact snapshot and purges only the target Session', async () => {
await withWorkspace(async (root) => {
const authority = createArtifactStoreWriteAuthority(root);
- await authority.recover();
const { store } = authority;
const retained = await store.create({
...artifactInput('retained-artifact', 'retained', 10),
@@ -278,7 +315,7 @@ describe('SQLite Artifact store', () => {
...artifactInput('deleted-artifact', 'deleted', 11),
turnId: 'turn-retained',
});
- await store.delete(deleted.id);
+ await store.deleteUserArtifactInSession('session-1', deleted.id);
await store.create({
...artifactInput('later-artifact', 'later', 20),
turnId: 'turn-later',
@@ -292,158 +329,31 @@ 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');
+ const target = await listArtifacts(store, 'session-copy');
assert.equal(target.length, 1);
assert.equal(target[0]?.id, copiedId);
assert.equal(target[0]?.turnId, retained.turnId);
assert.equal(copied.relativePaths.get(retained.relativePath), target[0]?.relativePath);
- assert.deepEqual(await store.readText(copiedId!), {
+ assert.deepEqual(await readArtifactText(store, copiedId!, 'session-copy'), {
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'), []);
- assert.deepEqual((await store.list('session-1')).map((record) => record.id).sort(), [
- 'later-artifact',
- 'retained-artifact',
- ]);
- });
- });
-
- 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),
- ['fixture'],
- );
- // 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),
- ['fixture'],
- );
-
- // 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 listArtifacts(store, 'session-copy'), []);
assert.deepEqual(
- (await store.list('session-1', { includeDeleted: true })).map((record) => record.id),
- ['kept-artifact'],
+ (await listArtifacts(store, 'session-1')).map((record) => record.id).sort(),
+ ['later-artifact', 'retained-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);
- await authority.recover();
const { store } = authority;
await store.create({
...artifactInput('retained-artifact', 'retained', 10),
@@ -464,17 +374,16 @@ describe('SQLite Artifact store', () => {
assert.equal(copied.artifactIds.has('excluded-archive'), false);
assert.deepEqual(
- (await store.list('session-copy')).map((record) => record.name),
+ (await listArtifacts(store, 'session-copy')).map((record) => record.name),
['retained-artifact.txt'],
);
- assert.equal((await store.get('excluded-archive'))?.sessionId, 'session-1');
+ assert.equal((await getArtifact(store, 'excluded-archive'))?.sessionId, 'session-1');
});
});
test('copies explicit linked child Artifacts into a conversation snapshot', async () => {
await withWorkspace(async (root) => {
const authority = createArtifactStoreWriteAuthority(root);
- await authority.recover();
const { store } = authority;
await store.create({
...artifactInput('child-artifact', 'child result', 10),
@@ -491,16 +400,21 @@ describe('SQLite Artifact store', () => {
const copiedId = copied.artifactIds.get('child-artifact');
assert.ok(copiedId);
- assert.deepEqual(await store.readText(copiedId), { ok: true, text: 'child result' });
- assert.equal((await store.get(copiedId))?.sessionId, 'session-copy');
- assert.equal((await store.get('child-artifact'))?.sessionId, 'child-session');
+ assert.deepEqual(await readArtifactText(store, copiedId, 'session-copy'), {
+ ok: true,
+ text: 'child result',
+ });
+ assert.equal((await getArtifact(store, copiedId, 'session-copy'))?.sessionId, 'session-copy');
+ assert.equal(
+ (await getArtifact(store, 'child-artifact', 'child-session'))?.sessionId,
+ 'child-session',
+ );
});
});
test('includes explicit same-Session Artifacts outside the copied turns', async () => {
await withWorkspace(async (root) => {
const authority = createArtifactStoreWriteAuthority(root);
- await authority.recover();
const { store } = authority;
await store.create({
...artifactInput('retained-artifact', 'retained', 10),
@@ -529,11 +443,14 @@ describe('SQLite Artifact store', () => {
});
const copiedUploadId = withInclude.artifactIds.get(upload.id);
assert.ok(copiedUploadId);
- assert.deepEqual(await store.readText(copiedUploadId), {
+ assert.deepEqual(await readArtifactText(store, copiedUploadId, 'session-copy-2'), {
ok: true,
text: 'uploaded bytes',
});
- assert.equal((await store.get(copiedUploadId))?.sessionId, 'session-copy-2');
+ assert.equal(
+ (await getArtifact(store, copiedUploadId, 'session-copy-2'))?.sessionId,
+ 'session-copy-2',
+ );
// Unknown include ids are a no-op, not an error.
const withUnknown = await store.copyConversationArtifacts({
sourceSessionId: 'session-1',
@@ -545,31 +462,35 @@ describe('SQLite Artifact store', () => {
});
});
- test('user delete evaluates current-generation policy before tombstone state', async () => {
+ test('user deletion respects the current artifact source while owner cleanup remains possible', async () => {
await withWorkspace(async (root) => {
const authority = createArtifactStoreWriteAuthority(root);
- await authority.recover();
const { store } = authority;
const input = artifactInput('current-policy', 'replaceable', 1);
await store.create(input);
- await store.purge([input.id]);
- const protectedRecord = await store.create({
+ await store.deleteUserArtifactInSession(input.sessionId, input.id);
+ await store.create({
...input,
content: 'protected replacement',
source: 'deep_research',
});
- assert.deepEqual(await store.deleteUserArtifactInSession(input.sessionId, input.id), {
- kind: 'protected',
+ assert.equal(
+ (await store.deleteUserArtifactInSession(input.sessionId, input.id)).kind,
+ 'protected',
+ );
+ assert.equal((await getArtifact(store, input.id))?.source, 'deep_research');
+ assert.deepEqual(await store.readTextInSession(input.sessionId, input.id), {
+ ok: true,
+ text: 'protected replacement',
});
- assert.deepEqual(await store.get(input.id), protectedRecord);
assert.deepEqual(await store.deleteUserArtifactInSession('different-session', input.id), {
kind: 'not_found',
});
- await store.delete(input.id);
+ await store.purgeSessionArtifacts(input.sessionId);
assert.deepEqual(await store.deleteUserArtifactInSession(input.sessionId, input.id), {
- kind: 'protected',
+ kind: 'not_found',
});
});
});
@@ -602,12 +523,14 @@ describe('SQLite Artifact store', () => {
const created = await createArtifactStore(root).create(input);
const reopened = createArtifactStore(root);
- assert.deepEqual(await reopened.list(input.sessionId), [
+ assert.deepEqual(await listArtifacts(reopened, input.sessionId), [
created,
- ...(await reopened.list(input.sessionId)).filter((record) => record.id !== created.id),
+ ...(await listArtifacts(reopened, input.sessionId)).filter(
+ (record) => record.id !== created.id,
+ ),
]);
- assert.deepEqual(await reopened.get(created.id), created);
- assert.deepEqual(await reopened.readText(created.id), {
+ assert.deepEqual(await getArtifact(reopened, created.id), created);
+ assert.deepEqual(await readArtifactText(reopened, created.id), {
ok: true,
text: input.content,
});
@@ -616,15 +539,17 @@ describe('SQLite Artifact store', () => {
});
});
- test('rejects metadata names that neither writer could have produced', async () => {
+ test('ignores metadata names that neither writer could have produced', async () => {
for (const name of ['short ', 'embedded\ttab', 'embedded\nnewline']) {
await withWorkspace(async (root) => {
await writeArtifactMetadata(root, [
canonicalRecord({ id: 'invalid-name', sessionId: 'session-1', name, sizeBytes: 0 }),
]);
- await assert.rejects(
- () => createArtifactStore(root).list('session-1', { includeDeleted: true }),
- /Invalid artifact metadata record 1/,
+ const store = createArtifactStore(root);
+ assert.deepEqual(await listArtifacts(store, 'session-1'), []);
+ assert.equal(
+ (await store.create(artifactInput('replacement', 'kept', 1))).id,
+ 'replacement',
);
});
}
@@ -640,16 +565,13 @@ describe('SQLite Artifact store', () => {
const created = await createArtifactStore(root).create(input);
assert.equal(created.name, 'a'.repeat(119));
- const targetPath = join(root, 'artifacts', created.relativePath);
- const stagingPath = publicationStagingPath(targetPath);
- await writeFile(stagingPath, input.content, { flag: 'wx' });
-
const authority = createArtifactStoreWriteAuthority(root);
const reopened = authority.store;
- await authority.recover();
- await assert.rejects(() => stat(stagingPath), { code: 'ENOENT' });
assert.deepEqual(await reopened.create(input), created);
- assert.deepEqual(await reopened.readText(created.id), { ok: true, text: input.content });
+ assert.deepEqual(await readArtifactText(reopened, created.id), {
+ ok: true,
+ text: input.content,
+ });
});
});
@@ -683,13 +605,13 @@ describe('SQLite Artifact store', () => {
});
const reopened = createArtifactStore(root);
- assert.deepEqual(await reopened.get(report.id), report);
- assert.deepEqual(await reopened.get(archive.id), archive);
- assert.deepEqual(await reopened.readText(report.id), {
+ assert.deepEqual(await getArtifact(reopened, report.id), report);
+ assert.deepEqual(await getArtifact(reopened, archive.id), archive);
+ assert.deepEqual(await readArtifactText(reopened, report.id), {
ok: true,
text: '<h1>Research</h1>',
});
- assert.deepEqual(await reopened.readText(archive.id), {
+ assert.deepEqual(await readArtifactText(reopened, archive.id), {
ok: true,
text: '{"ok":true}',
});
@@ -706,7 +628,7 @@ describe('SQLite Artifact store', () => {
const replayed = await createArtifactStore(root).create(input);
assert.deepEqual(replayed, first);
await assert.rejects(() => stat(metadataPath), { code: 'ENOENT' });
- assert.deepEqual(await createArtifactStore(root).readText(first.id), {
+ assert.deepEqual(await readArtifactText(createArtifactStore(root), first.id), {
ok: true,
text: '# Durable result',
});
@@ -732,31 +654,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');
+ await store.deleteOwnedArtifactInSession(input.sessionId, first.id, input.source);
+ assert.equal(await getArtifact(store, first.id), null);
- const revived = await createArtifactStore(root).create(input);
- assert.deepEqual(revived, { ...first, status: 'live' });
- assert.deepEqual(await createArtifactStore(root).readText(first.id), {
+ const recreated = await createArtifactStore(root).create(input);
+ assert.deepEqual({ ...recreated, createdAt: first.createdAt }, first);
+ assert.deepEqual(await readArtifactText(createArtifactStore(root), 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');
});
});
@@ -767,7 +678,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 listArtifacts(reopened, 'session-1');
assert.deepEqual(rows.map((record) => record.id).sort(), ids.sort());
await assert.rejects(() => stat(join(root, 'artifacts', 'metadata.jsonl')), {
code: 'ENOENT',
@@ -784,7 +695,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 listArtifacts(createArtifactStore(root), 'session-1');
assert.deepEqual(rows.map((record) => record.id).sort(), [
'independent-first',
'independent-second',
@@ -817,7 +728,7 @@ describe('SQLite Artifact store', () => {
kind: 'file',
content: bytes,
mimeType: 'application/octet-stream',
- source: 'fixture',
+ source: 'tool_result',
summary: 'accepted summary',
now: 7,
};
@@ -848,18 +759,17 @@ 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',
});
assert.deepEqual(
await readFile(join(root, 'artifacts', record.relativePath)),
Buffer.from('safe'),
);
const reopened = createArtifactStore(root);
- assert.deepEqual(await reopened.get(record.id), record);
- assert.deepEqual(await reopened.list('session-1'), [record]);
- assert.equal(await reopened.get('mutated-id'), null);
+ assert.deepEqual(await getArtifact(reopened, record.id), record);
+ assert.deepEqual(await listArtifacts(reopened, 'session-1'), [record]);
+ assert.equal(await getArtifact(reopened, 'mutated-id'), null);
});
});
@@ -869,38 +779,18 @@ describe('SQLite Artifact store', () => {
const writer = createArtifactStore(root);
await stale.create(artifactInput('first', 'first', 1));
assert.deepEqual(
- (await stale.list('session-1')).map((record) => record.id),
+ (await listArtifacts(stale, 'session-1')).map((record) => record.id),
['first'],
);
await writer.create(artifactInput('second', 'second', 2));
assert.deepEqual(
- (await stale.list('session-1')).map((record) => record.id),
+ (await listArtifacts(stale, 'session-1')).map((record) => record.id),
['second', 'first'],
);
- assert.equal((await stale.get('second'))?.id, 'second');
- assert.deepEqual(await stale.readText('second'), { ok: true, text: 'second' });
- });
- });
-
- test('snapshots purge inputs before lock waits', async () => {
- await withWorkspace(async (root) => {
- const authority = createArtifactStoreWriteAuthority(root);
- await authority.recover();
- const { store } = authority;
- await store.create(artifactInput('purge-me', 'purge', 2));
- await store.create(artifactInput('keep-me', 'keep', 3));
-
- const purgeIds = ['purge-me'];
- const purgeLock = await holdArtifactWriterLock(root);
- const purged = store.purge(purgeIds);
- purgeIds[0] = 'keep-me';
- purgeLock.release();
- 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 getArtifact(stale, 'second'))?.id, 'second');
+ assert.deepEqual(await readArtifactText(stale, 'second'), { ok: true, text: 'second' });
});
});
@@ -908,7 +798,7 @@ describe('SQLite Artifact store', () => {
await withWorkspace(async (root) => {
const store = createArtifactStore(root);
await store.create(artifactInput('published', 'kept', 1));
- store.close?.();
+ store.close();
await assert.rejects(
() => store.create(artifactInput('rejected', 'not durable', 2)),
@@ -919,62 +809,26 @@ describe('SQLite Artifact store', () => {
{ code: 'ENOENT' },
);
const reopened = createArtifactStore(root);
- assert.equal(await reopened.get('rejected'), null);
+ assert.equal(await getArtifact(reopened, 'rejected'), null);
});
});
- test('explicit write recovery removes an uncommitted publication and permits stable-id retry', async () => {
+ test('legacy publication residue cannot block stable-id creation', async () => {
await withWorkspace(async (root) => {
const residue = await createPublicationResidue(root, 'stable-retry', 'retry.txt', 'old');
const authority = createArtifactStoreWriteAuthority(root);
const { store } = authority;
-
- await assert.rejects(
- () =>
- store.create({
- ...artifactInput('stable-retry', 'new', 1),
- name: 'retry.txt',
- }),
- /Artifact write recovery is required/,
- );
- await assert.rejects(() => store.delete('missing'), /Artifact write recovery is required/);
- await assert.rejects(() => store.purge(['missing']), /Artifact write recovery is required/);
- assert.equal(await readFile(residue.stagingPath, 'utf8'), 'old');
- assert.equal(await readFile(residue.targetPath, 'utf8'), 'old');
-
- await authority.recover();
- await assert.rejects(() => stat(residue.stagingPath), { code: 'ENOENT' });
- await assert.rejects(() => stat(residue.targetPath), { code: 'ENOENT' });
-
const retried = await store.create({
...artifactInput('stable-retry', 'new', 1),
name: 'retry.txt',
});
assert.equal(retried.id, 'stable-retry');
- assert.deepEqual(await store.readText(retried.id), { ok: true, text: 'new' });
- });
- });
-
- test('explicit write recovery completes a committed publication without removing its payload', async () => {
- await withWorkspace(async (root) => {
- const first = createArtifactStore(root);
- const record = await first.create(artifactInput('committed', 'durable', 1));
- const targetPath = join(root, 'artifacts', record.relativePath);
- const stagingPath = publicationStagingPath(targetPath);
- await writeFile(stagingPath, await readFile(targetPath));
- assert.notEqual((await stat(targetPath)).ino, (await stat(stagingPath)).ino);
-
- const authority = createArtifactStoreWriteAuthority(root);
- const reopened = authority.store;
- await authority.recover();
-
- await assert.rejects(() => stat(stagingPath), { code: 'ENOENT' });
- assert.equal(await readFile(targetPath, 'utf8'), 'durable');
- assert.deepEqual(await reopened.readText(record.id), { ok: true, text: 'durable' });
+ assert.deepEqual(await readArtifactText(store, retried.id), { ok: true, text: 'new' });
+ assert.equal(await readFile(residue.stagingPath, 'utf8'), 'old');
});
});
- test('self-managed SQLite mutation adopts an exact stable-id target-only orphan', async () => {
+ test('stable-id creation replaces an untracked payload', async () => {
await withWorkspace(async (root) => {
const input = {
...artifactInput('target-orphan', 'orphan bytes', 1),
@@ -988,147 +842,14 @@ describe('SQLite Artifact store', () => {
const adopted = await bare.create(input);
assert.equal(adopted.name, 'report.txt');
assert.equal(adopted.relativePath, 'session-1/target-orphan-report.txt');
- assert.deepEqual(await createArtifactStore(root).readText(adopted.id), {
+ assert.deepEqual(await readArtifactText(createArtifactStore(root), adopted.id), {
ok: true,
text: input.content,
});
});
});
- test('self-managed SQLite mutations recover target-local publications without rescanning unrelated sessions', async () => {
- await withWorkspace(async (root) => {
- const store = createArtifactStore(root);
- await store.create(artifactInput('delete-me', 'delete', 1));
- await store.create(artifactInput('purge-me', 'purge', 2));
-
- const unrelated = await createPublicationResidue(
- root,
- 'unrelated',
- 'unrelated.txt',
- 'publication bytes',
- 'session-2',
- );
- await rm(unrelated.targetPath);
- await writeFile(unrelated.targetPath, 'different bytes');
-
- const created = await store.create({
- ...artifactInput('target-only', 'created', 3),
- sessionId: 'session-1',
- });
- 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('purge-me'), null);
-
- await assert.rejects(
- () => createArtifactStoreWriteAuthority(root).recover(),
- /Artifact publication residue does not match canonical state/,
- );
- });
- });
-
- test('authority recovery adopts only its pre-existing exact stable-id orphan snapshot', async () => {
- await withWorkspace(async (root) => {
- const input = artifactInput('drifted-orphan', 'expected bytes', 1);
- const orphanPath = join(root, 'artifacts', 'session-1', 'drifted-orphan-drifted-orphan.txt');
- await mkdir(dirname(orphanPath), { recursive: true });
- await writeFile(orphanPath, 'drifted bytes!', { flag: 'wx' });
- const authority = createArtifactStoreWriteAuthority(root);
- const authorized = authority.store;
- await authority.recover();
- await rm(orphanPath);
- await writeFile(orphanPath, input.content, { flag: 'wx' });
-
- await assert.rejects(
- () => authorized.create(input),
- /already exists with different metadata or content/,
- );
- assert.equal(await readFile(orphanPath, 'utf8'), input.content);
- await assert.rejects(() => stat(join(root, 'artifacts', 'metadata.jsonl')), {
- code: 'ENOENT',
- });
- });
-
- await withWorkspace(async (root) => {
- const input = artifactInput('late-payload', 'same bytes', 1);
- const authority = createArtifactStoreWriteAuthority(root);
- const store = authority.store;
- await authority.recover();
- const residue = await createPublicationResidue(
- root,
- input.id,
- input.name,
- input.content as string,
- );
-
- await assert.rejects(() => store.create(input), /Artifact write recovery is required/);
- assert.equal(await readFile(residue.stagingPath, 'utf8'), input.content);
- assert.equal(await readFile(residue.targetPath, 'utf8'), input.content);
- });
- });
-
- test('write recovery removes only canonical purge-intent temp files', async () => {
- await withWorkspace(async (root) => {
- const artifactRoot = join(root, 'artifacts');
- await mkdir(artifactRoot, { recursive: true });
- const canonicalTemps = [
- join(
- artifactRoot,
- '.artifact-purge-intent.json.123.00000000-0000-4000-8000-000000000000.tmp',
- ),
- ];
- const unknownFiles = [
- join(artifactRoot, 'metadata.jsonl.123.00000000-0000-4000-8000-000000000000.tmp'),
- join(artifactRoot, 'metadata.jsonl.123.1700000000000.tmp'),
- join(artifactRoot, 'metadata.jsonl.123.not-a-uuid.tmp'),
- join(artifactRoot, '.artifact-purge-intent.json.123.not-a-uuid.tmp'),
- join(artifactRoot, 'other.jsonl.123.00000000-0000-4000-8000-000000000000.tmp'),
- ];
- for (const path of canonicalTemps) await writeFile(path, 'stale', 'utf8');
- for (const path of unknownFiles) await writeFile(path, 'keep', 'utf8');
-
- await createArtifactStoreWriteAuthority(root).recover();
-
- for (const path of canonicalTemps) {
- await assert.rejects(() => stat(path), { code: 'ENOENT' });
- }
- for (const path of unknownFiles) assert.equal(await readFile(path, 'utf8'), 'keep');
- });
- });
-
- test('write recovery fails closed on a non-file canonical transaction temp', async () => {
- await withWorkspace(async (root) => {
- const artifactRoot = join(root, 'artifacts');
- const canonicalTemp = join(
- artifactRoot,
- '.artifact-purge-intent.json.123.00000000-0000-4000-8000-000000000000.tmp',
- );
- await mkdir(canonicalTemp, { recursive: true });
-
- await assert.rejects(
- () => createArtifactStoreWriteAuthority(root).recover(),
- /Artifact recovery temp is not a regular file/,
- );
- assert.equal((await stat(canonicalTemp)).isDirectory(), true);
- });
- });
-
- test('fails closed on mismatched publication residue', async () => {
- await withWorkspace(async (root) => {
- const residue = await createPublicationResidue(root, 'mismatch', 'file.txt', 'payload');
- await rm(residue.targetPath);
- await writeFile(residue.targetPath, 'different inode', 'utf8');
- await assert.rejects(
- () => createArtifactStoreWriteAuthority(root).recover(),
- /Artifact publication residue does not match canonical state/,
- );
- assert.equal(await readFile(residue.stagingPath, 'utf8'), 'payload');
- assert.equal(await readFile(residue.targetPath, 'utf8'), 'different inode');
- });
- });
-
- test('metadata fails closed on invalid path identities and bounded turn keys', async () => {
+ test('invalid path identities and turn keys cannot block later artifact writes', async () => {
const invalidIdentities = [
{ field: 'id', value: 'a'.repeat(ARTIFACT_ENTITY_ID_MAX_CHARS + 1) },
{ field: 'id', value: '.' },
@@ -1140,68 +861,107 @@ describe('SQLite Artifact store', () => {
for (const { field, value } of invalidIdentities) {
await withWorkspace(async (root) => {
await writeArtifactMetadata(root, [recordWithIdentity(field, value)]);
- await assert.rejects(
- () => createArtifactStore(root).list('session-1', { includeDeleted: true }),
- /Invalid artifact metadata record 1/,
+ const store = createArtifactStore(root);
+ assert.deepEqual(await listArtifacts(store, 'session-1'), []);
+ assert.equal(
+ (await store.create(artifactInput('replacement', 'kept', 1))).id,
+ 'replacement',
);
});
}
});
- test('purge-intent recovery fails closed on non-canonical artifact identities', async () => {
- for (const artifactId of [
- 'a'.repeat(ARTIFACT_ENTITY_ID_MAX_CHARS + 1),
- '.',
- 'artifact/id',
- 'artifact id',
- 'artifact\nid',
- ]) {
- await withWorkspace(async (root) => {
- const intentPath = join(root, 'artifacts', '.artifact-purge-intent.json');
- await mkdir(dirname(intentPath), { recursive: true });
- await writeFile(
- intentPath,
- JSON.stringify({ schemaVersion: 1, artifactIds: [artifactId] }),
- 'utf8',
- );
+ test('legacy purge-intent residue cannot block later writes', async () => {
+ await withWorkspace(async (root) => {
+ const intentPath = join(root, 'artifacts', '.artifact-purge-intent.json');
+ await mkdir(dirname(intentPath), { recursive: true });
+ await writeFile(intentPath, 'not valid json', 'utf8');
- await assert.rejects(
- () => createArtifactStoreWriteAuthority(root).recover(),
- /Invalid artifact purge intent/,
- );
- assert.equal((await stat(intentPath)).isFile(), true);
- });
- }
+ const authority = createArtifactStoreWriteAuthority(root);
+ const record = await authority.store.create(artifactInput('after-retired-purge', 'kept', 1));
+
+ assert.equal(record.id, 'after-retired-purge');
+ assert.equal(await readFile(intentPath, 'utf8'), 'not valid json');
+ });
});
- test('soft delete tombstones bytes until idempotent purge', async () => {
+ test('user delete physically removes metadata and bytes idempotently', async () => {
await withWorkspace(async (root) => {
const store = createArtifactStore(root);
const record = await store.create(artifactInput('artifact-1', '<h1>Report</h1>', 1));
- 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: '<h1>Report</h1>',
- });
- assert.equal(
- await readFile(join(root, 'artifacts', record.relativePath), 'utf8'),
- '<h1>Report</h1>',
- );
-
- await store.purge([record.id]);
- await store.purge([record.id]);
- assert.equal(await store.get(record.id), null);
+ await store.deleteUserArtifactInSession(record.sessionId, record.id);
+ await store.deleteUserArtifactInSession(record.sessionId, record.id);
+ assert.deepEqual(await listArtifacts(store, 'session-1'), []);
+ assert.equal(await getArtifact(store, record.id), null);
await assert.rejects(() => stat(join(root, 'artifacts', record.relativePath)), {
code: 'ENOENT',
});
});
});
+ test('a partial purge retains its cleanup obligations across reopen', async (t) => {
+ await withWorkspace(async (root) => {
+ const authority = createArtifactStoreWriteAuthority(root);
+ const first = await authority.store.create(artifactInput('purge-first', 'first', 1));
+ const second = await authority.store.create(artifactInput('purge-second', 'second', 2));
+ const target = await fsPromises.realpath(join(root, 'artifacts', second.relativePath));
+ const originalRm = fsPromises.rm;
+ const injected = t.mock.method(
+ fsPromises,
+ 'rm',
+ async (...[path, options]: Parameters<typeof originalRm>) => {
+ if (path === target)
+ throw Object.assign(new Error('injected unlink failure'), { code: 'EIO' });
+ return originalRm(path, options);
+ },
+ );
+ syncBuiltinESMExports();
+ try {
+ await assert.rejects(authority.store.purgeSessionArtifacts(first.sessionId), {
+ code: 'EIO',
+ });
+ } finally {
+ injected.mock.restore();
+ syncBuiltinESMExports();
+ }
+ authority.close();
+ const reopened = createArtifactStore(root);
+ assert.equal((await listArtifacts(reopened, first.sessionId)).length, 2);
+ if (process.platform !== 'win32') {
+ const originalOpen = fsPromises.open;
+ const syncFailure = t.mock.method(
+ fsPromises,
+ 'open',
+ async (...args: Parameters<typeof originalOpen>) => {
+ if (args[0] === dirname(target)) {
+ throw Object.assign(new Error('injected directory sync failure'), { code: 'EIO' });
+ }
+ return originalOpen(...args);
+ },
+ );
+ syncBuiltinESMExports();
+ try {
+ // The second attempt sees no payloads, but still owes the directory sync.
+ for (let attempt = 0; attempt < 2; attempt++) {
+ await assert.rejects(reopened.purgeSessionArtifacts(first.sessionId), { code: 'EIO' });
+ assert.equal((await listArtifacts(reopened, first.sessionId)).length, 2);
+ }
+ } finally {
+ syncFailure.mock.restore();
+ syncBuiltinESMExports();
+ }
+ }
+ await reopened.purgeSessionArtifacts(first.sessionId);
+ assert.deepEqual(await listArtifacts(reopened, first.sessionId), []);
+ for (const record of [first, second]) {
+ await assert.rejects(stat(join(root, 'artifacts', record.relativePath)), {
+ code: 'ENOENT',
+ });
+ }
+ });
+ });
+
test('purge rejects a symlink escape without deleting external bytes or metadata', async (t) => {
const outsideRoot = await mkdtemp(join(tmpdir(), 'maka-artifact-outside-'));
try {
@@ -1230,15 +990,18 @@ describe('SQLite Artifact store', () => {
const store = createArtifactStore(root);
await assert.rejects(
- () => store.purge([safeRecord.id, escapedRecord.id]),
+ () => store.deleteUserArtifactInSession(escapedRecord.sessionId, escapedRecord.id),
/outside the artifact root/,
);
assert.equal(await readFile(externalPath, 'utf8'), 'external');
assert.equal(await readFile(join(artifactRoot, safeRecord.relativePath), 'utf8'), 'safe');
assert.equal(await readFile(metadataPath, 'utf8'), metadataBefore);
- assert.equal((await store.get(safeRecord.id))?.id, safeRecord.id);
- assert.equal((await store.get(escapedRecord.id))?.id, escapedRecord.id);
+ assert.equal((await getArtifact(store, safeRecord.id))?.id, safeRecord.id);
+ assert.equal(
+ (await getArtifact(store, escapedRecord.id, escapedRecord.sessionId))?.id,
+ escapedRecord.id,
+ );
});
} finally {
await rm(outsideRoot, { recursive: true, force: true });
@@ -1263,11 +1026,11 @@ describe('SQLite Artifact store', () => {
await writeArtifactMetadata(root, [record]);
const store = createArtifactStore(root);
- await store.purge([record.id]);
+ await store.deleteUserArtifactInSession(record.sessionId, record.id);
assert.equal(await readFile(targetPath, 'utf8'), 'keep target');
await assert.rejects(() => stat(linkPath), { code: 'ENOENT' });
- assert.equal(await store.get(record.id), null);
+ assert.equal(await getArtifact(store, record.id), null);
});
});
@@ -1297,12 +1060,15 @@ describe('SQLite Artifact store', () => {
assert.equal(firstStat.ino, secondStat.ino);
const store = createArtifactStore(root);
- await store.purge([first.id]);
+ await store.deleteUserArtifactInSession(first.sessionId, first.id);
await assert.rejects(() => stat(firstPath), { code: 'ENOENT' });
assert.equal(await readFile(secondPath, 'utf8'), 'shared bytes');
- assert.equal((await store.get(second.id))?.id, second.id);
- assert.deepEqual(await store.readText(second.id), { ok: true, text: 'shared bytes' });
+ assert.equal((await getArtifact(store, second.id))?.id, second.id);
+ assert.deepEqual(await readArtifactText(store, second.id), {
+ ok: true,
+ text: 'shared bytes',
+ });
});
});
@@ -1332,9 +1098,12 @@ describe('SQLite Artifact store', () => {
await writeArtifactMetadata(root, [lower, upper]);
const store = createArtifactStore(root);
- await assert.rejects(() => store.purge([lower.id]), /path is still referenced/);
+ await assert.rejects(
+ () => store.deleteUserArtifactInSession(lower.sessionId, 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 getArtifact(store, upper.id))?.id, upper.id);
});
});
@@ -1367,102 +1136,42 @@ describe('SQLite Artifact store', () => {
await writeArtifactMetadata(root, [lower, upper]);
const store = createArtifactStore(root);
- await assert.rejects(() => store.purge([lower.id]), /path is still referenced/);
+ await assert.rejects(
+ () => store.deleteUserArtifactInSession(lower.sessionId, lower.id),
+ /path is still referenced/,
+ );
- assert.deepEqual(await store.readText(upper.id), { ok: true, text: 'shared bytes' });
+ assert.deepEqual(await readArtifactText(store, upper.id), { ok: true, text: 'shared bytes' });
assert.equal((await lstat(lowerPath)).isSymbolicLink(), true);
assert.equal((await lstat(upperPath)).isSymbolicLink(), true);
assert.equal(await readFile(targetPath, 'utf8'), 'shared bytes');
});
});
- test('startup recovery completes purge after payload removal but before metadata commit', async () => {
- await withWorkspace(async (root) => {
- const authority = createArtifactStoreWriteAuthority(root);
- const store = authority.store;
- await authority.recover();
- const record = await store.create(artifactInput('purge-retry', 'remove me', 1));
- const payloadPath = join(root, 'artifacts', record.relativePath);
- const metadataPath = join(root, 'artifacts', 'metadata.jsonl');
- const purgeIntentPath = join(root, 'artifacts', '.artifact-purge-intent.json');
- await rm(payloadPath);
- await writeFile(
- purgeIntentPath,
- JSON.stringify({ schemaVersion: 1, artifactIds: [record.id] }),
- 'utf8',
- );
- await assert.rejects(() => stat(payloadPath), { code: 'ENOENT' });
- assert.equal((await store.get(record.id))?.id, record.id);
- assert.deepEqual(JSON.parse(await readFile(purgeIntentPath, 'utf8')), {
- schemaVersion: 1,
- artifactIds: [record.id],
- });
- await assert.rejects(
- () => store.create(artifactInput('same-instance-blocked', 'blocked', 2)),
- /Artifact write recovery is required/,
- );
-
- await authority.recover();
- await authority.recover();
-
- assert.equal(await store.get(record.id), null);
- await assert.rejects(() => stat(metadataPath), { code: 'ENOENT' });
- await assert.rejects(() => stat(purgeIntentPath), { code: 'ENOENT' });
- await store.purge([record.id]);
-
- await writeFile(
- purgeIntentPath,
- JSON.stringify({ schemaVersion: 1, artifactIds: [record.id] }),
- 'utf8',
- );
- await createArtifactStoreWriteAuthority(root).recover();
- await assert.rejects(() => stat(metadataPath), { code: 'ENOENT' });
- await assert.rejects(() => stat(purgeIntentPath), { code: 'ENOENT' });
- });
- });
-
- test('self-managed SQLite stores recover an interrupted purge before the next write', async () => {
- await withWorkspace(async (root) => {
- const store = createArtifactStore(root);
- const record = await store.create(artifactInput('interrupted-purge', 'remove me', 1));
- await rm(join(root, 'artifacts', record.relativePath));
- await writeFile(
- join(root, 'artifacts', '.artifact-purge-intent.json'),
- JSON.stringify({ schemaVersion: 1, artifactIds: [record.id] }),
- 'utf8',
- );
-
- const reopened = createArtifactStore(root);
- const next = await reopened.create(artifactInput('after-recovery', 'kept', 2));
- assert.equal(next.id, 'after-recovery');
- assert.equal(await reopened.get(record.id), null);
- await assert.rejects(() => stat(join(root, 'artifacts', '.artifact-purge-intent.json')), {
- code: 'ENOENT',
- });
- });
- });
-
- 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');
+ await store.deleteUserArtifactInSession('session-1', 'image');
- assert.deepEqual(await store.readBinary('image'), { ok: false, reason: 'deleted' });
+ assert.deepEqual(await readArtifactBinary(store, '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' },
);
});
});
@@ -1478,8 +1187,8 @@ describe('SQLite Artifact store', () => {
name: 'unknown.bin',
});
- assert.deepEqual(await store.readText('large'), { ok: false, reason: 'too_large' });
- assert.deepEqual(await store.readBinary('unknown'), {
+ assert.deepEqual(await readArtifactText(store, 'large'), { ok: false, reason: 'too_large' });
+ assert.deepEqual(await readArtifactBinary(store, 'unknown'), {
ok: false,
reason: 'unsupported_mime',
});
@@ -1497,12 +1206,16 @@ describe('SQLite Artifact store', () => {
name: 'boundary.txt',
kind: 'file',
content: 'boundary',
+ source: 'tool_result',
now: 1,
});
assert.equal(record.id.length, ARTIFACT_ENTITY_ID_MAX_CHARS);
- assert.deepEqual(await createArtifactStore(root).get(boundaryId), record);
- assert.deepEqual(await createArtifactStore(root).list(boundaryId), [record]);
+ assert.deepEqual(
+ await getArtifact(createArtifactStore(root), boundaryId, boundaryId),
+ record,
+ );
+ assert.deepEqual(await listArtifacts(createArtifactStore(root), boundaryId), [record]);
});
});
@@ -1596,7 +1309,7 @@ describe('SQLite Artifact store', () => {
}),
{ ok: false, reason: 'not_allowed' },
);
- assert.deepEqual(await createArtifactStore(root).readText(record.id), {
+ assert.deepEqual(await readArtifactText(createArtifactStore(root), record.id), {
ok: false,
reason: 'not_allowed',
});
@@ -1657,7 +1370,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,8 +1405,7 @@ function canonicalRecord(input: {
kind: 'file',
relativePath: `${input.sessionId}/${input.id}-${input.name}`,
sizeBytes: input.sizeBytes,
- source: 'fixture',
- status: 'live',
+ source: 'tool_result',
};
}
diff --git a/packages/storage/src/__tests__/artifact-stores.test.ts b/packages/storage/src/__tests__/artifact-stores.test.ts
index c50bdab1b5..1965bbcc63 100644
--- a/packages/storage/src/__tests__/artifact-stores.test.ts
+++ b/packages/storage/src/__tests__/artifact-stores.test.ts
@@ -18,17 +18,17 @@
*/
import assert from 'node:assert/strict';
-import { mkdtemp, rm, stat } from 'node:fs/promises';
+import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
+import { DatabaseSync } from 'node:sqlite';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
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';
+import { ARTIFACT_WRITER_LOCK_FILE } from '../artifact-writer-lock.js';
import {
resolveStorageRoot,
StorageRootAuthorityError,
@@ -45,6 +45,96 @@ import {
after(removeTrackedControlDirectories);
describe('interactive artifact store authority', () => {
+ test('reads retained v1 payloads after upgrade without reviving retired rows', async () => {
+ await withInteractiveOwner(async (owner, root, track) => {
+ const initial = await openInteractiveArtifactStoreForWrite(owner.lease);
+ initial.close();
+ const db = new DatabaseSync(join(root, 'runtime.sqlite'));
+ db.exec(`
+ DROP TABLE artifact_records;
+ CREATE TABLE artifact_records (
+ storage_key TEXT PRIMARY KEY, artifact_id TEXT NOT NULL,
+ session_id TEXT NOT NULL, created_at INTEGER NOT NULL CHECK(created_at >= 0),
+ status TEXT NOT NULL CHECK(status IN ('live', 'deleted')),
+ relative_path TEXT NOT NULL, record_json TEXT NOT NULL
+ );
+ CREATE INDEX artifact_records_session_order ON artifact_records(session_id, created_at, storage_key);
+ CREATE UNIQUE INDEX artifact_records_relative_path ON artifact_records(relative_path);
+ UPDATE operational_schema_migrations SET version = 1 WHERE scope = 'artifact';
+ `);
+ const retained = [
+ 'tool_result',
+ 'tool_result_projection',
+ 'tool_result_archive',
+ 'subagent_writeback',
+ 'deep_research',
+ 'user_upload',
+ 'session_effect',
+ ];
+ const image = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN1sAAAAASUVORK5CYII=',
+ 'base64',
+ );
+ await mkdir(join(root, 'artifacts', 'session-1'), { recursive: true });
+ for (const source of [...retained, 'fixture', 'deleted', 'malformed']) {
+ const path = `session-1/${source}-result.txt`;
+ const content =
+ source === 'tool_result_projection' || source === 'user_upload'
+ ? image
+ : `original ${source}`;
+ const record = {
+ id: source,
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ createdAt: 1,
+ name: 'result.txt',
+ kind: 'file',
+ sizeBytes: Buffer.byteLength(content),
+ relativePath: path,
+ source: source === 'deleted' ? 'tool_result' : source,
+ status: source === 'deleted' ? 'deleted' : 'live',
+ };
+ await writeFile(join(root, 'artifacts', path), content);
+ db.prepare('INSERT INTO artifact_records VALUES (?, ?, ?, ?, ?, ?, ?)').run(
+ source,
+ source,
+ 'session-1',
+ 1,
+ record.status,
+ path,
+ source === 'malformed' ? '{' : JSON.stringify(record),
+ );
+ }
+ db.close();
+ for (let reopen = 0; reopen < 2; reopen += 1) {
+ const store = track(await openInteractiveArtifactStoreForWrite(owner.lease));
+ assert.deepEqual(
+ (await store.listTurnArtifacts('session-1', 'turn-1')).map((r) => r.id).sort(),
+ [...retained].sort(),
+ );
+ for (const source of retained) {
+ if (source === 'tool_result_projection' || source === 'user_upload') {
+ assert.deepEqual(
+ await store.readDurableAttachmentBinary({
+ sessionId: 'session-1',
+ artifactId: source,
+ }),
+ { ok: true, base64: image.toString('base64'), mimeType: 'image/png' },
+ );
+ continue;
+ }
+ const result = await store.readTextInSession('session-1', source);
+ assert.equal(result.ok, true);
+ if (result.ok) assert.equal(result.text, `original ${source}`);
+ }
+ for (const id of ['fixture', 'deleted', 'malformed']) {
+ assert.equal((await store.getInSession('session-1', id)).record, null);
+ }
+ store.close();
+ }
+ });
+ });
+
test('requires authentic leases and writer facades', async () => {
await assert.rejects(
() =>
@@ -72,22 +162,20 @@ describe('interactive artifact store authority', () => {
assert.strictEqual(first, second);
assert.strictEqual(authenticateInteractiveArtifactStoreWriter(first), first);
- await first.recover();
await first.create(artifactInput('deleted', 'delete me'));
const deleted = await first.deleteUserArtifactInSession('session-1', 'deleted');
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,
@@ -98,7 +186,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);
});
});
@@ -110,7 +198,6 @@ describe('interactive artifact store authority', () => {
const owner = await tryAcquireInteractiveRootOwner(capability);
assert.ok(owner);
const writer = track(await openInteractiveArtifactStoreForWrite(owner.lease));
- await writer.recover();
const accepted = writer.create(
artifactInput('accepted', new Uint8Array(8 * 1024 * 1024).fill(0x62)),
);
@@ -127,7 +214,6 @@ describe('interactive artifact store authority', () => {
test('snapshots create inputs and makes user deletion idempotent', async () => {
await withInteractiveOwner(async (owner, _root, track) => {
const writer = track(await openInteractiveArtifactStoreForWrite(owner.lease));
- await writer.recover();
const bytes = Uint8Array.from([0x73, 0x61, 0x66, 0x65]);
const createInput = artifactInput('accepted', bytes);
const created = writer.create(createInput);
@@ -147,207 +233,13 @@ describe('interactive artifact store authority', () => {
assert.equal((await deleted).kind, 'deleted');
assert.equal(
(await writer.deleteUserArtifactInSession('session-1', record.id)).kind,
- 'deleted',
- );
- assert.equal((await writer.getInSession('session-1', 'accepted')).record?.status, 'deleted');
- });
- });
-});
-
-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,
+ 'not_found',
);
- 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<void>((resolve) => {
- setTimeout(resolve, 300);
- });
- residue -= 1;
- previousEnd = Date.now();
- return { purged: 1, remaining: residue };
- },
+ assert.equal((await writer.getInSession('session-1', 'accepted')).record, null);
});
-
- 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<void>((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<void>((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<boolean>): Promise<void> {
- const deadline = Date.now() + 15_000;
- while (Date.now() < deadline) {
- if (await done()) return;
- await new Promise<void>((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<void> {
- await new Promise<void>((resolve) => {
- setTimeout(resolve, 400);
- });
-}
-
function artifactInput(id: string, content: string | Uint8Array) {
return {
id,
@@ -356,6 +248,7 @@ function artifactInput(id: string, content: string | Uint8Array) {
name: `${id}.txt`,
kind: 'file' as const,
content,
+ source: 'tool_result' as const,
now: 1,
};
}
diff --git a/packages/storage/src/__tests__/artifact-writer-lock.test.ts b/packages/storage/src/__tests__/artifact-writer-lock.test.ts
index 6647eea0fe..6dac4ec52a 100644
--- a/packages/storage/src/__tests__/artifact-writer-lock.test.ts
+++ b/packages/storage/src/__tests__/artifact-writer-lock.test.ts
@@ -41,9 +41,9 @@ import {
type InteractiveArtifactStoreWriter,
} from '../artifact-stores.js';
import {
- type ArtifactStore,
+ type ArtifactAuthorityStore,
type CreateArtifactInput,
- createSqliteArtifactStore,
+ createSqliteArtifactStoreWriteAuthority,
} from '../artifact-store.js';
import { withArtifactWriterLock } from '../artifact-writer-lock.js';
import {
@@ -72,12 +72,20 @@ const COMPETING_PAYLOAD_BYTES = 4 * 1024 * 1024;
const SKIP_OPEN_SQLITE_ROOT_REPLACEMENT = process.platform === 'win32';
const artifactStoreClosersByRoot = new Map<string, Set<() => void>>();
-function createArtifactStore(root: string): ArtifactStore {
- const store = createSqliteArtifactStore(root);
+function createArtifactStore(root: string): ArtifactAuthorityStore {
+ const authority = createSqliteArtifactStoreWriteAuthority(root);
const closers = artifactStoreClosersByRoot.get(root) ?? new Set<() => void>();
- closers.add(() => store.close?.());
+ closers.add(() => authority.close());
artifactStoreClosersByRoot.set(root, closers);
- return store;
+ return authority.store;
+}
+
+async function listArtifacts(store: ArtifactAuthorityStore, sessionId: string) {
+ return (await store.listPage(sessionId, { offset: 0, limit: Number.MAX_SAFE_INTEGER })).records;
+}
+
+function readArtifactText(store: ArtifactAuthorityStore, artifactId: string) {
+ return store.readTextInSession('session-1', artifactId);
}
async function openInteractiveArtifactStoreForWrite(
@@ -99,7 +107,7 @@ function closeArtifactStoresUnder(root: string): void {
}
}
-test('public Store mutation waits for a child-held writer lock and preserves metadata', {
+test('unleased write authority waits for a child-held writer lock and preserves metadata', {
timeout: TEST_TIMEOUT_MS,
}, async () => {
await withTemporaryDirectory(async (root) => {
@@ -114,7 +122,9 @@ test('public Store mutation waits for a child-held writer lock and preserves met
await withTimeout(mutation, OPERATION_TIMEOUT_MS, 'Store mutation');
assert.deepEqual(
- (await createArtifactStore(stateRoot).list('session-1')).map((record) => record.id).sort(),
+ (await listArtifacts(createArtifactStore(stateRoot), 'session-1'))
+ .map((record) => record.id)
+ .sort(),
['after-lock', 'seed'],
);
} finally {
@@ -123,7 +133,7 @@ test('public Store mutation waits for a child-held writer lock and preserves met
});
});
-test('public Store mutations in separate processes reload and publish under one OS lock', {
+test('unleased write authorities in separate processes reload and publish under one OS lock', {
timeout: TEST_TIMEOUT_MS,
}, async () => {
await withTemporaryDirectory(async (root) => {
@@ -131,7 +141,7 @@ test('public Store mutations in separate processes reload and publish under one
const seedRecord = await createArtifactStore(stateRoot).create(artifactInput('seed'));
const parentStore = createArtifactStore(stateRoot);
- await parentStore.list('session-1');
+ await listArtifacts(parentStore, 'session-1');
const child = await spawnPublicWriter(stateRoot, 'session-1');
const holder = await spawnLockHolder(stateRoot);
try {
@@ -148,21 +158,24 @@ test('public Store mutations in separate processes reload and publish under one
const [parentRecord, childCreated] = await withTimeout(
Promise.all([parentMutation, childMutation.created]),
OPERATION_TIMEOUT_MS,
- 'competing public Store mutations',
+ 'competing unleased write authority mutations',
);
await withTimeout(waitForExit(child), OPERATION_TIMEOUT_MS, 'public writer shutdown');
const freshStore = createArtifactStore(stateRoot);
- const records = (await freshStore.list('session-1')).sort(compareRecordsById);
+ const records = [...(await listArtifacts(freshStore, 'session-1'))].sort(compareRecordsById);
assert.deepEqual(
records,
[seedRecord, parentRecord, childCreated.record].sort(compareRecordsById),
);
- assert.deepEqual(await freshStore.readText('parent-public'), {
+ assert.deepEqual(await readArtifactText(freshStore, 'parent-public'), {
ok: true,
text: parentPayload,
});
- assert.deepEqual(await freshStore.readText('child-public'), { ok: true, text: childPayload });
+ assert.deepEqual(await readArtifactText(freshStore, 'child-public'), {
+ ok: true,
+ text: childPayload,
+ });
} finally {
await stopHolder(child);
await stopHolder(holder);
@@ -170,7 +183,7 @@ test('public Store mutations in separate processes reload and publish under one
});
});
-test('public Store mutations share the rootId writer lock used by lease-bound authority', {
+test('unleased writes share the rootId writer lock used by lease-bound authority', {
timeout: TEST_TIMEOUT_MS,
}, async () => {
await withTemporaryDirectory(async (root) => {
@@ -182,7 +195,7 @@ test('public Store mutations share the rootId writer lock used by lease-bound au
const holder = await spawnAuthorityLockHolder(stateRoot, capability.rootId);
try {
const mutation = createArtifactStore(stateRoot).create(artifactInput('public-marked-root'));
- await assertPending(mutation, 'public Store mutation on a marked root');
+ await assertPending(mutation, 'unleased write authority mutation on a marked root');
await releaseHolder(holder);
assert.equal(
@@ -190,7 +203,7 @@ test('public Store mutations share the rootId writer lock used by lease-bound au
await withTimeout(
mutation,
OPERATION_TIMEOUT_MS,
- 'public Store mutation on a marked root',
+ 'unleased write authority mutation on a marked root',
)
).id,
'public-marked-root',
@@ -212,13 +225,13 @@ test('mutations spanning initial root marking remain serialized by the bootstrap
const firstMutation = createArtifactStore(stateRoot).create(
artifactInput('before-root-marking'),
);
- await assertPending(firstMutation, 'public mutation started before root marking');
+ await assertPending(firstMutation, 'unleased mutation started before root marking');
trackControlDirectory(await resolveStorageRoot({ path: stateRoot, kind: 'interactive' }));
const secondMutation = createArtifactStore(stateRoot).create(
artifactInput('after-root-marking', undefined, 2),
);
- await assertPending(secondMutation, 'public mutation started after root marking');
+ await assertPending(secondMutation, 'unleased mutation started after root marking');
await releaseHolder(holder);
await withTimeout(
@@ -228,15 +241,15 @@ test('mutations spanning initial root marking remain serialized by the bootstrap
);
const freshStore = createArtifactStore(stateRoot);
- assert.deepEqual((await freshStore.list('session-1')).map((record) => record.id).sort(), [
- 'after-root-marking',
- 'before-root-marking',
- ]);
- assert.deepEqual(await freshStore.readText('before-root-marking'), {
+ assert.deepEqual(
+ (await listArtifacts(freshStore, 'session-1')).map((record) => record.id).sort(),
+ ['after-root-marking', 'before-root-marking'],
+ );
+ assert.deepEqual(await readArtifactText(freshStore, 'before-root-marking'), {
ok: true,
text: 'before-root-marking',
});
- assert.deepEqual(await freshStore.readText('after-root-marking'), {
+ assert.deepEqual(await readArtifactText(freshStore, 'after-root-marking'), {
ok: true,
text: 'after-root-marking',
});
@@ -246,7 +259,7 @@ test('mutations spanning initial root marking remain serialized by the bootstrap
});
});
-test('public mutation rejects an unmarked replacement installed while waiting for bootstrap', {
+test('unleased mutation rejects an unmarked replacement installed while waiting for bootstrap', {
timeout: TEST_TIMEOUT_MS,
skip: SKIP_OPEN_SQLITE_ROOT_REPLACEMENT,
}, async () => {
@@ -259,7 +272,10 @@ test('public mutation rejects an unmarked replacement installed while waiting fo
const mutation = createArtifactStore(stateRoot).create(
artifactInput('stale-public-replacement'),
);
- await assertPending(mutation, 'public mutation waiting for its captured bootstrap authority');
+ await assertPending(
+ mutation,
+ 'unleased mutation waiting for its captured bootstrap authority',
+ );
await rename(stateRoot, displacedRoot);
await mkdir(stateRoot);
@@ -276,7 +292,7 @@ test('public mutation rejects an unmarked replacement installed while waiting fo
});
});
-test('public mutation through a retargeted alias stays bound to its verified canonical root', {
+test('unleased mutation through a retargeted alias stays bound to its verified canonical root', {
timeout: TEST_TIMEOUT_MS,
}, async () => {
await withTemporaryDirectory(async (root) => {
@@ -292,14 +308,14 @@ test('public mutation through a retargeted alias stays bound to its verified can
await symlink(stateRoot, alias, process.platform === 'win32' ? 'junction' : 'dir');
const store = createArtifactStore(alias);
assert.deepEqual(
- (await store.list('session-1')).map((record) => record.id),
+ (await listArtifacts(store, 'session-1')).map((record) => record.id),
['seed'],
);
const holder = await spawnAuthorityLockHolder(stateRoot, capability.rootId);
try {
const mutation = store.create(artifactInput('alias-mutation', undefined, 2));
- await assertPending(mutation, 'public mutation through the original alias target');
+ await assertPending(mutation, 'unleased mutation through the original alias target');
await rm(alias, { force: true });
await symlink(replacementRoot, alias, process.platform === 'win32' ? 'junction' : 'dir');
@@ -307,7 +323,9 @@ test('public mutation through a retargeted alias stays bound to its verified can
assert.equal((await mutation).id, 'alias-mutation');
assert.deepEqual(
- (await createArtifactStore(stateRoot).list('session-1')).map((record) => record.id).sort(),
+ (await listArtifacts(createArtifactStore(stateRoot), 'session-1'))
+ .map((record) => record.id)
+ .sort(),
['alias-mutation', 'seed'],
);
assert.deepEqual(await readdir(replacementRoot), ['replacement-sentinel']);
@@ -426,17 +444,19 @@ test('bundle export excludes a mutation queued behind the same child-held writer
await withTimeout(bundleExport, BUNDLE_EXPORT_TIMEOUT_MS, 'bundle export');
await withTimeout(mutation, OPERATION_TIMEOUT_MS, 'Store mutation');
- const exportedStore = createSqliteArtifactStore(destinationRoot);
+ const exportedStore = createArtifactStore(destinationRoot);
try {
assert.deepEqual(
- (await exportedStore.list(session.id)).map((record) => record.id),
+ (await listArtifacts(exportedStore, session.id)).map((record) => record.id),
['seed'],
);
} finally {
exportedStore.close?.();
}
assert.deepEqual(
- (await createArtifactStore(stateRoot).list(session.id)).map((record) => record.id).sort(),
+ (await listArtifacts(createArtifactStore(stateRoot), session.id))
+ .map((record) => record.id)
+ .sort(),
['after-export', 'seed'],
);
} finally {
@@ -514,6 +534,7 @@ function artifactInput(id: string, content = id, now = 1): CreateArtifactInput {
name: `${id}.txt`,
kind: 'file' as const,
content,
+ source: 'tool_result',
now,
};
}
diff --git a/packages/storage/src/__tests__/fixtures/artifact-writer-lock-holder.ts b/packages/storage/src/__tests__/fixtures/artifact-writer-lock-holder.ts
index 7eb6337093..2e786bb301 100644
--- a/packages/storage/src/__tests__/fixtures/artifact-writer-lock-holder.ts
+++ b/packages/storage/src/__tests__/fixtures/artifact-writer-lock-holder.ts
@@ -21,7 +21,8 @@ import { createHash } from 'node:crypto';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import {
- createSqliteArtifactStore as createArtifactStore,
+ type ArtifactAuthorityStore,
+ createSqliteArtifactStoreWriteAuthority,
type CreateArtifactInput,
} from '../../artifact-store.js';
import {
@@ -41,6 +42,10 @@ if (!workspaceRoot || !process.send) {
);
}
+async function listArtifacts(store: ArtifactAuthorityStore, sessionId: string) {
+ return (await store.listPage(sessionId, { offset: 0, limit: Number.MAX_SAFE_INTEGER })).records;
+}
+
try {
if (modeOrTransientResidueSessionId === '--public-writer') {
if (!modeArgument) throw new Error('public writer session id is required');
@@ -100,9 +105,10 @@ async function runAuthorityLockHolder(workspaceRoot: string, rootId: string): Pr
}
async function runPublicWriter(workspaceRoot: string, sessionId: string): Promise<void> {
- const store = createArtifactStore(workspaceRoot);
+ const authority = createSqliteArtifactStoreWriteAuthority(workspaceRoot);
+ const store = authority.store;
try {
- await store.list(sessionId);
+ await listArtifacts(store, sessionId);
await send({ type: 'ready' });
const command = await waitForCreateCommand();
const mutation = store.create({
@@ -113,7 +119,7 @@ async function runPublicWriter(workspaceRoot: string, sessionId: string): Promis
await send({ type: 'queued' });
await send({ type: 'created', record: await mutation });
} finally {
- store.close?.();
+ authority.close();
}
}
diff --git a/packages/storage/src/__tests__/operational-state-backup.test.ts b/packages/storage/src/__tests__/operational-state-backup.test.ts
index 9965b9e7c3..228f3abf86 100644
--- a/packages/storage/src/__tests__/operational-state-backup.test.ts
+++ b/packages/storage/src/__tests__/operational-state-backup.test.ts
@@ -24,7 +24,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { test } from 'node:test';
-import { createSqliteArtifactStore } from '../artifact-store.js';
+import { createSqliteArtifactStoreWriteAuthority } from '../artifact-store.js';
import { createProjectCatalog } from '../project-catalog.js';
import { createSessionStore } from '../session-store.js';
import {
@@ -70,7 +70,8 @@ test('backs up and restores runtime.sqlite plus artifact bytes', async () => {
} as const;
await sessions.appendMessage(session.id, message);
await sessions.close?.();
- const artifacts = createSqliteArtifactStore(stateRoot);
+ const artifactAuthority = createSqliteArtifactStoreWriteAuthority(stateRoot);
+ const artifacts = artifactAuthority.store;
const artifact = await artifacts.create({
id: 'artifact-1',
sessionId: session.id,
@@ -78,10 +79,10 @@ 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?.();
+ artifactAuthority.close();
await createOperationalStateBackup({ stateRoot, destinationRoot: backupRoot, now: () => 10 });
assert.equal((await validateOperationalStateBackup(backupRoot)).createdAt, 10);
@@ -123,7 +124,8 @@ test('rejects a backup whose SQLite Artifact metadata has no matching payload',
const base = await mkdtemp(join(tmpdir(), 'maka-operational-backup-artifact-'));
const stateRoot = join(base, 'state');
try {
- const artifacts = createSqliteArtifactStore(stateRoot);
+ const artifactAuthority = createSqliteArtifactStoreWriteAuthority(stateRoot);
+ const artifacts = artifactAuthority.store;
const artifact = await artifacts.create({
id: 'artifact-1',
sessionId: 'session-1',
@@ -131,10 +133,10 @@ 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?.();
+ artifactAuthority.close();
await rm(join(stateRoot, 'artifacts', artifact.relativePath));
await assert.rejects(
diff --git a/packages/storage/src/__tests__/operational-state-store.test.ts b/packages/storage/src/__tests__/operational-state-store.test.ts
index 11b8bb9ea6..f6274475f6 100644
--- a/packages/storage/src/__tests__/operational-state-store.test.ts
+++ b/packages/storage/src/__tests__/operational-state-store.test.ts
@@ -28,6 +28,7 @@ import {
acquireOperationalStateDatabase,
OperationalStateMigrationBlockedError,
} from '../operational-state-store.js';
+import { SQLITE_ARTIFACT_SCHEMA_VERSION } from '../sqlite-artifact-schema.js';
import { SQLITE_RUNTIME_SCHEMA_VERSION } from '../sqlite-runtime-schema.js';
import { SQLITE_SESSION_METADATA_SCHEMA_VERSION } from '../sqlite-session-metadata-schema.js';
import { SQLITE_USAGE_SCHEMA_VERSION } from '../sqlite-usage-schema.js';
@@ -165,6 +166,76 @@ test('a non-owner rejects an older schema without migrating it behind the Runtim
}
});
+test('preserves live supported v1 Artifacts when opening existing Sessions', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-operational-artifact-v1-retirement-'));
+ const databasePath = join(root, 'runtime.sqlite');
+ try {
+ const lease = acquireOperationalStateDatabase(root);
+ const metadata = createSqliteSessionMetadataStore(databasePath, { databaseLease: lease });
+ await metadata.create(sessionHeader());
+ metadata.close();
+ lease.close();
+
+ const legacy = new DatabaseSync(databasePath);
+ legacy.exec(`
+ DROP TABLE artifact_records;
+ CREATE TABLE artifact_records (
+ storage_key TEXT PRIMARY KEY,
+ artifact_id TEXT NOT NULL,
+ session_id TEXT NOT NULL,
+ created_at INTEGER NOT NULL CHECK (created_at >= 0),
+ status TEXT NOT NULL CHECK (status IN ('live', 'deleted')),
+ relative_path TEXT NOT NULL,
+ record_json TEXT NOT NULL
+ );
+ CREATE INDEX artifact_records_session_order
+ ON artifact_records(session_id, created_at, storage_key);
+ CREATE UNIQUE INDEX artifact_records_relative_path
+ ON artifact_records(relative_path);
+ INSERT INTO artifact_records VALUES (
+ 'legacy-key',
+ 'legacy-artifact',
+ 'session-1',
+ 1,
+ 'live',
+ 'session-1/legacy-artifact-result.txt',
+ '{"id":"legacy-artifact","sessionId":"session-1","turnId":"turn-1","createdAt":1,"name":"result.txt","kind":"file","sizeBytes":4,"relativePath":"session-1/legacy-artifact-result.txt","source":"tool_result_archive","status":"live"}'
+ );
+ UPDATE operational_schema_migrations SET version = 1 WHERE scope = 'artifact';
+ `);
+ legacy.close();
+
+ const reopened = acquireOperationalStateDatabase(root);
+ assert.equal(
+ (
+ reopened.database
+ .prepare("SELECT COUNT(*) AS count FROM session_metadata WHERE session_id = 'session-1'")
+ .get() as { count: number }
+ ).count,
+ 1,
+ );
+ assert.equal(
+ (
+ reopened.database.prepare('SELECT COUNT(*) AS count FROM artifact_records').get() as {
+ count: number;
+ }
+ ).count,
+ 1,
+ );
+ assert.equal(
+ (
+ reopened.database
+ .prepare("SELECT version FROM operational_schema_migrations WHERE scope = 'artifact'")
+ .get() as { version: number }
+ ).version,
+ SQLITE_ARTIFACT_SCHEMA_VERSION,
+ );
+ reopened.close();
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
test('retires completed released migration metadata during schema convergence', async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-operational-cutover-retirement-'));
const databasePath = join(root, 'runtime.sqlite');
@@ -977,7 +1048,7 @@ for (const { name, mutation } of [
DROP INDEX artifact_records_relative_path;
CREATE UNIQUE INDEX artifact_records_relative_path
ON artifact_records(relative_path)
- WHERE status = 'live';
+ WHERE relative_path <> '';
`),
},
{
@@ -986,12 +1057,10 @@ for (const { name, mutation } of [
database.exec(`
DROP TABLE artifact_records;
CREATE TABLE artifact_records (
- storage_key TEXT PRIMARY KEY,
- artifact_id TEXT NOT NULL,
+ artifact_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
created_at INTEGER NOT NULL CHECK (created_at >= 0),
- status TEXT NOT NULL CHECK (status IN ('LIVE', 'DELETED')),
- relative_path TEXT NOT NULL,
+ relative_path TEXT NOT NULL CHECK (relative_path <> ''),
record_json TEXT NOT NULL
);
`),
diff --git a/packages/storage/src/__tests__/sqlite-artifact-metadata.test.ts b/packages/storage/src/__tests__/sqlite-artifact-metadata.test.ts
index a9214898ab..e8ddc5b96f 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' },
],
);
@@ -89,6 +89,44 @@ 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(
+ artifact_id,
+ session_id,
+ created_at,
+ relative_path,
+ record_json
+ ) VALUES (?, ?, ?, ?, ?)
+ `);
+ insert.run(
+ unsupported.id,
+ unsupported.sessionId,
+ unsupported.createdAt,
+ 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,
@@ -99,7 +137,6 @@ function artifactRecord(id: string): ArtifactRecord {
kind: 'file',
sizeBytes: id.length,
relativePath: `session-1/${id}-${id}.txt`,
- source: 'fixture',
- status: 'live',
+ source: 'tool_result',
};
}
diff --git a/packages/storage/src/artifact-attachments.ts b/packages/storage/src/artifact-attachments.ts
index 07d06c66c2..90ace71c6e 100644
--- a/packages/storage/src/artifact-attachments.ts
+++ b/packages/storage/src/artifact-attachments.ts
@@ -31,11 +31,7 @@ import {
import { createHash } from 'node:crypto';
import { type StorageRef, type ToolResultContent } from '@maka/core/events';
import type { ReadImageSnapshotReader } from '@maka/core/context-offload';
-import type {
- ArtifactAuthorityStore,
- ArtifactStore,
- DurableArtifactAttachmentReader,
-} from './artifact-store.js';
+import type { ArtifactAuthorityStore, DurableArtifactAttachmentReader } from './artifact-store.js';
import { sanitizeArtifactName } from './artifact-store.js';
export interface ArtifactAttachmentResourceReader {
@@ -48,9 +44,7 @@ export interface ArtifactAttachmentResourceReader {
/** Read a user-uploaded Artifact without exposing its storage path. */
export function createArtifactAttachmentResourceReader(input: {
- artifactStore:
- | Pick<ArtifactAuthorityStore, 'getInSession' | 'readTextInSession'>
- | Pick<ArtifactStore, 'get' | 'readText'>;
+ artifactStore: Pick<ArtifactAuthorityStore, 'getInSession' | 'readTextInSession'>;
}): ArtifactAttachmentResourceReader {
return Object.freeze({
async readAttachmentResource(
@@ -59,11 +53,8 @@ export function createArtifactAttachmentResourceReader(input: {
abortSignal: AbortSignal,
): Promise<ToolResultContent> {
abortSignal.throwIfAborted();
- const record =
- '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') {
+ const record = (await input.artifactStore.getInSession(sessionId, artifactId)).record;
+ if (!record || record.source !== 'user_upload') {
throw new Error('Attachment was not found in this Session');
}
if (record.sessionId !== sessionId) {
@@ -80,10 +71,7 @@ export function createArtifactAttachmentResourceReader(input: {
if (record.kind === 'pdf') {
throw new Error('PDF attachments cannot be decoded by Read');
}
- const read =
- 'readTextInSession' in input.artifactStore
- ? await input.artifactStore.readTextInSession(sessionId, artifactId)
- : await input.artifactStore.readText(artifactId);
+ const read = await input.artifactStore.readTextInSession(sessionId, artifactId);
abortSignal.throwIfAborted();
if (!read.ok) throw new Error(`Attachment could not be read: ${read.reason}`);
return { kind: 'text', text: read.text };
@@ -137,36 +125,11 @@ interface ReadImageSnapshotInput {
export interface ReadImageSnapshotPlan {
ref: Extract<StorageRef, { kind: 'session_file' }>;
persist(): Promise<void>;
- /**
- * Undo a publication whose projection was never admitted (#4283).
- *
- * A Tool Result projection carrying several images publishes them one at a
- * time; if a later one fails, the projection is rejected and the earlier
- * publications become artifacts no durable record will ever name. Retracting
- * them is best-effort by design: failing to retract only delays reclamation,
- * while failing to reject would put an unreferenced artifact in front of the
- * user as if the tool had produced it.
- */
- retract(): Promise<void>;
}
-export interface ReadImageSnapshotArtifactStore extends Pick<ArtifactStore, 'create'> {
- /** Create with a receipt saying whether this call published the artifact. */
- createOwned?: (input: Parameters<ArtifactStore['create']>[0]) => Promise<{
- record: Awaited<ReturnType<ArtifactStore['create']>>;
- publishedByThisCall: boolean;
- }>;
-}
+export type ReadImageSnapshotArtifactStore = Pick<ArtifactAuthorityStore, 'create'>;
-export function createReadImageSnapshotPlanner(
- artifactStore: ReadImageSnapshotArtifactStore,
- /**
- * Narrow reclaim for a `tool_result_projection` artifact this planner
- * published. Optional so callers that cannot reclaim still get the planner;
- * without it `retract()` is a no-op and reclamation waits for reachability.
- */
- retractPublished?: (sessionId: string, artifactId: string) => Promise<void>,
-) {
+export function createReadImageSnapshotPlanner(artifactStore: ReadImageSnapshotArtifactStore) {
return (input: ReadImageSnapshotInput): ReadImageSnapshotPlan => {
if (input.bytes.byteLength > MAX_READ_IMAGE_BYTES) {
throw new Error(READ_IMAGE_TOO_LARGE_MESSAGE);
@@ -199,11 +162,8 @@ export function createReadImageSnapshotPlanner(
.update(accepted.bytes)
.digest('hex')}`;
let publication: Promise<void> | undefined;
- // Ownership, not success. The id is derived from the bytes, so a create for
- // an image an earlier projection already published succeeds by replaying
- // that record — and reclaiming it would delete content that is still in
- // use. Only a create that actually published may be retracted.
- let owned = false;
+ // Content-derived identities are shared within the Turn. Their bytes live
+ // until Session cleanup, regardless of which individual projection succeeds.
const ref = Object.freeze({
kind: 'session_file' as const,
sessionId: accepted.sessionId,
@@ -222,30 +182,16 @@ export function createReadImageSnapshotPlanner(
mimeType: accepted.mimeType,
source: 'tool_result_projection' as const,
};
- publication ??= (
- artifactStore.createOwned
- ? artifactStore.createOwned(input)
- : artifactStore.create(input).then((record) => ({
- record,
- publishedByThisCall: false,
- }))
- ).then(({ record, publishedByThisCall }) => {
+ publication ??= artifactStore.create(input).then((record) => {
if (record.id !== id) throw new Error('Artifact publication changed its planned id');
- owned = publishedByThisCall;
});
return publication;
},
- async retract() {
- if (!owned || !retractPublished) return;
- owned = false;
- publication = undefined;
- await retractPublished(accepted.sessionId, id).catch(() => undefined);
- },
});
};
}
-export function createReadImageSnapshotter(artifactStore: Pick<ArtifactStore, 'create'>) {
+export function createReadImageSnapshotter(artifactStore: Pick<ArtifactAuthorityStore, 'create'>) {
const planSnapshot = createReadImageSnapshotPlanner(artifactStore);
return async (
input: ReadImageSnapshotInput,
diff --git a/packages/storage/src/artifact-metadata-codec.ts b/packages/storage/src/artifact-metadata-codec.ts
index e5f9d05b2d..217833f97b 100644
--- a/packages/storage/src/artifact-metadata-codec.ts
+++ b/packages/storage/src/artifact-metadata-codec.ts
@@ -17,11 +17,10 @@
* under the License.
*/
-import { isAbsolute, basename } from 'node:path';
+import { isAbsolute } from 'node:path';
import {
ARTIFACT_KINDS,
ARTIFACT_SOURCES,
- ARTIFACT_STATUSES,
type ArtifactKind,
type ArtifactRecord,
type ArtifactSource,
@@ -29,11 +28,9 @@ import {
isCanonicalArtifactEntityId,
} from '@maka/core/artifacts';
import { isDeepResearchArtifactRole } from '@maka/core/deep-research-run';
-import { ARTIFACT_PUBLICATION_STAGING_PATTERN } from './artifact-storage-layout.js';
const ARTIFACT_KIND_SET = new Set<ArtifactKind>(ARTIFACT_KINDS);
const ARTIFACT_SOURCE_SET = new Set<ArtifactSource>(ARTIFACT_SOURCES);
-const ARTIFACT_STATUS_SET = new Set<string>(ARTIFACT_STATUSES);
const ARTIFACT_RECORD_KEYS = new Set([
'id',
'sessionId',
@@ -47,29 +44,38 @@ const ARTIFACT_RECORD_KEYS = new Set([
'source',
'summary',
'deepResearchRole',
- 'status',
]);
export function decodeArtifactRecordJsons(values: readonly unknown[]): ArtifactRecord[] {
const records: ArtifactRecord[] = [];
const ids = new Set<string>();
for (const [index, value] of values.entries()) {
+ if (typeof value !== 'string') continue;
+ let parsed: unknown;
try {
- if (typeof value !== 'string') throw invalidMetadataRecord(index + 1);
- const record = decodeArtifactRecord(JSON.parse(value), index + 1);
- if (ids.has(record.id)) throw invalidMetadataRecord(index + 1);
+ parsed = JSON.parse(value);
+ } catch {
+ continue;
+ }
+ if (!hasSupportedArtifactSource(parsed)) continue;
+ try {
+ const record = decodeArtifactRecord(parsed, index + 1);
+ if (ids.has(record.id)) continue;
ids.add(record.id);
records.push(record);
- } catch (error) {
- if (error instanceof Error && error.message.startsWith('Invalid artifact metadata record')) {
- throw error;
- }
- throw invalidMetadataRecord(index + 1, error);
- }
+ } catch {}
}
return records;
}
+function hasSupportedArtifactSource(value: unknown): boolean {
+ return (
+ isRecord(value) &&
+ 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;
@@ -85,12 +91,6 @@ export function validateRelativeArtifactPath(relativePath: string): void {
}
}
-export function validateCanonicalArtifactTargetName(targetName: string): void {
- if (ARTIFACT_PUBLICATION_STAGING_PATTERN.test(targetName)) {
- throw new Error('Artifact target name uses the reserved publication staging namespace');
- }
-}
-
function decodeArtifactRecord(value: unknown, index: number): ArtifactRecord {
if (!isRecord(value)) throw invalidMetadataRecord(index);
if (Object.keys(value).some((key) => !ARTIFACT_RECORD_KEYS.has(key))) {
@@ -110,19 +110,14 @@ 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) ||
!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)))
+ typeof value.source !== 'string'
) {
throw invalidMetadataRecord(index);
}
validateRelativeArtifactPath(value.relativePath);
- validateCanonicalArtifactTargetName(basename(value.relativePath));
if (!isCompatibleArtifactName(value.name)) throw invalidMetadataRecord(index);
if (value.relativePath !== `${value.sessionId}/${value.id}-${value.name}`) {
throw invalidMetadataRecord(index);
@@ -138,11 +133,8 @@ function isCompatibleArtifactName(name: string): boolean {
return true;
}
-function invalidMetadataRecord(index: number, cause?: unknown): Error {
- return new Error(
- `Invalid artifact metadata record ${index}`,
- cause === undefined ? {} : { cause },
- );
+function invalidMetadataRecord(index: number): Error {
+ return new Error(`Invalid artifact metadata record ${index}`);
}
function isRecord(value: unknown): value is Record<string, unknown> {
diff --git a/packages/storage/src/artifact-metadata-repository.ts b/packages/storage/src/artifact-metadata-repository.ts
deleted file mode 100644
index 0ee907d385..0000000000
--- a/packages/storage/src/artifact-metadata-repository.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-import type { ArtifactRecord } from '@maka/core/artifacts';
-
-export interface ArtifactMetadataChanges {
- readonly upserts?: readonly ArtifactRecord[];
- readonly deleteIds?: readonly string[];
-}
-
-export interface ArtifactMetadataRepository {
- ready(): Promise<void>;
- readAll(): ArtifactRecord[];
- applyChanges(changes: ArtifactMetadataChanges): void;
- close(): void;
-}
diff --git a/packages/storage/src/artifact-storage-layout.ts b/packages/storage/src/artifact-storage-layout.ts
deleted file mode 100644
index 63b50ee369..0000000000
--- a/packages/storage/src/artifact-storage-layout.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements. See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership. The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-const UUID_V4_FRAGMENT = '[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}';
-
-export const ARTIFACT_PURGE_INTENT_FILE = '.artifact-purge-intent.json';
-export const ARTIFACT_WRITER_LOCK_FILE = '.maka-artifact-writer.lock';
-
-export const ARTIFACT_PURGE_INTENT_TEMP_PATTERN = new RegExp(
- `^\\.artifact-purge-intent\\.json\\.[0-9]+\\.${UUID_V4_FRAGMENT}\\.tmp$`,
-);
-
-export const ARTIFACT_PUBLICATION_STAGING_PATTERN = new RegExp(
- `^\\.artifact-publish\\.([a-f0-9]{64})\\.${UUID_V4_FRAGMENT}\\.tmp$`,
-);
-
-export function isArtifactPurgeRecoveryTempName(name: string): boolean {
- return ARTIFACT_PURGE_INTENT_TEMP_PATTERN.test(name);
-}
diff --git a/packages/storage/src/artifact-store.ts b/packages/storage/src/artifact-store.ts
index 174d12a49e..c2c5519f04 100644
--- a/packages/storage/src/artifact-store.ts
+++ b/packages/storage/src/artifact-store.ts
@@ -18,16 +18,14 @@
*/
import { createHash, randomUUID } from 'node:crypto';
-import { type BigIntStats, constants as fsConstants, createReadStream, type Dirent } from 'node:fs';
+import { type BigIntStats, constants as fsConstants } from 'node:fs';
import {
access,
copyFile,
- link,
lstat,
mkdir,
open,
readFile,
- readdir,
realpath,
rm,
stat,
@@ -53,15 +51,8 @@ import {
type DeepResearchArtifactRole,
} from '@maka/core/deep-research-run';
import { sniffAttachmentMimeType } from '@maka/core/attachments';
-import { publishMarkerFile, readBoundedMarkerFile } from './marker-file.js';
-import {
- ARTIFACT_PUBLICATION_STAGING_PATTERN,
- ARTIFACT_PURGE_INTENT_FILE,
- isArtifactPurgeRecoveryTempName,
-} from './artifact-storage-layout.js';
import {
isSafeRelativeArtifactPath,
- validateCanonicalArtifactTargetName,
validateRelativeArtifactPath,
} from './artifact-metadata-codec.js';
import {
@@ -70,40 +61,17 @@ import {
} from './artifact-writer-lock.js';
import type { ArtifactWriterLockAuthority } from './root-authority.js';
import { syncDirectory, syncDirectoryChain, syncFile } from './stable-storage.js';
-import type {
- ArtifactMetadataChanges,
- ArtifactMetadataRepository,
-} from './artifact-metadata-repository.js';
-import { createSqliteArtifactMetadataRepository } from './sqlite-artifact-metadata.js';
+import {
+ createSqliteArtifactMetadataRepository,
+ type ArtifactMetadataChanges,
+} from './sqlite-artifact-metadata.js';
export { isSafeRelativeArtifactPath } from './artifact-metadata-codec.js';
export const ARTIFACT_TEXT_PREVIEW_LIMIT_BYTES = 10 * 1024 * 1024;
export const ARTIFACT_BINARY_PREVIEW_LIMIT_BYTES = 50 * 1024 * 1024;
-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;
@@ -111,7 +79,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 {
@@ -128,14 +96,6 @@ interface ArtifactRemovalEntry {
type ArtifactRecordDraft = Omit<ArtifactRecord, 'sizeBytes'>;
-interface RecoverableOrphan {
- readonly canonicalPath: string;
- readonly dev: number;
- readonly ino: number;
- readonly size: number;
- readonly digest: string;
-}
-
export interface CreateArtifactInput {
sessionId: string;
turnId: string;
@@ -143,7 +103,7 @@ export interface CreateArtifactInput {
kind: ArtifactKind;
content: string | Uint8Array;
mimeType?: string;
- source?: ArtifactSource;
+ source: ArtifactSource;
summary?: string;
deepResearchRole?: DeepResearchArtifactRole;
now?: number;
@@ -198,16 +158,6 @@ export interface ConversationArtifactCopyResult {
readonly relativePaths: ReadonlyMap<string, string>;
}
-export interface ArtifactStoreReader {
- list(sessionId: string, opts?: { includeDeleted?: boolean }): Promise<ArtifactRecord[]>;
- get(artifactId: string): Promise<ArtifactRecord | null>;
- readText(
- artifactId: string,
- opts?: { maxBytes?: number; includeDeleted?: boolean },
- ): Promise<ArtifactTextReadResult>;
- readBinary(artifactId: string, opts?: { maxBytes?: number }): Promise<ArtifactBinaryReadResult>;
-}
-
export type DurableArtifactBinaryReadResult =
| ArtifactBinaryReadResult
| { ok: false; reason: 'session_mismatch' };
@@ -220,25 +170,23 @@ export interface DurableArtifactAttachmentReader {
}): Promise<DurableArtifactBinaryReadResult>;
}
-export interface ArtifactStore extends ArtifactStoreReader, DurableArtifactAttachmentReader {
- create(input: CreateArtifactInput): Promise<ArtifactRecord>;
- delete(artifactId: string): Promise<void>;
- purge(artifactIds: readonly string[]): Promise<void>;
- close?(): void;
-}
-
export type ArtifactUserDeleteResult =
- | { readonly kind: 'deleted'; readonly record: ArtifactRecord }
- | { readonly kind: 'not_found' }
- | { readonly kind: 'protected' };
+ | { readonly kind: 'deleted' }
+ | { readonly kind: 'protected' }
+ | { readonly kind: 'not_found' };
-export interface ArtifactAuthorityStore extends ArtifactStore {
+export interface ArtifactAuthorityStore extends DurableArtifactAttachmentReader {
+ create(input: CreateArtifactInput): Promise<ArtifactRecord>;
+ close(): void;
copyConversationArtifacts(
input: ConversationArtifactCopyInput,
): Promise<ConversationArtifactCopyResult>;
purgeSessionArtifacts(sessionId: string): Promise<void>;
- /** Drops up to `limit` retired prepared-request captures; reports what remains. */
- purgeRetiredCaptures(limit: number): Promise<{ purged: number; remaining: number }>;
+ deleteOwnedArtifactInSession(
+ sessionId: string,
+ artifactId: string,
+ source: ArtifactSource,
+ ): Promise<void>;
deleteUserArtifactInSession(
sessionId: string,
artifactId: string,
@@ -268,20 +216,9 @@ export interface ArtifactAuthorityStore extends ArtifactStore {
export interface ArtifactStoreWriteAuthority {
readonly store: ArtifactAuthorityStore;
- recover(): Promise<void>;
close(): void;
}
-export function createSqliteArtifactStore(workspaceRoot: string): ArtifactStore {
- return new SqliteArtifactStore(
- workspaceRoot,
- 'self_managed',
- createSqliteArtifactMetadataRepository(workspaceRoot),
- undefined,
- undefined,
- );
-}
-
export function createSqliteArtifactStoreWriteAuthority(
workspaceRoot: string,
options: {
@@ -291,39 +228,28 @@ export function createSqliteArtifactStoreWriteAuthority(
): ArtifactStoreWriteAuthority {
const store = new SqliteArtifactStore(
workspaceRoot,
- 'authority',
createSqliteArtifactMetadataRepository(workspaceRoot),
options.assertAuthority,
options.leaseBoundWriterLockAuthority,
);
return Object.freeze({
store,
- recover: () => store.recoverForWriteWithAuthority(),
close: () => store.close(),
});
}
class SqliteArtifactStore implements ArtifactAuthorityStore {
private artifactRoot: string;
- private purgeIntentPath: string;
private records: ArtifactRecord[] = [];
- private metadataReady = false;
- private recoveryRequired: boolean;
- private selfManagedRecoveryRequired: boolean;
- private recoverableOrphans = new Map<string, RecoverableOrphan>();
private queue: Promise<void> = Promise.resolve();
constructor(
private workspaceRoot: string,
- private readonly recoveryMode: 'self_managed' | 'authority',
- private readonly metadataRepository: ArtifactMetadataRepository,
+ private readonly metadataRepository: ReturnType<typeof createSqliteArtifactMetadataRepository>,
private readonly assertAuthority?: () => Promise<void>,
private readonly leaseBoundWriterLockAuthority?: ArtifactWriterLockAuthority,
) {
this.artifactRoot = join(workspaceRoot, 'artifacts');
- this.purgeIntentPath = join(this.artifactRoot, ARTIFACT_PURGE_INTENT_FILE);
- this.recoveryRequired = recoveryMode === 'authority';
- this.selfManagedRecoveryRequired = recoveryMode === 'self_managed';
}
close(): void {
@@ -337,7 +263,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
});
const id = acceptedInput.id ?? randomUUID();
if (!ARTIFACT_KIND_SET.has(acceptedInput.kind)) throw new Error('Invalid Artifact kind');
- if (acceptedInput.source !== undefined && !ARTIFACT_SOURCE_SET.has(acceptedInput.source)) {
+ if (!ARTIFACT_SOURCE_SET.has(acceptedInput.source)) {
throw new Error('Invalid Artifact source');
}
if (
@@ -358,13 +284,8 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
const name = sanitizeArtifactName(acceptedInput.name);
const relativePath = `${acceptedInput.sessionId}/${id}-${name}`;
validateRelativeArtifactPath(relativePath);
- validateCanonicalArtifactTargetName(basename(relativePath));
return this.enqueueMutation(async () => {
- await this.prepareMutationUnlocked({
- kind: 'create',
- input: acceptedInput,
- identity: { id, canonicalName: name },
- });
+ await this.prepareMutationUnlocked();
const existing = this.records.find((record) => record.id === id);
if (existing) {
return this.replayExistingArtifactUnlocked(existing, acceptedInput, {
@@ -373,19 +294,6 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
relativePath,
});
}
- await this.assertNoCompatiblePublicationStagingUnlocked(acceptedInput, {
- id,
- canonicalName: name,
- });
- const adopted = await this.adoptRecoverableOrphanUnlocked(acceptedInput, {
- id,
- canonicalName: name,
- });
- if (adopted) return adopted;
- await this.assertNoCompatiblePayloadExistsUnlocked(acceptedInput, {
- id,
- canonicalName: name,
- });
return this.publishNewArtifactUnlocked(
{
id,
@@ -396,14 +304,13 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
kind: acceptedInput.kind,
relativePath,
...(acceptedInput.mimeType ? { mimeType: acceptedInput.mimeType } : {}),
- ...(acceptedInput.source ? { source: acceptedInput.source } : {}),
+ source: acceptedInput.source,
...(acceptedInput.summary ? { summary: acceptedInput.summary } : {}),
...(acceptedInput.deepResearchRole
? { deepResearchRole: acceptedInput.deepResearchRole }
: {}),
- status: 'live',
},
- (tempPath) => writeFile(tempPath, acceptedInput.content, { flag: 'wx' }),
+ (targetPath) => writeFile(targetPath, acceptedInput.content, { flag: 'wx' }),
);
});
}
@@ -441,18 +348,13 @@ 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) {
for (const artifactId of artifactIds) {
const record = this.records.find(
- (candidate) =>
- candidate.sessionId === sessionId &&
- candidate.id === artifactId &&
- candidate.status !== 'deleted' &&
- !isRetiredCapture(candidate),
+ (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
@@ -468,8 +370,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);
@@ -486,9 +387,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}`);
}
@@ -511,27 +410,13 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
const source = prepared.record;
const name = sanitizeArtifactName(source.name);
const relativePath = `${targetSessionId}/${targetId}-${name}`;
- const publicationInput = { sessionId: targetSessionId, name };
assertCanonicalArtifactEntityId(targetId, 'id');
validateRelativeArtifactPath(relativePath);
- validateCanonicalArtifactTargetName(basename(relativePath));
return this.enqueueMutation(async () => {
- await this.prepareMutationUnlocked({
- kind: 'copy',
- input: publicationInput,
- identity: { id: targetId, canonicalName: name },
- });
+ await this.prepareMutationUnlocked();
if (this.records.some((record) => record.id === targetId)) {
throw new Error(`Artifact target already exists: ${targetId}`);
}
- await this.assertNoCompatiblePublicationStagingUnlocked(publicationInput, {
- id: targetId,
- canonicalName: name,
- });
- await this.assertNoCompatiblePayloadExistsUnlocked(publicationInput, {
- id: targetId,
- canonicalName: name,
- });
return this.publishNewArtifactUnlocked(
{
...source,
@@ -540,7 +425,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
name,
relativePath,
},
- (tempPath) => copyFile(prepared.path, tempPath, fsConstants.COPYFILE_EXCL),
+ (targetPath) => copyFile(prepared.path, targetPath, fsConstants.COPYFILE_EXCL),
source.sizeBytes,
);
});
@@ -548,7 +433,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
private async publishNewArtifactUnlocked(
draft: ArtifactRecordDraft,
- writeStaging: (tempPath: string) => Promise<void>,
+ writeTarget: (targetPath: string) => Promise<void>,
expectedSize?: number,
): Promise<ArtifactRecord> {
const target = join(this.artifactRoot, draft.relativePath);
@@ -558,64 +443,34 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
await syncDirectoryChain(targetDirectory, this.workspaceRoot);
}
await assertArtifactDirectory(this.artifactRoot, targetDirectory);
- const tempPath = join(targetDirectory, publicationStagingName(basename(target)));
- let preserveStaging = false;
- let targetLinked = false;
+ await rm(target, { force: true });
try {
- await writeStaging(tempPath);
- await syncFile(tempPath);
+ await writeTarget(target);
+ await syncFile(target);
await syncDirectory(targetDirectory);
- const size = await stat(tempPath);
+ const size = await stat(target);
if (expectedSize !== undefined && size.size !== expectedSize) {
throw new Error(`Artifact source changed while copying: ${draft.id}`);
}
const record: ArtifactRecord = { ...draft, sizeBytes: size.size };
const nextRecords = [...this.records, record];
- try {
- try {
- await link(tempPath, target);
- targetLinked = true;
- } catch (error) {
- if (isAlreadyExists(error)) {
- throw new Error(`Artifact target already exists: ${draft.id}`);
- }
- throw error;
- }
- await syncDirectory(targetDirectory);
- await this.writeMetadataUnlocked({ upserts: [record] });
- } catch (error) {
- if (targetLinked) {
- try {
- await removeFileDurably(target, targetDirectory);
- } catch (cleanupError) {
- preserveStaging = true;
- this.invalidateWriterState();
- throw new AggregateError(
- [error, cleanupError],
- `Artifact ${draft.id} metadata publication and payload cleanup both failed`,
- );
- }
- }
- throw error;
- }
+ await this.writeMetadataUnlocked({ upserts: [record] });
this.records = nextRecords;
return { ...record };
- } finally {
- if (!preserveStaging) {
- try {
- await removeFileDurably(tempPath, targetDirectory);
- } catch (error) {
- this.invalidateWriterState();
- throw error;
- }
- }
+ } catch (error) {
+ await removeFileDurably(target, targetDirectory).catch(() => undefined);
+ throw error;
}
}
async purgeSessionArtifacts(sessionId: string): Promise<void> {
assertCanonicalArtifactEntityId(sessionId, 'sessionId');
- const records = await this.list(sessionId, { includeDeleted: true });
- if (records.length > 0) await this.purge(records.map((record) => record.id));
+ await this.enqueueMutation(async () => {
+ await this.prepareMutationUnlocked();
+ await this.purgeRecordsUnlocked(
+ this.records.filter((record) => record.sessionId === sessionId),
+ );
+ });
}
private async replayExistingArtifactUnlocked(
@@ -659,51 +514,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 };
- }
-
- recoverForWriteWithAuthority(): Promise<void> {
- return this.enqueueMutation(async () => {
- this.recoveryRequired = true;
- this.recoverableOrphans.clear();
- await this.prepareRecoveryUnlocked();
- this.recoverableOrphans = await this.findRecoverableOrphansUnlocked();
- this.recoveryRequired = false;
- });
- }
-
- async list(
- sessionId: string,
- opts: { includeDeleted?: boolean } = {},
- ): Promise<ArtifactRecord[]> {
- const includeDeleted = opts.includeDeleted ?? false;
- 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)
- .map((record) => ({ ...record }))
- );
- });
- }
-
- async get(artifactId: string): Promise<ArtifactRecord | null> {
- return this.enqueue(async () => {
- await this.load();
- const record = this.records.find((item) => item.id === artifactId);
- return record ? { ...record } : null;
- });
+ return { ...existing };
}
async listPage(
@@ -731,7 +542,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 }));
});
}
@@ -748,30 +559,6 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
});
}
- async readText(
- artifactId: string,
- opts: { maxBytes?: number; includeDeleted?: boolean } = {},
- ): Promise<ArtifactTextReadResult> {
- const prepared = await this.prepareRead(
- artifactId,
- opts.maxBytes ?? ARTIFACT_TEXT_PREVIEW_LIMIT_BYTES,
- opts.includeDeleted ?? false,
- );
- return this.readPreparedText(prepared);
- }
-
- async readBinary(
- artifactId: string,
- opts: { maxBytes?: number } = {},
- ): Promise<ArtifactBinaryReadResult> {
- const prepared = await this.prepareRead(
- artifactId,
- opts.maxBytes ?? ARTIFACT_BINARY_PREVIEW_LIMIT_BYTES,
- false,
- );
- return this.readPreparedBinary(prepared);
- }
-
readTextInSession(
sessionId: string,
artifactId: string,
@@ -840,7 +627,6 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
const prepared = await this.prepareRecordRead(
record,
input.maxBytes ?? ARTIFACT_BINARY_PREVIEW_LIMIT_BYTES,
- false,
);
return this.readPreparedBinary(prepared);
});
@@ -865,19 +651,19 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
return { ok: true, base64: read.bytes.toString('base64'), mimeType };
}
- async delete(artifactId: string): Promise<void> {
- 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;
+ deleteOwnedArtifactInSession(
+ sessionId: string,
+ artifactId: string,
+ source: ArtifactSource,
+ ): Promise<void> {
+ return this.enqueueMutation(async () => {
+ await this.prepareMutationUnlocked();
+ const snapshot = this.sessionSnapshot(sessionId);
+ const existing = snapshot.records.find((record) => record.id === artifactId);
+ if (!existing || existing.source !== source) {
+ throw new Error('Artifact does not belong to the expected Session authority');
+ }
+ await this.purgeRecordsUnlocked([existing]);
});
}
@@ -886,68 +672,21 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
artifactId: string,
): Promise<ArtifactUserDeleteResult> {
return this.enqueueMutation(async () => {
- await this.prepareMutationUnlocked({ kind: 'delete' });
+ await this.prepareMutationUnlocked();
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' };
});
}
- async purge(artifactIds: readonly string[]): Promise<void> {
- const acceptedArtifactIds = Object.freeze([...artifactIds]);
- await this.enqueueMutation(async () => {
- await this.prepareMutationUnlocked({ kind: 'purge' });
- const ids = new Set(acceptedArtifactIds);
- await this.purgeRecordsUnlocked(this.records.filter((record) => ids.has(record.id)));
- });
- }
-
- /**
- * 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<void> {
if (records.length === 0) return;
const ids = new Set(records.map((record) => record.id));
const paths = await this.preparePurgePathsUnlocked(records);
- try {
- await this.publishPurgeIntentUnlocked([...ids]);
- await this.completePurgeUnlocked(ids, paths);
- } catch (error) {
- this.invalidateWriterState();
- throw error;
- }
+ await this.completePurgeUnlocked(ids, paths);
}
private async preparePurgePathsUnlocked(
@@ -1037,6 +776,7 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
ids: ReadonlySet<string>,
paths: readonly string[],
): Promise<void> {
+ const nextRecords = this.records.filter((record) => !ids.has(record.id));
const changedDirectories = new Set<string>();
try {
for (const path of paths) {
@@ -1046,20 +786,10 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
} finally {
for (const directory of changedDirectories) await syncDirectory(directory);
}
- const nextRecords = this.records.filter((record) => !ids.has(record.id));
+ // Keep the paths discoverable until physical cleanup is durable. Session
+ // retirement already owns the pending cleanup intent and retries on reopen.
await this.writeMetadataUnlocked({ deleteIds: [...ids] });
this.records = nextRecords;
- await this.removePurgeIntentUnlocked();
- }
-
- private async prepareRead(
- artifactId: string,
- maxBytes: number,
- includeDeleted = false,
- ): Promise<PreparedArtifactRead | ArtifactReadFailure> {
- const record = await this.get(artifactId);
- if (!record) return { ok: false, reason: 'not_found' };
- return this.prepareRecordRead(record, maxBytes, includeDeleted);
}
private async prepareReadInSessionUnlocked(
@@ -1071,15 +801,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<PreparedArtifactRead | ArtifactReadFailure> {
- if (record.status === 'deleted' && !includeDeleted) return { ok: false, reason: 'deleted' };
const resolved = await resolveArtifactPath({
artifactRoot: this.artifactRoot,
relativePath: record.relativePath,
@@ -1089,330 +817,26 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
}
private async load(): Promise<void> {
- await this.metadataRepository.ready();
- this.metadataReady = true;
this.records = this.metadataRepository.readAll();
}
private async writeMetadataUnlocked(changes: ArtifactMetadataChanges): Promise<void> {
- await this.metadataRepository.ready();
- this.metadataReady = true;
this.metadataRepository.applyChanges(changes);
}
- private async prepareMutationUnlocked(
- purpose:
- | {
- readonly kind: 'create';
- readonly input: CreateArtifactInput;
- readonly identity: { readonly id: string; readonly canonicalName: string };
- }
- | {
- readonly kind: 'copy';
- readonly input: Pick<CreateArtifactInput, 'sessionId' | 'name'>;
- readonly identity: { readonly id: string; readonly canonicalName: string };
- }
- | { readonly kind: 'delete' | 'purge' },
- ): Promise<void> {
- if (this.recoveryMode === 'self_managed') {
- this.recoverableOrphans.clear();
- if (this.selfManagedRecoveryRequired) {
- await this.prepareRecoveryUnlocked();
- this.selfManagedRecoveryRequired = false;
- } else {
- await this.reloadForMutationUnlocked();
- await this.recoverPurgeIntentUnlocked();
- }
- if (purpose.kind === 'create' || purpose.kind === 'copy') {
- await this.recoverCompatiblePublicationsUnlocked(purpose.input, purpose.identity);
- }
- if (purpose.kind === 'create') {
- this.recoverableOrphans = await this.findCompatibleRecoverableOrphansUnlocked(
- purpose.input,
- purpose.identity,
- );
- }
- return;
- }
+ private async prepareMutationUnlocked(): Promise<void> {
await this.reloadForMutationUnlocked();
- if (this.recoveryRequired) throw artifactWriteRecoveryRequired();
- if (!(await this.hasCanonicalRecoveryResidueUnlocked())) return;
- this.recoveryRequired = true;
- throw artifactWriteRecoveryRequired();
}
private async reloadForMutationUnlocked(): Promise<void> {
- await this.metadataRepository.ready();
- this.metadataReady = true;
this.records = this.metadataRepository.readAll();
}
- private async hasCanonicalRecoveryResidueUnlocked(): Promise<boolean> {
- try {
- await lstat(this.purgeIntentPath);
- return true;
- } catch (error) {
- if (!isNotFound(error)) throw error;
- }
-
- let sessionEntries: Dirent[];
- try {
- sessionEntries = await readdir(this.artifactRoot, { withFileTypes: true });
- } catch (error) {
- if (isNotFound(error)) return false;
- throw error;
- }
- if (sessionEntries.some((entry) => isArtifactPurgeRecoveryTempName(entry.name))) {
- return true;
- }
- for (const sessionEntry of sessionEntries) {
- if (!sessionEntry.isDirectory()) continue;
- const entries = await readdir(join(this.artifactRoot, sessionEntry.name), {
- withFileTypes: true,
- });
- if (entries.some((entry) => ARTIFACT_PUBLICATION_STAGING_PATTERN.test(entry.name))) {
- return true;
- }
- }
- return false;
- }
-
- private async prepareRecoveryUnlocked(): Promise<void> {
- await this.reloadForMutationUnlocked();
- try {
- await syncDirectory(this.artifactRoot);
- } catch (error) {
- if (!isNotFound(error)) throw error;
- }
- await this.recoverMetadataTempsUnlocked();
- await this.recoverPublicationsUnlocked();
- await this.recoverPurgeIntentUnlocked();
- }
-
- private async adoptRecoverableOrphanUnlocked(
- input: CreateArtifactInput,
- identity: { id: string; canonicalName: string },
- ): Promise<ArtifactRecord | null> {
- const names = [identity.canonicalName];
- const candidates = names
- .map((name) => ({
- name,
- relativePath: `${input.sessionId}/${identity.id}-${name}`,
- }))
- .map((candidate) => ({
- ...candidate,
- fingerprint: this.recoverableOrphans.get(filesystemPathKey(candidate.relativePath)),
- }))
- .filter(
- (
- candidate,
- ): candidate is typeof candidate & {
- readonly fingerprint: RecoverableOrphan;
- } => candidate.fingerprint !== undefined,
- );
- if (candidates.length === 0) return null;
- if (candidates.length !== 1) throw artifactReplayConflict(identity.id);
-
- const candidate = candidates[0]!;
- const resolved = await resolveArtifactPath({
- artifactRoot: this.artifactRoot,
- relativePath: candidate.relativePath,
- });
- if (!resolved.ok) throw artifactReplayConflict(identity.id);
- const expectedBytes = Buffer.from(input.content);
- const payloadStat = await lstat(resolved.path).catch(() => null);
- const expectedDigest = sha256(expectedBytes);
- if (
- !payloadStat?.isFile() ||
- payloadStat.isSymbolicLink() ||
- payloadStat.size !== expectedBytes.byteLength ||
- candidate.fingerprint.canonicalPath !== resolved.path ||
- candidate.fingerprint.dev !== payloadStat.dev ||
- candidate.fingerprint.ino !== payloadStat.ino ||
- candidate.fingerprint.size !== payloadStat.size ||
- candidate.fingerprint.digest !== expectedDigest ||
- (await digestFile(resolved.path)) !== expectedDigest
- ) {
- throw artifactReplayConflict(identity.id);
- }
-
- const record: ArtifactRecord = {
- id: identity.id,
- sessionId: input.sessionId,
- turnId: input.turnId,
- createdAt: input.now ?? Date.now(),
- name: candidate.name,
- kind: input.kind,
- relativePath: candidate.relativePath,
- sizeBytes: payloadStat.size,
- ...(input.mimeType ? { mimeType: input.mimeType } : {}),
- ...(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] });
- this.records = nextRecords;
- this.recoverableOrphans.delete(filesystemPathKey(candidate.relativePath));
- return { ...record };
- }
-
- private async findRecoverableOrphansUnlocked(): Promise<Map<string, RecoverableOrphan>> {
- const orphans = new Map<string, RecoverableOrphan>();
- const referencedPaths = new Set(
- this.records.map((record) => filesystemPathKey(record.relativePath)),
- );
- let sessionEntries: Dirent[];
- try {
- sessionEntries = await readdir(this.artifactRoot, { withFileTypes: true });
- } catch (error) {
- if (isNotFound(error)) return orphans;
- throw error;
- }
- for (const sessionEntry of sessionEntries) {
- if (!sessionEntry.isDirectory() || !isCanonicalArtifactEntityId(sessionEntry.name)) continue;
- const sessionDirectory = join(this.artifactRoot, sessionEntry.name);
- await assertArtifactDirectory(this.artifactRoot, sessionDirectory);
- for (const entry of await readdir(sessionDirectory, { withFileTypes: true })) {
- if (!entry.isFile() || ARTIFACT_PUBLICATION_STAGING_PATTERN.test(entry.name)) continue;
- const relativePath = `${sessionEntry.name}/${entry.name}`;
- if (referencedPaths.has(filesystemPathKey(relativePath))) continue;
- const path = join(sessionDirectory, entry.name);
- const [canonicalPath, pathStat] = await Promise.all([realpath(path), lstat(path)]);
- if (!pathStat.isFile() || pathStat.isSymbolicLink()) {
- throw new Error(
- `Artifact orphan changed while recovery was inspecting it: ${relativePath}`,
- );
- }
- orphans.set(filesystemPathKey(relativePath), {
- canonicalPath,
- dev: pathStat.dev,
- ino: pathStat.ino,
- size: pathStat.size,
- digest: await digestFile(path),
- });
- }
- }
- return orphans;
- }
-
- private async findCompatibleRecoverableOrphansUnlocked(
- input: CreateArtifactInput,
- identity: { id: string; canonicalName: string },
- ): Promise<Map<string, RecoverableOrphan>> {
- const orphans = new Map<string, RecoverableOrphan>();
- const sessionDirectory = join(this.artifactRoot, input.sessionId);
- try {
- await assertArtifactDirectory(this.artifactRoot, sessionDirectory);
- } catch (error) {
- if (isNotFound(error)) return orphans;
- throw error;
- }
- const referencedPaths = new Set(
- this.records.map((record) => filesystemPathKey(record.relativePath)),
- );
- for (const name of [identity.canonicalName]) {
- const relativePath = `${input.sessionId}/${identity.id}-${name}`;
- if (referencedPaths.has(filesystemPathKey(relativePath))) continue;
- const path = join(this.artifactRoot, relativePath);
- let pathStat;
- try {
- pathStat = await lstat(path);
- } catch (error) {
- if (isNotFound(error)) continue;
- throw error;
- }
- if (!pathStat.isFile() || pathStat.isSymbolicLink()) continue;
- orphans.set(filesystemPathKey(relativePath), {
- canonicalPath: await realpath(path),
- dev: pathStat.dev,
- ino: pathStat.ino,
- size: pathStat.size,
- digest: await digestFile(path),
- });
- }
- return orphans;
- }
-
- private async recoverCompatiblePublicationsUnlocked(
- input: Pick<CreateArtifactInput, 'sessionId' | 'name'>,
- identity: { id: string; canonicalName: string },
- ): Promise<void> {
- const sessionDirectory = join(this.artifactRoot, input.sessionId);
- const targetHashes = new Set(
- [identity.canonicalName].map((name) => artifactTargetHash(`${identity.id}-${name}`)),
- );
- let entries: Dirent[];
- try {
- entries = await readdir(sessionDirectory, { withFileTypes: true });
- } catch (error) {
- if (isNotFound(error)) return;
- throw error;
- }
- for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
- const match = ARTIFACT_PUBLICATION_STAGING_PATTERN.exec(entry.name);
- if (!match || !targetHashes.has(match[1]!)) continue;
- await this.recoverPublicationUnlocked({
- sessionId: input.sessionId,
- sessionDirectory,
- stagingName: entry.name,
- targetHash: match[1]!,
- });
- }
- }
-
- private async assertNoCompatiblePayloadExistsUnlocked(
- input: Pick<CreateArtifactInput, 'sessionId' | 'name'>,
- identity: { id: string; canonicalName: string },
- ): Promise<void> {
- const names = new Set([identity.canonicalName]);
- for (const name of names) {
- const path = join(this.artifactRoot, input.sessionId, `${identity.id}-${name}`);
- try {
- await lstat(path);
- throw new Error(`Artifact target already exists: ${identity.id}`);
- } catch (error) {
- if (!isNotFound(error)) throw error;
- }
- }
- }
-
- private async assertNoCompatiblePublicationStagingUnlocked(
- input: Pick<CreateArtifactInput, 'sessionId' | 'name'>,
- identity: { id: string; canonicalName: string },
- ): Promise<void> {
- const targetHashes = new Set(
- [identity.canonicalName].map((name) => artifactTargetHash(`${identity.id}-${name}`)),
- );
- let entries: Dirent[];
- try {
- entries = await readdir(join(this.artifactRoot, input.sessionId), { withFileTypes: true });
- } catch (error) {
- if (isNotFound(error)) return;
- throw error;
- }
- for (const entry of entries) {
- const match = ARTIFACT_PUBLICATION_STAGING_PATTERN.exec(entry.name);
- if (match && targetHashes.has(match[1]!)) {
- throw new Error(`Artifact target already exists: ${identity.id}`);
- }
- }
- }
-
- private invalidateWriterState(): void {
- if (this.recoveryMode === 'authority') this.recoveryRequired = true;
- else this.selfManagedRecoveryRequired = true;
- }
-
private bindMutationRoot(canonicalRoot: string): void {
if (this.workspaceRoot === canonicalRoot) return;
this.workspaceRoot = canonicalRoot;
this.artifactRoot = join(canonicalRoot, 'artifacts');
- this.purgeIntentPath = join(this.artifactRoot, ARTIFACT_PURGE_INTENT_FILE);
this.records = [];
- this.recoverableOrphans.clear();
- if (this.recoveryMode === 'self_managed') this.selfManagedRecoveryRequired = true;
}
/**
@@ -1431,177 +855,6 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
return { records, revision: artifactListRevision(records) };
}
- private async publishPurgeIntentUnlocked(artifactIds: readonly string[]): Promise<void> {
- const contents = JSON.stringify({
- schemaVersion: PURGE_INTENT_SCHEMA_VERSION,
- artifactIds,
- });
- const result = await publishMarkerFile({
- root: this.artifactRoot,
- markerFile: ARTIFACT_PURGE_INTENT_FILE,
- contents,
- maxBytes: MAX_PURGE_INTENT_BYTES,
- publication: 'create',
- invalidFile: invalidPurgeIntent,
- });
- if (result === 'already_exists') {
- throw new Error('Artifact purge intent already exists');
- }
- }
-
- private async recoverPurgeIntentUnlocked(): Promise<void> {
- let contents: string;
- try {
- contents = await readBoundedMarkerFile({
- path: this.purgeIntentPath,
- maxBytes: MAX_PURGE_INTENT_BYTES,
- invalidFile: invalidPurgeIntent,
- });
- } catch (error) {
- if (isNotFound(error)) return;
- throw error;
- }
- const intent = parsePurgeIntent(contents);
- const ids = new Set(intent.artifactIds);
- const records = this.records.filter((record) => ids.has(record.id));
- if (records.length === 0) {
- await this.removePurgeIntentUnlocked();
- return;
- }
- if (records.length !== intent.artifactIds.length) {
- throw invalidPurgeIntent();
- }
- const paths = await this.preparePurgePathsUnlocked(records);
- await this.completePurgeUnlocked(ids, paths);
- }
-
- private async removePurgeIntentUnlocked(): Promise<void> {
- try {
- await unlink(this.purgeIntentPath);
- await syncDirectory(this.artifactRoot);
- } catch (error) {
- if (!isNotFound(error)) throw error;
- }
- }
-
- private async recoverMetadataTempsUnlocked(): Promise<void> {
- let entries: Dirent[];
- try {
- entries = await readdir(this.artifactRoot, { withFileTypes: true });
- } catch (error) {
- if (isNotFound(error)) return;
- throw error;
- }
- for (const entry of entries) {
- if (!isArtifactPurgeRecoveryTempName(entry.name)) continue;
- if (!entry.isFile()) {
- throw new Error(`Artifact recovery temp is not a regular file: ${entry.name}`);
- }
- await removeFileDurably(join(this.artifactRoot, entry.name), this.artifactRoot);
- }
- }
-
- private async recoverPublicationsUnlocked(): Promise<void> {
- let sessionEntries: Dirent[];
- try {
- sessionEntries = await readdir(this.artifactRoot, { withFileTypes: true });
- } catch (error) {
- if (isNotFound(error)) return;
- throw error;
- }
-
- for (const sessionEntry of sessionEntries) {
- if (!sessionEntry.isDirectory()) continue;
- const sessionDirectory = join(this.artifactRoot, sessionEntry.name);
- const entries = await readdir(sessionDirectory, { withFileTypes: true });
- for (const entry of entries) {
- const match = ARTIFACT_PUBLICATION_STAGING_PATTERN.exec(entry.name);
- if (!match) continue;
- await this.recoverPublicationUnlocked({
- sessionId: sessionEntry.name,
- sessionDirectory,
- stagingName: entry.name,
- targetHash: match[1]!,
- });
- }
- }
- }
-
- private async recoverPublicationUnlocked(input: {
- sessionId: string;
- sessionDirectory: string;
- stagingName: string;
- targetHash: string;
- }): Promise<void> {
- const stagingPath = join(input.sessionDirectory, input.stagingName);
- const stagingStat = await lstat(stagingPath);
- if (!stagingStat.isFile() || stagingStat.isSymbolicLink()) {
- throw invalidPublicationResidue(input.stagingName);
- }
-
- const metadataMatches = this.records.filter(
- (record) =>
- record.sessionId === input.sessionId &&
- artifactTargetHash(basename(record.relativePath)) === input.targetHash,
- );
- if (metadataMatches.length > 1) throw invalidPublicationResidue(input.stagingName);
-
- const directoryEntries = await readdir(input.sessionDirectory, { withFileTypes: true });
- const matchingTargets: Array<{ name: string; path: string; size: number }> = [];
- for (const entry of directoryEntries) {
- if (entry.name === input.stagingName || ARTIFACT_PUBLICATION_STAGING_PATTERN.test(entry.name))
- continue;
- if (artifactTargetHash(entry.name) !== input.targetHash) continue;
- const candidatePath = join(input.sessionDirectory, entry.name);
- const candidateStat = await lstat(candidatePath);
- if (!candidateStat.isFile() || candidateStat.isSymbolicLink()) {
- throw invalidPublicationResidue(input.stagingName);
- }
- matchingTargets.push({
- name: entry.name,
- path: candidatePath,
- size: candidateStat.size,
- });
- }
-
- const metadataRecord = metadataMatches[0];
- if (matchingTargets.length === 0 && !metadataRecord) {
- await removeFileDurably(stagingPath, input.sessionDirectory);
- return;
- }
- if (matchingTargets.length !== 1) {
- throw invalidPublicationResidue(input.stagingName);
- }
-
- const [linkedTarget] = matchingTargets;
- if (
- !linkedTarget ||
- linkedTarget.size !== stagingStat.size ||
- (await digestFile(linkedTarget.path)) !== (await digestFile(stagingPath))
- ) {
- throw invalidPublicationResidue(input.stagingName);
- }
- const relativePath = `${input.sessionId}/${linkedTarget.name}`;
- validateRelativeArtifactPath(relativePath);
-
- if (metadataRecord) {
- if (
- metadataRecord.relativePath !== relativePath ||
- metadataRecord.sizeBytes !== linkedTarget.size
- ) {
- throw invalidPublicationResidue(input.stagingName);
- }
- await removeFileDurably(stagingPath, input.sessionDirectory);
- return;
- }
-
- await removeFileDurably(
- join(input.sessionDirectory, linkedTarget.name),
- input.sessionDirectory,
- );
- await removeFileDurably(stagingPath, input.sessionDirectory);
- }
-
private enqueueSerialized<T>(operation: () => Promise<T>): Promise<T> {
const next = this.queue.then(operation, operation);
this.queue = next.then(
@@ -1613,14 +866,6 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
return this.enqueueSerialized(async () => {
- if (!this.metadataReady) {
- return this.runWithWriterLock(async () => {
- await this.assertAuthority?.();
- await this.metadataRepository.ready();
- this.metadataReady = true;
- return operation();
- });
- }
await this.assertAuthority?.();
return operation();
});
@@ -1647,10 +892,6 @@ class SqliteArtifactStore implements ArtifactAuthorityStore {
}
}
-function publicationStagingName(targetBasename: string): string {
- return `.artifact-publish.${artifactTargetHash(targetBasename)}.${randomUUID()}.tmp`;
-}
-
async function openRealTarget(path: string) {
const noFollowFlags = fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW;
try {
@@ -1725,14 +966,6 @@ async function readPreparedChunk(
}
}
-function artifactTargetHash(targetBasename: string): string {
- return createHash('sha256').update(targetBasename).digest('hex');
-}
-
-function invalidPublicationResidue(stagingName: string): Error {
- return new Error(`Artifact publication residue does not match canonical state: ${stagingName}`);
-}
-
function artifactReplayConflict(artifactId: string): Error {
return new Error(`Artifact ${artifactId} already exists with different metadata or content`);
}
@@ -1747,10 +980,6 @@ function conversationCopyArtifactId(
.digest('hex')}`;
}
-function artifactWriteRecoveryRequired(): Error {
- return new Error('Artifact write recovery is required before another mutation');
-}
-
function optionalCanonicalText(value: string | undefined): string | undefined {
return value ? value : undefined;
}
@@ -1781,8 +1010,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
);
}
@@ -1792,12 +1020,6 @@ function assertPageBound(value: number, allowZero: boolean, label: string): void
}
}
-async function digestFile(path: string): Promise<string> {
- const hash = createHash('sha256');
- for await (const chunk of createReadStream(path)) hash.update(chunk);
- return hash.digest('hex');
-}
-
export async function resolveArtifactPath(input: {
artifactRoot: string;
relativePath: string;
@@ -1829,10 +1051,6 @@ export function sanitizeArtifactName(name: string): string {
return truncated || 'artifact';
}
-function filesystemPathKey(path: string): string {
- return Buffer.from(path, 'utf8').toString('utf8');
-}
-
function truncateWithoutSplittingSurrogate(value: string, maxCodeUnits: number): string {
const truncated = value.slice(0, maxCodeUnits);
const last = truncated.charCodeAt(truncated.length - 1);
@@ -1867,39 +1085,6 @@ function assertArtifactTurnKey(value: unknown): asserts value is string {
}
}
-interface ArtifactPurgeIntent {
- schemaVersion: typeof PURGE_INTENT_SCHEMA_VERSION;
- artifactIds: string[];
-}
-
-function parsePurgeIntent(contents: string): ArtifactPurgeIntent {
- let value: unknown;
- try {
- value = JSON.parse(contents);
- } catch {
- throw invalidPurgeIntent();
- }
- if (
- !isRecord(value) ||
- Object.keys(value).some((key) => key !== 'schemaVersion' && key !== 'artifactIds') ||
- value.schemaVersion !== PURGE_INTENT_SCHEMA_VERSION ||
- !Array.isArray(value.artifactIds) ||
- value.artifactIds.length === 0 ||
- !value.artifactIds.every(isCanonicalArtifactEntityId) ||
- new Set(value.artifactIds).size !== value.artifactIds.length
- ) {
- throw invalidPurgeIntent();
- }
- return {
- schemaVersion: PURGE_INTENT_SCHEMA_VERSION,
- artifactIds: value.artifactIds,
- };
-}
-
-function invalidPurgeIntent(): Error {
- return new Error('Invalid artifact purge intent');
-}
-
const ARTIFACT_KIND_SET = new Set<ArtifactKind>(ARTIFACT_KINDS);
const ARTIFACT_SOURCE_SET = new Set<ArtifactSource>(ARTIFACT_SOURCES);
@@ -1928,7 +1113,13 @@ async function resolveArtifactRemovalEntry(
try {
const parent = await realpath(dirname(target));
const entry = join(parent, basename(target));
- const entryStat = await lstat(entry, { bigint: true });
+ const entryStat = await lstat(entry, { bigint: true }).catch((error) => {
+ if (isNotFound(error)) return undefined;
+ throw error;
+ });
+ // A previous attempt may have unlinked the file but failed to sync its
+ // parent. Retain that parent in the next purge's durability barrier.
+ if (!entryStat) return { unlinkPath: entry, comparisonIdentity: `path:${entry}` };
if (entryStat.isSymbolicLink()) {
return {
unlinkPath: entry,
@@ -1976,10 +1167,6 @@ function isNotFound(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
}
-function isAlreadyExists(error: unknown): boolean {
- return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST';
-}
-
function sniffAllowedBinaryMime(bytes: Uint8Array): string | null {
// Core owns the binary signatures, shared with the attachment and image-read
// paths so the three cannot drift. SVG needs a wider text scan than a fixed
diff --git a/packages/storage/src/artifact-stores.ts b/packages/storage/src/artifact-stores.ts
index 2a8ad31455..58d82b3d73 100644
--- a/packages/storage/src/artifact-stores.ts
+++ b/packages/storage/src/artifact-stores.ts
@@ -56,27 +56,12 @@ export interface InteractiveArtifactStoreWriter extends DurableArtifactAttachmen
readonly kind: 'interactive';
readonly access: 'write';
readonly [writerBrand]: true;
- recover(): Promise<void>;
create(input: CreateArtifactInput): Promise<ArtifactRecord>;
- /**
- * Create, reporting whether THIS call is the one that published the artifact.
- *
- * `create` is idempotent on a content-derived id: a second caller for the same
- * bytes gets the existing record back. A caller that may later reclaim what it
- * published cannot infer ownership from that success — it would reclaim an
- * artifact an earlier, already-committed projection still references. The
- * probe and the create share one write lease, so the receipt is exact.
- */
- createOwned(
- input: CreateArtifactInput,
- ): Promise<{ record: ArtifactRecord; publishedByThisCall: boolean }>;
/**
* Narrow system delete for one Session-owned artifact of a declared source.
*
- * Not a user delete: the sources this serves are `userDeletable: false`
- * precisely because durable replay may depend on them. The caller must name
- * the source it believes it owns, and a mismatch throws — so a caller that is
- * wrong about what it is reclaiming reclaims nothing.
+ * The caller must name the source it believes it owns, and a mismatch throws,
+ * so a caller that is wrong about what it is reclaiming reclaims nothing.
*/
deleteOwnedArtifactInSession(
sessionId: string,
@@ -87,7 +72,6 @@ export interface InteractiveArtifactStoreWriter extends DurableArtifactAttachmen
input: ConversationArtifactCopyInput,
): Promise<ConversationArtifactCopyResult>;
purgeSessionArtifacts(sessionId: string): Promise<void>;
- purgeRetiredCaptures: ArtifactAuthorityStore['purgeRetiredCaptures'];
listPage: ArtifactAuthorityStore['listPage'];
listTurnArtifacts: ArtifactAuthorityStore['listTurnArtifacts'];
getInSession: ArtifactAuthorityStore['getInSession'];
@@ -161,30 +145,12 @@ function createWriterFacade(
readChunkInSession: (sessionId, artifactId, options) =>
run(() => store.readChunkInSession(sessionId, artifactId, options)),
readDurableAttachmentBinary: (input) => run(() => store.readDurableAttachmentBinary(input)),
- recover: () => run(() => authority.recover()),
create: (input) => {
const acceptedInput = snapshotCreateInput(input);
return run(() => store.create(acceptedInput));
},
- createOwned: (input) => {
- const acceptedInput = snapshotCreateInput(input);
- return run(async () => {
- const plannedId = acceptedInput.id;
- const existing = plannedId
- ? await store.getInSession(acceptedInput.sessionId, plannedId)
- : undefined;
- const record = await store.create(acceptedInput);
- return { record, publishedByThisCall: !existing?.record };
- });
- },
deleteOwnedArtifactInSession: (sessionId, artifactId, source) =>
- run(async () => {
- const entry = await store.getInSession(sessionId, artifactId);
- if (!entry.record || entry.record.source !== source) {
- throw new Error('Artifact does not belong to the expected Session authority');
- }
- await store.delete(artifactId);
- }),
+ run(() => store.deleteOwnedArtifactInSession(sessionId, artifactId, source)),
copyConversationArtifacts: (input) => {
const acceptedInput: ConversationArtifactCopyInput = Object.freeze({
...input,
@@ -211,7 +177,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 +187,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<InteractiveArtifactStoreWriter, 'purgeRetiredCaptures'>,
- options: { readonly onError?: (error: unknown) => void | Promise<void> } = {},
-): () => 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<void>((resolve) => {
- setTimeout(resolve, pauseMs).unref();
- });
- }
- })();
- return () => {
- stopped = true;
- };
-}
-
function snapshotCreateInput(input: CreateArtifactInput): CreateArtifactInput {
return Object.freeze({
...input,
diff --git a/packages/storage/src/artifact-writer-lock.ts b/packages/storage/src/artifact-writer-lock.ts
index 20869b83a3..6fef705c5f 100644
--- a/packages/storage/src/artifact-writer-lock.ts
+++ b/packages/storage/src/artifact-writer-lock.ts
@@ -23,7 +23,6 @@ import { constants as fsConstants } from 'node:fs';
import { lstat, mkdir, open, realpath, type FileHandle } from 'node:fs/promises';
import { join } from 'node:path';
import { unlock, waitForLock } from 'fs-native-extensions';
-import { ARTIFACT_WRITER_LOCK_FILE } from './artifact-storage-layout.js';
import { withArtifactWriterBootstrapLock } from './artifact-writer-bootstrap-lock.js';
import {
prepareArtifactWriterBootstrapAuthority,
@@ -149,3 +148,4 @@ function releaseLock(handle: FileHandle): void {
// Closing the handle is the final OS-level release path.
}
}
+export const ARTIFACT_WRITER_LOCK_FILE = '.maka-artifact-writer.lock';
diff --git a/packages/storage/src/operational-state-backup.ts b/packages/storage/src/operational-state-backup.ts
index cc64fabf1a..f92f61aad2 100644
--- a/packages/storage/src/operational-state-backup.ts
+++ b/packages/storage/src/operational-state-backup.ts
@@ -365,27 +365,25 @@ function validateSqlite(path: string, files: readonly OperationalBackupFile[]):
const artifactRows = database
.prepare(`
- SELECT artifact_id, session_id, created_at, status, relative_path, record_json
+ SELECT artifact_id, session_id, created_at, relative_path, record_json
FROM artifact_records
- ORDER BY created_at, storage_key
+ ORDER BY created_at, artifact_id
`)
.all() as Array<{
artifact_id?: unknown;
session_id?: unknown;
created_at?: unknown;
- status?: unknown;
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.relative_path !== record.relativePath
) {
throw new Error(`artifact indexes do not match record: ${record.id}`);
diff --git a/packages/storage/src/session-bundle-policy.ts b/packages/storage/src/session-bundle-policy.ts
index 6cd27ac8d8..7b215646d4 100644
--- a/packages/storage/src/session-bundle-policy.ts
+++ b/packages/storage/src/session-bundle-policy.ts
@@ -114,7 +114,7 @@ export async function planSessionBundleExport(
}
const rows = database
.prepare(
- 'SELECT record_json FROM artifact_records WHERE session_id = ? ORDER BY created_at, storage_key',
+ 'SELECT record_json FROM artifact_records WHERE session_id = ? ORDER BY created_at, artifact_id',
)
.all(input.sessionId) as Array<{ record_json?: unknown }>;
artifacts = decodeArtifactRecordJsons(rows.map((row) => row.record_json));
diff --git a/packages/storage/src/sqlite-artifact-metadata.ts b/packages/storage/src/sqlite-artifact-metadata.ts
index c1678b6011..08c75685e2 100644
--- a/packages/storage/src/sqlite-artifact-metadata.ts
+++ b/packages/storage/src/sqlite-artifact-metadata.ts
@@ -17,26 +17,24 @@
* under the License.
*/
-import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import type { ArtifactRecord } from '@maka/core/artifacts';
-import type {
- ArtifactMetadataChanges,
- ArtifactMetadataRepository,
-} from './artifact-metadata-repository.js';
import { decodeArtifactRecordJsons } from './artifact-metadata-codec.js';
import {
acquireOperationalStateDatabase,
type OperationalStateDatabaseLease,
} from './operational-state-store.js';
-export function createSqliteArtifactMetadataRepository(
- workspaceRoot: string,
-): ArtifactMetadataRepository {
+export interface ArtifactMetadataChanges {
+ readonly upserts?: readonly ArtifactRecord[];
+ readonly deleteIds?: readonly string[];
+}
+
+export function createSqliteArtifactMetadataRepository(workspaceRoot: string) {
return new SqliteArtifactMetadataRepository(workspaceRoot);
}
-class SqliteArtifactMetadataRepository implements ArtifactMetadataRepository {
+class SqliteArtifactMetadataRepository {
readonly #lease: OperationalStateDatabaseLease;
#closed = false;
@@ -44,18 +42,13 @@ class SqliteArtifactMetadataRepository implements ArtifactMetadataRepository {
this.#lease = acquireOperationalStateDatabase(resolve(workspaceRoot));
}
- ready(): Promise<void> {
- this.assertOpen();
- return Promise.resolve();
- }
-
readAll(): ArtifactRecord[] {
this.assertOpen();
const rows = this.#lease.database
.prepare(`
SELECT record_json
FROM artifact_records
- ORDER BY created_at, storage_key
+ ORDER BY created_at, artifact_id
`)
.all() as Array<{ record_json: string }>;
return decodeRows(rows);
@@ -65,41 +58,33 @@ class SqliteArtifactMetadataRepository implements ArtifactMetadataRepository {
this.assertOpen();
this.#lease.transaction('write', () => {
const remove = this.#lease.database.prepare(
- 'DELETE FROM artifact_records WHERE storage_key = ?',
+ 'DELETE FROM artifact_records WHERE artifact_id = ?',
);
- for (const id of changes.deleteIds ?? []) remove.run(artifactIdentityKey(id));
+ for (const id of changes.deleteIds ?? []) remove.run(id);
const upsert = this.#lease.database.prepare(`
INSERT INTO artifact_records(
- storage_key,
artifact_id,
session_id,
created_at,
- status,
relative_path,
record_json
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(storage_key) DO UPDATE SET
- artifact_id = excluded.artifact_id,
+ ) VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(artifact_id) DO UPDATE SET
session_id = excluded.session_id,
created_at = excluded.created_at,
- status = excluded.status,
relative_path = excluded.relative_path,
record_json = excluded.record_json
- WHERE artifact_id IS NOT excluded.artifact_id
- OR session_id IS NOT excluded.session_id
+ WHERE session_id IS NOT excluded.session_id
OR created_at IS NOT excluded.created_at
- OR status IS NOT excluded.status
OR relative_path IS NOT excluded.relative_path
OR record_json IS NOT excluded.record_json
`);
for (const record of changes.upserts ?? []) {
upsert.run(
- artifactIdentityKey(record.id),
record.id,
record.sessionId,
record.createdAt,
- record.status,
record.relativePath,
JSON.stringify(record),
);
@@ -121,7 +106,3 @@ class SqliteArtifactMetadataRepository implements ArtifactMetadataRepository {
function decodeRows(rows: readonly { record_json: string }[]): ArtifactRecord[] {
return decodeArtifactRecordJsons(rows.map((row) => row.record_json));
}
-
-function artifactIdentityKey(id: string): string {
- return createHash('sha256').update(JSON.stringify(id)).digest('hex');
-}
diff --git a/packages/storage/src/sqlite-artifact-schema.ts b/packages/storage/src/sqlite-artifact-schema.ts
index d330e66833..f8d3f3e733 100644
--- a/packages/storage/src/sqlite-artifact-schema.ts
+++ b/packages/storage/src/sqlite-artifact-schema.ts
@@ -18,25 +18,61 @@
*/
import type { DatabaseSync } from 'node:sqlite';
+import { decodeArtifactRecordJsons } from './artifact-metadata-codec.js';
-export const SQLITE_ARTIFACT_SCHEMA_VERSION = 1;
+export const SQLITE_ARTIFACT_SCHEMA_VERSION = 3;
export function migrateSqliteArtifactDatabase(db: DatabaseSync): void {
+ const columns = db.prepare('PRAGMA table_info(artifact_records)').all() as Array<{
+ name?: unknown;
+ }>;
+ const retained: string[] = [];
+ if (columns.some(({ name }) => name === 'status' || name === 'storage_key')) {
+ const rows = db.prepare('SELECT * FROM artifact_records').all();
+ for (const row of rows) {
+ if (columns.some(({ name }) => name === 'status') && row.status !== 'live') continue;
+ try {
+ const parsed = JSON.parse(String(row.record_json));
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
+ if (parsed.status !== undefined && parsed.status !== 'live') continue;
+ delete parsed.status;
+ if (
+ parsed.id !== row.artifact_id ||
+ parsed.sessionId !== row.session_id ||
+ parsed.createdAt !== row.created_at ||
+ parsed.relativePath !== row.relative_path
+ )
+ continue;
+ retained.push(JSON.stringify(parsed));
+ } catch {}
+ }
+ db.exec('DROP TABLE artifact_records');
+ }
db.exec(`
CREATE TABLE IF NOT EXISTS artifact_records (
- storage_key TEXT PRIMARY KEY,
- artifact_id TEXT NOT NULL,
+ artifact_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
created_at INTEGER NOT NULL CHECK (created_at >= 0),
- status TEXT NOT NULL CHECK (status IN ('live', 'deleted')),
relative_path TEXT NOT NULL,
record_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS artifact_records_session_order
- ON artifact_records(session_id, created_at, storage_key);
+ ON artifact_records(session_id, created_at, artifact_id);
CREATE UNIQUE INDEX IF NOT EXISTS artifact_records_relative_path
ON artifact_records(relative_path);
`);
+ const insert = db.prepare(`
+ INSERT INTO artifact_records VALUES (?, ?, ?, ?, ?)
+ `);
+ for (const record of decodeArtifactRecordJsons(retained)) {
+ insert.run(
+ record.id,
+ record.sessionId,
+ record.createdAt,
+ record.relativePath,
+ JSON.stringify(record),
+ );
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org