Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/daemon/src/__tests__/attachment-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,4 +280,21 @@ describe('SQLite attachment store', () => {
),
).toBe(false);
});

it('refuses a commit naming a lease the reaper already removed and writes nothing', async () => {
const { store } = await fixture();
const draft = lease('up-late', 'abc');
await store.beginUpload(draft);
expect(await store.sweep({ now: draft.expiresAt, graceBefore: 0 })).toEqual([]);

await expect(async () =>
store.commitAttachment({
blob: blob('abc'),
attachment: attachment('att-late'),
uploadId: UploadIdSchema.parse('up-late'),
}),
).rejects.toThrow('Upload lease is gone');
expect(await store.getAttachment(AttachmentIdSchema.parse('att-late'))).toBeUndefined();
expect(await store.getBlob(blob('abc').blobId)).toBeUndefined();
});
});
6 changes: 5 additions & 1 deletion apps/daemon/src/attachment-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
AttachmentSweepWindow,
StoredAttachment,
} from '@linkcode/engine';
import { UploadLeaseGoneError } from '@linkcode/engine';
import type {
AttachmentId,
BlobId,
Expand Down Expand Up @@ -136,10 +137,13 @@ export function createAttachmentStore(db: DaemonDatabaseClient): AttachmentStore
})
.run();
if (uploadId !== undefined) {
tx.update(uploadLeases)
const claimed = tx
.update(uploadLeases)
.set({ blobId: blob.blobId, attachmentId: attachment.attachmentId })
.where(eq(uploadLeases.uploadId, uploadId))
.run();
// Throwing rolls the whole commit back: no row may outlive the pin that kept its bytes.
if (claimed.changes === 0) throw new UploadLeaseGoneError(uploadId);
}
});
return Promise.resolve();
Expand Down
5 changes: 2 additions & 3 deletions apps/desktop/src/renderer/src/shell/desktop-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ export function DesktopShell({
newSessionWorkspaceId,
onNewSessionWorkspaceChange,
runtimeCues,
attachmentSupport,
agentCatalogs,
selectableHarnesses,
accountModels,
Expand Down Expand Up @@ -115,6 +114,7 @@ export function DesktopShell({
mentionItems,
onMentionQueryChange,
conversationComposer,
onPrepareAttachment,
onRespondPermission,
onRespondQuestion,
onHostArtifact,
Expand Down Expand Up @@ -421,7 +421,6 @@ export function DesktopShell({
workspaceId={newSessionWorkspaceId}
onWorkspaceChange={onNewSessionWorkspaceChange}
runtimeCues={runtimeCues}
attachmentSupport={attachmentSupport}
agentCatalogs={agentCatalogs}
selectableHarnesses={selectableHarnesses}
accountModels={accountModels}
Expand All @@ -438,6 +437,7 @@ export function DesktopShell({
onPickDirectory={pickDirectory}
onRegisterWorkspace={onRegisterWorkspace}
onPickAttachmentFiles={pickAttachmentFiles}
onPrepareAttachment={onPrepareAttachment}
/>
) : (
// Keyed per session: switching resets the composer draft and scroll without touching the shell.
Expand All @@ -450,7 +450,6 @@ export function DesktopShell({
agentLabel={agentLabel}
accountModels={active ? accountModels?.[active.kind] : undefined}
accountId={active?.accountId}
attachmentsSupported={Boolean(active && attachmentSupport?.[active.kind])}
cwd={active?.cwd}
runtimeCues={runtimeCues}
onOpenProviderSettings={onOpenProviderSettings}
Expand Down
7 changes: 4 additions & 3 deletions packages/client/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ Rules the projection store enforces — keep them when touching it:
no watermark, supersedes nothing, and takes its baseline from the first stamped event.
- **A stamped repeat stays in the `EventBuffer`** (attach replays resolved asks): dropping it would
read as a gap. Only unstamped repeats are deduped.
- **The echo's attachment blocks win** for the row sharing its identity: durable prompts are
text-only until attachment refs land, so the store overlays the buffered echo's content on a read
row with the same id. Remove this with the attachment store.
- **Durable user rows carry attachment refs** (`resource_link` with an `attachment:` URI). The live
echo is text-only; do not overlay echo content onto a read row — that leaked inline base64 and
hid the durable refs. Pending drafts render from the client's blob cache until submit roots the
attachment.
- Live user echoes carry no envelope `turnId` (they precede turn tracking); never bucket by it.

`history-unavailable` read items become `ConversationItem`s of that kind under the current turn:
Expand Down
27 changes: 27 additions & 0 deletions packages/client/core/src/__tests__/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,33 @@ describe('buildConversation', () => {
});
});

it('keeps durable attachment refs when a later echo for the same row is text-only', () => {
const messageId = 'msg-turn-1' as MessageId;
const link = {
type: 'resource_link' as const,
uri: 'attachment:att-1',
name: 'shot.png',
};
const c = buildConversation([
{
type: 'user-message',
messageId,
content: [{ type: 'text', text: 'describe this' }, link],
},
{
type: 'user-message',
messageId,
content: [{ type: 'text', text: 'describe this' }],
},
]);

expect(c.items).toHaveLength(1);
expect(c.items[0]).toMatchObject({
id: messageId,
blocks: [{ type: 'text', text: 'describe this' }, link],
});
});

it('rewinds the selected prompt and every later conversation event before replacement', () => {
const c = buildConversation([
{ type: 'status', status: 'idle' },
Expand Down
52 changes: 46 additions & 6 deletions packages/client/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import type {
StartOptions,
TerminalMetadata,
TerminalReplayEvent,
TurnSubmitInput,
UploadId,
WireMessage,
WorkspaceFile,
Expand Down Expand Up @@ -109,13 +110,18 @@ import type { SequencedAgentEvent } from './client/event-buffer';
import { EventBuffer } from './client/event-buffer';
import { LoopLogBuffer } from './client/loop-log-buffer';
import type {
AttachmentChunkAck,
AttachmentCommitResult,
AttachmentReadResult,
AttachmentUploadBegun,
ConversationGraphSnapshot,
ConversationReadPage,
PluginList,
PluginMutation,
RandomUUID,
RequestAck,
SessionStartResult,
TurnSubmitResult,
} from './client/pending-registry';
import { PendingRegistry, resolveRandomUUID } from './client/pending-registry';
import { TerminalChannel } from './client/terminal-channel';
Expand All @@ -127,6 +133,7 @@ export type {
AttachmentReadBytes,
} from './client/attachment-channel';
export type { Sha256Hex } from './client/blob-cache';
export { base64ToBytes } from './client/blob-cache';
export type { BrowserCommandExecutor } from './client/browser-host-channel';
export type {
ConversationReadClientOptions,
Expand All @@ -141,6 +148,7 @@ export type {
PluginList,
PluginMutation,
SessionStartResult,
TurnSubmitResult,
} from './client/pending-registry';

type EventCb = (entry: SequencedAgentEvent) => void;
Expand Down Expand Up @@ -474,6 +482,9 @@ export class LinkCodeClient {
...(p.cursor !== undefined && { cursor: p.cursor }),
});
break;
case 'turn.submitted':
this.pending.resolve('turnSubmit', p.replyTo, { turnId: p.turnId });
break;
case 'conversation.graph.changed':
this.graphChanges.note(p.sessionId, {
graphRevision: p.graphRevision,
Expand Down Expand Up @@ -836,6 +847,11 @@ export class LinkCodeClient {
return this.control.readConversation(sessionId, opts);
}

/** See {@link ControlChannel.submitTurn}. */
submitTurn(sessionId: SessionId, input: TurnSubmitInput): Promise<TurnSubmitResult> {
return this.control.submitTurn(sessionId, input);
}

/** The newest `conversation.graph.changed` seen for the session on this connection. */
latestGraphChange(sessionId: SessionId): ConversationGraphChange | undefined {
return this.graphChanges.get(sessionId);
Expand Down Expand Up @@ -1379,28 +1395,51 @@ export class LinkCodeClient {
return this.control.hostResource(resourceId);
}

beginAttachmentUpload(input: AttachmentBeginInput) {
/** A peer below the store's wire version drops `attachment.*` frames unanswered — the request
* would hang forever — so every attachment method fails typed instead. */
private attachmentStoreUnsupported(): Promise<never> {
return Promise.reject(
new Error(`Peer wire ${this.peerWire?.version ?? 'unknown'} has no attachment store`),
);
}

beginAttachmentUpload(input: AttachmentBeginInput): Promise<AttachmentUploadBegun> {
if (!this.supportsAttachmentStore) return this.attachmentStoreUnsupported();
return this.attachments.beginUpload(input);
}

sendAttachmentChunk(uploadId: UploadId, offset: number, data: string) {
sendAttachmentChunk(
uploadId: UploadId,
offset: number,
data: string,
): Promise<AttachmentChunkAck> {
if (!this.supportsAttachmentStore) return this.attachmentStoreUnsupported();
return this.attachments.sendChunk(uploadId, offset, data);
}

commitAttachmentUpload(uploadId: UploadId) {
commitAttachmentUpload(uploadId: UploadId): Promise<AttachmentCommitResult> {
if (!this.supportsAttachmentStore) return this.attachmentStoreUnsupported();
return this.attachments.commit(uploadId);
}

abortAttachmentUpload(uploadId: UploadId) {
abortAttachmentUpload(uploadId: UploadId): Promise<{ ok: true }> {
if (!this.supportsAttachmentStore) return this.attachmentStoreUnsupported();
return this.attachments.abort(uploadId);
}

readAttachment(sessionId: SessionId, attachmentId: AttachmentId, offset: number, length: number) {
readAttachment(
sessionId: SessionId,
attachmentId: AttachmentId,
offset: number,
length: number,
): Promise<AttachmentReadResult> {
if (!this.supportsAttachmentStore) return this.attachmentStoreUnsupported();
return this.attachments.read(sessionId, attachmentId, offset, length);
}

/** Hash + windowed chunked upload. Identical bytes commit with no transfer. */
putAttachment(input: AttachmentPutInput) {
putAttachment(input: AttachmentPutInput): Promise<AttachmentCommitResult> {
if (!this.supportsAttachmentStore) return this.attachmentStoreUnsupported();
return this.attachments.put(input);
}

Expand All @@ -1409,6 +1448,7 @@ export class LinkCodeClient {
sessionId: SessionId,
attachmentId: AttachmentId,
): Promise<AttachmentReadBytes> {
if (!this.supportsAttachmentStore) return this.attachmentStoreUnsupported();
return this.attachments.get(sessionId, attachmentId);
}
subscribeResources(cb: ResourceEventCb): Unsubscribe {
Expand Down
16 changes: 12 additions & 4 deletions packages/client/core/src/client/attachment-channel.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { AttachmentId, OperationId, SessionId, UploadId } from '@linkcode/schema';
import { ATTACHMENT_UPLOAD_CHUNK_BYTES, ATTACHMENT_UPLOAD_WINDOW_CHUNKS } from '@linkcode/schema';
import {
ATTACHMENT_UPLOAD_CHUNK_BYTES,
ATTACHMENT_UPLOAD_WINDOW_CHUNKS,
MAX_ATTACHMENT_BYTES,
} from '@linkcode/schema';
import type { Transport } from '@linkcode/transport';
import { noop } from 'foxts/noop';
import type { Sha256Hex } from './blob-cache';
Expand Down Expand Up @@ -31,8 +35,8 @@ export interface AttachmentPutInput {
}

/**
* Chunked attachment upload/read. Credit-windowed puts retry from zero; completed re-sends are
* free via the daemon's SHA-256 short-circuit. The cache is keyed by `blobId`.
* Chunked attachment upload/read. A failed put aborts its lease and the caller retries from zero;
* a completed re-send is free via the daemon's SHA-256 short-circuit. The cache is keyed by `blobId`.
*/
export class AttachmentChannel {
readonly cache = new AttachmentBlobCache();
Expand Down Expand Up @@ -100,6 +104,10 @@ export class AttachmentChannel {

/** Hash, begin, windowed chunks, commit. Identical bytes short-circuit to `exists`. */
async put(input: AttachmentPutInput): Promise<AttachmentCommitResult> {
// The wire schema caps `declaredSize`; an oversize begin is dropped unanswered, never refused.
if (input.bytes.byteLength > MAX_ATTACHMENT_BYTES) {
throw new Error(`Attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes`);
}
const declaredSha256 = await this.digest(input.bytes);
const begun = await this.beginUpload({
declaredSha256,
Expand All @@ -120,7 +128,7 @@ export class AttachmentChannel {
await this.abort(begun.uploadId).catch(noop);
throw error;
}
this.cache.set(committed.blobId, input.bytes);
this.cache.set(committed.blobId, input.bytes.slice());
return committed;
}

Expand Down
17 changes: 17 additions & 0 deletions packages/client/core/src/client/control-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,15 @@ import type {
StandaloneSkillScope,
StartOptions,
TurnId,
TurnSubmitInput,
WirePayload,
WorkspaceFile,
WorkspaceId,
WorkspaceKind,
WorkspaceRecord,
WorkspaceScript,
} from '@linkcode/schema';
import { OperationIdSchema } from '@linkcode/schema';
import type { Transport } from '@linkcode/transport';
import { createWireMessage } from '@linkcode/transport';
import type {
Expand All @@ -81,6 +83,7 @@ import type {
PluginMutation,
RequestAck,
SessionStartResult,
TurnSubmitResult,
} from './pending-registry';
import { sendCorrelated } from './pending-registry';

Expand Down Expand Up @@ -182,6 +185,20 @@ export class ControlChannel {
}));
}

/**
* Plain send onto the active leaf. `parentTurnId` is omitted on purpose — explicit-parent
* submit is a later client. Idempotency is a fresh `operationId` per call.
*/
submitTurn(sessionId: SessionId, input: TurnSubmitInput): Promise<TurnSubmitResult> {
return this.sendCorrelated('turnSubmit', (clientReqId) => ({
kind: 'turn.submit',
clientReqId,
sessionId,
operationId: OperationIdSchema.parse(`op-${clientReqId}`),
input,
}));
}

/** One page of the host-composed projection toward a leaf. Only the final page carries the live
* tail and the `(epoch, seq)` watermark; `readConversationProjection` walks the whole read. */
readConversation(
Expand Down
8 changes: 8 additions & 0 deletions packages/client/core/src/client/pending-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type {
SimulatorStreamCodec,
StandaloneSkill,
TerminalMetadata,
TurnId,
WirePayload,
WorkspaceFile,
WorkspaceRecord,
Expand Down Expand Up @@ -88,6 +89,11 @@ export type ConversationReadPage = Omit<
'kind' | 'replyTo'
>;

/** `turn.submitted` without its correlation fields. Plain-send only — parent/revision is later. */
export interface TurnSubmitResult {
readonly turnId: TurnId;
}

export type AttachmentUploadBegun = Omit<
Extract<WirePayload, { kind: 'attachment.upload.begun' }>,
'kind' | 'replyTo'
Expand Down Expand Up @@ -133,6 +139,7 @@ export interface PendingValueMap {
historyRead: AgentHistoryReadResult;
conversationGraph: ConversationGraphSnapshot;
conversationRead: ConversationReadPage;
turnSubmit: TurnSubmitResult;
configGet: ProvidersConfig;
accountsGet: Accounts;
accountModels: AccountModel[];
Expand Down Expand Up @@ -201,6 +208,7 @@ export class PendingRegistry {
historyRead: new Map(),
conversationGraph: new Map(),
conversationRead: new Map(),
turnSubmit: new Map(),
configGet: new Map(),
accountsGet: new Map(),
accountModels: new Map(),
Expand Down
Loading
Loading