feat(tui): add safe session resume flow

This commit is contained in:
NI0317
2026-07-24 01:29:35 -07:00
committed by ZiyaZhang
parent 65d29da8a1
commit 2ae9f4fdf3
57 files changed
+2312 -192

No files matched your search

@@ -15,8 +15,9 @@ import { mkdir, open } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import {
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
type StoredPrefix,
sessionLeaseProcessIsLive, shareSessionLiveLease,
type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner,
type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
import {
@@ -161,6 +162,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
return this.coordinator.inspect(id)
}
override claimLive(id: SessionId): Promise<SessionLiveLease> {
return this.coordinator.claimLive(id)
}
override isLive(id: SessionId): Promise<boolean> {
return this.coordinator.isLive(id)
}
// One method serves both public `list` and the backend hook; delegating it to
// the coordinator would call this hook recursively.
@@ -271,6 +280,55 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
}))
}
/** Atomically acquire one SQLite-backed process lease. */
async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise<void>> {
await this.ready
return shareSessionLiveLease(
`sqlite:${this.storeIdentity}:${id}`,
() => Promise.resolve().then(() => this.acquireLiveRow(id, owner)),
)
}
private acquireLiveRow(id: SessionId, owner: SessionLiveOwner): () => Promise<void> {
this.db.exec('BEGIN IMMEDIATE')
try {
const current = this.liveLeaseFor(id)
if (current !== undefined
&& (current.pid !== owner.pid || current.nonce !== owner.nonce)) {
if (sessionLeaseProcessIsLive(current.pid)) {
throw new Error(`session "${id}" is occupied by another live process`)
}
this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ?').run(id)
}
this.db.prepare(`
INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET pid = excluded.pid, nonce = excluded.nonce
`).run(id, owner.pid, owner.nonce)
this.db.exec('COMMIT')
} catch (error) {
this.db.exec('ROLLBACK')
throw error
}
return async () => {
await this.ready
this.db.prepare(
'DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?',
).run(id, owner.pid, owner.nonce)
}
}
/** Report a non-stale SQLite lease and remove a crashed owner's row. */
async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise<boolean> {
await this.ready
const current = this.liveLeaseFor(id)
if (current === undefined) return false
if ((current.pid === owner.pid && current.nonce === owner.nonce)
|| sessionLeaseProcessIsLive(current.pid)) return true
this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?')
.run(id, current.pid, current.nonce)
return false
}
/** Close the database handle (awaited by the coordinator's dispose, post-drain). */
async close(): Promise<void> {
await this.ready
@@ -284,6 +342,11 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
return this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as unknown as SessionRow | undefined
}
private liveLeaseFor(id: SessionId): { pid: number; nonce: string } | undefined {
return this.db.prepare('SELECT pid, nonce FROM live_session_leases WHERE session_id = ?')
.get(id) as { pid: number; nonce: string } | undefined
}
/**
* Insert-or-replace a session's metadata row. The only caller is the first
* materializing `appendBatch`, so writing the row IS the materialization (its
@@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee
* layout; orthogonal to a session's own `version` (which versions the EVENT
* vocabulary, stored per session in the `sessions` row).
*/
export const SCHEMA_VERSION = 8
export const SCHEMA_VERSION = 9
/**
* A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
@@ -68,7 +68,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
* rather than being migrated in place.
* @param path - the SQLite database file to open (created when absent).
* @param journalMode - validated journal pragma.
* @returns the open handle with pragmas applied and all three tables ensured.
* @returns the open handle with pragmas applied and all tables ensured.
*/
export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync {
const db = new DatabaseSync(path)
@@ -128,6 +128,13 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM
PRIMARY KEY (session_id, seq)
) STRICT
`)
db.exec(`
CREATE TABLE IF NOT EXISTS live_session_leases (
session_id TEXT PRIMARY KEY,
pid INTEGER NOT NULL,
nonce TEXT NOT NULL
) STRICT
`)
}
/**