113 lines
3.7 KiB
JavaScript
113 lines
3.7 KiB
JavaScript
import { randomUUID } from 'node:crypto'
|
|
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { appConfigFilePath } from './vault-path.js'
|
|
|
|
const MAX_SESSION_ID_BYTES = 128
|
|
const MAX_EVENT_KIND_BYTES = 64
|
|
const MAX_SUMMARY_BYTES = 12_000
|
|
const MAX_DETAIL_COUNT = 64
|
|
const MAX_DETAIL_BYTES = 12_000
|
|
const MAX_CHECKPOINT_BYTES = 1_000_000
|
|
|
|
function runtimeBasePath() {
|
|
return appConfigFilePath('hldp-runtime')
|
|
}
|
|
|
|
function validatedSessionId(value) {
|
|
const sessionId = typeof value === 'string' ? value.trim() : ''
|
|
if (
|
|
!sessionId
|
|
|| Buffer.byteLength(sessionId) > MAX_SESSION_ID_BYTES
|
|
|| !/^[A-Za-z0-9._-]+$/.test(sessionId)
|
|
) {
|
|
throw new Error('HLDP session id must use only letters, numbers, dot, dash, or underscore.')
|
|
}
|
|
return sessionId
|
|
}
|
|
|
|
function validatedText(label, value, maximumBytes) {
|
|
const text = typeof value === 'string' ? value.trim() : ''
|
|
if (!text) throw new Error(`${label} must not be empty.`)
|
|
if (Buffer.byteLength(text) > maximumBytes) {
|
|
throw new Error(`${label} exceeds the ${maximumBytes}-byte limit.`)
|
|
}
|
|
return text
|
|
}
|
|
|
|
function journalPath(basePath, sessionId) {
|
|
return path.join(basePath, 'sessions', `${sessionId}.hdlp.jsonl`)
|
|
}
|
|
|
|
function checkpointPath(basePath, sessionId) {
|
|
return path.join(basePath, 'checkpoints', `${sessionId}.hdlp`)
|
|
}
|
|
|
|
export async function recordHldpHeartbeat(args = {}, { basePath = runtimeBasePath() } = {}) {
|
|
const sessionId = validatedSessionId(args.session_id)
|
|
const eventKind = validatedText('HLDP event kind', args.event_kind, MAX_EVENT_KIND_BYTES)
|
|
const summary = validatedText('HLDP summary', args.summary, MAX_SUMMARY_BYTES)
|
|
const details = Array.isArray(args.details) ? args.details : []
|
|
if (details.length > MAX_DETAIL_COUNT) {
|
|
throw new Error(`HLDP heartbeat accepts at most ${MAX_DETAIL_COUNT} detail entries.`)
|
|
}
|
|
const normalizedDetails = details.map(detail => (
|
|
validatedText('HLDP detail', detail, MAX_DETAIL_BYTES)
|
|
))
|
|
const timestamp = new Date().toISOString()
|
|
const receiptId = randomUUID()
|
|
const targetPath = journalPath(basePath, sessionId)
|
|
await mkdir(path.dirname(targetPath), { recursive: true })
|
|
await appendFile(targetPath, `${JSON.stringify({
|
|
schema: 'hldp-heartbeat/v0.1',
|
|
session_id: sessionId,
|
|
receipt_id: receiptId,
|
|
timestamp,
|
|
event_kind: eventKind,
|
|
summary,
|
|
details: normalizedDetails,
|
|
source: 'persona',
|
|
})}\n`, 'utf8')
|
|
return {
|
|
sessionId,
|
|
receiptId,
|
|
relativePath: path.relative(basePath, targetPath),
|
|
timestamp,
|
|
}
|
|
}
|
|
|
|
export async function readHldpHeartbeat(args = {}, { basePath = runtimeBasePath() } = {}) {
|
|
const sessionId = validatedSessionId(args.session_id)
|
|
const requestedLimit = Number.isInteger(args.limit) ? args.limit : 200
|
|
const limit = Math.min(Math.max(requestedLimit, 1), 2_000)
|
|
try {
|
|
const content = await readFile(journalPath(basePath, sessionId), 'utf8')
|
|
return content
|
|
.trim()
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.slice(-limit)
|
|
.map(line => JSON.parse(line))
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return []
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function finalizeHldpCheckpoint(args = {}, { basePath = runtimeBasePath() } = {}) {
|
|
const sessionId = validatedSessionId(args.session_id)
|
|
const checkpoint = validatedText(
|
|
'HLDP checkpoint',
|
|
args.checkpoint,
|
|
MAX_CHECKPOINT_BYTES,
|
|
)
|
|
const targetPath = checkpointPath(basePath, sessionId)
|
|
await mkdir(path.dirname(targetPath), { recursive: true })
|
|
await writeFile(targetPath, `${checkpoint}\n`, { encoding: 'utf8', flag: 'wx' })
|
|
return {
|
|
sessionId,
|
|
receiptId: randomUUID(),
|
|
relativePath: path.relative(basePath, targetPath),
|
|
timestamp: new Date().toISOString(),
|
|
}
|
|
}
|