import test from "node:test"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdtemp, mkdir, readFile, rm, stat, writeFile } 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 { 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")); }); test("session worktree manager recreates a task worktree after stale metadata prune", async () => { const root = await mkdtemp(resolve(tmpdir(), "ai-ops-session-worktree-prune-")); 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-prune-1"; const taskId = "task-prune-1"; const baseWorkspacePath = manager.resolveBaseWorkspacePath(sessionId); await manager.initializeSessionBaseWorkspace({ sessionId, projectPath, baseWorkspacePath, }); const initialTaskWorktreePath = ( await manager.ensureTaskWorktree({ sessionId, taskId, baseWorkspacePath, }) ).taskWorktreePath; await rm(initialTaskWorktreePath, { recursive: true, force: true }); const recreatedTaskWorktreePath = ( await manager.ensureTaskWorktree({ sessionId, taskId, baseWorkspacePath, }) ).taskWorktreePath; assert.equal(recreatedTaskWorktreePath, initialTaskWorktreePath); const stats = await stat(recreatedTaskWorktreePath); assert.equal(stats.isDirectory(), true); }); test("session worktree manager applies target path sparse checkout and task working directory", async () => { const root = await mkdtemp(resolve(tmpdir(), "ai-ops-session-worktree-target-")); const projectPath = resolve(root, "project"); const worktreeRoot = resolve(root, "worktrees"); await mkdir(resolve(projectPath, "app", "src"), { recursive: true }); await mkdir(resolve(projectPath, "infra"), { 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, "app", "src", "index.ts"), "export const app = true;\n", "utf8"); await writeFile(resolve(projectPath, "infra", "notes.txt"), "infra\n", "utf8"); await git(["-C", projectPath, "add", "."]); await git(["-C", projectPath, "commit", "-m", "initial commit"]); const manager = new SessionWorktreeManager({ worktreeRoot, baseRef: "HEAD", targetPath: "app", }); const sessionId = "session-target-1"; const baseWorkspacePath = manager.resolveBaseWorkspacePath(sessionId); await manager.initializeSessionBaseWorkspace({ sessionId, projectPath, baseWorkspacePath, }); const baseWorkingDirectory = manager.resolveWorkingDirectoryForWorktree(baseWorkspacePath); assert.equal(baseWorkingDirectory, resolve(baseWorkspacePath, "app")); const baseWorkingStats = await stat(baseWorkingDirectory); assert.equal(baseWorkingStats.isDirectory(), true); await assert.rejects(() => stat(resolve(baseWorkspacePath, "infra")), { code: "ENOENT", }); const ensured = await manager.ensureTaskWorktree({ sessionId, taskId: "task-target-1", baseWorkspacePath, }); assert.equal(ensured.taskWorkingDirectory, resolve(ensured.taskWorktreePath, "app")); await writeFile(resolve(ensured.taskWorkingDirectory, "src", "feature.ts"), "export const feature = true;\n", "utf8"); const mergeOutcome = await manager.mergeTaskIntoBase({ taskId: "task-target-1", baseWorkspacePath, taskWorktreePath: ensured.taskWorktreePath, }); assert.equal(mergeOutcome.kind, "success"); const merged = await readFile(resolve(baseWorkingDirectory, "src", "feature.ts"), "utf8"); assert.equal(merged, "export const feature = true;\n"); });