fix(apiproxy,client): the list projection column becomes a seedable watermarked block
Review finding (PR #791): the column carried bare values (no seq), so the client could not seed its value store without risking a stale list block outranking newer push frames — and nothing consumed the column at all, leaving cold titles absent after a restart. SessionSummary.projections is now the same SessionProjectionsBlock as the history tail (values + asOfSeq; attached rows cut the live registry, cold rows serve the cache's identity-checked cachedSnapshot whose asOfSeq is the lowest served-row watermark). SessionManager.refreshList seeds each row's block into the per-session projection store via per-key apply — partial-baseline semantics: an absent key never clears, and higher-seq-wins keeps stale list blocks beneath push frames and tail baselines — so cold titles surface in the sidebar without opening a session.
This commit is contained in:
@@ -29,7 +29,7 @@ import type {
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
|
||||
import type {} from '@deepseek-ai/dsh-session-projection-cache'
|
||||
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
|
||||
@@ -300,21 +300,23 @@ function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | u
|
||||
}
|
||||
|
||||
/**
|
||||
* The projection column of one session.list row, fail-soft: attached
|
||||
* The projection baseline of one session.list row, fail-soft: attached
|
||||
* sessions cut the registry's live watermark cache; cold sessions view the
|
||||
* persisted projection cache's stored rows (zero log loads either way — the
|
||||
* listing use case the cache exists for). Any failure — and an empty value
|
||||
* set — yields an absent column: a listing without projections is degraded,
|
||||
* never broken.
|
||||
* persisted projection cache's identity-checked stored rows (zero log loads
|
||||
* either way — the listing use case the cache exists for). The block shape
|
||||
* (values + asOfSeq) matches the history tail's, so a client seeds its
|
||||
* value store under the same higher-seq-wins rule. Any failure — and an
|
||||
* empty value set — yields an absent block: a listing without projections
|
||||
* is degraded, never broken.
|
||||
*/
|
||||
function listProjectionsFor(ctx: Context, id: SessionId, session: Session | undefined): Partial<SessionProjectionMap> | undefined {
|
||||
function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session | undefined): SessionProjectionsBlock | undefined {
|
||||
try {
|
||||
const values = session !== undefined
|
||||
? ctx.get('sessionProjections')?.snapshot(session).values
|
||||
: ctx.get('sessionProjectionCache')?.cachedValues(id)
|
||||
return values !== undefined && Object.keys(values).length > 0 ? values : undefined
|
||||
const block = session !== undefined
|
||||
? ctx.get('sessionProjections')?.snapshot(session)
|
||||
: ctx.get('sessionProjectionCache')?.cachedSnapshot(meta)
|
||||
return block !== undefined && Object.keys(block.values).length > 0 ? block : undefined
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`session.list: projection column for "${id}" failed (serving the row without it): ${String(error)}`)
|
||||
ctx.logger.warn(`session.list: projection column for "${meta.id}" failed (serving the row without it): ${String(error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -676,7 +678,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
async list(request) {
|
||||
const items = ctx.sessions.list().map((session) => {
|
||||
const agent = ctx.agents.get(session.id)
|
||||
const projections = listProjectionsFor(ctx, session.id, session)
|
||||
const projections = listProjectionsFor(ctx, session.header, session)
|
||||
return {
|
||||
...summarize(session, agent?.status === 'running'),
|
||||
...projections === undefined ? {} : { projections },
|
||||
@@ -689,7 +691,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
items.push(...await Promise.all(cold.map(async (meta) => {
|
||||
// Cold rows read the persisted projection cache only — never a
|
||||
// log load; a session without a cache row simply has no column.
|
||||
const projections = listProjectionsFor(ctx, meta.id, undefined)
|
||||
const projections = listProjectionsFor(ctx, meta, undefined)
|
||||
return {
|
||||
...await summarizeCold(persistence, meta),
|
||||
...projections === undefined ? {} : { projections },
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type {
|
||||
@@ -38,16 +37,7 @@ export const sessionEventSchema = z.object({
|
||||
surfaceOp: z.unknown().optional(),
|
||||
}) as unknown as z.ZodType<SessionEvent>
|
||||
|
||||
/**
|
||||
* Projection-values passthrough (same posture as
|
||||
* {@link sessionProjectionsBlockSchema}): each value already passed its
|
||||
* unit's own schema on the host side; deep-validating here would import
|
||||
* every domain's schema into the carrier.
|
||||
*/
|
||||
const projectionValuesSchema =
|
||||
z.record(z.string(), z.unknown()) as unknown as z.ZodType<Partial<SessionProjectionMap>>
|
||||
|
||||
/** SessionSummary row of session.list. */
|
||||
/** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */
|
||||
export const sessionSummarySchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
updatedAt: z.number(),
|
||||
@@ -55,8 +45,8 @@ export const sessionSummarySchema = z.object({
|
||||
blank: z.boolean(),
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
projections: projectionValuesSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<SessionSummary>>
|
||||
projections: z.lazy(() => sessionProjectionsBlockSchema).optional(),
|
||||
}) as unknown as z.ZodType<Wire<SessionSummary>>
|
||||
|
||||
/** session.list request payload (cursor is a reserved seat, unimplemented in v1). */
|
||||
export const sessionListRequestSchema = z.object({
|
||||
@@ -64,9 +54,9 @@ export const sessionListRequestSchema = z.object({
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'session.list'>>>
|
||||
|
||||
/** session.list response value. */
|
||||
export const sessionListValueSchema = z.object({
|
||||
export const sessionListValueSchema: z.ZodType<Wire<ResponseValue<'session.list'>>> = z.object({
|
||||
items: z.array(sessionSummarySchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.list'>>>
|
||||
})
|
||||
|
||||
/** session.create request payload (at most one of workspaceId / cwd). */
|
||||
export const sessionCreateRequestSchema = z.object({
|
||||
|
||||
@@ -144,16 +144,17 @@ export interface SessionSummary {
|
||||
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
|
||||
cwd?: string
|
||||
/**
|
||||
* Whole current value per projection key, with zero log loads: attached
|
||||
* sessions read the registry's live watermark cut; cold sessions read the
|
||||
* persisted projection cache's stored rows — as stale as that session's
|
||||
* last durable checkpoint, never wrong, superseded by the history tail
|
||||
* baseline the moment the session is opened. Absent when no value is
|
||||
* available (no registry, no cache row for a cold session, or a fail-soft
|
||||
* cache read miss); a listing client treats absence as "no title yet",
|
||||
* exactly like a blank session.
|
||||
* Projection baseline for this row, with zero log loads: attached sessions
|
||||
* read the registry's live watermark cut; cold sessions read the persisted
|
||||
* projection cache's stored rows — as stale as that session's last durable
|
||||
* checkpoint (`asOfSeq` says exactly how stale), never wrong, and directly
|
||||
* seedable into the client's per-session value store under its
|
||||
* higher-seq-wins rule (a list baseline can never overwrite a newer push
|
||||
* frame). Absent when no value is available (no registry, no cache row for
|
||||
* a cold session, or a fail-soft cache read miss); a listing client treats
|
||||
* absence as "no title yet", exactly like a blank session.
|
||||
*/
|
||||
projections?: Partial<SessionProjectionMap>
|
||||
projections?: SessionProjectionsBlock
|
||||
}
|
||||
|
||||
/** Session-domain unary methods (the map keys session.* of RpcMethodMap). */
|
||||
|
||||
@@ -126,14 +126,15 @@ describe('session.history projections block', () => {
|
||||
})
|
||||
|
||||
describe('session.list projections column', () => {
|
||||
it('serves attached rows from the live registry cut', async () => {
|
||||
it('serves attached rows from the live registry cut, watermarked for client seeding', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
ctx.sessionProjections.register(lastUserUnit())
|
||||
seedMessages(session, 1)
|
||||
const response = await api(ctx).sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const row = response.result.value.items.find(item => item.sessionId === session.id)
|
||||
expect(row?.projections?.['test/last-user']).toEqual({ text: 'm0' })
|
||||
expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' })
|
||||
expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
|
||||
})
|
||||
|
||||
it('omits the column entirely when no registry is mounted', async () => {
|
||||
@@ -158,13 +159,17 @@ describe('session.list projections column', () => {
|
||||
readFrom: load,
|
||||
} as never)
|
||||
ctx.provide('sessionProjectionCache', {
|
||||
cachedValues: (id: unknown) => (id === coldId ? { 'test/last-user': { text: 'cached' } } : {}),
|
||||
// The carrier hands the listed header through as the identity witness.
|
||||
cachedSnapshot: (meta: { id: unknown; createdAt: number }) =>
|
||||
(meta.id === coldId && meta.createdAt === 5
|
||||
? { asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } }
|
||||
: undefined),
|
||||
} as never)
|
||||
const response = await api(ctx).sessions.list(request({}))
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const row = response.result.value.items.find(item => item.sessionId === coldId)
|
||||
expect(row?.running).toBe(false)
|
||||
expect(row?.projections?.['test/last-user']).toEqual({ text: 'cached' })
|
||||
expect(row?.projections).toEqual({ asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } })
|
||||
})
|
||||
|
||||
it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => {
|
||||
|
||||
Reference in New Issue
Block a user