netxops/src/netx/session-export.ts
oliver a4993b2407 Add Netx Ops i18n and promote settings to a dedicated section.
Localize alarm prompts and UI reason codes for zh/en, and restyle the settings page like uds-auth as its own settings.section.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 22:09:47 +08:00

487 lines
16 KiB
TypeScript

/**
* Bulk session-log export for Ops: pack every durable session on this Host
* into one ZIP for offline HQ analysis. Soft-depends on sessionPersistence
* (JSONL raw artifacts); live sessions are flushed when a SessionStore is present.
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { homedir, hostname as osHostname } from 'node:os'
import { join } from 'node:path'
import { strToU8, zipSync, Zip, ZipDeflate } from 'fflate'
import type { Context } from '@deepseek-ai/cordis'
import {
NETXOPS_SESSIONS_EXPORT_PATH,
type SessionsExportStatus,
} from '../session-export-shared.ts'
export { NETXOPS_SESSIONS_EXPORT_PATH }
export type { SessionsExportStatus }
/** Default DEFLATE level for each ZIP entry. */
const COMPRESSION_LEVEL = 6
/** How many code units of artifact text one zip push carries. */
const PUSH_CHUNK_CODE_UNITS = 1 << 16
/** Subdir under `$DSH_HOME` where Ops bulk exports land. */
export const SESSIONS_EXPORT_DIRNAME = 'exports'
/** Duck-typed SessionPersistence surface used by export (no hard dep on dsh-session). */
export interface SessionPersistenceExportApi {
readonly supportsRawArtifacts: boolean
list: (signal?: AbortSignal) => Promise<readonly SessionExportHeader[]>
readRaw: (
id: string,
signal?: AbortSignal,
) => Promise<SessionExportRawArtifact | undefined>
}
/** Minimal durable session header fields copied into the export manifest. */
export interface SessionExportHeader {
readonly id: string
readonly version: number
readonly createdAt: number
readonly cwd?: string
readonly parentSession?: string
readonly origin?: string
readonly agentPreset?: string
readonly delegationDepth?: number
}
/** One verbatim persistence artifact. */
export interface SessionExportRawArtifact {
readonly meta: SessionExportHeader
readonly filename: string
readonly content: string
}
/** Optional live SessionStore flush barrier. */
interface SessionStoreFlushApi {
get?: (id: string) => unknown
flush?: (session: unknown) => Promise<void>
}
/** One exported file inside the bulk archive. */
type ZipTextEntry = { readonly path: string; readonly content: string }
/** Result of writing the bulk archive under `$DSH_HOME/exports`. */
export interface SessionsExportDiskResult {
readonly path: string
readonly filename: string
readonly exportDir: string
readonly sessionCountListed: number
readonly sessionCountIncluded: number
readonly sessionCountSkipped: number
readonly bytes: number
}
/** Harness home used for sessions + exports. */
export function resolveDshHome(): string {
const fromEnv = process.env.DSH_HOME?.trim()
if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv
return join(homedir(), '.dsh')
}
/**
* Resolve sessionPersistence from the host context when the web profile mounted it.
* @param ctx - cordis host context.
*/
export function resolveSessionPersistence(ctx: Context): SessionPersistenceExportApi | undefined {
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
? (ctx as { get: (name: string) => unknown }).get('sessionPersistence')
: undefined
const persistence = (fromGet
?? (ctx as { sessionPersistence?: SessionPersistenceExportApi }).sessionPersistence) as
| SessionPersistenceExportApi
| undefined
if (!persistence || typeof persistence.list !== 'function' || typeof persistence.readRaw !== 'function') {
return undefined
}
return persistence
}
/**
* Resolve the optional live session store for flush-before-read.
* @param ctx - cordis host context.
*/
function resolveSessionStore(ctx: Context): SessionStoreFlushApi | undefined {
const fromGet = typeof (ctx as { get?: (name: string) => unknown }).get === 'function'
? (ctx as { get: (name: string) => unknown }).get('sessions')
: undefined
const sessions = (fromGet ?? (ctx as { sessions?: SessionStoreFlushApi }).sessions) as
| SessionStoreFlushApi
| undefined
if (!sessions || typeof sessions.get !== 'function' || typeof sessions.flush !== 'function') {
return undefined
}
return sessions
}
/**
* Preflight: whether bulk export can run and how many sessions would ship.
* @param ctx - cordis host context.
* @param signal - optional cancellation.
*/
export async function getSessionsExportStatus(
ctx: Context,
signal?: AbortSignal,
): Promise<SessionsExportStatus> {
const persistence = resolveSessionPersistence(ctx)
if (!persistence) {
return {
available: false,
sessionCount: 0,
supportsRawArtifacts: false,
reasonCode: 'no_persistence',
reason: 'sessionPersistence unavailable — mount a JSONL session backend (web profile default)',
}
}
if (!persistence.supportsRawArtifacts) {
return {
available: false,
sessionCount: 0,
supportsRawArtifacts: false,
reasonCode: 'no_raw_artifacts',
reason: 'persistence backend does not expose per-session raw artifacts (SQLite export unsupported)',
}
}
try {
const headers = await persistence.list(signal)
signal?.throwIfAborted()
return {
available: true,
sessionCount: headers.length,
supportsRawArtifacts: true,
}
} catch (error) {
return {
available: false,
sessionCount: 0,
supportsRawArtifacts: true,
reasonCode: 'list_failed',
reason: error instanceof Error ? error.message : String(error),
}
}
}
/**
* Sanitize one path segment for ZIP entry names (no separators / traversal).
* Keeps a single basename; dots in extensions like `.jsonl` are allowed.
* @param id - raw session id, hostname fragment, or artifact filename.
*/
export function safePathSegment(id: string): string {
const base = String(id || '').replace(/\\/g, '/').split('/').pop() ?? ''
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '')
return cleaned || 'unnamed'
}
/**
* Browser download filename for a bulk export.
* @param exportedAt - Date used for the timestamp stamp.
* @param host - optional host label (defaults to os.hostname()).
*/
export function sessionsExportZipFilename(exportedAt: Date = new Date(), host = osHostname()): string {
const stamp = [
exportedAt.getUTCFullYear(),
String(exportedAt.getUTCMonth() + 1).padStart(2, '0'),
String(exportedAt.getUTCDate()).padStart(2, '0'),
'-',
String(exportedAt.getUTCHours()).padStart(2, '0'),
String(exportedAt.getUTCMinutes()).padStart(2, '0'),
String(exportedAt.getUTCSeconds()).padStart(2, '0'),
].join('')
return `dsh-sessions-${safePathSegment(host)}-${stamp}.zip`
}
async function flushLiveSession(
sessions: SessionStoreFlushApi | undefined,
id: string,
signal?: AbortSignal,
): Promise<void> {
signal?.throwIfAborted()
if (!sessions?.get || !sessions.flush) return
const live = sessions.get(id)
if (live === undefined || live === null) return
await sessions.flush(live)
signal?.throwIfAborted()
}
/**
* Yield ZIP text entries: manifest first, then each session artifact under sessions/<id>/.
* Missing artifacts are recorded in the manifest and skipped (fail-soft for Ops pickup).
* @param ctx - cordis host context.
* @param signal - cancellation.
*/
export async function* sessionsExportEntries(
ctx: Context,
signal?: AbortSignal,
): AsyncGenerator<ZipTextEntry> {
const persistence = resolveSessionPersistence(ctx)
if (!persistence) {
throw new Error('sessionPersistence unavailable')
}
if (!persistence.supportsRawArtifacts) {
throw new Error('persistence backend does not expose per-session raw artifacts')
}
const sessions = resolveSessionStore(ctx)
const headers = await persistence.list(signal)
signal?.throwIfAborted()
const exportedAt = new Date().toISOString()
const host = osHostname()
const included: Array<{
id: string
path: string
createdAt: number
cwd?: string
agentPreset?: string
parentSession?: string
origin?: string
}> = []
const skipped: Array<{ id: string; reason: string }> = []
const artifactEntries: ZipTextEntry[] = []
for (const header of headers) {
signal?.throwIfAborted()
const id = String(header.id)
try {
await flushLiveSession(sessions, id, signal)
const raw = await persistence.readRaw(id, signal)
signal?.throwIfAborted()
if (raw === undefined) {
skipped.push({ id, reason: 'no stored artifact' })
continue
}
const filename = safePathSegment(
raw.filename && raw.filename.length > 0 ? raw.filename : 'session.jsonl',
) || 'session.jsonl'
const path = `sessions/${safePathSegment(id)}/${filename}`
artifactEntries.push({ path, content: raw.content })
included.push({
id,
path,
createdAt: header.createdAt,
cwd: header.cwd,
agentPreset: header.agentPreset,
parentSession: header.parentSession !== undefined ? String(header.parentSession) : undefined,
origin: header.origin,
})
} catch (error) {
skipped.push({
id,
reason: error instanceof Error ? error.message : String(error),
})
}
}
const manifest = {
kind: 'dsh-netxops-sessions-export',
version: 1,
exportedAt,
hostname: host,
sessionCountListed: headers.length,
sessionCountIncluded: included.length,
sessionCountSkipped: skipped.length,
sessions: included,
skipped,
}
yield {
path: 'manifest.json',
content: `${JSON.stringify(manifest, null, 2)}\n`,
}
for (const entry of artifactEntries) {
signal?.throwIfAborted()
yield entry
}
}
/**
* Push artifact text into a deflate stream in bounded chunks without splitting
* surrogate pairs (lone high surrogates would corrupt UTF-8 as U+FFFD).
*/
async function pushArtifactChunks(
deflate: ZipDeflate,
content: string,
signal: AbortSignal,
): Promise<void> {
const encoder = new TextEncoder()
let offset = 0
let finalChunk: boolean
do {
signal.throwIfAborted()
let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length)
if (end < content.length && end - offset > 1) {
const last = content.charCodeAt(end - 1)
if (last >= 0xd800 && last <= 0xdbff) end -= 1
}
finalChunk = end >= content.length
deflate.push(encoder.encode(content.slice(offset, end)), finalChunk)
offset = end
} while (!finalChunk)
}
/**
* Stream the bulk sessions ZIP as a WHATWG ReadableStream.
* @param ctx - cordis host context.
* @param signal - request / consumer cancellation.
*/
export function streamSessionsExportZip(
ctx: Context,
signal: AbortSignal,
): ReadableStream<Uint8Array> {
const consumerAbort = new AbortController()
const producerSignal = AbortSignal.any([signal, consumerAbort.signal])
let zip: Zip | undefined
let zipTerminated = false
const terminateZip = (): void => {
if (zip === undefined || zipTerminated) return
zipTerminated = true
zip.terminate()
}
return new ReadableStream<Uint8Array>({
start(controller) {
const archive = new Zip((error, data, final) => {
if (error) {
controller.error(error)
return
}
if (data.byteLength > 0) controller.enqueue(data)
if (final) controller.close()
})
zip = archive
void (async () => {
try {
for await (const entry of sessionsExportEntries(ctx, producerSignal)) {
const deflate = new ZipDeflate(entry.path, { level: COMPRESSION_LEVEL })
archive.add(deflate)
await pushArtifactChunks(deflate, entry.content, producerSignal)
}
archive.end()
} catch (error) {
terminateZip()
controller.error(error instanceof Error ? error : new Error(String(error)))
}
})()
},
cancel(reason) {
consumerAbort.abort(
reason instanceof Error ? reason : new Error('sessions export stream cancelled'),
)
terminateZip()
},
})
}
/**
* Write every durable session into `$DSH_HOME/exports/<filename>.zip`.
* This is the Ops-primary path: the absolute file location is returned so
* operators can copy the archive off the Host without relying on the browser
* download manager (which often swallows programmatic `<a download>` clicks).
* @param ctx - cordis host context.
* @param signal - optional cancellation.
*/
export async function writeSessionsExportZip(
ctx: Context,
signal?: AbortSignal,
): Promise<SessionsExportDiskResult> {
const status = await getSessionsExportStatus(ctx, signal)
if (!status.available) {
throw new Error(status.reason ?? 'sessions export unavailable')
}
const files: Record<string, Uint8Array> = {}
let included = 0
let skipped = 0
let listed = 0
for await (const entry of sessionsExportEntries(ctx, signal)) {
files[entry.path] = strToU8(entry.content)
if (entry.path === 'manifest.json') {
try {
const manifest = JSON.parse(entry.content) as {
sessionCountListed?: number
sessionCountIncluded?: number
sessionCountSkipped?: number
}
listed = typeof manifest.sessionCountListed === 'number' ? manifest.sessionCountListed : 0
included = typeof manifest.sessionCountIncluded === 'number' ? manifest.sessionCountIncluded : 0
skipped = typeof manifest.sessionCountSkipped === 'number' ? manifest.sessionCountSkipped : 0
} catch {
// Manifest parse is best-effort; ZIP still ships.
}
}
}
const zipped = zipSync(files, { level: COMPRESSION_LEVEL })
const exportDir = join(resolveDshHome(), SESSIONS_EXPORT_DIRNAME)
mkdirSync(exportDir, { recursive: true })
const filename = sessionsExportZipFilename()
const path = join(exportDir, filename)
writeFileSync(path, zipped)
return {
path,
filename,
exportDir,
sessionCountListed: listed || status.sessionCount,
sessionCountIncluded: included,
sessionCountSkipped: skipped,
bytes: zipped.byteLength,
}
}
/**
* Build the HTTP Response for GET /api/netxops.sessions.export.
* HEAD should use {@link sessionsExportHeadResponse} so listing does not start ZIP work.
* @param ctx - cordis host context.
* @param request - incoming fetch request.
*/
export async function sessionsExportResponse(
ctx: Context,
request: Request,
): Promise<Response> {
const status = await getSessionsExportStatus(ctx, request.signal)
if (!status.available) {
return new Response(status.reason ?? 'sessions export unavailable', {
status: status.supportsRawArtifacts === false && status.reason?.includes('raw artifacts')
? 501
: 500,
})
}
const filename = sessionsExportZipFilename()
const body = streamSessionsExportZip(ctx, request.signal)
return new Response(body, {
headers: {
'content-type': 'application/zip',
'content-disposition': `attachment; filename="${filename}"`,
'x-netxops-session-count': String(status.sessionCount),
},
})
}
/**
* Build the HTTP Response for HEAD /api/netxops.sessions.export (preflight only).
* @param ctx - cordis host context.
* @param request - incoming fetch request.
*/
export async function sessionsExportHeadResponse(
ctx: Context,
request: Request,
): Promise<Response> {
const status = await getSessionsExportStatus(ctx, request.signal)
if (!status.available) {
return new Response(null, {
status: status.supportsRawArtifacts === false && status.reason?.includes('raw artifacts')
? 501
: 500,
headers: {
'x-netxops-export-error': status.reason ?? 'sessions export unavailable',
},
})
}
const filename = sessionsExportZipFilename()
return new Response(null, {
status: 200,
headers: {
'content-type': 'application/zip',
'content-disposition': `attachment; filename="${filename}"`,
'x-netxops-session-count': String(status.sessionCount),
},
})
}