Files
ai_ops/tests/session-lifecycle.test.ts

180 lines
5.8 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, readFile, writeFile, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
import { promisify } from "node:util";
import {
FileSystemSessionMetadataStore,
SessionWorktreeManager,
type SessionMetadata,
} from "../src/agents/session-lifecycle.js";
const execFileAsync = promisify(execFile);
async function git(args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, {
encoding: "utf8",
});
return stdout.trim();
}
test("session metadata store persists and updates session metadata", async () => {
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-session-store-"));
const store = new FileSystemSessionMetadataStore({ stateRoot });
const created = await store.createSession({
sessionId: "session-abc",
projectPath: resolve(stateRoot, "project"),
baseWorkspacePath: resolve(stateRoot, "worktrees", "session-abc", "base"),
});
assert.equal(created.sessionStatus, "active");
assert.equal(created.sessionId, "session-abc");
const listed = await store.listSessions();
assert.equal(listed.length, 1);
assert.equal(listed[0]?.sessionId, "session-abc");
const updated = await store.updateSession("session-abc", {
sessionStatus: "closed",
});
assert.equal(updated.sessionStatus, "closed");
const readBack = await store.readSession("session-abc");
assert.equal(readBack?.sessionStatus, "closed");
const closedWithConflicts = await store.updateSession("session-abc", {
sessionStatus: "closed_with_conflicts",
});
assert.equal(closedWithConflicts.sessionStatus, "closed_with_conflicts");
});
test("session worktree manager provisions and merges task worktrees", async () => {
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-session-worktree-"));
const projectPath = resolve(root, "project");
const worktreeRoot = resolve(root, "worktrees");
await mkdir(projectPath, { recursive: true });
await git(["init", projectPath]);
await git(["-C", projectPath, "config", "user.name", "AI Ops"]);
await git(["-C", projectPath, "config", "user.email", "ai-ops@example.local"]);
await writeFile(resolve(projectPath, "README.md"), "# project\n", "utf8");
await git(["-C", projectPath, "add", "README.md"]);
await git(["-C", projectPath, "commit", "-m", "initial commit"]);
const manager = new SessionWorktreeManager({
worktreeRoot,
baseRef: "HEAD",
});
const sessionId = "session-1";
const baseWorkspacePath = manager.resolveBaseWorkspacePath(sessionId);
await manager.initializeSessionBaseWorkspace({
sessionId,
projectPath,
baseWorkspacePath,
});
const baseStats = await stat(baseWorkspacePath);
assert.equal(baseStats.isDirectory(), true);
const taskWorktreePath = (
await manager.ensureTaskWorktree({
sessionId,
taskId: "task-1",
baseWorkspacePath,
})
).taskWorktreePath;
await writeFile(resolve(taskWorktreePath, "feature.txt"), "task output\n", "utf8");
const mergeOutcome = await manager.mergeTaskIntoBase({
taskId: "task-1",
baseWorkspacePath,
taskWorktreePath,
});
assert.equal(mergeOutcome.kind, "success");
const mergedFile = await readFile(resolve(baseWorkspacePath, "feature.txt"), "utf8");
assert.equal(mergedFile, "task output\n");
const session: SessionMetadata = {
sessionId,
projectPath,
baseWorkspacePath,
sessionStatus: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const closeOutcome = await manager.closeSession({
session,
taskWorktreePaths: [],
mergeBaseIntoProject: false,
});
assert.equal(closeOutcome.kind, "success");
await assert.rejects(() => stat(baseWorkspacePath), {
code: "ENOENT",
});
});
test("session worktree manager returns conflict outcome instead of throwing", async () => {
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-session-worktree-conflict-"));
const projectPath = resolve(root, "project");
const worktreeRoot = resolve(root, "worktrees");
await mkdir(projectPath, { recursive: true });
await git(["init", projectPath]);
await git(["-C", projectPath, "config", "user.name", "AI Ops"]);
await git(["-C", projectPath, "config", "user.email", "ai-ops@example.local"]);
await writeFile(resolve(projectPath, "README.md"), "base\n", "utf8");
await git(["-C", projectPath, "add", "README.md"]);
await git(["-C", projectPath, "commit", "-m", "initial commit"]);
const manager = new SessionWorktreeManager({
worktreeRoot,
baseRef: "HEAD",
});
const sessionId = "session-conflict-1";
const baseWorkspacePath = manager.resolveBaseWorkspacePath(sessionId);
await manager.initializeSessionBaseWorkspace({
sessionId,
projectPath,
baseWorkspacePath,
});
const taskWorktreePath = (
await manager.ensureTaskWorktree({
sessionId,
taskId: "task-conflict",
baseWorkspacePath,
})
).taskWorktreePath;
await writeFile(resolve(baseWorkspacePath, "README.md"), "base branch change\n", "utf8");
await git(["-C", baseWorkspacePath, "add", "README.md"]);
await git(["-C", baseWorkspacePath, "commit", "-m", "base update"]);
await writeFile(resolve(taskWorktreePath, "README.md"), "task branch change\n", "utf8");
const mergeOutcome = await manager.mergeTaskIntoBase({
taskId: "task-conflict",
baseWorkspacePath,
taskWorktreePath,
});
assert.equal(mergeOutcome.kind, "conflict");
if (mergeOutcome.kind !== "conflict") {
throw new Error("Expected merge conflict outcome.");
}
assert.equal(mergeOutcome.taskId, "task-conflict");
assert.equal(mergeOutcome.worktreePath, taskWorktreePath);
assert.ok(mergeOutcome.conflictFiles.includes("README.md"));
});