refactor(session): open journal streams from snapshots

This commit is contained in:
imccyu
2026-08-25 06:07:11 +08:00
parent 7fb2ca07e4
commit e7952d82ed
15 changed files with 938 additions and 601 deletions
@@ -29,7 +29,11 @@ export interface SessionSnapshot {
readonly sessionId: SessionId
readonly queue: readonly QueuedMessage[]
readonly running: boolean
readonly subagent: { readonly address: SubagentAddress; readonly parentAvailable: boolean } | null
readonly subagent: {
readonly address: SubagentAddress
/** Absent until the direct-parent catalog resolves. */
readonly parentAvailable?: boolean
} | null
readonly removed: boolean
readonly openState: OpenState
readonly openError: ClientFailure | null
@@ -45,7 +45,7 @@ export const PAGE_MESSAGES = 50
export interface SessionOptions {
/** Catalog-discovered address selecting non-activating subagent transport. */
address?: SubagentAddress
/** Whether the exact direct parent Agent was live at the latest catalog read. */
/** Whether the exact direct parent Agent was live at the latest catalog read; absent before that read. */
parentAvailable?: boolean
/**
* First ACCEPTED prompt on a blank session (fires at most once, on the
@@ -86,7 +86,7 @@ export class Session implements SessionFace {
private readonly queueMirror = new SessionQueueMirror()
private running = false
private address: SubagentAddress | undefined
private parentAvailable = false
private parentAvailable: boolean | undefined
/**
* Sticky send marker, private input of the composerPhase derivation: set
* synchronously before prompt()'s first await, never reset — the blank →
@@ -144,7 +144,7 @@ export class Session implements SessionFace {
) {
this.projections = options.projections ?? new ProjectionValueStore()
this.address = options.address
this.parentAvailable = options.parentAvailable ?? false
this.parentAvailable = options.parentAvailable
this.notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
})
@@ -467,9 +467,9 @@ export class Session implements SessionFace {
* Install or clear the catalog-discovered transport address. A changed
* address rebuilds an already-open window through its new history route.
* @param address - direct parent/child address, or undefined for ordinary transport.
* @param parentAvailable - latest exact-parent availability hint.
* @param parentAvailable - latest exact-parent availability hint, or undefined before a catalog read.
*/
configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void {
configureSubagent(address: SubagentAddress | undefined, parentAvailable?: boolean): void {
const same = this.address?.parentSessionId === address?.parentSessionId
&& this.address?.childSessionId === address?.childSessionId
&& this.address?.mode === address?.mode
@@ -623,7 +623,10 @@ export class Session implements SessionFace {
running: this.running,
subagent: this.address === undefined
? null
: { address: this.address, parentAvailable: this.parentAvailable },
: {
address: this.address,
...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }),
},
removed: this.removed,
openState: this.openState,
openError: this.openError,
@@ -17,6 +17,7 @@ import type {
SessionEventEntry,
SessionPage,
SessionPageRequest,
SessionProjectionBaseline,
} from '../types.ts'
export {
@@ -30,8 +31,13 @@ export type ClientSessionPageRequest = Omit<SessionPageRequest, 'address' | 'thr
/** Complete generated `ctx.remote.session` namespace. */
export type SessionRemote = ClientRemote['session']
/** Opening metadata carried only by a follow snapshot, never by loadOlder pages. */
interface SessionJournalPage extends SessionPage {
readonly projections?: SessionProjectionBaseline
}
/** One complete publication from the Session journal stream. */
export type SessionJournalChange = RemoteJournalChange<SessionPage, SessionEventEntry>
export type SessionJournalChange = RemoteJournalChange<SessionJournalPage, SessionEventEntry>
type SessionControlBaselineFrame = Extract<SessionControlFrame, { type: 'baseline' }>
type SessionControlDeltaFrame = Exclude<SessionControlFrame, SessionControlBaselineFrame>
@@ -93,7 +99,7 @@ export function createSessionControlStream(
/** Gateway-owned event journal bound to one ordinary or direct-subagent Session address. */
export class SessionEventStream extends RemoteJournalStream<
SessionPage,
SessionJournalPage,
SessionEventEntry,
number,
ClientSessionPageRequest
@@ -126,15 +132,23 @@ export class SessionEventStream extends RemoteJournalStream<
/** @inheritdoc */
protected override async * follow(
afterSeq: number | undefined,
request: ClientSessionPageRequest,
signal: AbortSignal,
): AsyncIterable<RemoteJournalFrame<SessionEventEntry, number>> {
const request = afterSeq === undefined
? { address: this.address }
: { address: this.address, afterSeq }
for await (const frame of this.remote.session.follow(request, signal)) {
if (frame.type === 'opened') {
yield frame
): AsyncIterable<RemoteJournalFrame<SessionEventEntry, number, SessionJournalPage>> {
for await (const frame of this.remote.session.follow({
address: this.address,
...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
}, signal)) {
if (frame.type === 'snapshot') {
yield {
type: 'opened',
cursor: frame.cursor,
page: {
events: frame.events,
hasMore: frame.hasMore,
projections: frame.projections,
},
}
continue
}
const { type: _type, ...entry } = frame
@@ -147,7 +161,7 @@ export class SessionEventStream extends RemoteJournalStream<
request: ClientSessionPageRequest,
throughSeq: number,
signal: AbortSignal,
): Promise<SessionPage> {
): Promise<SessionJournalPage> {
const result = await this.remote.session.page(
{ address: this.address, throughSeq, ...request },
signal,