From f1e67b331bc32f2eb680bf98ea3e1fac23d5373e Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 29 Apr 2026 21:28:05 +0200 Subject: [PATCH] Persist agent session metadata on disk GitGuardex needs a package-level session store that tools can share without reaching into VS Code-specific schemas. This adds a small CommonJS persistence module backed by per-session JSON files under .guardex/agents/sessions and covers create/read/update/list/remove behavior with temp-dir tests. Constraint: User requested edits only to src/agents/sessions.js and test/agents-sessions.test.js Rejected: Reuse VS Code active-agent session schema | it is extension-oriented and stores different records Confidence: high Scope-risk: narrow Directive: Keep this module independent from UI session-schema assumptions unless a CLI contract explicitly merges them Tested: node --test test/agents-sessions.test.js Tested: npm test --- src/agents/sessions.js | 141 ++++++++++++++++++++++++++++++++ test/agents-sessions.test.js | 153 +++++++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 src/agents/sessions.js create mode 100644 test/agents-sessions.test.js diff --git a/src/agents/sessions.js b/src/agents/sessions.js new file mode 100644 index 00000000..4ce91c7d --- /dev/null +++ b/src/agents/sessions.js @@ -0,0 +1,141 @@ +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const SESSION_FIELDS = [ + 'id', + 'task', + 'agent', + 'branch', + 'worktreePath', + 'base', + 'status', + 'createdAt', + 'updatedAt', +]; + +function sessionsDir(repoRoot) { + return path.join(repoRoot, '.guardex', 'agents', 'sessions'); +} + +function assertSessionId(sessionId) { + if (typeof sessionId !== 'string' || sessionId.trim() === '') { + throw new Error('Agent session id must be a non-empty string.'); + } + if (sessionId.includes('/') || sessionId.includes('\\') || sessionId === '.' || sessionId === '..') { + throw new Error(`Invalid agent session id: ${sessionId}`); + } +} + +function sessionFilePath(repoRoot, sessionId) { + assertSessionId(sessionId); + return path.join(sessionsDir(repoRoot), `${sessionId}.json`); +} + +function pickSessionFields(record) { + const session = {}; + for (const field of SESSION_FIELDS) { + session[field] = record[field] ?? null; + } + return session; +} + +function normalizeSessionPayload(payload, now) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('Agent session payload must be an object.'); + } + + const id = payload.id == null ? crypto.randomUUID() : payload.id; + assertSessionId(id); + + return pickSessionFields({ + ...payload, + id, + status: payload.status || 'active', + createdAt: payload.createdAt || now, + updatedAt: now, + }); +} + +function writeJsonAtomic(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tempPath = path.join( + path.dirname(filePath), + `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`, + ); + const body = `${JSON.stringify(value, null, 2)}\n`; + fs.writeFileSync(tempPath, body, { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(tempPath, filePath); +} + +function readJsonFile(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function createAgentSession(repoRoot, payload) { + const now = new Date().toISOString(); + const session = normalizeSessionPayload(payload, now); + writeJsonAtomic(sessionFilePath(repoRoot, session.id), session); + return session; +} + +function readAgentSession(repoRoot, sessionId) { + const filePath = sessionFilePath(repoRoot, sessionId); + if (!fs.existsSync(filePath)) { + return null; + } + return pickSessionFields(readJsonFile(filePath)); +} + +function updateAgentSession(repoRoot, sessionId, patch) { + if (!patch || typeof patch !== 'object' || Array.isArray(patch)) { + throw new Error('Agent session patch must be an object.'); + } + + const existing = readAgentSession(repoRoot, sessionId); + if (!existing) { + return null; + } + + const now = new Date().toISOString(); + const session = pickSessionFields({ + ...existing, + ...patch, + id: existing.id, + createdAt: existing.createdAt, + updatedAt: now, + }); + writeJsonAtomic(sessionFilePath(repoRoot, sessionId), session); + return session; +} + +function listAgentSessions(repoRoot) { + const dir = sessionsDir(repoRoot); + if (!fs.existsSync(dir)) { + return []; + } + + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => readAgentSession(repoRoot, entry.name.slice(0, -'.json'.length))) + .filter(Boolean) + .sort((left, right) => left.id.localeCompare(right.id)); +} + +function removeAgentSession(repoRoot, sessionId) { + const filePath = sessionFilePath(repoRoot, sessionId); + if (!fs.existsSync(filePath)) { + return false; + } + fs.unlinkSync(filePath); + return true; +} + +module.exports = { + createAgentSession, + readAgentSession, + updateAgentSession, + listAgentSessions, + removeAgentSession, +}; diff --git a/test/agents-sessions.test.js b/test/agents-sessions.test.js new file mode 100644 index 00000000..699cc7d4 --- /dev/null +++ b/test/agents-sessions.test.js @@ -0,0 +1,153 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + createAgentSession, + readAgentSession, + updateAgentSession, + listAgentSessions, + removeAgentSession, +} = require('../src/agents/sessions'); + +function makeRepoRoot() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-agent-sessions-')); +} + +function sessionPath(repoRoot, sessionId) { + return path.join(repoRoot, '.guardex', 'agents', 'sessions', `${sessionId}.json`); +} + +test('createAgentSession stores required metadata under .guardex/agents/sessions', () => { + const repoRoot = makeRepoRoot(); + + const session = createAgentSession(repoRoot, { + id: 'agent-session-1', + task: 'Implement agent sessions', + agent: 'codex', + branch: 'agent/codex/sessions', + worktreePath: path.join(repoRoot, '.omx', 'agent-worktrees', 'sessions'), + base: 'main', + }); + + assert.equal(session.id, 'agent-session-1'); + assert.equal(session.status, 'active'); + assert.equal(typeof session.createdAt, 'string'); + assert.equal(typeof session.updatedAt, 'string'); + assert.deepEqual(Object.keys(session), [ + 'id', + 'task', + 'agent', + 'branch', + 'worktreePath', + 'base', + 'status', + 'createdAt', + 'updatedAt', + ]); + + const stored = JSON.parse(fs.readFileSync(sessionPath(repoRoot, session.id), 'utf8')); + assert.deepEqual(stored, session); +}); + +test('readAgentSession returns a stored session or null for missing sessions', () => { + const repoRoot = makeRepoRoot(); + const created = createAgentSession(repoRoot, { + id: 'agent-session-2', + task: 'Read session metadata', + agent: 'claude', + branch: 'agent/claude/read-session', + worktreePath: path.join(repoRoot, '.omc', 'agent-worktrees', 'read-session'), + base: 'dev', + status: 'working', + }); + + assert.deepEqual(readAgentSession(repoRoot, created.id), created); + assert.equal(readAgentSession(repoRoot, 'missing-session'), null); +}); + +test('updateAgentSession patches metadata while preserving id and createdAt', async () => { + const repoRoot = makeRepoRoot(); + const created = createAgentSession(repoRoot, { + id: 'agent-session-3', + task: 'Patch session metadata', + agent: 'codex', + branch: 'agent/codex/patch-session', + worktreePath: path.join(repoRoot, 'worktree-a'), + base: 'main', + status: 'active', + }); + + await new Promise((resolve) => setTimeout(resolve, 5)); + + const updated = updateAgentSession(repoRoot, created.id, { + id: 'ignored-id', + status: 'blocked', + worktreePath: path.join(repoRoot, 'worktree-b'), + createdAt: 'ignored-created-at', + }); + + assert.equal(updated.id, created.id); + assert.equal(updated.createdAt, created.createdAt); + assert.equal(updated.status, 'blocked'); + assert.equal(updated.worktreePath, path.join(repoRoot, 'worktree-b')); + assert.notEqual(updated.updatedAt, created.updatedAt); + assert.deepEqual(readAgentSession(repoRoot, created.id), updated); + assert.equal(updateAgentSession(repoRoot, 'missing-session', { status: 'gone' }), null); +}); + +test('listAgentSessions returns stored sessions sorted by id', () => { + const repoRoot = makeRepoRoot(); + assert.deepEqual(listAgentSessions(repoRoot), []); + + const second = createAgentSession(repoRoot, { + id: 'session-b', + task: 'Second', + agent: 'codex', + branch: 'agent/codex/b', + worktreePath: path.join(repoRoot, 'b'), + base: 'main', + }); + const first = createAgentSession(repoRoot, { + id: 'session-a', + task: 'First', + agent: 'codex', + branch: 'agent/codex/a', + worktreePath: path.join(repoRoot, 'a'), + base: 'main', + }); + + assert.deepEqual(listAgentSessions(repoRoot), [first, second]); +}); + +test('removeAgentSession deletes sessions and reports whether anything was removed', () => { + const repoRoot = makeRepoRoot(); + createAgentSession(repoRoot, { + id: 'agent-session-4', + task: 'Remove session metadata', + agent: 'codex', + branch: 'agent/codex/remove-session', + worktreePath: path.join(repoRoot, 'remove-session'), + base: 'main', + }); + + assert.equal(fs.existsSync(sessionPath(repoRoot, 'agent-session-4')), true); + assert.equal(removeAgentSession(repoRoot, 'agent-session-4'), true); + assert.equal(readAgentSession(repoRoot, 'agent-session-4'), null); + assert.equal(removeAgentSession(repoRoot, 'agent-session-4'), false); +}); + +test('session ids cannot escape the sessions directory', () => { + const repoRoot = makeRepoRoot(); + + assert.throws( + () => createAgentSession(repoRoot, { id: '../escape' }), + /Invalid agent session id/, + ); + assert.throws( + () => readAgentSession(repoRoot, 'nested/session'), + /Invalid agent session id/, + ); +});