Files
deepseek-harness/packages/session-registry/session-registry-file/src/index.ts

234 lines
9.7 KiB
TypeScript

/**
* File-backed live-session registry: one lock-guarded JSON file under the
* Harness home implements the `@deepseek-ai/dsh-session-registry` seam. Every
* operation is a read-modify-write under an advisory lock, because concurrent
* launchers write the same file — the storage-hub JSON backend documents
* last-write-wins for exactly this case and cannot be reused. Liveness is
* derived at read time from the recorded pid.
* @module @deepseek-ai/dsh-session-registry-file
*/
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, rename, writeFile, open } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { Context } from 'cordis'
import lockfile from 'proper-lockfile'
import z from 'schemastery'
import type { SessionId } from '@deepseek-ai/dsh-session'
import {
SessionRegistry, BootId,
type SessionRegistration, type SessionRegistryRecord,
} from '@deepseek-ai/dsh-session-registry'
import { EMPTY_REGISTRY, parseRegistry, serializeRegistry } from './file.ts'
import { isPidAlive } from './liveness.ts'
export { SESSION_REGISTRY_FORMAT_VERSION, parseRegistry, serializeRegistry } from './file.ts'
export type { RegistryFileContents } from './file.ts'
export { isPidAlive } from './liveness.ts'
/** The file name holding the registry, relative to {@link Config.root}. */
export const REGISTRY_FILE_NAME = 'sessions.json'
/** Default lock staleness threshold; a held lock older than this is reclaimed. */
const DEFAULT_LOCK_STALE_MS = 10_000
/** Default retry budget for a contended lock acquisition. */
const DEFAULT_LOCK_RETRIES = 10
/**
* Plugin config as callers write it: `root` is required — a cwd fallback would
* scatter registries — while the lock tunables are optional because
* `static Config` supplies their defaults.
*/
export interface Config {
/** Directory holding the registry file; created `0o700` on demand. */
root: string
/** Milliseconds after which a held lock is considered abandoned and reclaimed. */
lockStaleMs?: number
/** Retries before a contended acquisition fails loud. */
lockRetries?: number
}
/** The file-backed {@link SessionRegistry} implementation. */
export class SessionRegistryFile extends SessionRegistry {
static Config: z<Config> = z.object({
root: z.string().required(),
lockStaleMs: z.natural().default(DEFAULT_LOCK_STALE_MS),
lockRetries: z.natural().default(DEFAULT_LOCK_RETRIES),
})
/** Absolute path of the registry file this service reads and writes. */
readonly file: string
/** Directory holding {@link file}, created `0o700` on demand. */
private readonly root: string
/** Tail of the in-process serialization chain; see {@link mutate}. */
private chain: Promise<void> = Promise.resolve()
/** Resolved lock staleness threshold in milliseconds, fixed at construction. */
private readonly stale: number
/** Resolved contended-acquisition retry budget, fixed at construction. */
private readonly retries: number
constructor(ctx: Context, config: Config) {
super(ctx, BootId(randomUUID()))
this.root = config.root
this.file = join(this.root, REGISTRY_FILE_NAME)
// Resolve the optional tunables here, once: `static Config` supplies these
// same defaults for a Loader mount, and a direct programmatic mount that
// omits them gets them too rather than an undefined lock option.
this.stale = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS
this.retries = config.lockRetries ?? DEFAULT_LOCK_RETRIES
}
/** @inheritdoc */
async register(registration: SessionRegistration): Promise<() => Promise<void>> {
const record: SessionRegistryRecord = {
sessionId: registration.sessionId,
pid: process.pid,
cwd: registration.cwd,
startedAt: Date.now(),
bootId: this.bootId,
...registration.title !== undefined && { title: registration.title },
}
await this.mutate(records => [
...records.filter(other => other.sessionId !== record.sessionId),
record,
])
// The disposer is awaited by Cordis teardown, so the record is durably gone
// before disposal completes rather than racing process exit. A failure here
// is reported, not thrown: the record is already pid-prunable, and an
// unwinding teardown must not be turned into a rejection.
return this.ctx.effect(() => async () => {
try {
await this.mutate(records => records.filter(other => !this.isSelf(other, record)))
} catch (error) {
this.ctx.logger.warn('failed to deregister %s: %s', record.sessionId, String(error))
}
})
}
/** @inheritdoc */
async retitle(sessionId: SessionId, title: string): Promise<void> {
await this.mutate(records => records.map(record =>
record.sessionId === sessionId && record.pid === process.pid && record.bootId === this.bootId
? { ...record, title }
: record))
}
/** @inheritdoc */
async list(): Promise<SessionRegistryRecord[]> {
// Pruning is a write, so the read path takes the same lock: a listing that
// observed a half-written file could omit a live session.
return this.mutate(records => [...records])
}
/** True when a stored record is this exact registration (pid AND incarnation). */
private isSelf(candidate: SessionRegistryRecord, self: SessionRegistryRecord): boolean {
return candidate.sessionId === self.sessionId
&& candidate.pid === self.pid
&& candidate.bootId === self.bootId
}
/**
* Serialize one read-modify-write cycle against every other cycle in THIS
* process, then run it under the cross-process lock.
*
* Both layers are required and neither substitutes for the other. The advisory
* lock excludes other processes but is tracked per process, so it rejects a
* same-process concurrent acquisition outright (`ELOCKED`) instead of queueing
* — and a composition that creates several sessions at once really does
* overlap these calls. This chain gives those callers a queue; the lock gives
* independent processes exclusion.
*/
private mutate(
change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[],
): Promise<SessionRegistryRecord[]> {
// Failures must not poison the chain for later callers, so the tail only
// tracks settlement, never the rejection itself.
const result = this.chain.then(() => this.mutateExclusively(change))
this.chain = result.then(() => undefined, () => undefined)
return result
}
/**
* Run one locked read-modify-write cycle: read, prune dead records, apply
* `change`, and republish when the result differs from what was stored.
*/
private async mutateExclusively(
change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[],
): Promise<SessionRegistryRecord[]> {
await mkdir(this.root, { recursive: true, mode: 0o700 })
// proper-lockfile needs the target to exist before it can guard it; an
// exclusive create loses harmlessly to a concurrent launcher doing the same.
await this.ensureFile()
const release = await lockfile.lock(this.file, {
stale: this.stale,
retries: { retries: this.retries, minTimeout: 20, maxTimeout: 500 },
})
try {
const before = await this.read()
const live = before.records.filter(record => isPidAlive(record.pid))
const next = change(live)
// Republish when a record changed or the medium itself was damaged, so a
// foreign or torn file heals instead of being re-parsed on every read.
if (!before.intact || !sameRecords(before.records, next)) await this.write(next)
return next
} finally {
await release()
}
}
/** Create the registry file if absent, without disturbing existing content. */
private async ensureFile(): Promise<void> {
try {
const handle = await open(this.file, 'wx', 0o600)
try {
await handle.writeFile(serializeRegistry(EMPTY_REGISTRY.records))
} finally {
await handle.close()
}
} catch (error) {
// Swallows only EEXIST: another launcher created the file first, which is
// the intended outcome. Every other errno propagates.
/* v8 ignore next -- a non-EEXIST create failure needs a permission or IO fault on a root this cycle just created 0o700. */
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
}
}
/** Read and parse the registry file; a missing file reads as empty. */
private async read(): Promise<{ records: SessionRegistryRecord[]; intact: boolean }> {
// The caller holds the lock, and acquiring it requires the file to exist, so
// a read failure here is real corruption rather than an absent registry and
// propagates: a swallowed error would report "no live sessions" for a medium
// that could not be read.
return parseRegistry(await readFile(this.file, 'utf8'))
}
/** Publish the complete record set via temp-write plus atomic rename. */
private async write(records: readonly SessionRegistryRecord[]): Promise<void> {
const temp = join(dirname(this.file), `.${REGISTRY_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`)
await writeFile(temp, serializeRegistry(records), { mode: 0o600 })
await rename(temp, this.file)
}
}
/** Compare record lists by identity fields, to decide whether a write is needed. */
function sameRecords(left: readonly SessionRegistryRecord[], right: readonly SessionRegistryRecord[]): boolean {
if (left.length !== right.length) return false
return left.every((record, index) => {
const other = right[index]
return other !== undefined
&& record.sessionId === other.sessionId
&& record.pid === other.pid
&& record.bootId === other.bootId
&& record.cwd === other.cwd
&& record.startedAt === other.startedAt
&& record.title === other.title
})
}
export default SessionRegistryFile