Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions src/agents/sessions.js
Original file line number Diff line number Diff line change
@@ -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,
};
153 changes: 153 additions & 0 deletions test/agents-sessions.test.js
Original file line number Diff line number Diff line change
@@ -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/,
);
});
Loading