Skip to content
Draft
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
3 changes: 3 additions & 0 deletions apps/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Approval and user-input responses are bound to their active thread and turn. The
is single-client stdio, matching the desktop packaging decision in
`docs/adr/0001-desktop-runtime-sidecar.md`.

Lifecycle snapshots live under `threads-v1`; their message projection uses the same id in the
canonical session-v1 index. Legacy-only sessions are imported lazily on resume.

After a workspace build, run `node apps/server/dist/cli.js` and send one JSON request per line:

```json
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { ProtocolNotification } from '@deepcode/protocol';

import { createDefaultTurnExecutor } from './default-runtime.js';
import { AppServer, type TurnExecutor } from './server.js';
import { FileThreadStore } from './store.js';
import { CanonicalThreadStore } from './store.js';
import { ProtocolLineWriter, serveStdio } from './stdio.js';

export interface RunAppServerOptions {
Expand All @@ -19,7 +19,10 @@ export async function runAppServer(options: RunAppServerOptions): Promise<void>
const writer = new ProtocolLineWriter(options.output);
const server = new AppServer({
executor: options.executor ?? createDefaultTurnExecutor(),
store: new FileThreadStore(join(options.home, 'threads-v1')),
store: new CanonicalThreadStore(
join(options.home, 'threads-v1'),
join(options.home, 'sessions'),
),
onEvent: (event) => {
const notification: ProtocolNotification = { method: 'event', params: event };
void writer.enqueue(notification);
Expand Down
124 changes: 124 additions & 0 deletions apps/server/src/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { SessionManager, writeMeta } from '@deepcode/core/sessions';
import type { ThreadSnapshot } from '@deepcode/protocol';
import { afterEach, describe, expect, it } from 'vitest';

import { CanonicalThreadStore } from './store.js';

const roots: string[] = [];

afterEach(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
roots.length = 0;
});

async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'deepcode-thread-store-'));
roots.push(root);
const sessionsRoot = join(root, 'sessions');
return {
store: new CanonicalThreadStore(join(root, 'threads-v1'), sessionsRoot),
sessions: new SessionManager({ root: sessionsRoot }),
};
}

describe('CanonicalThreadStore', () => {
it('materializes protocol history into the canonical session index', async () => {
const { store, sessions } = await fixture();
const thread: ThreadSnapshot = {
id: 'thread-1',
cwd: '/workspace',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:02.000Z',
turns: [
{
id: 'turn-1',
threadId: 'thread-1',
status: 'completed',
startedAt: '2026-08-01T00:00:01.000Z',
completedAt: '2026-08-01T00:00:02.000Z',
items: [
{
id: 'item-1',
type: 'user_message',
payload: { text: 'Review the repository', model: 'deepseek-chat' },
completedAt: '2026-08-01T00:00:01.000Z',
},
{
id: 'item-2',
type: 'assistant_message',
payload: {
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Done' }],
},
},
completedAt: '2026-08-01T00:00:02.000Z',
},
],
},
],
};

await store.save(thread);

await expect(sessions.list()).resolves.toEqual([
expect.objectContaining({
id: thread.id,
cwd: '/workspace',
title: 'Review the repository',
model: 'deepseek-chat',
}),
]);
await expect(sessions.load(thread.id)).resolves.toEqual({
meta: expect.objectContaining({ id: thread.id }),
messages: [
{ role: 'user', content: [{ type: 'text', text: 'Review the repository' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'Done' }] },
],
});

const current = await sessions.load(thread.id);
await writeMeta(sessions.root, { ...current!.meta, title: 'Renamed by user' });
await store.save({ ...thread, updatedAt: '2026-08-01T00:00:03.000Z' });
await expect(sessions.load(thread.id)).resolves.toEqual({
meta: expect.objectContaining({ title: 'Renamed by user' }),
messages: expect.any(Array),
});
});

it('lazily imports a canonical or legacy session as a resumable protocol thread', async () => {
const { store, sessions } = await fixture();
const meta = await sessions.create('/legacy', { title: 'Existing chat' });
await sessions.append(meta.id, {
role: 'user',
content: [{ type: 'text', text: 'Continue this' }],
});
await sessions.append(meta.id, {
role: 'assistant',
content: [{ type: 'text', text: 'Ready' }],
});

const imported = await store.load(meta.id);

expect(imported).toEqual(
expect.objectContaining({
id: meta.id,
cwd: '/legacy',
turns: [
expect.objectContaining({
status: 'completed',
items: [
expect.objectContaining({ type: 'user_message' }),
expect.objectContaining({ type: 'assistant_message' }),
],
}),
],
}),
);
await expect(store.load(meta.id)).resolves.toEqual(imported);
});
});
107 changes: 107 additions & 0 deletions apps/server/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import process from 'node:process';

import { type StoredMessage } from '@deepcode/core';
import { SessionManager, type SessionMeta } from '@deepcode/core/sessions';
import type { ThreadSnapshot, ThreadStore } from '@deepcode/protocol';

import { historyFromThread } from './runtime-executor.js';

function validThreadId(threadId: string): boolean {
return /^[a-zA-Z0-9._-]+$/.test(threadId);
}
Expand Down Expand Up @@ -36,3 +40,106 @@ export class FileThreadStore implements ThreadStore {
return join(this.directory, `${threadId}.json`);
}
}

/**
* Rich protocol snapshots plus a canonical session-v1 message projection.
*
* The protocol snapshot preserves lifecycle/items. The canonical projection
* keeps the existing CLI/desktop session index and legacy readers continuous
* during rollout. Both use the same id, and legacy-only sessions are imported
* lazily the first time the app-server resumes them.
*/
export class CanonicalThreadStore implements ThreadStore {
private readonly snapshots: FileThreadStore;
private readonly sessions: SessionManager;

constructor(snapshotDirectory: string, sessionsDirectory: string) {
this.snapshots = new FileThreadStore(snapshotDirectory);
this.sessions = new SessionManager({ root: sessionsDirectory });
}

async load(threadId: string): Promise<ThreadSnapshot | null> {
const snapshot = await this.snapshots.load(threadId);
if (snapshot) return snapshot;
const session = await this.sessions.load(threadId);
if (!session) return null;
const imported = threadFromSession(session.meta, session.messages);
await this.snapshots.save(imported);
return imported;
}

async save(thread: ThreadSnapshot): Promise<void> {
const messages = historyFromThread(thread);
await this.sessions.materialize(metaFromThread(thread), messages);
await this.snapshots.save(thread);
}
}

function metaFromThread(thread: ThreadSnapshot): SessionMeta {
const firstInput = thread.turns
.flatMap((turn) => turn.items)
.find((item) => item.type === 'user_message');
const text = typeof firstInput?.payload.text === 'string' ? firstInput.payload.text : '';
const model =
typeof firstInput?.payload.model === 'string' ? firstInput.payload.model : undefined;
return {
id: thread.id,
cwd: thread.cwd,
createdAt: thread.createdAt,
updatedAt: thread.updatedAt,
title: titleFrom(text),
model,
};
}

function titleFrom(text: string): string | undefined {
const firstLine = text
.split('\n')
.map((line) => line.trim())
.find(Boolean);
return firstLine ? [...firstLine].slice(0, 60).join('') : undefined;
}

function threadFromSession(meta: SessionMeta, messages: StoredMessage[]): ThreadSnapshot {
if (messages.length === 0) {
return {
id: meta.id,
cwd: meta.cwd,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
turns: [],
};
}
return {
id: meta.id,
cwd: meta.cwd,
createdAt: meta.createdAt,
updatedAt: meta.updatedAt,
turns: [
{
id: `legacy-${meta.id}`,
threadId: meta.id,
status: 'completed',
startedAt: meta.createdAt,
completedAt: meta.updatedAt,
items: messages.map((message, index) => itemFromMessage(message, meta, index)),
},
],
};
}

function itemFromMessage(message: StoredMessage, meta: SessionMeta, index: number) {
const only = message.content.length === 1 ? message.content[0] : undefined;
const simpleUserText = message.role === 'user' && only?.type === 'text' ? only.text : undefined;
return {
id: `legacy-item-${index + 1}`,
type:
message.role === 'assistant'
? ('assistant_message' as const)
: simpleUserText !== undefined
? ('user_message' as const)
: ('tool_result' as const),
payload: simpleUserText !== undefined ? { text: simpleUserText } : { message },
completedAt: message.timestamp ?? meta.updatedAt,
};
}
2 changes: 2 additions & 0 deletions docs/CODEX_ALIGNMENT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ model tool call
`mac-agent` 仅作 feature fallback。
- app-server 已补齐按 active thread/turn 绑定的 approval、AskUserQuestion、tool 与 usage 事件;interrupt
会解除所有待响应请求,避免 sidecar 因 UI 离线而悬挂。
- protocol snapshot 与 canonical session-v1 共享 id;新 thread 会进入现有 session 索引,旧 session
在首次 resume 时惰性投影为 compatibility turn,避免桌面迁移形成第二套不可见历史。
- React 只消费协议事件;接入真实 interrupt、恢复与 structured items。
- 把 `preview-app.html` 变成自动化 fixture harness;收敛现有 Changes/Files/Inspector。

Expand Down
12 changes: 6 additions & 6 deletions docs/design/app-server-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ tool process after a crash.

## Storage and security

The Node-specific `FileThreadStore` writes one mode-0600 JSON snapshot per thread through a
same-directory temporary file and atomic rename. The app-server CLI stores these under
`~/.deepcode/threads-v1` by default. This is the protocol rollout store; canonical session-v1 files
remain readable compatibility data until the client migration joins their indexes.
The Node-specific `CanonicalThreadStore` writes one mode-0600 lifecycle snapshot per thread under
`~/.deepcode/threads-v1` through a same-directory temporary file and atomic rename. It also
materializes the message history under the same id in canonical `~/.deepcode/sessions/*.v1.jsonl`,
using the shared cross-process writer lock. Existing CLI/desktop session lists therefore see new
protocol threads immediately. If only a canonical or legacy session exists, the store lazily
imports its messages into a completed compatibility turn without modifying the legacy file.

`RuntimeHostExecutor` reconstructs exact stored provider messages from completed protocol items.
The default server runtime resolves credentials only in the trusted backend and uses the central
Expand All @@ -83,5 +85,3 @@ headless output contracts remain unchanged during this experimental phase.
- config provenance;
- thread listing, archive, fork, and search;
- multi-client subscriptions or active-turn attachment;
- joining the new thread snapshot index with legacy/canonical session listings;
- a production-bundled CommonJS app-server artifact and pinned Node 22 runtime.
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
"types": "./dist/runtime/index.d.ts",
"import": "./dist/runtime/index.js"
},
"./sessions": {
"types": "./dist/sessions/index.d.ts",
"import": "./dist/sessions/index.js"
},
"./tools": {
"types": "./dist/tools/index.d.ts",
"import": "./dist/tools/index.js"
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/sessions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
defaultSessionsDir,
newSessionId,
readSessionRecords,
writeMeta,
SessionCorruptionError,
SessionWriterConflictError,
type SessionMeta,
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/sessions/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
newSessionId,
readMessages,
readMeta,
replaceSession,
writeMeta,
type SessionMeta,
} from './storage.js';
Expand Down Expand Up @@ -55,6 +56,10 @@ export class SessionManager {
await appendMessage(this.root, sessionId, msg);
}

async materialize(meta: SessionMeta, messages: StoredMessage[]): Promise<void> {
await replaceSession(this.root, meta, messages);
}

async list(): Promise<SessionMeta[]> {
return listSessionsLow(this.root);
}
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/sessions/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
readMessages,
readMeta,
readSessionRecords,
replaceSession,
SessionCorruptionError,
SessionWriterConflictError,
sessionFiles,
Expand Down Expand Up @@ -75,6 +76,33 @@ describe('session storage', () => {
]);
});

it('materializes an idempotent full projection while preserving an existing title', async () => {
const meta = {
id: 'protocol-thread',
cwd: '/workspace',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:01.000Z',
title: 'User title',
};
await replaceSession(root, meta, [
{ role: 'user', content: [{ type: 'text', text: 'first' }] },
]);
await replaceSession(
root,
{ ...meta, updatedAt: '2026-08-01T00:00:02.000Z', title: undefined },
[
{ role: 'user', content: [{ type: 'text', text: 'first' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'second' }] },
],
);

await expect(readMeta(root, meta.id)).resolves.toMatchObject({
title: 'User title',
updatedAt: '2026-08-01T00:00:02.000Z',
});
await expect(readMessages(root, meta.id)).resolves.toHaveLength(2);
});

it('readMessages returns [] when jsonl missing', async () => {
expect(await readMessages(root, 'nope')).toEqual([]);
});
Expand Down
Loading
Loading