refactor(tui): resume rows fold titles only, timestamp from artifact mtime
Rows no longer read logs for anything but the batch title fold: the activity timestamp is a live session's last in-memory event time or the artifact mtime via the optional sessionPersistence.locate(), falling back to creation time; the last-turn, route, and goal columns are gone. Route availability moves to the Enter-time preflight, which already fully reads and replay-validates the one chosen log. The projectSessions public API this PR had added to session-query is reverted — the change is now confined to the TUI package.
This commit is contained in:
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* Session-resume sub-controller for the interactive chat channel: the
|
||||
* `/resume` selector, one batch summary projection that tolerates a corrupt
|
||||
* `/resume` selector, one metadata-plus-title scan that tolerates a corrupt
|
||||
* neighbor, the pre-handoff preflight, and the terminal handoff itself.
|
||||
* @module @deepseek-ai/dsh-tui/chat/resume
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type { TUI } from '@earendil-works/pi-tui'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {
|
||||
LogicalSessionSource,
|
||||
SessionQueryService,
|
||||
SessionRecord,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
@@ -66,51 +67,70 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
const workspaceLabel = (cwd: string | undefined): string =>
|
||||
runtime.formatCwd?.(cwd) ?? formatCwd(cwd)
|
||||
|
||||
/** Summarize one record from a borrowed source, retaining only the record and derived scalars. */
|
||||
/** Summarize one record from metadata and its batch-folded title. */
|
||||
const summarize = (
|
||||
record: SessionRecord,
|
||||
source: LogicalSessionSource,
|
||||
providers: ReadonlySet<string>,
|
||||
title: string | undefined,
|
||||
lastActivityAt: number | undefined,
|
||||
): ResumeCandidate => summarizeResumeCandidate(
|
||||
record,
|
||||
source,
|
||||
title,
|
||||
lastActivityAt,
|
||||
agent.session.id,
|
||||
agent.session.header.cwd,
|
||||
providers,
|
||||
workspaceLabel,
|
||||
)
|
||||
|
||||
/** The disabled fallback row for a session whose log cannot be summarized. */
|
||||
const unreadableCandidate = (record: SessionRecord, error: unknown): ResumeCandidate => ({
|
||||
/** The disabled fallback row for a session whose title read failed. */
|
||||
const unreadableCandidate = (
|
||||
record: SessionRecord,
|
||||
lastActivityAt: number | undefined,
|
||||
error: unknown,
|
||||
): ResumeCandidate => ({
|
||||
record,
|
||||
title: 'Unreadable session',
|
||||
lastActivityAt: record.header.createdAt,
|
||||
lastTurn: 'log unavailable',
|
||||
lastActivityAt: lastActivityAt ?? record.header.createdAt,
|
||||
currentWorkspace: record.header.cwd === agent.session.header.cwd,
|
||||
workspaceLabel: workspaceLabel(record.header.cwd),
|
||||
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
|
||||
})
|
||||
|
||||
/** Build one exact candidate from a live-preferred read that replay-validates a persisted log. */
|
||||
const readResumeCandidate = async (
|
||||
record: SessionRecord,
|
||||
providers: ReadonlySet<string>,
|
||||
): Promise<ResumeCandidate> => {
|
||||
/**
|
||||
* Metadata-only activity time: a live session's last in-memory event time,
|
||||
* otherwise the persisted artifact's mtime. Never reads a log, so browsing
|
||||
* cost stays independent of log size; any append (including bookkeeping)
|
||||
* moves it.
|
||||
*/
|
||||
const lastActivityAt = async (record: SessionRecord): Promise<number | undefined> => {
|
||||
const live = ctx.sessions.get(record.header.id)
|
||||
if (live !== undefined) return live.events.at(-1)?.time
|
||||
const location = ctx.get('sessionPersistence')?.locate(record.header)
|
||||
if (location === undefined) return undefined
|
||||
try {
|
||||
const readQuery = sessionQuery()
|
||||
/* v8 ignore start -- caller proves the optional service before mapping records */
|
||||
if (readQuery === undefined) throw new Error('session query is unavailable')
|
||||
/* v8 ignore stop */
|
||||
const snapshot = await readQuery.readSession(record.header.id)
|
||||
return summarize(record, { header: snapshot.session, events: snapshot.events }, providers)
|
||||
} catch (error: unknown) {
|
||||
return unreadableCandidate(record, error)
|
||||
return (await stat(location.path)).mtimeMs
|
||||
} catch {
|
||||
// Only a just-deleted or never-materialized artifact fails stat; the row falls back to created-at.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** The latest logged provider/model route, for the preflight availability check. */
|
||||
const resumeRoute = (events: readonly SessionEvent[]): { provider: string; model: string } | undefined => {
|
||||
const header = events.findLast(item => item.type === 'request/header')
|
||||
if (header?.type === 'request/header') {
|
||||
return { provider: header.data.header.config.provider, model: header.data.header.config.model }
|
||||
}
|
||||
const assistant = events.findLast(item => item.type === 'assistant/message')
|
||||
return assistant?.type === 'assistant/message'
|
||||
? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model }
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read every mutable precondition immediately before terminal handoff and
|
||||
* resolve the exact identity and workspace the host will re-exec into.
|
||||
* resolve the exact identity and workspace the host will re-exec into. This
|
||||
* is where the one chosen log is fully read, replay-validated, and checked
|
||||
* for a currently-available route — the listing never does any of that.
|
||||
*/
|
||||
const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => {
|
||||
const query = sessionQuery()
|
||||
@@ -121,17 +141,24 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
|
||||
const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId)
|
||||
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
|
||||
const candidate = await readResumeCandidate(
|
||||
record,
|
||||
new Set(ctx.llm.listProviders().map(provider => provider.id)),
|
||||
)
|
||||
const candidate = summarize(record, undefined, undefined)
|
||||
if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
|
||||
const cwd = candidate.record.header.cwd
|
||||
let events: readonly SessionEvent[]
|
||||
try {
|
||||
events = (await query.readSession(record.header.id)).events
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`session cannot be loaded: ${errorChain(error)}`)
|
||||
}
|
||||
const route = resumeRoute(events)
|
||||
if (route !== undefined && !ctx.llm.listProviders().some(provider => provider.id === route.provider)) {
|
||||
throw new Error(`session is complete, but route is currently unavailable (${route.provider}/${route.model})`)
|
||||
}
|
||||
const cwd = record.header.cwd
|
||||
/* v8 ignore next -- summarizeResumeCandidate disables a cwd-less record, so the check above already rejected it */
|
||||
if (cwd === undefined) throw new Error(`Session "${sessionId}" has no recorded workspace to resume in.`)
|
||||
const finalStatus = deps.agentStatus()
|
||||
if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
|
||||
return { id: candidate.record.header.id, cwd }
|
||||
return { id: record.header.id, cwd }
|
||||
}
|
||||
|
||||
const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
|
||||
@@ -235,30 +262,25 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
const scanStale = (): boolean =>
|
||||
deps.isDisposed() || scan !== resumeScan || scanAbort.signal.aborted
|
||||
const scanCandidates = async (): Promise<void> => {
|
||||
// Every workspace in the store is listed; the picker owns the
|
||||
// current-workspace/all-workspaces scope split over the whole set.
|
||||
const records = await listQuery.listSessions(scanAbort.signal)
|
||||
if (scanStale()) return
|
||||
// Every workspace in the store is summarized; the picker owns the
|
||||
// current-workspace/all-workspaces scope split over the whole set.
|
||||
const providers = new Set(ctx.llm.listProviders().map(provider => provider.id))
|
||||
// One bounded batch projection over borrowed logs: unlike a
|
||||
// per-candidate readSession, it lists persistence once and skips
|
||||
// replay validation and log cloning, bounding memory by what each
|
||||
// summary retains. A corrupt neighbor degrades to one disabled row.
|
||||
const recordById = new Map(records.map(record => [record.header.id, record]))
|
||||
const listedRecord = (id: SessionId): SessionRecord => {
|
||||
const record = recordById.get(id)
|
||||
/* v8 ignore next 2 -- projection ids come from this map; the corpus verifies each loaded header id */
|
||||
if (record === undefined) throw new Error(`resume scan returned unlisted session "${id}"`)
|
||||
return record
|
||||
}
|
||||
const results = await listQuery.projectSessions(
|
||||
records.map(record => record.header.id),
|
||||
source => summarize(listedRecord(source.header.id), source, providers),
|
||||
scanAbort.signal,
|
||||
)
|
||||
const candidates = results.map(result => result.status === 'fulfilled'
|
||||
? result.value
|
||||
: unreadableCandidate(listedRecord(result.sessionId), result.reason))
|
||||
// Rows need only metadata, an mtime, and the batch-folded title — the
|
||||
// one per-log read the selector performs. A corrupt neighbor degrades
|
||||
// to one disabled row.
|
||||
const [titles, activity] = await Promise.all([
|
||||
listQuery.readTitleSnapshots(records.map(record => record.header.id), scanAbort.signal),
|
||||
Promise.all(records.map(record => lastActivityAt(record))),
|
||||
])
|
||||
const candidates = records.map((record, index) => {
|
||||
const title = titles[index]
|
||||
/* v8 ignore next 2 -- readTitleSnapshots returns one result per unique listed id in input order */
|
||||
if (title === undefined || title.sessionId !== record.header.id) throw new Error(`resume scan misaligned at "${record.header.id}"`)
|
||||
return title.status === 'fulfilled'
|
||||
? summarize(record, title.value.title?.title, activity[index])
|
||||
: unreadableCandidate(record, activity[index], title.reason)
|
||||
})
|
||||
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|
||||
|| a.record.header.id.localeCompare(b.record.header.id))
|
||||
if (scanStale()) return
|
||||
@@ -266,9 +288,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
picker?.setCandidates(candidates)
|
||||
deps.requestRender()
|
||||
}
|
||||
// One catch covers both stages, so a projection failure cannot strand
|
||||
// the overlay on its loading placeholder; an aborted scan's rejection
|
||||
// stays silent because the user already dismissed the picker.
|
||||
// One catch covers listing, titles, and mtimes, so a scan failure
|
||||
// cannot strand the overlay on its loading placeholder; an aborted
|
||||
// scan's rejection stays silent because the user already dismissed the
|
||||
// picker.
|
||||
void scanCandidates().catch((error: unknown) => {
|
||||
if (scanStale()) return
|
||||
void session.close()
|
||||
|
||||
@@ -23,14 +23,8 @@ import {
|
||||
type AgentLlmTarget,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
LogicalSessionSource,
|
||||
SessionRecord,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction'
|
||||
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts'
|
||||
import { dialogSelectTheme, type Palette } from './theme.ts'
|
||||
@@ -432,97 +426,53 @@ export class ModelDialog implements Component {
|
||||
}
|
||||
}
|
||||
|
||||
/** The provider/model route recovered from a resume candidate's log. */
|
||||
export interface ResumeRoute {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** A preflighted resume selector row summarizing one persisted session. */
|
||||
/** A resume selector row summarizing one session from metadata and its folded title. */
|
||||
export interface ResumeCandidate {
|
||||
record: SessionRecord
|
||||
title: string
|
||||
/** Last observed change: live last-event time or artifact mtime, falling back to creation. */
|
||||
lastActivityAt: number
|
||||
lastTurn: string
|
||||
/** Whether the session's workspace is the one the current session runs in, which selects the picker scope that lists it. */
|
||||
currentWorkspace: boolean
|
||||
/** The session's own workspace as a prompt-style label; the all-workspaces scope shows it per row. */
|
||||
workspaceLabel: string
|
||||
route?: ResumeRoute
|
||||
goalPhase?: GoalPhase
|
||||
disabledReason?: string
|
||||
}
|
||||
|
||||
function resumeTurnLabel(source: LogicalSessionSource): string {
|
||||
const event = source.events.findLast(item => item.type === 'turn/end')
|
||||
if (event === undefined) return 'no completed turn'
|
||||
const reason = event.data.reason
|
||||
switch (reason.kind) {
|
||||
case 'completed': return `turn ${event.data.turn}: completed`
|
||||
case 'aborted': return `turn ${event.data.turn}: cancelled`
|
||||
case 'error': return `turn ${event.data.turn}: error`
|
||||
case 'disposed': return `turn ${event.data.turn}: disposed`
|
||||
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
|
||||
case 'interrupted': return `turn ${event.data.turn}: interrupted`
|
||||
default: return `turn ${event.data.turn}: unknown result`
|
||||
}
|
||||
}
|
||||
|
||||
function resumeRoute(source: LogicalSessionSource): ResumeRoute | undefined {
|
||||
const header = source.events.findLast(item => item.type === 'request/header')
|
||||
if (header?.type === 'request/header') {
|
||||
return { provider: header.data.header.config.provider, model: header.data.header.config.model }
|
||||
}
|
||||
const assistant = source.events.findLast(item => item.type === 'assistant/message')
|
||||
return assistant?.type === 'assistant/message'
|
||||
? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model }
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one resume selector row from a record and its borrowed log source,
|
||||
* deriving the title, route, goal phase, workspace scope, and any reason the
|
||||
* session cannot be resumed here. A workspace other than the current one is a
|
||||
* scope, not a disabled reason: resuming it hands the process off into that
|
||||
* directory. The result retains only the record and derived scalars, so a
|
||||
* borrowed source stays valid for exactly this call.
|
||||
* Build one resume selector row from a record, its batch-folded title, and a
|
||||
* metadata-derived activity time, deriving the workspace scope and any reason
|
||||
* the session cannot be resumed here. A workspace other than the current one
|
||||
* is a scope, not a disabled reason: resuming it hands the process off into
|
||||
* that directory. Rows carry no per-log detail beyond the title — route and
|
||||
* replay validity are checked by the Enter-time preflight against the one
|
||||
* chosen log.
|
||||
* @param record - The session record.
|
||||
* @param source - The session's borrowed header and raw event log.
|
||||
* @param title - The session's batch-folded title, absent for an untitled log.
|
||||
* @param lastActivityAt - Metadata activity time; absent falls back to the header's creation time.
|
||||
* @param currentId - The current session id.
|
||||
* @param cwd - The CURRENT session's workspace, which decides the picker scope this row falls in.
|
||||
* @param availableProviders - Providers registered in this runtime.
|
||||
* @param formatWorkspace - Renders THIS record's own cwd as its prompt-style label.
|
||||
* @returns The summarized resume candidate.
|
||||
*/
|
||||
export function summarizeResumeCandidate(
|
||||
record: SessionRecord,
|
||||
source: LogicalSessionSource,
|
||||
title: string | undefined,
|
||||
lastActivityAt: number | undefined,
|
||||
currentId: SessionId,
|
||||
cwd: string | undefined,
|
||||
availableProviders: ReadonlySet<string>,
|
||||
formatWorkspace: (cwd: string | undefined) => string,
|
||||
): ResumeCandidate {
|
||||
const title = foldSessionTitle(source.events)?.title ?? 'Untitled session'
|
||||
const route = resumeRoute(source)
|
||||
const foldedGoal = foldGoal(source.events).goal
|
||||
let disabledReason: string | undefined
|
||||
if (record.header.id === currentId) disabledReason = 'current session'
|
||||
else if (record.live) disabledReason = 'session is already live in this runtime'
|
||||
else if (record.header.cwd === undefined) disabledReason = 'session has no recorded workspace'
|
||||
else if (route !== undefined && !availableProviders.has(route.provider)) {
|
||||
disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
|
||||
}
|
||||
return {
|
||||
record,
|
||||
title,
|
||||
// Excludes a prior pickup's boundary, or every browsed session floats up.
|
||||
lastActivityAt: lastActivityTime(source.events) ?? source.header.createdAt,
|
||||
lastTurn: resumeTurnLabel(source),
|
||||
title: title ?? 'Untitled session',
|
||||
lastActivityAt: lastActivityAt ?? record.header.createdAt,
|
||||
currentWorkspace: record.header.cwd === cwd,
|
||||
workspaceLabel: formatWorkspace(record.header.cwd),
|
||||
...route === undefined ? {} : { route },
|
||||
/* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */
|
||||
...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
|
||||
...disabledReason === undefined ? {} : { disabledReason },
|
||||
}
|
||||
}
|
||||
@@ -601,7 +551,7 @@ export class ResumePicker implements Component, Focusable {
|
||||
private visibleCandidateCount(): number {
|
||||
// The all-workspaces scope adds a per-row workspace line, so a row costs
|
||||
// one more terminal row there than in the single-workspace scope.
|
||||
const rowHeight = this.scope === 'all' ? 5 : 4
|
||||
const rowHeight = this.scope === 'all' ? 4 : 3
|
||||
const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / rowHeight))
|
||||
return Math.min(this.maxVisible, candidateBudget)
|
||||
}
|
||||
@@ -748,11 +698,7 @@ export class ResumePicker implements Component, Focusable {
|
||||
].filter((value): value is string => value !== undefined).join(' · ')
|
||||
const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}`
|
||||
push(active ? this.palette.bold(this.palette.accent(lead)) : lead)
|
||||
const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}`
|
||||
/* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */
|
||||
const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}`
|
||||
push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
|
||||
push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
|
||||
push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${status} · ${displayText(candidate.record.header.id)}`))
|
||||
// Only the all-workspaces scope mixes directories, so the per-row
|
||||
// workspace is redundant in the scope that already names one.
|
||||
if (this.scope === 'all') {
|
||||
|
||||
Reference in New Issue
Block a user