fix(web): hide verified cold blank sessions

This commit is contained in:
_Kerman
2026-08-13 14:04:16 +08:00
parent 2c456a00ea
commit 3c06c66ac4
26 changed files with 548 additions and 125 deletions
+104 -31
View File
@@ -15,7 +15,7 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import { isAppendSurfaceEvent, isJsonValue, lastActivityTime } from '@deepseek-ai/dsh-session'
import { isAppendSurfaceEvent, isJsonValue } from '@deepseek-ai/dsh-session'
import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
@@ -38,7 +38,7 @@ import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
ModelCatalogFailure, ModelProviderGroup,
ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionListMetadata, SessionProjectionsBlock, SessionSearchItem,
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, JobView, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
@@ -90,7 +90,7 @@ import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-a
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
import type {} from '@deepseek-ai/dsh-user-approval'
import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
import { imageLimitsProjectionSchema } from './api/sessions.schema.ts'
import { imageLimitsProjectionSchema, sessionListMetadataProjectionSchema } from './api/sessions.schema.ts'
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
import { RpcId } from './api/rpc.ts'
@@ -132,6 +132,8 @@ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
/** Bound cold-log stat fan-out and settle each started batch before cancellation returns. */
const COLD_SUMMARY_BATCH_SIZE = 16
/** Default maximum artifact size eligible for one cold blankness read. */
export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024
/** Conversation message event types (the pagination counting unit). */
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
@@ -506,6 +508,29 @@ function sessionBlank(session: Session): boolean {
return !session.events.some(event => event.type === 'turn/start')
}
/** Advance the Session-list hint projection by one committed event. */
function applySessionListMetadata(state: SessionListMetadata, event: SessionEvent): SessionListMetadata {
const blank = state.blank && event.type !== 'turn/start'
const lastPromptAt = event.type === 'user/message' && event.data.source.kind === 'user'
? event.time
: state.lastPromptAt
return blank === state.blank && lastPromptAt === state.lastPromptAt
? state
: { blank, lastPromptAt }
}
/** Fold exact list metadata for an attached Session. */
function sessionListMetadata(events: readonly SessionEvent[]): SessionListMetadata {
let state: SessionListMetadata = { blank: true, lastPromptAt: null }
for (const event of events) state = applySessionListMetadata(state, event)
return state
}
/** Sort by creation or latest human prompt, whichever is newer. */
function sessionListUpdatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number {
return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0)
}
/** Shared Session-header projection for list baselines and creation frames. */
function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): {
parentSessionId?: SessionId
@@ -527,47 +552,71 @@ function sessionListFields(header: SessionHeader, events: readonly SessionEvent[
/** SessionSummary projection for attached (in-memory) sessions. */
function summarize(session: Session, running: boolean): SessionSummary {
const metadata = sessionListMetadata(session.events)
return {
sessionId: session.id,
// Excludes end-seed: a resumed-but-untouched session
// must not sort as freshly worked in.
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
updatedAt: sessionListUpdatedAt(session.header, metadata),
running,
blank: sessionBlank(session),
blank: metadata.blank,
...sessionListFields(session.header, session.events),
}
}
/**
* SessionSummary projection for cold (persisted, unattached) sessions.
* updatedAt is the log file's mtime; backends without a per-session file
* (locate() undefined) fall back to the header's createdAt.
* Verify a possibly blank cold Session only when its physical artifact is
* within the configured per-Session read bound. A stale `blank: true`, an
* absent cache row, a large or location-less artifact, and read failures all
* resolve to visible (`false`); listing must never hide a conversation on a
* cache hint or an unavailable optimization.
*/
async function summarizeCold(
async function probeColdSessionBlank(
ctx: Context,
persistence: SessionPersistence,
meta: SessionHeader,
maxBytes: number,
signal?: AbortSignal,
): Promise<boolean> {
if (maxBytes === 0) return false
signal?.throwIfAborted()
const location = persistence.locate(meta)
if (location === undefined) return false
signal?.throwIfAborted()
let size: number
try {
size = (await stat(location.path)).size
} catch {
signal?.throwIfAborted()
return false
}
if (size > maxBytes) return false
try {
const { events } = await persistence.readFrom(meta.id, 0, signal)
signal?.throwIfAborted()
return !events.some(event => event.type === 'turn/start')
} catch (error) {
signal?.throwIfAborted()
ctx.logger.warn(`session.list: blank probe for "${meta.id}" failed (serving it as visible): ${String(error)}`)
return false
}
}
/** SessionSummary projection for a cold persisted Session. */
async function summarizeCold(
ctx: Context,
persistence: SessionPersistence,
meta: SessionHeader,
metadata: SessionListMetadata | undefined,
blankProbeMaxBytes: number,
signal?: AbortSignal,
): Promise<SessionSummary> {
signal?.throwIfAborted()
let updatedAt = meta.createdAt
const location = persistence.locate(meta)
signal?.throwIfAborted()
if (location !== undefined) {
try {
updatedAt = (await stat(location.path)).mtimeMs
} catch {
// The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
}
signal?.throwIfAborted()
}
const blank = metadata?.blank === false
? false
: await probeColdSessionBlank(ctx, persistence, meta, blankProbeMaxBytes, signal)
return {
sessionId: meta.id,
updatedAt,
updatedAt: sessionListUpdatedAt(meta, metadata),
running: false,
// Lazy persistence keeps never-appended sessions out of list(); reading
// a cold log to check for turns would defeat the index read, so a listed
// cold session is served as not-blank (its log holds its conversation).
blank: false,
blank,
// Header-only: reading the log for a blank-window preset switch would
// defeat the same index read, and attaching the session replaces this row
// with `summarize()`, which resolves the switch from the events.
@@ -608,6 +657,8 @@ export interface ApiProxyDefaults {
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
/** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */
sessionExportCompressionLevel?: SessionLogCompressionLevel
/** Maximum artifact size eligible for one cold blankness read. */
coldBlankProbeMaxBytes?: number
/**
* Whether handing a path to the native opener can work at all — the
* `hasDocument` capability the preset roster reports, and the switch
@@ -1055,6 +1106,8 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
const coldBlankProbeMaxBytes = defaults.coldBlankProbeMaxBytes
?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES
/** The seed model each create/resume declares; re-read so it never goes stale. */
const agentOptions = (): AgentOptions => {
const { provider, model } = defaults.defaultModelSelection()
@@ -1233,6 +1286,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
})
// The cache supplies recency and a monotonic non-blank hint. A cached
// `blank: true` remains only a prefix fact and is verified on the cold path.
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
key: 'sessionListMetadata',
schema: sessionListMetadataProjectionSchema,
init: () => ({ blank: true, lastPromptAt: null }),
apply: applySessionListMetadata,
view: state => state,
stateVersion: 1,
})
})
// The imageLimits projection unit: the attachments config this proxy
// enforces at prompt admission, constant per host boot. `apply` keeps the
// same state reference for every event, so no change frames are ever
@@ -1678,11 +1744,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const batch = cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
const settled = await Promise.allSettled(
batch.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.
// Projection hints remain optional. Blank verification may read
// this Session's artifact only when it fits the configured bound.
const projections = listProjectionsFor(ctx, meta, undefined)
return {
...await summarizeCold(persistence, meta, signal),
...await summarizeCold(
ctx,
persistence,
meta,
projections?.values.sessionListMetadata,
coldBlankProbeMaxBytes,
signal,
),
...projections === undefined ? {} : { projections },
}
}),
+1 -1
View File
@@ -45,7 +45,7 @@ export interface ApiProxy {
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels,
SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary,
SessionListMetadata, SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type {
@@ -12,7 +12,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
ModelReasoningEffort, ModelSelection, SessionListMetadata, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
@@ -215,6 +215,12 @@ export const sessionProjectionsBlockSchema = z.object({
values: z.record(z.string(), z.unknown()),
}) as unknown as z.ZodType<Wire<SessionProjectionsBlock>>
/** Host-side validation for the persisted Session-list projection. */
export const sessionListMetadataProjectionSchema: z.ZodType<SessionListMetadata> = z.object({
blank: z.boolean(),
lastPromptAt: z.number().nullable(),
})
/**
* imageLimits projection unit schema (host-side view validation). zod widens
* `readonly ImageMediaType[]` to `string[]`; on the JSON wire the two
@@ -17,6 +17,13 @@ import type { WorkspaceId } from './workspace.ts'
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/**
* Session-list hints persisted by the projection cache. `blank: false`
* is monotonic and may suppress a cold-log probe; `blank: true` is only a
* checkpoint-prefix fact and must not hide a cold Session without direct
* verification. `lastPromptAt` is the latest human-authored prompt time.
*/
sessionListMetadata: SessionListMetadata
/**
* The deployment's image-intake limits: the attachments service's config
* as this proxy enforces it at prompt admission, constant per host boot.
@@ -28,6 +35,14 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
}
}
/** Persisted hints used to summarize a cold Session without reading a large log. */
export interface SessionListMetadata {
/** Whether the checkpoint prefix contains no turn/start event. */
blank: boolean
/** Latest source.kind=user message time in the checkpoint prefix. */
lastPromptAt: number | null
}
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
/**
+11 -1
View File
@@ -16,7 +16,7 @@ import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-agent-default-model'
import type { ApiProxy } from './api/index.ts'
import { createApiProxy } from './api-proxy.ts'
import { createApiProxy, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './api-proxy.ts'
import {
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
type SessionLogCompressionLevel,
@@ -53,6 +53,12 @@ export interface Config {
* @default 6
*/
sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
/**
* Maximum physical size of a cold Session artifact eligible for blankness
* verification. Zero disables probes.
* @default 1024
*/
coldBlankProbeMaxBytes?: number
}
/**
@@ -70,6 +76,7 @@ export class ApiProxyService extends Service implements ApiProxy {
nativeOpen: z.boolean(),
sessionExportCompressionLevel: z.number().step(1).min(0).max(9)
.default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z<SessionLogCompressionLevel>,
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
})
readonly sessions: ApiProxy['sessions']
@@ -96,6 +103,9 @@ export class ApiProxyService extends Service implements ApiProxy {
...(config.sessionExportCompressionLevel === undefined
? {}
: { sessionExportCompressionLevel: config.sessionExportCompressionLevel }),
...(config.coldBlankProbeMaxBytes === undefined
? {}
: { coldBlankProbeMaxBytes: config.coldBlankProbeMaxBytes }),
})
this.sessions = api.sessions
this.subagents = api.subagents