feat(trajectory): virtualize long session histories

This commit is contained in:
_Kerman
2026-08-04 13:57:19 +08:00
parent 26742effdd
commit ae1008b4fb
26 changed files with 966 additions and 198 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 89e58f967f852bb0786a5b7d73fa8e924fa282e0
README.zh.md: 960e2fceede1b500af9ee2063ec9283e2b7b271a
README.md: 3e9bcdd587764ee21326c27e0cd642914cf84f8f
README.zh.md: 8e13bc3d09ae220f7a64640b21892c03a37bae29

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
## Workspace and Session lists

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
## Workspace 与 Session 列表

View File

@@ -17,11 +17,17 @@ export interface SessionHistoryFace
extends ObservableSnapshot<SessionHistorySnapshot> {
readonly sessionId: SessionId
/**
* Load the tail and exhaust every available older page.
* @param signal - Consumer lifetime; abort is observed between page requests.
* @returns When the available ledger is complete or stops advancing.
* Load the current tail without reading older pages.
* @param signal - Consumer lifetime.
* @returns When the tail is ready or loading fails.
*/
loadAll(signal?: AbortSignal): Promise<void>
loadTail(signal?: AbortSignal): Promise<void>
/**
* Prepend one older page when the current window has a predecessor.
* @param signal - Consumer lifetime.
* @returns Whether the loaded window advanced.
*/
loadOlder(signal?: AbortSignal): Promise<boolean>
}
/** Runtime service resolving independent history sources. */

View File

@@ -36,7 +36,6 @@ export class SessionHistorySource implements SessionHistoryFace {
value: SessionHistorySnapshot['inspection']
} | null = null
private streamPublishToken: object | null = null
private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null
private streamPartial: PartialAccumulator | null = null
private snapshotCache: SessionHistorySnapshot
private readonly notifier = new Notifier(() => {
@@ -73,37 +72,34 @@ export class SessionHistorySource implements SessionHistoryFace {
}
/**
* Load the tail and exhaust all available older pages.
* Load the current tail without reading older pages.
* @param signal - Consumer lifetime.
* @returns When paging completes, fails to advance, or is aborted.
* @returns When the tail is ready or loading fails.
*/
async loadAll(signal?: AbortSignal): Promise<void> {
if (signal?.aborted === true) return
async loadTail(signal?: AbortSignal): Promise<void> {
if (isAborted(signal)) return
this.trackConsumer(signal)
await this.open()
while (
!isAborted(signal)
&& this.state === 'ready'
&& this.hasMore
) {
const previousBaseSeq = this.baseSeq
await this.loadOlder()
if (isAborted(signal) || this.baseSeq === previousBaseSeq) return
}
}
/** Rebuild and page for whichever mounted consumers survive a reconnect. */
/**
* Prepend one older page when the current window has a predecessor.
* @param signal - Consumer lifetime.
* @returns Whether the loaded window advanced.
*/
async loadOlder(signal?: AbortSignal): Promise<boolean> {
if (isAborted(signal)) return false
this.trackConsumer(signal)
await this.open()
if (isAborted(signal)) return false
const previousBaseSeq = this.baseSeq
await this.loadOlderPage()
return this.baseSeq !== previousBaseSeq
}
/** Rebuild the tail for whichever mounted consumers survive a reconnect. */
private async loadForConsumers(): Promise<void> {
await this.open()
while (
this.hasConsumer()
&& this.state === 'ready'
&& this.hasMore
) {
const previousBaseSeq = this.baseSeq
await this.loadOlder()
if (!this.hasConsumer() || this.baseSeq === previousBaseSeq) return
}
}
/**
@@ -161,7 +157,6 @@ export class SessionHistorySource implements SessionHistoryFace {
this.olderPromise = null
this.liveBuffer = []
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
}
@@ -234,7 +229,7 @@ export class SessionHistorySource implements SessionHistoryFace {
}
}
private loadOlder(): Promise<void> {
private loadOlderPage(): Promise<void> {
if (this.olderPromise !== null) return this.olderPromise
if (this.state !== 'ready' || !this.hasMore) return Promise.resolve()
const generation = this.generation
@@ -339,8 +334,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.inspectionCache = { entries: this.entries, value: inspection }
return false
}
const base = this.streamBaseInspection ?? this.currentInspection()
this.streamBaseInspection = base
const base = this.currentInspection()
if (
this.streamPartial === null
|| this.streamPartial.turn !== turn
@@ -382,7 +376,6 @@ export class SessionHistorySource implements SessionHistoryFace {
/** Publish structural changes immediately and invalidate an older scheduled stream publish. */
private publishDirtyNow(): void {
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
this.notifier.markDirty()
}

View File

@@ -16,7 +16,7 @@ function histResponse(events: SessionEvent[], hasMore = false) {
}
describe('SessionHistorySource', () => {
it('loads every older page without changing a Chat session', async () => {
it('loads the tail first and prepends older pages on demand', async () => {
const pages = [
plainTurn(0, 0, '最早问', '最早答'),
plainTurn(6, 1, '中间问', '中间答'),
@@ -30,7 +30,16 @@ describe('SessionHistorySource', () => {
}
const source = new SessionHistorySource(SID, api)
await source.loadAll()
await source.loadTail()
expect(api.callsOf('session.history')).toHaveLength(1)
expect(source.getSnapshot().hasMore).toBe(true)
expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq))
.toEqual([13, 15])
expect(await source.loadOlder()).toBe(true)
expect(await source.loadOlder()).toBe(true)
expect(await source.loadOlder()).toBe(false)
expect(api.callsOf('session.history')).toHaveLength(3)
expect(source.getSnapshot().hasMore).toBe(false)
@@ -42,7 +51,7 @@ describe('SessionHistorySource', () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
await source.loadTail()
const before = source.getSnapshot()
source.handleMuxFrame({
@@ -60,7 +69,7 @@ describe('SessionHistorySource', () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
await source.loadTail()
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
@@ -132,13 +141,14 @@ describe('SessionHistorySource', () => {
}))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
await source.loadTail()
expect(await source.loadOlder()).toBe(false)
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)
})
it('observes consumer cancellation between older pages', async () => {
it('finishes an already started older page after consumer cancellation', async () => {
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const olderStarted = deferred<undefined>()
const api = new FakeApiClient()
@@ -151,7 +161,8 @@ describe('SessionHistorySource', () => {
}
const source = new SessionHistorySource(SID, api)
const controller = new AbortController()
const complete = source.loadAll(controller.signal)
await source.loadTail(controller.signal)
const complete = source.loadOlder(controller.signal)
await olderStarted.promise
controller.abort()
middle.resolve(ok({
@@ -159,7 +170,7 @@ describe('SessionHistorySource', () => {
hasMore: true,
}))
await complete
expect(await complete).toBe(true)
expect(api.callsOf('session.history')).toHaveLength(2)
expect(source.getSnapshot().hasMore).toBe(true)