diff --git a/.gitignore b/.gitignore index e5dfed62e..f3bc5833c 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ agent-orchestrator.yaml # OS-specific files .DS_Store Thumbs.db +.gstack/ diff --git a/README.md b/README.md index 4d67f1c3f..22d8f0920 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Spawn parallel AI coding agents, each in its own git worktree. Agents autonomously fix CI failures, address review comments, and open PRs — you supervise from one dashboard. [![GitHub stars](https://img.shields.io/github/stars/ComposioHQ/agent-orchestrator?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator/stargazers) +[![npm version](https://img.shields.io/npm/v/%40composio%2Fao?style=flat-square)](https://www.npmjs.com/package/@composio/ao) [![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) [![PRs merged](https://img.shields.io/badge/PRs_merged-61-brightgreen?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator/pulls?q=is%3Amerged) [![Tests](https://img.shields.io/badge/test_cases-3%2C288-blue?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator/releases/tag/metrics-v1) diff --git a/packages/cli/__tests__/commands/dashboard.test.ts b/packages/cli/__tests__/commands/dashboard.test.ts index 4971c0509..a3940890c 100644 --- a/packages/cli/__tests__/commands/dashboard.test.ts +++ b/packages/cli/__tests__/commands/dashboard.test.ts @@ -89,45 +89,83 @@ describe("findRunningDashboardPid", () => { }); }); -describe("findProcessWebDir", () => { - it("extracts cwd from lsof output", async () => { - const webDir = join(tmpDir, "web"); - mkdirSync(webDir, { recursive: true }); - writeFileSync(join(webDir, "package.json"), "{}"); +describe("isInstalledUnderNodeModules", () => { + it("returns true for a Unix node_modules path segment", async () => { + const { isInstalledUnderNodeModules } = await import("../../src/lib/dashboard-rebuild.js"); - // Simulate lsof -p -Fn output - mockExecSilent.mockResolvedValue( - `p12345\nfcwd\nn${webDir}\nftxt\nn/usr/bin/node`, - ); - - const { findProcessWebDir } = await import("../../src/lib/dashboard-rebuild.js"); - - const result = await findProcessWebDir("12345"); - expect(result).toBe(webDir); + expect(isInstalledUnderNodeModules("/usr/local/lib/node_modules/@composio/ao-web")).toBe(true); }); - it("returns null when cwd has no package.json", async () => { - const webDir = join(tmpDir, "web"); - mkdirSync(webDir, { recursive: true }); - // No package.json + it("returns true for a Windows node_modules path segment", async () => { + const { isInstalledUnderNodeModules } = await import("../../src/lib/dashboard-rebuild.js"); - mockExecSilent.mockResolvedValue( - `p12345\nfcwd\nn${webDir}\nftxt\nn/usr/bin/node`, - ); - - const { findProcessWebDir } = await import("../../src/lib/dashboard-rebuild.js"); - - const result = await findProcessWebDir("12345"); - expect(result).toBeNull(); + expect(isInstalledUnderNodeModules("C:\\Users\\me\\node_modules\\@composio\\ao-web")).toBe(true); }); - it("returns null when lsof fails", async () => { - mockExecSilent.mockResolvedValue(null); + it("returns false for source paths containing node_modules as plain text", async () => { + const { isInstalledUnderNodeModules } = await import("../../src/lib/dashboard-rebuild.js"); - const { findProcessWebDir } = await import("../../src/lib/dashboard-rebuild.js"); + expect( + isInstalledUnderNodeModules("/home/user/node_modules_backup/agent-orchestrator/packages/web"), + ).toBe(false); + }); +}); - const result = await findProcessWebDir("12345"); - expect(result).toBeNull(); +describe("assertDashboardRebuildSupported", () => { + it("passes for a source checkout", async () => { + const { assertDashboardRebuildSupported } = await import("../../src/lib/dashboard-rebuild.js"); + + expect(() => + assertDashboardRebuildSupported("/home/user/agent-orchestrator/packages/web"), + ).not.toThrow(); + }); + + it("throws for an npm-installed package path", async () => { + const { assertDashboardRebuildSupported } = await import("../../src/lib/dashboard-rebuild.js"); + + expect(() => + assertDashboardRebuildSupported("/usr/local/lib/node_modules/@composio/ao-web"), + ).toThrow("Dashboard rebuild is only available from a source checkout"); + }); +}); + +describe("rebuildDashboardProductionArtifacts", () => { + it("cleans .next and runs pnpm build on success", async () => { + const webDir = join(tmpDir, "packages", "web"); + mkdirSync(webDir, { recursive: true }); + mkdirSync(join(webDir, ".next"), { recursive: true }); + + mockExec.mockResolvedValue({ stdout: "", stderr: "" }); + + const { rebuildDashboardProductionArtifacts } = await import("../../src/lib/dashboard-rebuild.js"); + + await rebuildDashboardProductionArtifacts(webDir); + + // .next should be cleaned + expect(existsSync(join(webDir, ".next"))).toBe(false); + // pnpm build should be called from workspace root (../../ relative to webDir) + expect(mockExec).toHaveBeenCalledWith("pnpm", ["build"], { cwd: tmpDir }); + }); + + it("throws when pnpm build fails", async () => { + const webDir = join(tmpDir, "packages", "web"); + mkdirSync(webDir, { recursive: true }); + + mockExec.mockRejectedValue(new Error("build failed")); + + const { rebuildDashboardProductionArtifacts } = await import("../../src/lib/dashboard-rebuild.js"); + + await expect(rebuildDashboardProductionArtifacts(webDir)).rejects.toThrow( + "Failed to rebuild dashboard production artifacts", + ); + }); + + it("throws when called from an npm-installed path", async () => { + const { rebuildDashboardProductionArtifacts } = await import("../../src/lib/dashboard-rebuild.js"); + + await expect( + rebuildDashboardProductionArtifacts("/usr/local/lib/node_modules/@composio/ao-web"), + ).rejects.toThrow("Dashboard rebuild is only available from a source checkout"); }); }); diff --git a/packages/cli/__tests__/commands/session.test.ts b/packages/cli/__tests__/commands/session.test.ts index 8909add8d..86ad95582 100644 --- a/packages/cli/__tests__/commands/session.test.ts +++ b/packages/cli/__tests__/commands/session.test.ts @@ -354,7 +354,7 @@ describe("session kill", () => { const output = consoleSpy.mock.calls.map((c) => String(c[0])).join("\n"); expect(output).toContain("Session app-1 killed."); - expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: true }); + expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: false }); }); it("calls session manager kill with the session name", async () => { @@ -364,7 +364,7 @@ describe("session kill", () => { await program.parseAsync(["node", "test", "session", "kill", "app-1"]); - expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: true }); + expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: false }); }); it("passes purge flag for OpenCode cleanup", async () => { @@ -374,22 +374,6 @@ describe("session kill", () => { expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: true }); }); - - it("passes keep-session flag to prevent OpenCode purge", async () => { - mockSessionManager.kill.mockResolvedValue(undefined); - - await program.parseAsync(["node", "test", "session", "kill", "app-1", "--keep-session"]); - - expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: false }); - }); - - it("defaults to purge OpenCode session when neither flag is set", async () => { - mockSessionManager.kill.mockResolvedValue(undefined); - - await program.parseAsync(["node", "test", "session", "kill", "app-1"]); - - expect(mockSessionManager.kill).toHaveBeenCalledWith("app-1", { purgeOpenCode: true }); - }); }); describe("session attach", () => { diff --git a/packages/cli/__tests__/commands/setup.test.ts b/packages/cli/__tests__/commands/setup.test.ts index ef399e226..70b2c040d 100644 --- a/packages/cli/__tests__/commands/setup.test.ts +++ b/packages/cli/__tests__/commands/setup.test.ts @@ -56,9 +56,7 @@ import { registerSetup } from "../../src/commands/setup.js"; const MINIMAL_CONFIG = ` port: 3000 -defaults: - notifiers: - - desktop +defaults: {} projects: my-app: name: my-app @@ -70,7 +68,6 @@ const CONFIG_WITH_OPENCLAW = ` port: 3000 defaults: notifiers: - - desktop - openclaw notifiers: openclaw: @@ -235,8 +232,36 @@ describe("setup openclaw command", () => { const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; expect(writtenYaml).toContain("openclaw"); - // Should have both desktop and openclaw in defaults.notifiers - expect(writtenYaml).toContain("desktop"); + expect(writtenYaml).not.toContain("desktop"); + }); + + it("does not add desktop to defaults.notifiers when initializing notifiers", async () => { + // Config with no notifiers at all + mockReadFileSync.mockReturnValue(` +port: 3000 +defaults: {} +projects: + my-app: + name: my-app +`); + const program = createProgram(); + + await program.parseAsync([ + "node", + "test", + "setup", + "openclaw", + "--url", + "http://127.0.0.1:18789/hooks/agent", + "--token", + "tok", + "--non-interactive", + ]); + + const writtenYaml = mockWriteFileSync.mock.calls[0][1] as string; + const parsed = parseYaml(writtenYaml) as { defaults?: { notifiers?: string[] } }; + expect(parsed.defaults?.notifiers).not.toContain("desktop"); + expect(parsed.defaults?.notifiers).toContain("openclaw"); }); it("does not duplicate openclaw in defaults.notifiers", async () => { diff --git a/packages/cli/__tests__/commands/start.test.ts b/packages/cli/__tests__/commands/start.test.ts index a869dc56a..161d0d67d 100644 --- a/packages/cli/__tests__/commands/start.test.ts +++ b/packages/cli/__tests__/commands/start.test.ts @@ -6,9 +6,10 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { parse as parseYaml } from "yaml"; import type { SessionManager } from "@composio/ao-core"; // --------------------------------------------------------------------------- @@ -48,6 +49,10 @@ const { mockDetectOpenClawInstallation } = vi.hoisted(() => ({ mockDetectOpenClawInstallation: vi.fn(), })); +const { mockProcessCwd } = vi.hoisted(() => ({ + mockProcessCwd: vi.fn<[], string>(), +})); + vi.mock("../../src/lib/shell.js", () => ({ tmux: vi.fn(), exec: mockExec, @@ -64,6 +69,7 @@ vi.mock("ora", () => ({ stop: vi.fn().mockReturnThis(), succeed: vi.fn().mockReturnThis(), fail: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), text: "", }), })); @@ -107,9 +113,8 @@ vi.mock("../../src/lib/web-dir.js", () => ({ })); vi.mock("../../src/lib/dashboard-rebuild.js", () => ({ - cleanNextCache: vi.fn(), findRunningDashboardPid: vi.fn().mockResolvedValue(null), - findProcessWebDir: vi.fn().mockResolvedValue(null), + rebuildDashboardProductionArtifacts: vi.fn().mockResolvedValue(undefined), waitForPortFree: vi.fn(), })); @@ -148,7 +153,7 @@ vi.mock("../../src/lib/detect-agent.js", () => ({ })); vi.mock("../../src/lib/project-detection.js", () => ({ - detectProjectType: vi.fn().mockReturnValue(null), + detectProjectType: vi.fn().mockReturnValue({ languages: [], frameworks: [] }), generateRulesFromTemplates: vi.fn().mockReturnValue(null), formatProjectTypeForDisplay: vi.fn().mockReturnValue(""), })); @@ -167,12 +172,26 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); +// Mock node:process so that `import { cwd } from "node:process"` in start.ts +// can be intercepted per-test via mockProcessCwd. +vi.mock("node:process", async (importOriginal) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importOriginal(); + return { + ...actual, + cwd: () => { + const override = mockProcessCwd(); + return override ?? actual.cwd(); + }, + }; +}); + // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- import { Command } from "commander"; -import { registerStart, registerStop } from "../../src/commands/start.js"; +import { registerStart, registerStop, createConfigOnly } from "../../src/commands/start.js"; let tmpDir: string; let program: Command; @@ -196,6 +215,8 @@ beforeEach(() => { const fakeChild = { on: vi.fn(), kill: vi.fn(), emit: vi.fn(), stdout: null, stderr: null }; mockSpawn.mockReturnValue(fakeChild); + mockSessionManager.list.mockReset(); + mockSessionManager.list.mockResolvedValue([]); mockSessionManager.get.mockReset(); mockSessionManager.spawnOrchestrator.mockReset(); mockSessionManager.kill.mockReset(); @@ -230,6 +251,7 @@ beforeEach(() => { probe: { reachable: false, error: "not running" }, }); mockSpawn.mockClear(); + mockProcessCwd.mockReset(); }); afterEach(() => { @@ -250,7 +272,7 @@ function makeConfig(projects: Record>): Record { expect(output).not.toContain("reused existing session"); }, ); + + it("handles existing orchestrator sessions by auto-selecting when --no-dashboard", async () => { + mockConfigRef.current = makeConfig({ "my-app": makeProject() }); + + // Return an existing orchestrator session + mockSessionManager.list.mockResolvedValue([ + { + id: "app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + lastActivityAt: new Date(), + runtimeHandle: { id: "tmux-session-existing" }, + }, + ]); + + await program.parseAsync(["node", "test", "start", "--no-dashboard"]); + + const output = getLoggedOutput(); + // When --no-dashboard is used, auto-selects the most recent orchestrator + // and shows the tmux attach command (not the dashboard selection message) + expect(output).toContain("tmux attach -t tmux-session-existing"); + expect(output).not.toContain("existing sessions found — select one in the dashboard"); + + // Should NOT spawn a new orchestrator when existing ones exist + expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled(); + }); + + it("navigates directly to session page when one existing orchestrator found with dashboard enabled", async () => { + mockConfigRef.current = makeConfig({ "my-app": makeProject() }); + + // Mock findWebDir + const { findWebDir } = await import("../../src/lib/web-dir.js"); + vi.mocked(findWebDir).mockReturnValue(tmpDir); + writeFileSync(join(tmpDir, "package.json"), "{}"); + + const fakeDashboard = { + on: vi.fn(), + kill: vi.fn(), + emit: vi.fn(), + }; + mockSpawn.mockReturnValue(fakeDashboard); + + // Return a single existing orchestrator session + mockSessionManager.list.mockResolvedValue([ + { + id: "app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + lastActivityAt: new Date(), + runtimeHandle: { id: "tmux-session-existing" }, + }, + ]); + + await program.parseAsync(["node", "test", "start"]); + + const output = getLoggedOutput(); + // With one orchestrator, goes directly to the session page — shows tmux attach, no selection message + expect(output).toContain("tmux attach -t tmux-session-existing"); + expect(output).not.toContain("multiple sessions found"); + expect(output).not.toContain("select one in the dashboard"); + + // Should NOT spawn a new orchestrator when existing one exists + expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled(); + }); + + it("opens orchestrator selection page when multiple existing orchestrators found with dashboard enabled", async () => { + mockConfigRef.current = makeConfig({ "my-app": makeProject() }); + + // Mock findWebDir + const { findWebDir } = await import("../../src/lib/web-dir.js"); + vi.mocked(findWebDir).mockReturnValue(tmpDir); + writeFileSync(join(tmpDir, "package.json"), "{}"); + + const fakeDashboard = { + on: vi.fn(), + kill: vi.fn(), + emit: vi.fn(), + }; + mockSpawn.mockReturnValue(fakeDashboard); + + const now = new Date(); + // Return two existing orchestrator sessions + mockSessionManager.list.mockResolvedValue([ + { + id: "app-orchestrator-1", + projectId: "my-app", + metadata: { role: "orchestrator" }, + lastActivityAt: new Date(now.getTime() - 1000), + runtimeHandle: { id: "tmux-session-1" }, + }, + { + id: "app-orchestrator-2", + projectId: "my-app", + metadata: { role: "orchestrator" }, + lastActivityAt: now, + runtimeHandle: { id: "tmux-session-2" }, + }, + ]); + + await program.parseAsync(["node", "test", "start"]); + + const output = getLoggedOutput(); + // With multiple orchestrators, shows selection message so user can choose or spawn a new one + expect(output).toContain("multiple sessions found — select one in the dashboard"); + + // Should NOT spawn a new orchestrator when existing ones exist + expect(mockSessionManager.spawnOrchestrator).not.toHaveBeenCalled(); + }); + + it("fails and cleans up dashboard when orchestrator setup throws", async () => { + mockConfigRef.current = makeConfig({ "my-app": makeProject() }); + + // Mock findWebDir + const { findWebDir } = await import("../../src/lib/web-dir.js"); + vi.mocked(findWebDir).mockReturnValue(tmpDir); + writeFileSync(join(tmpDir, "package.json"), "{}"); + + const fakeDashboard = { + on: vi.fn(), + kill: vi.fn(), + emit: vi.fn(), + }; + mockSpawn.mockReturnValue(fakeDashboard); + + mockSessionManager.list.mockResolvedValue([]); + mockSessionManager.spawnOrchestrator.mockRejectedValue(new Error("Spawn failed")); + + await expect(program.parseAsync(["node", "test", "start"])).rejects.toThrow("process.exit(1)"); + + const errors = vi + .mocked(console.error) + .mock.calls.map((c) => c.join(" ")) + .join("\n"); + expect(errors).toContain("Failed to setup orchestrator: Spawn failed"); + + // Should have killed the dashboard + expect(fakeDashboard.kill).toHaveBeenCalled(); + }); }); // --------------------------------------------------------------------------- @@ -875,7 +1035,7 @@ describe("stop command", () => { await program.parseAsync(["node", "test", "stop"]); expect(mockSessionManager.kill).toHaveBeenCalledWith("app-orchestrator", { - purgeOpenCode: true, + purgeOpenCode: false, }); expect(mockStopLifecycleWorker).toHaveBeenCalledWith( expect.objectContaining({ configPath: expect.any(String) }), @@ -907,30 +1067,6 @@ describe("stop command", () => { expect(output).toContain("is not running"); }); - it("defaults to purge OpenCode session when stopping orchestrator", async () => { - mockConfigRef.current = makeConfig({ "my-app": makeProject() }); - mockSessionManager.get.mockResolvedValue({ id: "app-orchestrator", status: "running" }); - mockSessionManager.kill.mockResolvedValue(undefined); - - await program.parseAsync(["node", "test", "stop"]); - - expect(mockSessionManager.kill).toHaveBeenCalledWith("app-orchestrator", { - purgeOpenCode: true, - }); - }); - - it("keeps OpenCode session when stopping with --keep-session", async () => { - mockConfigRef.current = makeConfig({ "my-app": makeProject() }); - mockSessionManager.get.mockResolvedValue({ id: "app-orchestrator", status: "running" }); - mockSessionManager.kill.mockResolvedValue(undefined); - - await program.parseAsync(["node", "test", "stop", "--keep-session"]); - - expect(mockSessionManager.kill).toHaveBeenCalledWith("app-orchestrator", { - purgeOpenCode: false, - }); - }); - it("passes purge flag when stopping orchestrator with --purge-session", async () => { mockConfigRef.current = makeConfig({ "my-app": makeProject() }); mockSessionManager.get.mockResolvedValue({ id: "app-orchestrator", status: "running" }); @@ -943,3 +1079,48 @@ describe("stop command", () => { }); }); }); + +// --------------------------------------------------------------------------- +// autoCreateConfig — config generation defaults +// --------------------------------------------------------------------------- + +describe("start command — autoCreateConfig", () => { + it("generates config with empty notifiers array (no desktop notifier added by default)", async () => { + const { detectEnvironment } = await import("../../src/lib/detect-env.js"); + vi.mocked(detectEnvironment).mockResolvedValue({ + isGitRepo: false, + gitRemote: null, + ownerRepo: null, + currentBranch: null, + defaultBranch: null, + hasTmux: true, + hasGh: false, + ghAuthed: false, + hasLinearKey: false, + hasSlackWebhook: false, + }); + + const { detectProjectType } = await import("../../src/lib/project-detection.js"); + vi.mocked(detectProjectType).mockReturnValue({ languages: [], frameworks: [] }); + + const { detectAvailableAgents, detectAgentRuntime } = await import("../../src/lib/detect-agent.js"); + vi.mocked(detectAvailableAgents).mockResolvedValue([]); + vi.mocked(detectAgentRuntime).mockResolvedValue("claude-code"); + + const { findFreePort } = await import("../../src/lib/web-dir.js"); + vi.mocked(findFreePort).mockResolvedValue(3000); + + // start.ts uses `import { cwd } from "node:process"` which is intercepted + // by the node:process mock defined at the top of this file. + mockProcessCwd.mockReturnValue(tmpDir); + + await createConfigOnly(); + + const configPath = join(tmpDir, "agent-orchestrator.yaml"); + expect(existsSync(configPath)).toBe(true); + + const content = readFileSync(configPath, "utf-8"); + const parsed = parseYaml(content) as { defaults?: { notifiers?: unknown[] } }; + expect(parsed.defaults?.notifiers).toEqual([]); + }); +}); diff --git a/packages/cli/__tests__/index.test.ts b/packages/cli/__tests__/index.test.ts new file mode 100644 index 000000000..01f781616 --- /dev/null +++ b/packages/cli/__tests__/index.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from "vitest"; + +const parse = vi.fn(); + +vi.mock("../src/program.js", () => ({ + createProgram: () => ({ parse }), +})); + +describe("cli entrypoint", () => { + it("parses the created program", async () => { + await import("../src/index.js"); + expect(parse).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cli/__tests__/lib/preflight.test.ts b/packages/cli/__tests__/lib/preflight.test.ts index dc7536ce4..42a64770f 100644 --- a/packages/cli/__tests__/lib/preflight.test.ts +++ b/packages/cli/__tests__/lib/preflight.test.ts @@ -18,6 +18,11 @@ vi.mock("node:fs", () => ({ existsSync: mockExistsSync, })); +vi.mock("../../src/lib/dashboard-rebuild.js", () => ({ + isInstalledUnderNodeModules: (path: string) => + path.includes("/node_modules/") || path.includes("\\node_modules\\"), +})); + import { preflight } from "../../src/lib/preflight.js"; beforeEach(() => { @@ -58,9 +63,12 @@ describe("preflight.checkBuilt", () => { // /web/node_modules/@composio/ao-core — miss // /node_modules/@composio/ao-core — hit // /node_modules/@composio/ao-core/dist/index.js — exists + // /web/.next/BUILD_ID and /web/dist-server/start-all.js — exist mockExistsSync .mockReturnValueOnce(false) .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) .mockReturnValueOnce(true); await expect(preflight.checkBuilt("/web")).resolves.toBeUndefined(); }); @@ -88,6 +96,38 @@ describe("preflight.checkBuilt", () => { "Packages not built", ); }); + + it("throws when web production artifacts are missing", async () => { + // findPackageUp finds ao-core, dist/index.js exists, but .next/BUILD_ID missing + mockExistsSync + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + await expect(preflight.checkBuilt("/web")).rejects.toThrow( + "Packages not built", + ); + }); + + it("throws npm hint when web artifacts missing in global install", async () => { + // ao-core found at first check, dist exists, but .next/BUILD_ID missing + mockExistsSync + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + await expect( + preflight.checkBuilt("/usr/local/lib/node_modules/@composio/ao-web"), + ).rejects.toThrow("npm install -g @composio/ao@latest"); + }); + + it("throws npm hint when ao-core dist is missing in global install", async () => { + // ao-core found, but dist/index.js missing + mockExistsSync + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + await expect( + preflight.checkBuilt("/usr/local/lib/node_modules/@composio/ao-web"), + ).rejects.toThrow("npm install -g @composio/ao@latest"); + }); }); describe("preflight.checkTmux", () => { diff --git a/packages/cli/__tests__/lib/session-utils.test.ts b/packages/cli/__tests__/lib/session-utils.test.ts index 5d42dadab..a2d072736 100644 --- a/packages/cli/__tests__/lib/session-utils.test.ts +++ b/packages/cli/__tests__/lib/session-utils.test.ts @@ -146,4 +146,49 @@ describe("isOrchestratorSessionName", () => { const config = makeConfig({ "my-app": { sessionPrefix: "app" } }); expect(isOrchestratorSessionName(config, "app-12", "my-app")).toBe(false); }); + + it("matches worktree orchestrator IDs (orchestrator-N) for a known project", () => { + const config = makeConfig({ "my-app": { sessionPrefix: "app" } }); + expect(isOrchestratorSessionName(config, "app-orchestrator-1", "my-app")).toBe(true); + expect(isOrchestratorSessionName(config, "app-orchestrator-42", "my-app")).toBe(true); + }); + + it("matches worktree orchestrator IDs without an explicit project", () => { + const config = makeConfig({ "my-app": { sessionPrefix: "app" } }); + expect(isOrchestratorSessionName(config, "app-orchestrator-1")).toBe(true); + }); + + it("does not false-positive on a worker when prefix ends with -orchestrator", () => { + const config = makeConfig({ "my-app": { sessionPrefix: "my-orchestrator" } }); + // my-orchestrator-1 is a worker session, not a worktree orchestrator + expect(isOrchestratorSessionName(config, "my-orchestrator-1", "my-app")).toBe(false); + // my-orchestrator-orchestrator is the canonical orchestrator + expect(isOrchestratorSessionName(config, "my-orchestrator-orchestrator", "my-app")).toBe(true); + }); + + it("does not cross-project false-positive when one prefix is another's {prefix}-orchestrator", () => { + // project A prefix "app", project B prefix "app-orchestrator" + // "app-orchestrator-1" is a worker of B, NOT an orchestrator of A + const config = makeConfig({ + "project-a": { sessionPrefix: "app" }, + "project-b": { sessionPrefix: "app-orchestrator" }, + }); + expect(isOrchestratorSessionName(config, "app-orchestrator-1")).toBe(false); + // "app-orchestrator-orchestrator-1" IS an orchestrator of B + expect(isOrchestratorSessionName(config, "app-orchestrator-orchestrator-1")).toBe(true); + }); + + it("does not cross-project false-positive when projectId is provided", () => { + // project A prefix "app", project B prefix "app-orchestrator" + // "app-orchestrator-1" is a worker of B — must not be classified as orchestrator of A + // even when called with projectId="project-a" + const config = makeConfig({ + "project-a": { sessionPrefix: "app" }, + "project-b": { sessionPrefix: "app-orchestrator" }, + }); + expect(isOrchestratorSessionName(config, "app-orchestrator-1", "project-a")).toBe(false); + // The canonical orchestrator of A is still recognized + expect(isOrchestratorSessionName(config, "app-orchestrator", "project-a")).toBe(true); + expect(isOrchestratorSessionName(config, "app-orchestrator-2", "project-a")).toBe(false); + }); }); diff --git a/packages/cli/__tests__/options/version.test.ts b/packages/cli/__tests__/options/version.test.ts new file mode 100644 index 000000000..506c1494e --- /dev/null +++ b/packages/cli/__tests__/options/version.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import packageJson from "../../package.json" with { type: "json" }; +import { getCliVersion } from "../../src/options/version.js"; + +describe("getCliVersion", () => { + it("matches the CLI package version", () => { + expect(getCliVersion()).toBe(packageJson.version); + }); +}); diff --git a/packages/cli/__tests__/program.test.ts b/packages/cli/__tests__/program.test.ts new file mode 100644 index 000000000..656ac51be --- /dev/null +++ b/packages/cli/__tests__/program.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import packageJson from "../package.json" with { type: "json" }; +import { createProgram } from "../src/program.js"; + +describe("createProgram", () => { + it("uses the CLI package version", () => { + expect(createProgram().version()).toBe(packageJson.version); + }); +}); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 8f494c77d..cb32cabce 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -1,11 +1,16 @@ import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; import { resolve } from "node:path"; import chalk from "chalk"; import type { Command } from "commander"; import { loadConfig } from "@composio/ao-core"; import { findWebDir, buildDashboardEnv, waitForPortAndOpen } from "../lib/web-dir.js"; -import { cleanNextCache, findRunningDashboardPid, findProcessWebDir, waitForPortFree } from "../lib/dashboard-rebuild.js"; +import { + findRunningDashboardPid, + isInstalledUnderNodeModules, + rebuildDashboardProductionArtifacts, + waitForPortFree, +} from "../lib/dashboard-rebuild.js"; +import { preflight } from "../lib/preflight.js"; export function registerDashboard(program: Command): void { program @@ -14,6 +19,7 @@ export function registerDashboard(program: Command): void { .option("-p, --port ", "Port to listen on") .option("--no-open", "Don't open browser automatically") .option("--rebuild", "Clean stale build artifacts and rebuild before starting") + /* c8 ignore start -- process-spawning startup code, tested via integration/onboarding */ .action(async (opts: { port?: string; open?: boolean; rebuild?: boolean }) => { const config = loadConfig(); const port = opts.port ? parseInt(opts.port, 10) : (config.port ?? 3000); @@ -28,11 +34,9 @@ export function registerDashboard(program: Command): void { if (opts.rebuild) { // Check if a dashboard is already running on this port. const runningPid = await findRunningDashboardPid(port); - const runningWebDir = runningPid ? await findProcessWebDir(runningPid) : null; - const targetWebDir = runningWebDir ?? localWebDir; if (runningPid) { - // Kill the running server, clean .next, then start fresh below. + // Stop the running server before rebuilding or restarting below. console.log( chalk.dim(`Stopping dashboard (PID ${runningPid}) on port ${port}...`), ); @@ -45,8 +49,10 @@ export function registerDashboard(program: Command): void { await waitForPortFree(port, 5000); } - await cleanNextCache(targetWebDir); + await rebuildDashboardProductionArtifacts(localWebDir); // Fall through to start the dashboard on this port. + } else { + await preflight.checkBuilt(localWebDir); } const webDir = localWebDir; @@ -60,21 +66,12 @@ export function registerDashboard(program: Command): void { config.directTerminalPort, ); - // In dev mode (monorepo), use `pnpm run dev` which starts Next.js AND - // the terminal WebSocket servers via concurrently. Without the WS servers, - // the live terminal in the dashboard won't work. - const isDevMode = existsSync(resolve(webDir, "server")); - const child = isDevMode - ? spawn("pnpm", ["run", "dev"], { - cwd: webDir, - stdio: ["inherit", "inherit", "pipe"], - env, - }) - : spawn("npx", ["next", "dev", "-p", String(port)], { - cwd: webDir, - stdio: ["inherit", "inherit", "pipe"], - env, - }); + const startScript = resolve(webDir, "dist-server", "start-all.js"); + const child = spawn("node", [startScript], { + cwd: webDir, + stdio: ["inherit", "inherit", "pipe"], + env, + }); const stderrChunks: string[] = []; @@ -108,10 +105,13 @@ export function registerDashboard(program: Command): void { if (code !== 0 && code !== null && !opts.rebuild) { const stderr = stderrChunks.join(""); if (looksLikeStaleBuild(stderr)) { + const recoveryCommand = isInstalledUnderNodeModules(webDir) + ? "npm install -g @composio/ao@latest" + : "ao dashboard --rebuild"; console.error( chalk.yellow( "\nThis looks like a stale build cache issue. Try:\n\n" + - ` ${chalk.cyan("ao dashboard --rebuild")}\n`, + ` ${chalk.cyan(recoveryCommand)}\n`, ), ); } @@ -120,6 +120,7 @@ export function registerDashboard(program: Command): void { process.exit(code ?? 0); }); }); + /* c8 ignore stop */ } /** diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index d0e7fcc07..9edd697b9 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -343,7 +343,12 @@ async function sendTestNotifications( const targets = new Map(); for (const [name, notifierConfig] of configuredNotifiers) { - targets.set(notifierConfig.plugin, { label: name, pluginName: notifierConfig.plugin }); + if (notifierConfig.plugin) { + targets.set(notifierConfig.plugin, { label: name, pluginName: notifierConfig.plugin }); + } else { + // External plugin without explicit plugin name - manifest.name not yet resolved + warn(`${name}: notifier plugin name not resolved (external plugin may not be loaded yet)`); + } } for (const name of activeNotifierNames) { diff --git a/packages/cli/src/commands/session.ts b/packages/cli/src/commands/session.ts index 12e486216..ca1df02e8 100644 --- a/packages/cli/src/commands/session.ts +++ b/packages/cli/src/commands/session.ts @@ -125,23 +125,19 @@ export function registerSession(program: Command): void { .command("kill") .description("Kill a session and remove its worktree") .argument("", "Session name to kill") - .option("--keep-session", "Keep mapped OpenCode session after kill") .option("--purge-session", "Delete mapped OpenCode session during kill") - .action( - async (sessionName: string, opts: { keepSession?: boolean; purgeSession?: boolean }) => { - const config = loadConfig(); - const sm = await getSessionManager(config); + .action(async (sessionName: string, opts: { purgeSession?: boolean }) => { + const config = loadConfig(); + const sm = await getSessionManager(config); - try { - const purgeOpenCode = opts.purgeSession === true ? true : opts.keepSession !== true; - await sm.kill(sessionName, { purgeOpenCode }); - console.log(chalk.green(`\nSession ${sessionName} killed.`)); - } catch (err) { - console.error(chalk.red(`Failed to kill session ${sessionName}: ${err}`)); - process.exit(1); - } - }, - ); + try { + await sm.kill(sessionName, { purgeOpenCode: opts.purgeSession === true }); + console.log(chalk.green(`\nSession ${sessionName} killed.`)); + } catch (err) { + console.error(chalk.red(`Failed to kill session ${sessionName}: ${err}`)); + process.exit(1); + } + }); session .command("cleanup") diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 31388df8e..96cfa6faf 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -290,7 +290,7 @@ function writeOpenClawConfig( // Add "openclaw" to defaults.notifiers if not already present if (!rawConfig.defaults) rawConfig.defaults = {}; - if (!rawConfig.defaults.notifiers) rawConfig.defaults.notifiers = ["desktop"]; + if (!rawConfig.defaults.notifiers) rawConfig.defaults.notifiers = []; if (!Array.isArray(rawConfig.defaults.notifiers)) { rawConfig.defaults.notifiers = [rawConfig.defaults.notifiers]; } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index f1c14a95a..5eb257ffd 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -28,6 +28,8 @@ import { generateConfigFromUrl, configToYaml, normalizeOrchestratorSessionStrategy, + isOrchestratorSession, + isTerminalSession, ConfigNotFoundError, type OrchestratorConfig, type ProjectConfig, @@ -46,7 +48,7 @@ import { findFreePort, MAX_PORT_SCAN, } from "../lib/web-dir.js"; -import { cleanNextCache } from "../lib/dashboard-rebuild.js"; +import { rebuildDashboardProductionArtifacts } from "../lib/dashboard-rebuild.js"; import { preflight } from "../lib/preflight.js"; import { register, unregister, isAlreadyRunning, getRunning, waitForExit } from "../lib/running-state.js"; import { isHumanCaller } from "../lib/caller-context.js"; @@ -580,7 +582,7 @@ async function autoCreateConfig(workingDir: string): Promise runtime: "tmux", agent, workspace: "worktree", - notifiers: ["desktop"], + notifiers: [], }, projects: { [projectId]: { @@ -747,22 +749,29 @@ export async function createConfigOnly(): Promise { * Start dashboard server in the background. * Returns the child process handle for cleanup. */ +/* c8 ignore start -- process-spawning startup code, tested via integration/onboarding */ async function startDashboard( port: number, webDir: string, configPath: string | null, terminalPort?: number, directTerminalPort?: number, + devMode?: boolean, ): Promise { const env = await buildDashboardEnv(port, configPath, terminalPort, directTerminalPort); - // Detect dev vs production: the `server/` source directory only exists in the - // monorepo. Published npm packages only have `dist-server/`. - const isDevMode = existsSync(resolve(webDir, "server")); + // Detect monorepo vs npm install: the `server/` source directory only exists + // in the monorepo. Published npm packages only have `dist-server/`. + const isMonorepo = existsSync(resolve(webDir, "server")); + + // In monorepo: use HMR dev server only when --dev is passed explicitly. + // Default is optimized production server for faster loading. + const useDevServer = isMonorepo && devMode === true; let child: ChildProcess; - if (isDevMode) { - // Monorepo development: use pnpm run dev (tsx, HMR, etc.) + if (useDevServer) { + // Monorepo with --dev: use pnpm run dev (tsx watch, HMR, etc.) + console.log(chalk.dim(" Mode: development (HMR enabled)")); child = spawn("pnpm", ["run", "dev"], { cwd: webDir, stdio: "inherit", @@ -770,8 +779,13 @@ async function startDashboard( env, }); } else { - // Production (installed from npm): use pre-built start-all script - child = spawn("node", [resolve(webDir, "dist-server", "start-all.js")], { + // Production: use pre-built start-all script. + if (isMonorepo) { + console.log(chalk.dim(" Mode: optimized (production bundles)")); + console.log(chalk.dim(" Tip: use --dev for hot reload when editing dashboard UI\n")); + } + const startScript = resolve(webDir, "dist-server", "start-all.js"); + child = spawn("node", [startScript], { cwd: webDir, stdio: "inherit", detached: false, @@ -780,8 +794,8 @@ async function startDashboard( } child.on("error", (err) => { - const cmd = isDevMode ? "pnpm" : "node"; - const args = isDevMode ? ["run", "dev"] : [resolve(webDir, "dist-server", "start-all.js")]; + const cmd = useDevServer ? "pnpm" : "node"; + const args = useDevServer ? ["run", "dev"] : [resolve(webDir, "dist-server", "start-all.js")]; const formatted = formatCommandError(err, { cmd, args, @@ -795,6 +809,7 @@ async function startDashboard( return child; } +/* c8 ignore stop */ /** * Ensure tmux is available — interactive install with user consent if missing. @@ -897,7 +912,7 @@ async function runStartup( config: OrchestratorConfig, projectId: string, project: ProjectConfig, - opts?: { dashboard?: boolean; orchestrator?: boolean; rebuild?: boolean }, + opts?: { dashboard?: boolean; orchestrator?: boolean; rebuild?: boolean; dev?: boolean }, ): Promise { // Ensure tmux is available before doing anything — covers all entry paths // (normal start, URL start, retry with existing config) @@ -948,10 +963,15 @@ async function runStartup( port = newPort; } const webDir = findWebDir(); // throws with install-specific guidance if not found - await preflight.checkBuilt(webDir); - + // Dev mode (HMR) only works in the monorepo where `server/` source exists. + // For npm installs, --dev is silently ignored and production server runs, + // so preflight must still verify production artifacts exist. + const isMonorepo = existsSync(resolve(webDir, "server")); + const willUseDevServer = isMonorepo && opts?.dev === true; if (opts?.rebuild) { - await cleanNextCache(webDir); + await rebuildDashboardProductionArtifacts(webDir); + } else if (!willUseDevServer) { + await preflight.checkBuilt(webDir); } spinner.start("Starting dashboard"); @@ -961,6 +981,7 @@ async function runStartup( config.configPath, config.terminalPort, config.directTerminalPort, + opts?.dev, ); spinner.succeed(`Dashboard starting on http://localhost:${port}`); console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n")); @@ -987,32 +1008,83 @@ async function runStartup( } } - // Create orchestrator session (unless --no-orchestrator or already exists) + // Create orchestrator session (unless --no-orchestrator or existing orchestrators found) let tmuxTarget = sessionId; + let hasExistingOrchestrators = false; + let selectedOrchestratorId: string | null = null; + if (opts?.orchestrator !== false) { const sm = await getSessionManager(config); + // Check for existing orchestrator sessions for this project + let allSessions; try { - spinner.start("Creating orchestrator session"); - const systemPrompt = generateOrchestratorPrompt({ config, projectId, project }); - const session = await sm.spawnOrchestrator({ projectId, systemPrompt }); - if (session.runtimeHandle?.id) { - tmuxTarget = session.runtimeHandle.id; - } - reused = - orchestratorSessionStrategy === "reuse" && - session.metadata?.["orchestratorSessionReused"] === "true"; - spinner.succeed(reused ? "Orchestrator session reused" : "Orchestrator session created"); + allSessions = await sm.list(projectId); } catch (err) { - spinner.fail("Orchestrator setup failed"); + spinner.fail("Failed to list sessions"); if (dashboardProcess) { dashboardProcess.kill(); } throw new Error( - `Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`, + `Failed to list sessions: ${err instanceof Error ? err.message : String(err)}`, { cause: err }, ); } + const allSessionPrefixes = Object.entries(config.projects).map( + ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""), + ); + const existingOrchestrators = allSessions.filter( + (s) => + isOrchestratorSession(s, project.sessionPrefix ?? projectId, allSessionPrefixes) && + !isTerminalSession(s), + ); + + if (existingOrchestrators.length > 0) { + // Existing orchestrators found — always auto-select the most recently active one. + // With a single orchestrator, navigate directly to its session page. + // With multiple orchestrators, keep the selection page so the user can choose or spawn a + // new one — the dashboard only links to one orchestrator per project, so the selection page + // is the only startup path for multi-orchestrator projects. + const sortedOrchestrators = [...existingOrchestrators].sort( + (a, b) => (b.lastActivityAt?.getTime() ?? 0) - (a.lastActivityAt?.getTime() ?? 0), + ); + const selected = sortedOrchestrators[0]; + selectedOrchestratorId = selected.id; + // Use runtimeHandle.id if available, otherwise fall back to the session ID + tmuxTarget = selected.runtimeHandle?.id ?? selected.id; + if (opts?.dashboard !== false && existingOrchestrators.length > 1) { + hasExistingOrchestrators = true; + } + spinner.succeed( + `Using existing orchestrator session: ${selected.id}` + + (existingOrchestrators.length > 1 + ? ` (${existingOrchestrators.length - 1} other session(s) available)` : ""), + ); + } else { + // No existing orchestrators — spawn a new one + try { + spinner.start("Creating orchestrator session"); + const systemPrompt = generateOrchestratorPrompt({ config, projectId, project }); + const session = await sm.spawnOrchestrator({ projectId, systemPrompt }); + selectedOrchestratorId = session.id; + if (session.runtimeHandle?.id) { + tmuxTarget = session.runtimeHandle.id; + } + reused = + orchestratorSessionStrategy === "reuse" && + session.metadata?.["orchestratorSessionReused"] === "true"; + spinner.succeed(reused ? "Orchestrator session reused" : "Orchestrator session created"); + } catch (err) { + spinner.fail("Orchestrator setup failed"); + if (dashboardProcess) { + dashboardProcess.kill(); + } + throw new Error( + `Failed to setup orchestrator: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ); + } + } } // Print summary @@ -1030,7 +1102,12 @@ async function runStartup( console.log(chalk.cyan("Lifecycle:"), lifecycleTarget); } - if (opts?.orchestrator !== false && !reused) { + if (hasExistingOrchestrators) { + console.log( + chalk.cyan("Orchestrator:"), + "multiple sessions found — select one in the dashboard", + ); + } else if (opts?.orchestrator !== false && !reused) { console.log(chalk.cyan("Orchestrator:"), `tmux attach -t ${tmuxTarget}`); } else if (reused) { console.log(chalk.cyan("Orchestrator:"), `reused existing session (${sessionId})`); @@ -1038,21 +1115,28 @@ async function runStartup( console.log(chalk.dim(`Config: ${config.configPath}`)); - // Show next step hint - const projectIds = Object.keys(config.projects); - if (projectIds.length > 0) { - console.log(chalk.bold("\nNext step:\n")); - console.log(` Spawn an agent session:`); - console.log(chalk.cyan(` ao spawn \n`)); + // Show next step hint (only if no existing orchestrators requiring selection) + if (!hasExistingOrchestrators) { + const projectIds = Object.keys(config.projects); + if (projectIds.length > 0) { + console.log(chalk.bold("\nNext step:\n")); + console.log(` Spawn an agent session:`); + console.log(chalk.cyan(` ao spawn \n`)); + } } - // Auto-open browser to orchestrator session page once the server is accepting connections. + // Auto-open browser once the server is ready. + // With a single orchestrator (or a newly created one), navigate directly to the session page. + // With multiple existing orchestrators, open the selection page so the user can choose or + // spawn a new one — the dashboard only links one orchestrator per project. // Polls the port instead of using a fixed delay — deterministic and works regardless of // how long Next.js takes to compile. AbortController cancels polling on early exit. let openAbort: AbortController | undefined; if (opts?.dashboard !== false) { openAbort = new AbortController(); - const orchestratorUrl = `http://localhost:${port}/sessions/${sessionId}`; + const orchestratorUrl = hasExistingOrchestrators + ? `http://localhost:${port}/orchestrators?project=${projectId}` + : `http://localhost:${port}/sessions/${selectedOrchestratorId ?? sessionId}`; void waitForPortAndOpen(port, orchestratorUrl, openAbort.signal); } @@ -1109,6 +1193,7 @@ export function registerStart(program: Command): void { .option("--no-dashboard", "Skip starting the dashboard server") .option("--no-orchestrator", "Skip starting the orchestrator agent") .option("--rebuild", "Clean and rebuild dashboard before starting") + .option("--dev", "Use Next.js dev server with hot reload (for dashboard UI development)") .option("--interactive", "Prompt to configure config settings") .action( async ( @@ -1117,6 +1202,7 @@ export function registerStart(program: Command): void { dashboard?: boolean; orchestrator?: boolean; rebuild?: boolean; + dev?: boolean; interactive?: boolean; }, ) => { @@ -1320,13 +1406,12 @@ export function registerStop(program: Command): void { program .command("stop [project]") .description("Stop orchestrator agent and dashboard") - .option("--keep-session", "Keep mapped OpenCode session after stopping") .option("--purge-session", "Delete mapped OpenCode session when stopping") .option("--all", "Stop all running AO instances") .action( async ( projectArg?: string, - opts: { keepSession?: boolean; purgeSession?: boolean; all?: boolean } = {}, + opts: { purgeSession?: boolean; all?: boolean } = {}, ) => { try { // Check running.json first @@ -1364,7 +1449,7 @@ export function registerStop(program: Command): void { if (existing) { const spinner = ora("Stopping orchestrator session").start(); - const purgeOpenCode = opts.purgeSession === true ? true : opts.keepSession !== true; + const purgeOpenCode = opts?.purgeSession === true; await sm.kill(sessionId, { purgeOpenCode }); spinner.succeed("Orchestrator session stopped"); } else { @@ -1405,6 +1490,5 @@ export function registerStop(program: Command): void { } process.exit(1); } - }, - ); + }); } diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 625e06370..a65499cf3 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -74,7 +74,11 @@ async function gatherSessionInfo( scm: SCM, projectConfig: ReturnType, ): Promise { - const suppressPROwnership = isOrchestratorSession(session); + const sessionPrefix = projectConfig.projects[session.projectId]?.sessionPrefix ?? session.projectId; + const allSessionPrefixes = Object.entries(projectConfig.projects).map( + ([id, p]) => p.sessionPrefix ?? id, + ); + const suppressPROwnership = isOrchestratorSession(session, sessionPrefix, allSessionPrefixes); let branch = session.branch; const status = session.status; const summary = session.metadata["summary"] ?? null; @@ -144,7 +148,7 @@ async function gatherSessionInfo( return { name: session.id, - role: isOrchestratorSession(session) ? "orchestrator" : "worker", + role: isOrchestratorSession(session, sessionPrefix, allSessionPrefixes) ? "orchestrator" : "worker", branch, status, summary, @@ -386,7 +390,7 @@ export function registerStatus(program: Command): void { let unverifiedTotal = 0; for (const projectId of projectIds) { const project: ProjectConfig | undefined = config.projects[projectId]; - if (!project?.tracker) continue; + if (!project?.tracker?.plugin) continue; const tracker = registry.get("tracker", project.tracker.plugin); if (!tracker?.listIssues) continue; try { diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index 40d6ee043..c82223a87 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -60,6 +60,10 @@ async function getTracker( const registry = createPluginRegistry(); await registry.loadFromConfig(config, importPluginModuleFromSource); + if (!project.tracker.plugin) { + console.error(chalk.red("Project tracker plugin not configured.")); + process.exit(1); + } const tracker = registry.get("tracker", project.tracker.plugin); if (!tracker) { console.error(chalk.red(`Tracker plugin "${project.tracker.plugin}" not found.`)); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8fb960663..b7791ffbc 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,53 +1,5 @@ #!/usr/bin/env node -import { Command } from "commander"; -import { registerInit } from "./commands/init.js"; -import { registerStatus } from "./commands/status.js"; -import { registerSpawn, registerBatchSpawn } from "./commands/spawn.js"; -import { registerSession } from "./commands/session.js"; -import { registerSend } from "./commands/send.js"; -import { registerReviewCheck } from "./commands/review-check.js"; -import { registerDashboard } from "./commands/dashboard.js"; -import { registerOpen } from "./commands/open.js"; -import { registerStart, registerStop } from "./commands/start.js"; -import { registerLifecycleWorker } from "./commands/lifecycle-worker.js"; -import { registerVerify } from "./commands/verify.js"; -import { registerDoctor } from "./commands/doctor.js"; -import { registerUpdate } from "./commands/update.js"; -import { registerSetup } from "./commands/setup.js"; -import { registerPlugin } from "./commands/plugin.js"; -import { getConfigInstruction } from "./lib/config-instruction.js"; +import { createProgram } from "./program.js"; -const program = new Command(); - -program - .name("ao") - .description("Agent Orchestrator — manage parallel AI coding agents") - .version("0.1.0"); - -registerInit(program); -registerStart(program); -registerStop(program); -registerStatus(program); -registerSpawn(program); -registerBatchSpawn(program); -registerSession(program); -registerSend(program); -registerReviewCheck(program); -registerDashboard(program); -registerOpen(program); -registerLifecycleWorker(program); -registerVerify(program); -registerDoctor(program); -registerUpdate(program); -registerSetup(program); -registerPlugin(program); - -program - .command("config-help") - .description("Show config schema and guide for creating agent-orchestrator.yaml") - .action(() => { - console.log(getConfigInstruction()); - }); - -program.parse(); +createProgram().parse(); diff --git a/packages/cli/src/lib/dashboard-rebuild.ts b/packages/cli/src/lib/dashboard-rebuild.ts index b6de94011..ad38d3869 100644 --- a/packages/cli/src/lib/dashboard-rebuild.ts +++ b/packages/cli/src/lib/dashboard-rebuild.ts @@ -1,12 +1,33 @@ /** - * Dashboard cache utilities — cleans stale .next artifacts and detects - * running dashboard processes. + * Dashboard cache utilities — cleans stale .next artifacts, detects + * running dashboard processes, and rebuilds production artifacts. */ import { resolve } from "node:path"; import { existsSync, rmSync } from "node:fs"; import ora from "ora"; -import { execSilent } from "./shell.js"; +import { exec, execSilent } from "./shell.js"; + +/** + * Check if the web directory is inside a node_modules tree (npm/yarn global install). + * Matches node_modules as a path segment, not just a substring. + */ +export function isInstalledUnderNodeModules(path: string): boolean { + return path.includes("/node_modules/") || path.includes("\\node_modules\\"); +} + +/** + * Guard: rebuilds are only possible from a source checkout. + * Global npm installs ship prebuilt artifacts and cannot rebuild in place. + */ +export function assertDashboardRebuildSupported(webDir: string): void { + if (isInstalledUnderNodeModules(webDir)) { + throw new Error( + "Dashboard rebuild is only available from a source checkout. " + + "Run `ao update`, or reinstall with `npm install -g @composio/ao@latest`.", + ); + } +} /** * Find the PID of a process listening on the given port. @@ -21,28 +42,6 @@ export async function findRunningDashboardPid(port: number): Promise { - const lsofDetail = await execSilent("lsof", ["-p", pid, "-Ffn"]); - if (!lsofDetail) return null; - - // lsof -Fn outputs lines like "n/path/to/cwd" — the cwd entry follows "fcwd" - const lines = lsofDetail.split("\n"); - for (let i = 0; i < lines.length; i++) { - if (lines[i] === "fcwd" && i + 1 < lines.length && lines[i + 1]?.startsWith("n/")) { - const cwd = lines[i + 1].slice(1); - if (existsSync(resolve(cwd, "package.json"))) { - return cwd; - } - } - } - - return null; -} - /** * Wait for a port to be free (no process listening). * Throws if the port is still busy after the timeout. @@ -58,9 +57,7 @@ export async function waitForPortFree(port: number, timeoutMs: number): Promise< } /** - * Clean just the .next cache directory. Use when a dev server is running — - * it will recompile on next request. Does NOT run pnpm build (which would - * create a production .next that the dev server can't use). + * Remove the .next directory before a rebuild. */ export async function cleanNextCache(webDir: string): Promise { const nextDir = resolve(webDir, ".next"); @@ -72,3 +69,27 @@ export async function cleanNextCache(webDir: string): Promise { } } +/** + * Rebuild dashboard production artifacts (Next.js build + server compilation) + * from a source checkout. Throws if called from an npm global install. + */ +export async function rebuildDashboardProductionArtifacts(webDir: string): Promise { + assertDashboardRebuildSupported(webDir); + + await cleanNextCache(webDir); + + const workspaceRoot = resolve(webDir, "../.."); + const spinner = ora("Rebuilding dashboard production artifacts").start(); + + try { + await exec("pnpm", ["build"], { cwd: workspaceRoot }); + spinner.succeed("Rebuilt dashboard production artifacts"); + } catch (error) { + spinner.fail("Dashboard rebuild failed"); + throw new Error( + "Failed to rebuild dashboard production artifacts. Run `pnpm build` and try again.", + { cause: error }, + ); + } +} + diff --git a/packages/cli/src/lib/preflight.ts b/packages/cli/src/lib/preflight.ts index 0f7721f85..e22cbadb3 100644 --- a/packages/cli/src/lib/preflight.ts +++ b/packages/cli/src/lib/preflight.ts @@ -11,6 +11,7 @@ import { existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { isPortAvailable } from "./web-dir.js"; import { exec } from "./shell.js"; +import { isInstalledUnderNodeModules } from "./dashboard-rebuild.js"; /** * Check that the dashboard port is free. @@ -32,16 +33,26 @@ async function checkPort(port: number): Promise { * installs (hoisted to a parent node_modules). */ async function checkBuilt(webDir: string): Promise { + const isNpmInstall = isInstalledUnderNodeModules(webDir); const corePkgDir = findPackageUp(webDir, "@composio", "ao-core"); if (!corePkgDir) { - const hint = webDir.includes("node_modules") + const hint = isNpmInstall ? "Run: npm install -g @composio/ao@latest" : "Run: pnpm install && pnpm build"; throw new Error(`Dependencies not installed. ${hint}`); } const coreEntry = resolve(corePkgDir, "dist", "index.js"); if (!existsSync(coreEntry)) { - const hint = webDir.includes("node_modules") + const hint = isNpmInstall + ? "Run: npm install -g @composio/ao@latest" + : "Run: pnpm build"; + throw new Error(`Packages not built. ${hint}`); + } + + const webBuildId = resolve(webDir, ".next", "BUILD_ID"); + const startAllEntry = resolve(webDir, "dist-server", "start-all.js"); + if (!existsSync(webBuildId) || !existsSync(startAllEntry)) { + const hint = isNpmInstall ? "Run: npm install -g @composio/ao@latest" : "Run: pnpm build"; throw new Error(`Packages not built. ${hint}`); diff --git a/packages/cli/src/lib/session-utils.ts b/packages/cli/src/lib/session-utils.ts index 2837afdb2..7f8e0c4b8 100644 --- a/packages/cli/src/lib/session-utils.ts +++ b/packages/cli/src/lib/session-utils.ts @@ -30,10 +30,27 @@ export function isOrchestratorSessionName( sessionName: string, projectId?: string, ): boolean { + // If sessionName is a numbered worker for any configured project, it is not an orchestrator. + // This guard runs first to prevent cross-project false positives: e.g. prefix "app" would + // match "app-orchestrator-1" as an orchestrator pattern, but if another project has prefix + // "app-orchestrator" then "app-orchestrator-1" is a worker, not an orchestrator. + for (const [id, project] of Object.entries(config.projects) as Array< + [string, OrchestratorConfig["projects"][string]] + >) { + const prefix = project.sessionPrefix || id; + if (matchesPrefix(sessionName, prefix)) return false; + } + if (projectId) { const project = config.projects[projectId]; - if (project && sessionName === `${project.sessionPrefix || projectId}-orchestrator`) { - return true; + if (project) { + const prefix = project.sessionPrefix || projectId; + if ( + sessionName === `${prefix}-orchestrator` || + new RegExp(`^${escapeRegex(prefix)}-orchestrator-\\d+$`).test(sessionName) + ) { + return true; + } } } @@ -41,7 +58,10 @@ export function isOrchestratorSessionName( [string, OrchestratorConfig["projects"][string]] >) { const prefix = project.sessionPrefix || id; - if (sessionName === `${prefix}-orchestrator`) { + if ( + sessionName === `${prefix}-orchestrator` || + new RegExp(`^${escapeRegex(prefix)}-orchestrator-\\d+$`).test(sessionName) + ) { return true; } } diff --git a/packages/cli/src/options/version.ts b/packages/cli/src/options/version.ts new file mode 100644 index 000000000..c8f49a58e --- /dev/null +++ b/packages/cli/src/options/version.ts @@ -0,0 +1,8 @@ +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +export function getCliVersion(): string { + const packageJson = require("../../package.json") as { version: string }; + return packageJson.version; +} diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts new file mode 100644 index 000000000..883ba0f85 --- /dev/null +++ b/packages/cli/src/program.ts @@ -0,0 +1,54 @@ +import { Command } from "commander"; +import { registerInit } from "./commands/init.js"; +import { registerStatus } from "./commands/status.js"; +import { registerSpawn, registerBatchSpawn } from "./commands/spawn.js"; +import { registerSession } from "./commands/session.js"; +import { registerSend } from "./commands/send.js"; +import { registerReviewCheck } from "./commands/review-check.js"; +import { registerDashboard } from "./commands/dashboard.js"; +import { registerOpen } from "./commands/open.js"; +import { registerStart, registerStop } from "./commands/start.js"; +import { registerLifecycleWorker } from "./commands/lifecycle-worker.js"; +import { registerVerify } from "./commands/verify.js"; +import { registerDoctor } from "./commands/doctor.js"; +import { registerUpdate } from "./commands/update.js"; +import { registerSetup } from "./commands/setup.js"; +import { registerPlugin } from "./commands/plugin.js"; +import { getConfigInstruction } from "./lib/config-instruction.js"; +import { getCliVersion } from "./options/version.js"; + +export function createProgram(): Command { + const program = new Command(); + + program + .name("ao") + .description("Agent Orchestrator — manage parallel AI coding agents") + .version(getCliVersion()); + + registerInit(program); + registerStart(program); + registerStop(program); + registerStatus(program); + registerSpawn(program); + registerBatchSpawn(program); + registerSession(program); + registerSend(program); + registerReviewCheck(program); + registerDashboard(program); + registerOpen(program); + registerLifecycleWorker(program); + registerVerify(program); + registerDoctor(program); + registerUpdate(program); + registerSetup(program); + registerPlugin(program); + + program + .command("config-help") + .description("Show config schema and guide for creating agent-orchestrator.yaml") + .action(() => { + console.log(getConfigInstruction()); + }); + + return program; +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 8379aa72b..a8c6fa709 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ @@ -16,4 +17,40 @@ export default defineConfig({ reporter: ["lcov"], }, }, + resolve: { + alias: [ + { + find: "@composio/ao-core/scm-webhook-utils", + replacement: resolve(__dirname, "../core/src/scm-webhook-utils.ts"), + }, + { + find: "@composio/ao-core/types", + replacement: resolve(__dirname, "../core/src/types.ts"), + }, + { + find: "@composio/ao-core", + replacement: resolve(__dirname, "../core/src/index.ts"), + }, + { + find: "@composio/ao-plugin-agent-claude-code", + replacement: resolve(__dirname, "../plugins/agent-claude-code/src/index.ts"), + }, + { + find: "@composio/ao-plugin-agent-codex", + replacement: resolve(__dirname, "../plugins/agent-codex/src/index.ts"), + }, + { + find: "@composio/ao-plugin-agent-aider", + replacement: resolve(__dirname, "../plugins/agent-aider/src/index.ts"), + }, + { + find: "@composio/ao-plugin-agent-opencode", + replacement: resolve(__dirname, "../plugins/agent-opencode/src/index.ts"), + }, + { + find: "@composio/ao-plugin-scm-github", + replacement: resolve(__dirname, "../plugins/scm-github/src/index.ts"), + }, + ], + }, }); diff --git a/packages/core/src/__tests__/config-generator.test.ts b/packages/core/src/__tests__/config-generator.test.ts index b86cd60d4..cd818af83 100644 --- a/packages/core/src/__tests__/config-generator.test.ts +++ b/packages/core/src/__tests__/config-generator.test.ts @@ -255,7 +255,7 @@ describe("generateConfigFromUrl", () => { runtime: "tmux", agent: "claude-code", workspace: "worktree", - notifiers: ["desktop"], + notifiers: [], }); // Check project config diff --git a/packages/core/src/__tests__/config-validation.test.ts b/packages/core/src/__tests__/config-validation.test.ts index 0ba53c0cc..9de7d3dfb 100644 --- a/packages/core/src/__tests__/config-validation.test.ts +++ b/packages/core/src/__tests__/config-validation.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for config validation (project uniqueness, prefix collisions). + * Unit tests for config validation (project uniqueness, prefix collisions, external plugins). */ import { describe, it, expect } from "vitest"; @@ -582,3 +582,508 @@ describe("Config Defaults", () => { expect(validated.projects.proj1.scm).toEqual({ plugin: "gitlab" }); }); }); + +describe("Config Validation - External Plugin Schema", () => { + it("accepts tracker with plugin only (built-in)", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + plugin: "github", + }, + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.projects.proj1.tracker?.plugin).toBe("github"); + }); + + it("accepts tracker with package only (external npm)", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "@acme/ao-plugin-tracker-jira", + teamId: "TEAM-123", + }, + }, + }, + }; + + const validated = validateConfig(config); + // Plugin name should be auto-generated from package + expect(validated.projects.proj1.tracker?.plugin).toBe("jira"); + expect(validated.projects.proj1.tracker?.package).toBe("@acme/ao-plugin-tracker-jira"); + }); + + it("accepts tracker with path only (local plugin)", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + path: "./plugins/my-tracker", + }, + }, + }, + }; + + const validated = validateConfig(config); + // Plugin name should be auto-generated from path + expect(validated.projects.proj1.tracker?.plugin).toBe("my-tracker"); + expect(validated.projects.proj1.tracker?.path).toBe("./plugins/my-tracker"); + }); + + it("accepts tracker with both plugin and package (explicit naming)", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + plugin: "jira", + package: "@acme/ao-plugin-tracker-jira", + }, + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.projects.proj1.tracker?.plugin).toBe("jira"); + expect(validated.projects.proj1.tracker?.package).toBe("@acme/ao-plugin-tracker-jira"); + }); + + it("rejects tracker with neither plugin nor package/path", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + teamId: "TEAM-123", + }, + }, + }, + }; + + expect(() => validateConfig(config)).toThrow(/plugin.*package.*path/i); + }); + + it("rejects tracker with both package and path", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "@acme/ao-plugin-tracker-jira", + path: "./plugins/my-tracker", + }, + }, + }, + }; + + expect(() => validateConfig(config)).toThrow(/cannot have both/i); + }); + + it("accepts scm with package only", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + scm: { + package: "@acme/ao-plugin-scm-bitbucket", + }, + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.projects.proj1.scm?.plugin).toBe("bitbucket"); + }); + + it("accepts notifier with package only", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + }, + }, + notifiers: { + teams: { + package: "@acme/ao-plugin-notifier-teams", + webhookUrl: "https://teams.webhook.url", + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.notifiers["teams"]?.plugin).toBe("teams"); + expect(validated.notifiers["teams"]?.package).toBe("@acme/ao-plugin-notifier-teams"); + }); + + it("preserves plugin-specific config alongside package", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "@acme/ao-plugin-tracker-jira", + host: "https://jira.company.com", + teamId: "TEAM-123", + }, + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.projects.proj1.tracker?.host).toBe("https://jira.company.com"); + expect(validated.projects.proj1.tracker?.teamId).toBe("TEAM-123"); + }); +}); + +describe("collectExternalPluginConfigs", () => { + // Note: validateConfig() internally calls collectExternalPluginConfigs() and stores + // the results in config._externalPluginEntries. We test this by checking the stored entries. + + it("collects tracker with explicit plugin (validates manifest.name)", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + plugin: "jira", + package: "@acme/ao-plugin-tracker-jira", + }, + }, + }, + }); + + // Check entries stored by validateConfig + const entries = config._externalPluginEntries ?? []; + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + source: "projects.proj1.tracker", + location: { kind: "project", projectId: "proj1", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + expectedPluginName: "jira", // User explicitly specified plugin - will be validated + }); + }); + + it("collects scm with path (no explicit plugin - infers from manifest)", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + scm: { + path: "./plugins/my-scm", + }, + }, + }, + }); + + // Check entries stored by validateConfig + const entries = config._externalPluginEntries ?? []; + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + source: "projects.proj1.scm", + location: { kind: "project", projectId: "proj1", configType: "scm" }, + slot: "scm", + path: "./plugins/my-scm", + }); + // expectedPluginName should be undefined when plugin is not explicitly specified + // This allows any manifest.name to be accepted + expect(entries[0].expectedPluginName).toBeUndefined(); + }); + + it("collects notifier with package (no explicit plugin - infers from manifest)", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + }, + }, + notifiers: { + teams: { + package: "@acme/ao-plugin-notifier-teams", + }, + }, + }); + + // Check entries stored by validateConfig + const entries = config._externalPluginEntries ?? []; + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + source: "notifiers.teams", + location: { kind: "notifier", notifierId: "teams" }, + slot: "notifier", + package: "@acme/ao-plugin-notifier-teams", + }); + // expectedPluginName should be undefined when plugin is not explicitly specified + expect(entries[0].expectedPluginName).toBeUndefined(); + }); + + it("collects multiple external plugins", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "@acme/ao-plugin-tracker-jira", + }, + scm: { + path: "./plugins/my-scm", + }, + }, + }, + notifiers: { + teams: { + package: "@acme/ao-plugin-notifier-teams", + }, + }, + }); + + // Check entries stored by validateConfig + const entries = config._externalPluginEntries ?? []; + expect(entries).toHaveLength(3); + }); + + it("ignores built-in plugins (plugin only)", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + plugin: "github", + }, + scm: { + plugin: "github", + }, + }, + }, + }); + + // No external plugins when only plugin name is specified (no package/path) + const entries = config._externalPluginEntries ?? []; + expect(entries).toHaveLength(0); + }); + + it("auto-generates plugins array entries from external plugin configs", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "@acme/ao-plugin-tracker-jira", + }, + }, + }, + }); + + // plugins array should be auto-populated + expect(config.plugins).toBeDefined(); + expect(config.plugins).toContainEqual( + expect.objectContaining({ + source: "npm", + package: "@acme/ao-plugin-tracker-jira", + enabled: true, + }), + ); + }); + + it("stores external plugin entries on config for validation", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + plugin: "jira", + package: "@acme/ao-plugin-tracker-jira", + }, + }, + }, + }); + + expect(config._externalPluginEntries).toBeDefined(); + expect(config._externalPluginEntries).toHaveLength(1); + expect(config._externalPluginEntries?.[0]).toMatchObject({ + source: "projects.proj1.tracker", + expectedPluginName: "jira", + }); + }); +}); + +describe("External Plugin Name Generation", () => { + it("extracts plugin name from scoped npm package", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "@acme/ao-plugin-tracker-jira", + }, + }, + }, + }); + + expect(config.projects.proj1.tracker?.plugin).toBe("jira"); + }); + + it("extracts plugin name from unscoped npm package", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "ao-plugin-tracker-jira", + }, + }, + }, + }); + + expect(config.projects.proj1.tracker?.plugin).toBe("jira"); + }); + + it("extracts plugin name from local path", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + path: "./plugins/my-custom-tracker", + }, + }, + }, + }); + + expect(config.projects.proj1.tracker?.plugin).toBe("my-custom-tracker"); + }); + + it("extracts plugin name from absolute path", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + path: "/home/user/plugins/custom-tracker", + }, + }, + }, + }); + + expect(config.projects.proj1.tracker?.plugin).toBe("custom-tracker"); + }); + + it("does not override explicit plugin name", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + plugin: "my-custom-name", + package: "@acme/ao-plugin-tracker-jira", + }, + }, + }, + }); + + expect(config.projects.proj1.tracker?.plugin).toBe("my-custom-name"); + }); + + it("handles local path without slashes correctly", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + path: "my-tracker", + }, + }, + }, + }); + + // Should use the path as-is (not split by hyphens like npm packages) + expect(config.projects.proj1.tracker?.plugin).toBe("my-tracker"); + }); + + it("preserves multi-word plugin names from scoped npm packages", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "@acme/ao-plugin-tracker-jira-cloud", + }, + }, + }, + }); + + // Should extract "jira-cloud" not just "cloud" + expect(config.projects.proj1.tracker?.plugin).toBe("jira-cloud"); + }); + + it("preserves multi-word plugin names from unscoped npm packages", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + scm: { + package: "ao-plugin-scm-azure-devops", + }, + }, + }, + }); + + // Should extract "azure-devops" not just "devops" + expect(config.projects.proj1.scm?.plugin).toBe("azure-devops"); + }); +}); diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index f07c7a59e..70af67407 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -8,6 +8,7 @@ import type { SessionManager, Agent, ActivityState, + SessionStatus, } from "../types.js"; import { createTestEnvironment, @@ -1329,6 +1330,137 @@ describe("reactions", () => { }); }); +describe("pollAll terminal status accounting", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("treats all TERMINAL_STATUSES as inactive for all-complete", async () => { + const notifier = createMockNotifier(); + const registryWithNotifier: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return plugins.runtime; + if (slot === "agent") return plugins.agent; + if (slot === "notifier" && name === "desktop") return notifier; + return null; + }), + }; + + // All sessions in various terminal states — should count as inactive + const terminalSessions = [ + makeSession({ id: "s-1", status: "killed" as SessionStatus }), + makeSession({ id: "s-2", status: "merged" as SessionStatus }), + makeSession({ id: "s-3", status: "done" as SessionStatus }), + makeSession({ id: "s-4", status: "errored" as SessionStatus }), + makeSession({ id: "s-5", status: "terminated" as SessionStatus }), + makeSession({ id: "s-6", status: "cleanup" as SessionStatus }), + ]; + + vi.mocked(mockSessionManager.list).mockResolvedValue(terminalSessions); + + // Route info-priority notifications to desktop so we can observe them + config.notificationRouting.info = ["desktop"]; + config.reactions = { + "all-complete": { auto: true, action: "notify" }, + }; + + const lm = createLifecycleManager({ + config, + registry: registryWithNotifier, + sessionManager: mockSessionManager, + }); + + lm.start(60_000); + // Let the immediate pollAll() run + await vi.advanceTimersByTimeAsync(0); + + expect(notifier.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: "reaction.triggered" }), + ); + + lm.stop(); + }); + + it("does not fire all-complete when a session is in non-terminal status like done is missing", async () => { + const notifier = createMockNotifier(); + const registryWithNotifier: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return plugins.runtime; + if (slot === "agent") return plugins.agent; + if (slot === "notifier" && name === "desktop") return notifier; + return null; + }), + }; + + // Mix of terminal and active sessions + const sessions = [ + makeSession({ id: "s-1", status: "killed" as SessionStatus }), + makeSession({ id: "s-2", status: "working" as SessionStatus }), + ]; + + vi.mocked(mockSessionManager.list).mockResolvedValue(sessions); + + config.reactions = { + "all-complete": { auto: true, action: "notify" }, + }; + + const lm = createLifecycleManager({ + config, + registry: registryWithNotifier, + sessionManager: mockSessionManager, + }); + + lm.start(60_000); + await vi.advanceTimersByTimeAsync(0); + + // all-complete should NOT have fired — "working" is still active + const allCompleteNotifications = vi.mocked(notifier.notify).mock.calls.filter( + (call: unknown[]) => { + const event = call[0] as Record | undefined; + const data = event?.data as Record | undefined; + return event?.type === "reaction.triggered" && data?.reactionKey === "all-complete"; + }, + ); + expect(allCompleteNotifications).toHaveLength(0); + + lm.stop(); + }); + + it("skips polling sessions in terminal statuses like done or errored", async () => { + // Sessions in "done" / "errored" should not be polled + const sessions = [ + makeSession({ id: "s-done", status: "done" as SessionStatus }), + makeSession({ id: "s-errored", status: "errored" as SessionStatus }), + ]; + + vi.mocked(mockSessionManager.list).mockResolvedValue(sessions); + + // If these sessions were polled, determineStatus would call runtime.isAlive. + // Reset call count and verify it's not called. + vi.mocked(plugins.runtime.isAlive).mockClear(); + + const lm = createLifecycleManager({ + config, + registry: mockRegistry, + sessionManager: mockSessionManager, + }); + + lm.start(60_000); + await vi.advanceTimersByTimeAsync(0); + + // Terminal sessions should not be polled — runtime.isAlive should not be called + expect(plugins.runtime.isAlive).not.toHaveBeenCalled(); + + lm.stop(); + }); +}); + describe("getStates", () => { it("returns copy of states map", async () => { const lm = setupCheck("app-1", { @@ -1345,3 +1477,301 @@ describe("getStates", () => { expect(lm.getStates().get("app-1")).toBe("working"); }); }); + +describe("rate limiting optimizations", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // PR with owner/repo that matches the test config's "org/my-app" + function makeMatchingPR() { + return makePR({ owner: "org", repo: "my-app" }); + } + + it("skips getMergeability() when batch enrichment has hasConflicts data", async () => { + config.reactions = { + "merge-conflicts": { + auto: true, + action: "send-to-agent", + message: "Resolve conflicts.", + }, + }; + + const pr = makeMatchingPR(); + const getMergeabilityMock = vi.fn(); + const mockSCM = createMockSCM({ + getMergeability: getMergeabilityMock, + getCISummary: vi.fn().mockResolvedValue("passing"), + enrichSessionsPRBatch: vi.fn().mockResolvedValue( + new Map([ + [ + `${pr.owner}/${pr.repo}#${pr.number}`, + { + state: "open" as const, + ciStatus: "passing" as const, + reviewDecision: "none" as const, + mergeable: false, + hasConflicts: true, + }, + ], + ]), + ), + }); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + }); + + const session = makeSession({ id: "s-1", status: "pr_open", pr }); + vi.mocked(mockSessionManager.list).mockResolvedValue([session]); + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const lm = createLifecycleManager({ config, registry, sessionManager: mockSessionManager }); + lm.start(60_000); + await vi.advanceTimersByTimeAsync(0); + lm.stop(); + + // getMergeability() should NOT be called — batch enrichment has the data + expect(getMergeabilityMock).not.toHaveBeenCalled(); + // Conflict notification should have been sent + expect(mockSessionManager.send).toHaveBeenCalledWith("s-1", "Resolve conflicts."); + }); + + it("skips getCIChecks() when batch enrichment has ciChecks data", async () => { + config.reactions = { + "ci-failed": { + auto: true, + action: "send-to-agent", + message: "CI failing.", + retries: 3, + escalateAfter: 3, + }, + }; + + const pr = makeMatchingPR(); + const getCIChecksMock = vi.fn(); + const mockSCM = createMockSCM({ + getCIChecks: getCIChecksMock, + getCISummary: vi.fn().mockResolvedValue("failing"), + enrichSessionsPRBatch: vi.fn().mockResolvedValue( + new Map([ + [ + `${pr.owner}/${pr.repo}#${pr.number}`, + { + state: "open" as const, + ciStatus: "failing" as const, + reviewDecision: "none" as const, + mergeable: false, + hasConflicts: false, + ciChecks: [ + { name: "lint", status: "failed" as const, conclusion: "FAILURE", url: "https://example.com/lint" }, + { name: "test", status: "passed" as const, conclusion: "SUCCESS" }, + ], + }, + ], + ]), + ), + }); + + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + }); + + // Start with pr_open state so that ci_failed transition happens on first poll + const session = makeSession({ id: "s-2", status: "pr_open", pr }); + vi.mocked(mockSessionManager.list).mockResolvedValue([session]); + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const lm = createLifecycleManager({ config, registry, sessionManager: mockSessionManager }); + lm.start(60_000); + // First poll: transitions to ci_failed, sends reaction message + await vi.advanceTimersByTimeAsync(0); + + vi.mocked(mockSessionManager.send).mockClear(); + + // Second poll: dispatches detailed CI failure info + await vi.advanceTimersByTimeAsync(60_000); + + // getCIChecks() should NOT be called — batch enrichment has ciChecks + expect(getCIChecksMock).not.toHaveBeenCalled(); + // Detailed message with lint check name/URL should be sent + const calls = vi.mocked(mockSessionManager.send).mock.calls; + const sentMessages = calls.map((c) => c[1] as string); + const detailMessage = sentMessages.find((m) => m.includes("lint")); + expect(detailMessage).toBeDefined(); + expect(detailMessage).toContain("https://example.com/lint"); + // Passing check should not be included + expect(detailMessage).not.toContain("test"); + + lm.stop(); + }); + + it("throttles review backlog API calls to at most once per 2 minutes", async () => { + config.reactions = { + "changes-requested": { + auto: true, + action: "send-to-agent", + message: "Handle review comments.", + }, + }; + + const getPendingMock = vi.fn().mockResolvedValue([ + { + id: "c1", + author: "reviewer", + body: "Please fix this", + path: "src/index.ts", + line: 10, + isResolved: false, + createdAt: new Date(), + url: "https://example.com/comment/1", + }, + ]); + const getAutomatedMock = vi.fn().mockResolvedValue([]); + const mockSCM = createMockSCM({ + getPendingComments: getPendingMock, + getAutomatedComments: getAutomatedMock, + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + }); + + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "pr_open", pr: makePR() }), + registry, + }); + + // First check: API called, dispatch happens + await lm.check("app-1"); + expect(getPendingMock).toHaveBeenCalledTimes(1); + vi.mocked(mockSessionManager.send).mockClear(); + getPendingMock.mockClear(); + + // Second check immediately after: throttled — API NOT called + await lm.check("app-1"); + expect(getPendingMock).not.toHaveBeenCalled(); + expect(mockSessionManager.send).not.toHaveBeenCalled(); + + // Advance time past the 2-minute throttle window + await vi.advanceTimersByTimeAsync(2 * 60 * 1000 + 100); + + // Third check: throttle expired — API called again + await lm.check("app-1"); + expect(getPendingMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("summary pinning", () => { + it("pins first quality summary when pinnedSummary not set", async () => { + const session = makeSession({ + status: "working", + agentInfo: { + summary: "Implementing authentication flow", + summaryIsFallback: false, + agentSessionId: "abc", + }, + metadata: {}, + }); + const lm = setupCheck("app-1", { session }); + + await lm.check("app-1"); + + const meta = readMetadataRaw(env.sessionsDir, "app-1"); + expect(meta!["pinnedSummary"]).toBe("Implementing authentication flow"); + }); + + it("skips pinning when summaryIsFallback is true", async () => { + const session = makeSession({ + status: "working", + agentInfo: { + summary: "You are working on issue #42...", + summaryIsFallback: true, + agentSessionId: "abc", + }, + metadata: {}, + }); + const lm = setupCheck("app-1", { session }); + + await lm.check("app-1"); + + const meta = readMetadataRaw(env.sessionsDir, "app-1"); + expect(meta!["pinnedSummary"]).toBeUndefined(); + }); + + it("skips pinning when pinnedSummary already exists", async () => { + const session = makeSession({ + status: "working", + agentInfo: { + summary: "New summary that should not overwrite", + summaryIsFallback: false, + agentSessionId: "abc", + }, + metadata: { pinnedSummary: "Original pinned summary" }, + }); + const lm = setupCheck("app-1", { + session, + metaOverrides: { pinnedSummary: "Original pinned summary" }, + }); + + await lm.check("app-1"); + + const meta = readMetadataRaw(env.sessionsDir, "app-1"); + expect(meta!["pinnedSummary"]).toBe("Original pinned summary"); + }); + + it("skips pinning when trimmed summary is shorter than 5 chars", async () => { + const session = makeSession({ + status: "working", + agentInfo: { + summary: " Hi ", + summaryIsFallback: false, + agentSessionId: "abc", + }, + metadata: {}, + }); + const lm = setupCheck("app-1", { session }); + + await lm.check("app-1"); + + const meta = readMetadataRaw(env.sessionsDir, "app-1"); + expect(meta!["pinnedSummary"]).toBeUndefined(); + }); + + it("does not throw when metadata write fails", async () => { + const session = makeSession({ + status: "working", + agentInfo: { + summary: "Valid summary for pinning", + summaryIsFallback: false, + agentSessionId: "abc", + }, + metadata: {}, + }); + // Use a config with invalid path to trigger write failure + const badConfig = { + ...config, + projects: { + "my-app": { + ...config.projects["my-app"], + path: "/nonexistent/path/that/does/not/exist", + }, + }, + }; + const lm = setupCheck("app-1", { session, configOverride: badConfig }); + + // Should not throw — error is swallowed + await expect(lm.check("app-1")).resolves.not.toThrow(); + }); +}); diff --git a/packages/core/src/__tests__/metadata.test.ts b/packages/core/src/__tests__/metadata.test.ts index 369713742..a03e167f1 100644 --- a/packages/core/src/__tests__/metadata.test.ts +++ b/packages/core/src/__tests__/metadata.test.ts @@ -96,6 +96,18 @@ describe("writeMetadata + readMetadata", () => { expect(content).not.toContain("pr="); expect(content).not.toContain("summary="); }); + + it("serializes pinnedSummary field when present", () => { + writeMetadata(dataDir, "app-5", { + worktree: "/tmp/w", + branch: "feat/test", + status: "working", + pinnedSummary: "First quality summary", + }); + + const content = readFileSync(join(dataDir, "app-5"), "utf-8"); + expect(content).toContain("pinnedSummary=First quality summary\n"); + }); }); describe("readMetadataRaw", () => { diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts index 799c947fa..a297d0c78 100644 --- a/packages/core/src/__tests__/plugin-registry.test.ts +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -262,6 +262,63 @@ describe("loadBuiltins", () => { }); }); + it("strips package loading metadata from notifier config", async () => { + const registry = createPluginRegistry(); + const fakeWebhook = makePlugin("notifier", "webhook"); + const cfg = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + notifiers: { + mywebhook: { + plugin: "webhook", + // package field is allowed for resolution but should be stripped: + package: "@composio/ao-plugin-notifier-webhook", + // These are plugin-specific fields that should be passed through: + url: "https://webhook.example.com/notify", + retries: 3, + }, + }, + }); + + await registry.loadBuiltins(cfg, async (pkg: string) => { + if (pkg === "@composio/ao-plugin-notifier-webhook") return fakeWebhook; + throw new Error(`Not found: ${pkg}`); + }); + + // Loading metadata (package) should be stripped to prevent leakage + // Plugin-specific fields (url, retries) should be passed through + expect(fakeWebhook.create).toHaveBeenCalledWith({ + url: "https://webhook.example.com/notify", + retries: 3, + configPath: "/test/config.yaml", + }); + }); + + it("warns and skips when path is used alongside plugin name in notifier config", async () => { + const registry = createPluginRegistry(); + const fakeWebhook = makePlugin("notifier", "webhook"); + const cfg = makeOrchestratorConfig({ + notifiers: { + mywebhook: { + plugin: "webhook", + path: "./some/path", // This triggers the collision check + }, + }, + }); + + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + await registry.loadBuiltins(cfg, async (pkg: string) => { + if (pkg === "@composio/ao-plugin-notifier-webhook") return fakeWebhook; + return null; + }); + + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('"path" field conflicts with reserved')); + stderrSpy.mockRestore(); + + // Plugin should not be registered due to config error + expect(registry.get("notifier", "webhook")).toBeNull(); + }); + it("does not match notifier key when explicit plugin points to another notifier", async () => { const registry = createPluginRegistry(); const fakeOpenClaw = makePlugin("notifier", "openclaw"); @@ -455,3 +512,424 @@ describe("loadFromConfig", () => { expect(registry.get("agent", "goose")).toBeNull(); }); }); + +describe("External plugin manifest validation", () => { + it("accepts matching manifest.name and expectedPluginName", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "jira", slot: "tracker" as const, version: "1.0.0", description: "Jira tracker" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test", + tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" }, + }, + }, + _externalPluginEntries: [ + { + source: "projects.proj1.tracker", + location: { kind: "project", projectId: "proj1", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + expectedPluginName: "jira", + }, + ], + }); + + // Should not throw + await registry.loadFromConfig(config, importFn); + expect(registry.get("tracker", "jira")).not.toBeNull(); + }); + + it("warns when manifest.name does not match expectedPluginName but still registers plugin", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "jira-enterprise", slot: "tracker" as const, version: "1.0.0", description: "Jira Enterprise" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test", + tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" }, + }, + }, + _externalPluginEntries: [ + { + source: "projects.proj1.tracker", + location: { kind: "project", projectId: "proj1", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + expectedPluginName: "jira", + }, + ], + }); + + // Should warn about validation failure but still register the plugin + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await registry.loadFromConfig(config, importFn); + + // Plugin should still be registered under its manifest.name + expect(registry.get("tracker", "jira-enterprise")).not.toBeNull(); + + // Should have logged a validation warning + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("Config validation failed for projects.proj1.tracker"), + ); + stderrSpy.mockRestore(); + }); + + it("infers plugin name when expectedPluginName is not specified", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "jira", slot: "tracker" as const, version: "1.0.0", description: "Jira tracker" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test", + // Plugin field will be updated with manifest.name + tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" }, + }, + }, + _externalPluginEntries: [ + { + source: "projects.proj1.tracker", + location: { kind: "project", projectId: "proj1", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + // No expectedPluginName - should accept any manifest.name + }, + ], + }); + + await registry.loadFromConfig(config, importFn); + + // Plugin should be registered under manifest.name + expect(registry.get("tracker", "jira")).not.toBeNull(); + // Config should be updated with actual manifest.name + expect(config.projects.proj1.tracker?.plugin).toBe("jira"); + }); + + it("updates config with actual manifest.name for notifiers", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "ms-teams", slot: "notifier" as const, version: "1.0.0", description: "Teams notifier" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + { name: "teams", source: "npm", package: "@acme/ao-plugin-notifier-teams", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test", + }, + }, + notifiers: { + myteams: { plugin: "teams", package: "@acme/ao-plugin-notifier-teams" }, + }, + _externalPluginEntries: [ + { + source: "notifiers.myteams", + location: { kind: "notifier", notifierId: "myteams" }, + slot: "notifier", + package: "@acme/ao-plugin-notifier-teams", + // No expectedPluginName - will accept any manifest.name + }, + ], + }); + + await registry.loadFromConfig(config, importFn); + + // Config should be updated with actual manifest.name + expect(config.notifiers?.myteams?.plugin).toBe("ms-teams"); + // Plugin should be registered under manifest.name + expect(registry.get("notifier", "ms-teams")).not.toBeNull(); + }); + + it("passes notifier config to plugin even when manifest name differs from temp name", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "ms-teams", slot: "notifier" as const, version: "1.0.0", description: "Teams notifier" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + // Temp name is "teams" (from package name), but manifest.name is "ms-teams" + { name: "teams", source: "npm", package: "@acme/ao-plugin-notifier-teams", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test", + }, + }, + notifiers: { + myteams: { + plugin: "teams", // Temp name - will be updated to "ms-teams" + package: "@acme/ao-plugin-notifier-teams", + webhookUrl: "https://teams.webhook.url/abc123", + channel: "#alerts", + }, + }, + _externalPluginEntries: [ + { + source: "notifiers.myteams", + location: { kind: "notifier", notifierId: "myteams" }, + slot: "notifier", + package: "@acme/ao-plugin-notifier-teams", + // No expectedPluginName - config.plugin will be updated to manifest.name + }, + ], + }); + + await registry.loadFromConfig(config, importFn); + + // Config should be updated BEFORE extractPluginConfig is called + expect(config.notifiers?.myteams?.plugin).toBe("ms-teams"); + + // Plugin should receive its config (webhookUrl, channel) despite name mismatch + expect(mockPlugin.create).toHaveBeenCalledWith({ + webhookUrl: "https://teams.webhook.url/abc123", + channel: "#alerts", + configPath: "/test/config.yaml", + }); + + // Plugin should be registered + expect(registry.get("notifier", "ms-teams")).not.toBeNull(); + }); + + it("warns when plugin slot does not match config slot", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "jira", slot: "notifier" as const, version: "1.0.0", description: "Wrong slot!" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test", + tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" }, + }, + }, + _externalPluginEntries: [ + { + source: "projects.proj1.tracker", + location: { kind: "project", projectId: "proj1", configType: "tracker" }, + slot: "tracker", // Expected tracker + package: "@acme/ao-plugin-tracker-jira", + }, + ], + }); + + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await registry.loadFromConfig(config, importFn); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("has slot \"notifier\" but was configured as \"tracker\""), + ); + stderrSpy.mockRestore(); + }); + + it("updates all projects sharing same external plugin with manifest.name", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "jira-cloud", slot: "tracker" as const, version: "1.0.0", description: "Jira Cloud" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test1", + repo: "org/test1", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test1", + tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" }, + }, + proj2: { + path: "/repos/test2", + repo: "org/test2", + name: "proj2", + defaultBranch: "main", + sessionPrefix: "test2", + // Same external plugin as proj1 + tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" }, + }, + }, + _externalPluginEntries: [ + { + source: "projects.proj1.tracker", + location: { kind: "project", projectId: "proj1", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + // No expectedPluginName - will accept any manifest.name + }, + { + source: "projects.proj2.tracker", + location: { kind: "project", projectId: "proj2", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + // No expectedPluginName - will accept any manifest.name + }, + ], + }); + + await registry.loadFromConfig(config, importFn); + + // Both projects should be updated with the actual manifest.name + expect(config.projects.proj1.tracker?.plugin).toBe("jira-cloud"); + expect(config.projects.proj2.tracker?.plugin).toBe("jira-cloud"); + // Plugin should be registered under manifest.name + expect(registry.get("tracker", "jira-cloud")).not.toBeNull(); + }); + + it("registers plugin even when one project has misconfigured expectedPluginName", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "jira-cloud", slot: "tracker" as const, version: "1.0.0", description: "Jira Cloud" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test1", + repo: "org/test1", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test1", + tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" }, + }, + proj2: { + path: "/repos/test2", + repo: "org/test2", + name: "proj2", + defaultBranch: "main", + sessionPrefix: "test2", + // Same external plugin but with WRONG explicit plugin name + tracker: { plugin: "wrong-name", package: "@acme/ao-plugin-tracker-jira" }, + }, + }, + _externalPluginEntries: [ + { + source: "projects.proj1.tracker", + location: { kind: "project", projectId: "proj1", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + // No expectedPluginName - will accept any manifest.name + }, + { + source: "projects.proj2.tracker", + location: { kind: "project", projectId: "proj2", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + expectedPluginName: "wrong-name", // Mismatches manifest.name "jira-cloud" + }, + ], + }); + + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await registry.loadFromConfig(config, importFn); + + // Plugin should STILL be registered despite proj2's misconfiguration + expect(registry.get("tracker", "jira-cloud")).not.toBeNull(); + + // proj1 should be updated correctly (no expectedPluginName = accepts any) + expect(config.projects.proj1.tracker?.plugin).toBe("jira-cloud"); + + // proj2's config should NOT be updated (validation failed) + expect(config.projects.proj2.tracker?.plugin).toBe("wrong-name"); + + // Should have logged a warning about proj2's validation failure + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("Config validation failed for projects.proj2.tracker"), + ); + + stderrSpy.mockRestore(); + }); +}); diff --git a/packages/core/src/__tests__/session-manager/communication.test.ts b/packages/core/src/__tests__/session-manager/communication.test.ts index cd1a3f391..b46e9ec63 100644 --- a/packages/core/src/__tests__/session-manager/communication.test.ts +++ b/packages/core/src/__tests__/session-manager/communication.test.ts @@ -8,7 +8,6 @@ import { createSessionManager } from "../../session-manager.js"; import { writeMetadata, readMetadataRaw, - updateMetadata, } from "../../metadata.js"; import type { OrchestratorConfig, @@ -54,37 +53,6 @@ describe("send", () => { expect(mockRuntime.sendMessage).toHaveBeenCalledWith(makeHandle("rt-1"), "Fix the CI failures"); }); - it("blocks send to worker sessions while the project is globally paused", async () => { - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator")), - }); - updateMetadata(sessionsDir, "app-orchestrator", { - globalPauseUntil: new Date(Date.now() + 60_000).toISOString(), - globalPauseReason: "Rate limit reached", - globalPauseSource: "app-9", - }); - - writeMetadata(sessionsDir, "app-1", { - worktree: "/tmp", - branch: "main", - status: "working", - project: "my-app", - runtimeHandle: JSON.stringify(makeHandle("rt-1")), - }); - - const sm = createSessionManager({ config, registry: mockRegistry }); - - await expect(sm.send("app-1", "Fix the CI failures")).rejects.toThrow( - "Project is paused due to model rate limit until", - ); - expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); - }); - it("restores a dead session before sending the message", async () => { const wsPath = join(tmpDir, "ws-app-1"); mkdirSync(wsPath, { recursive: true }); @@ -305,7 +273,7 @@ describe("send", () => { await sm.send("app-1", "confirm via updated timestamp"); const elapsedMs = Date.now() - startedAt; - expect(elapsedMs).toBeLessThan(2_000); + expect(elapsedMs).toBeLessThan(5_000); expect(readFileSync(listLogPath, "utf-8").trim().split("\n").length).toBeGreaterThanOrEqual(2); expect(mockRuntime.sendMessage).toHaveBeenCalledWith( makeHandle("rt-1"), @@ -442,7 +410,7 @@ describe("remap", () => { expect(mapped).toBe("ses_slow_discovery"); const meta = readMetadataRaw(sessionsDir, "app-1"); expect(meta?.["opencodeSessionId"]).toBe("ses_slow_discovery"); - }); + }, 20000); it("throws when OpenCode session id mapping is missing", async () => { const deleteLogPath = join(tmpDir, "opencode-delete-missing-remap.log"); diff --git a/packages/core/src/__tests__/session-manager/lifecycle.test.ts b/packages/core/src/__tests__/session-manager/lifecycle.test.ts index 3e7cf94e3..7a24277db 100644 --- a/packages/core/src/__tests__/session-manager/lifecycle.test.ts +++ b/packages/core/src/__tests__/session-manager/lifecycle.test.ts @@ -225,7 +225,7 @@ describe("kill", () => { await sm.kill("app-1", { purgeOpenCode: true }); expect(existsSync(deleteLogPath)).toBe(false); - }); + }, 15000); }); describe("cleanup", () => { diff --git a/packages/core/src/__tests__/session-manager/restore.test.ts b/packages/core/src/__tests__/session-manager/restore.test.ts index 66bb0fb7c..9b2e737d6 100644 --- a/packages/core/src/__tests__/session-manager/restore.test.ts +++ b/packages/core/src/__tests__/session-manager/restore.test.ts @@ -365,7 +365,7 @@ describe("restore", () => { expect(restored.status).toBe("spawning"); const meta = readMetadataRaw(sessionsDir, "app-1"); expect(meta?.["opencodeSessionId"]).toBe("ses_restore_discovered"); - }); + }, 15000); it("uses orchestratorModel when restoring orchestrator sessions", async () => { const wsPath = join(tmpDir, "ws-app-orchestrator-restore"); diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 07aa3b741..560386e1f 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { chmodSync, mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { mkdirSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { createSessionManager } from "../../session-manager.js"; import { validateConfig } from "../../config.js"; @@ -7,9 +7,6 @@ import { writeMetadata, readMetadata, readMetadataRaw, - deleteMetadata, - reserveSessionId, - updateMetadata, } from "../../metadata.js"; import type { OrchestratorConfig, @@ -18,7 +15,6 @@ import type { Agent, Workspace, Tracker, - RuntimeHandle, } from "../../types.js"; import { setupTestContext, @@ -75,29 +71,6 @@ describe("spawn", () => { expect(mockRuntime.create).toHaveBeenCalled(); }); - it("blocks spawn while the project is globally paused", async () => { - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator")), - }); - updateMetadata(sessionsDir, "app-orchestrator", { - globalPauseUntil: new Date(Date.now() + 60_000).toISOString(), - globalPauseReason: "Rate limit reached", - globalPauseSource: "app-9", - }); - - const sm = createSessionManager({ config, registry: mockRegistry }); - - await expect(sm.spawn({ projectId: "my-app" })).rejects.toThrow( - "Project is paused due to model rate limit until", - ); - expect(mockRuntime.create).not.toHaveBeenCalled(); - }); - it("uses issue ID to derive branch name", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); @@ -995,26 +968,23 @@ describe("spawn", () => { }, 20_000); describe("spawnOrchestrator", () => { - it("blocks orchestrator spawn while the project is globally paused", async () => { - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator")), - }); - updateMetadata(sessionsDir, "app-orchestrator", { - globalPauseUntil: new Date(Date.now() + 60_000).toISOString(), - globalPauseReason: "Rate limit reached", - globalPauseSource: "app-9", - }); - - const sm = createSessionManager({ config, registry: mockRegistry }); + it("throws when no workspace plugin is configured", async () => { + const registryNoWorkspace: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return mockAgent; + return null; // no workspace plugin + }), + }; + const sm = createSessionManager({ config, registry: registryNoWorkspace }); await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow( - "Project is paused due to model rate limit until", + "spawnOrchestrator requires a workspace plugin", ); + + // Reserved session metadata should be cleaned up + expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")).toBeNull(); expect(mockRuntime.create).not.toHaveBeenCalled(); }); @@ -1023,12 +993,42 @@ describe("spawn", () => { const session = await sm.spawnOrchestrator({ projectId: "my-app" }); - expect(session.id).toBe("app-orchestrator"); + expect(session.id).toBe("app-orchestrator-1"); expect(session.status).toBe("working"); expect(session.projectId).toBe("my-app"); - expect(session.branch).toBe("main"); + expect(session.branch).toBe("orchestrator/app-orchestrator-1"); expect(session.issueId).toBeNull(); - expect(session.workspacePath).toBe(join(tmpDir, "my-app")); + expect(session.workspacePath).toBe("/tmp/ws"); + }); + + it("creates a worktree with an orchestrator branch", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(mockWorkspace.create).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "app-orchestrator-1", + branch: "orchestrator/app-orchestrator-1", + projectId: "my-app", + }), + ); + }); + + it("uses the worktree path returned by the workspace plugin", async () => { + const worktreePath = join(tmpDir, "orchestrator-ws"); + (mockWorkspace.create as ReturnType).mockResolvedValueOnce({ + path: worktreePath, + branch: "orchestrator/app-orchestrator-1", + sessionId: "app-orchestrator-1", + projectId: "my-app", + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const session = await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(session.workspacePath).toBe(worktreePath); + expect(session.branch).toBe("orchestrator/app-orchestrator-1"); }); it("writes metadata with proper fields", async () => { @@ -1036,23 +1036,113 @@ describe("spawn", () => { await sm.spawnOrchestrator({ projectId: "my-app" }); - const meta = readMetadata(sessionsDir, "app-orchestrator"); + const meta = readMetadata(sessionsDir, "app-orchestrator-1"); expect(meta).not.toBeNull(); expect(meta!.status).toBe("working"); expect(meta!.project).toBe("my-app"); - expect(meta!.worktree).toBe(join(tmpDir, "my-app")); - expect(meta!.branch).toBe("main"); + expect(meta!.branch).toBe("orchestrator/app-orchestrator-1"); expect(meta!.tmuxName).toBeDefined(); expect(meta!.runtimeHandle).toBeDefined(); }); + it("writes metadata with worktree path and orchestrator role", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.spawnOrchestrator({ projectId: "my-app" }); + + const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1"); + expect(meta?.["role"]).toBe("orchestrator"); + expect(meta?.["branch"]).toBe("orchestrator/app-orchestrator-1"); + expect(meta?.["status"]).toBe("working"); + expect(meta?.["project"]).toBe("my-app"); + }); + + it("increments the orchestrator counter for each new session", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + const s1 = await sm.spawnOrchestrator({ projectId: "my-app" }); + const s2 = await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(s1.id).toBe("app-orchestrator-1"); + expect(s2.id).toBe("app-orchestrator-2"); + expect(mockWorkspace.create).toHaveBeenCalledTimes(2); + }); + + it("cleans up reserved metadata on workspace creation failure", async () => { + (mockWorkspace.create as ReturnType).mockRejectedValueOnce( + new Error("workspace creation failed"), + ); + const sm = createSessionManager({ config, registry: mockRegistry }); + + await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow( + "workspace creation failed", + ); + + // Reserved session file should be cleaned up + expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")).toBeNull(); + }); + + it("destroys the worktree and metadata when runtime creation fails", async () => { + const worktreePath = join(tmpDir, "orchestrator-ws-rt-fail"); + (mockWorkspace.create as ReturnType).mockResolvedValueOnce({ + path: worktreePath, + branch: "orchestrator/app-orchestrator-1", + sessionId: "app-orchestrator-1", + projectId: "my-app", + }); + (mockRuntime.create as ReturnType).mockRejectedValueOnce( + new Error("runtime creation failed"), + ); + const sm = createSessionManager({ config, registry: mockRegistry }); + + await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow( + "runtime creation failed", + ); + + expect(mockWorkspace.destroy).toHaveBeenCalledWith(worktreePath); + expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")).toBeNull(); + }); + + it("destroys the worktree when post-launch setup fails", async () => { + const worktreePath = join(tmpDir, "orchestrator-ws-postlaunch-fail"); + (mockWorkspace.create as ReturnType).mockResolvedValueOnce({ + path: worktreePath, + branch: "orchestrator/app-orchestrator-1", + sessionId: "app-orchestrator-1", + projectId: "my-app", + }); + const postLaunchError = new Error("post-launch setup failed"); + const agentWithPostLaunch: typeof mockAgent = { + ...mockAgent, + postLaunchSetup: vi.fn().mockRejectedValueOnce(postLaunchError), + }; + const registryWithPostLaunch: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return agentWithPostLaunch; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + const sm = createSessionManager({ config, registry: registryWithPostLaunch }); + + await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow( + "post-launch setup failed", + ); + + expect(mockRuntime.destroy).toHaveBeenCalled(); + expect(mockWorkspace.destroy).toHaveBeenCalledWith(worktreePath); + expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")).toBeNull(); + }); + it("deletes previous OpenCode orchestrator sessions before starting", async () => { const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator.log"); const mockBin = installMockOpencode( tmpDir, JSON.stringify([ - { id: "ses_old", title: "AO:app-orchestrator", updated: "2025-01-01T00:00:00.000Z" }, - { id: "ses_new", title: "AO:app-orchestrator", updated: "2025-01-02T00:00:00.000Z" }, + { id: "ses_old", title: "AO:app-orchestrator-1", updated: "2025-01-01T00:00:00.000Z" }, + { id: "ses_new", title: "AO:app-orchestrator-1", updated: "2025-01-02T00:00:00.000Z" }, ]), deleteLogPath, ); @@ -1094,14 +1184,14 @@ describe("spawn", () => { expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: "app-orchestrator", + sessionId: "app-orchestrator-1", projectConfig: expect.objectContaining({ agentConfig: expect.not.objectContaining({ opencodeSessionId: expect.any(String) }), }), }), ); - const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1"); expect(meta?.["agent"]).toBe("opencode"); expect(meta?.["opencodeSessionId"]).toBeUndefined(); }); @@ -1113,7 +1203,7 @@ describe("spawn", () => { JSON.stringify([ { id: "ses_discovered_orchestrator", - title: "AO:app-orchestrator", + title: "AO:app-orchestrator-1", updated: 1_772_777_000_000, }, ]), @@ -1151,205 +1241,20 @@ describe("spawn", () => { const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); await sm.spawnOrchestrator({ projectId: "my-app" }); - const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1"); expect(meta?.["opencodeSessionId"]).toBe("ses_discovered_orchestrator"); }); - it("reuses an existing orchestrator session when strategy is reuse", async () => { - const listLogPath = join(tmpDir, "opencode-list-orchestrator-reuse.log"); - const mockBin = join(tmpDir, "mock-bin-reuse-no-list"); - mkdirSync(mockBin, { recursive: true }); - const scriptPath = join(mockBin, "opencode"); - writeFileSync( - scriptPath, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - 'if [[ "$1" == "session" && "$2" == "list" ]]; then', - ` printf '%s\\n' "$*" >> '${listLogPath.replace(/'/g, "'\\''")}'`, - " printf '[]\\n'", - " exit 0", - "fi", - "exit 0", - "", - ].join("\n"), - "utf-8", - ); - chmodSync(scriptPath, 0o755); - process.env.PATH = `${mockBin}:${originalPath ?? ""}`; - - const opencodeAgent: Agent = { - ...mockAgent, - name: "opencode", - }; - const registryWithOpenCode: PluginRegistry = { - ...mockRegistry, - get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; - if (slot === "agent") return opencodeAgent; - if (slot === "workspace") return mockWorkspace; - return null; - }), - }; - - const configWithReuse: OrchestratorConfig = { - ...config, - defaults: { ...config.defaults, agent: "opencode" }, - projects: { - ...config.projects, - "my-app": { - ...config.projects["my-app"], - agent: "opencode", - orchestratorSessionStrategy: "reuse", - }, - }, - }; - - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(makeHandle("rt-existing")), - opencodeSessionId: "ses_existing", - createdAt: new Date().toISOString(), - }); - - const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); - const session = await sm.spawnOrchestrator({ projectId: "my-app" }); - - expect(session.id).toBe("app-orchestrator"); - expect(session.metadata["orchestratorSessionReused"]).toBe("true"); - expect(mockRuntime.create).not.toHaveBeenCalled(); - expect(mockRuntime.destroy).not.toHaveBeenCalled(); - expect(existsSync(listLogPath)).toBe(false); - }); - - it("destroys orphaned runtime when reuse strategy finds alive runtime but get returns null", async () => { - const deleteLogPath = join(tmpDir, "opencode-delete-orphaned-runtime.log"); - const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath); - process.env.PATH = `${mockBin}:${originalPath ?? ""}`; - - const opencodeAgent: Agent = { - ...mockAgent, - name: "opencode", - }; - const registryWithOpenCode: PluginRegistry = { - ...mockRegistry, - get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; - if (slot === "agent") return opencodeAgent; - if (slot === "workspace") return mockWorkspace; - return null; - }), - }; - - const configWithReuse: OrchestratorConfig = { - ...config, - defaults: { ...config.defaults, agent: "opencode" }, - projects: { - ...config.projects, - "my-app": { - ...config.projects["my-app"], - agent: "opencode", - orchestratorSessionStrategy: "reuse", - }, - }, - }; - - const orphanedHandle = makeHandle("rt-orphaned"); - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(orphanedHandle), - createdAt: new Date().toISOString(), - }); - - vi.mocked(mockRuntime.isAlive).mockImplementation(async (handle: RuntimeHandle) => { - if (handle?.id === "rt-orphaned") { - deleteMetadata(sessionsDir, "app-orchestrator"); - return true; - } - return false; - }); - - const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); - const session = await sm.spawnOrchestrator({ projectId: "my-app" }); - - expect(session.id).toBe("app-orchestrator"); - expect(mockRuntime.destroy).toHaveBeenCalledWith(orphanedHandle); - expect(mockRuntime.create).toHaveBeenCalled(); - }); - - it("reuses mapped OpenCode session id when strategy is reuse and runtime is restarted", async () => { - const opencodeAgent: Agent = { - ...mockAgent, - name: "opencode", - }; - const registryWithOpenCode: PluginRegistry = { - ...mockRegistry, - get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; - if (slot === "agent") return opencodeAgent; - if (slot === "workspace") return mockWorkspace; - return null; - }), - }; - - const configWithReuse: OrchestratorConfig = { - ...config, - defaults: { ...config.defaults, agent: "opencode" }, - projects: { - ...config.projects, - "my-app": { - ...config.projects["my-app"], - agent: "opencode", - orchestratorSessionStrategy: "reuse", - }, - }, - }; - - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(makeHandle("rt-existing")), - opencodeSessionId: "ses_existing", - createdAt: new Date().toISOString(), - }); - - vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); - - const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); - await sm.spawnOrchestrator({ projectId: "my-app" }); - - expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( - expect.objectContaining({ - projectConfig: expect.objectContaining({ - agentConfig: expect.objectContaining({ opencodeSessionId: "ses_existing" }), - }), - }), - ); - const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); - expect(meta?.["opencodeSessionId"]).toBe("ses_existing"); - }); - - it("reuses archived OpenCode mapping for orchestrator when active metadata has no mapping", async () => { - const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-archived.log"); + it("reuses mapped OpenCode session id when strategy is reuse and opencode lists it by title", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-restart.log"); const mockBin = installMockOpencode( tmpDir, JSON.stringify([ - null, - { id: "ses_existing", title: "AO:app-orchestrator", updated: 1_772_777_000_000 }, + { + id: "ses_existing", + title: "AO:app-orchestrator-1", + updated: 1_772_777_000_000, + }, ]), deleteLogPath, ); @@ -1382,30 +1287,57 @@ describe("spawn", () => { }, }; - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(makeHandle("rt-existing")), - opencodeSessionId: "ses_existing", - createdAt: new Date().toISOString(), - }); - deleteMetadata(sessionsDir, "app-orchestrator", true); - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(makeHandle("rt-existing")), - createdAt: new Date().toISOString(), - }); + const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); + await sm.spawnOrchestrator({ projectId: "my-app" }); - vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); + expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + projectConfig: expect.objectContaining({ + agentConfig: expect.objectContaining({ opencodeSessionId: "ses_existing" }), + }), + }), + ); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1"); + expect(meta?.["opencodeSessionId"]).toBe("ses_existing"); + }); + + it("discovers OpenCode mapping by title when no archived mapping exists for new session id", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-title-fallback.log"); + const mockBin = installMockOpencode( + tmpDir, + JSON.stringify([ + { id: "ses_existing", title: "AO:app-orchestrator-1", updated: 1_772_777_000_000 }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + const opencodeAgent: Agent = { + ...mockAgent, + name: "opencode", + }; + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return opencodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const configWithReuse: OrchestratorConfig = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "opencode", + orchestratorSessionStrategy: "reuse", + }, + }, + }; const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); await sm.spawnOrchestrator({ projectId: "my-app" }); @@ -1424,8 +1356,7 @@ describe("spawn", () => { const mockBin = installMockOpencode( tmpDir, JSON.stringify([ - null, - { id: "ses_title_match", title: "AO:app-orchestrator", updated: 1_772_777_000_000 }, + { id: "ses_title_match", title: "AO:app-orchestrator-1", updated: 1_772_777_000_000 }, ]), deleteLogPath, ); @@ -1458,19 +1389,6 @@ describe("spawn", () => { }, }; - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(makeHandle("rt-existing")), - createdAt: new Date().toISOString(), - }); - - vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); - const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); await sm.spawnOrchestrator({ projectId: "my-app" }); @@ -1481,81 +1399,11 @@ describe("spawn", () => { }), }), ); - const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1"); expect(meta?.["opencodeSessionId"]).toBe("ses_title_match"); }); - it("starts fresh without deleting prior OpenCode sessions when strategy is ignore", async () => { - const deleteLogPath = join(tmpDir, "opencode-delete-ignore.log"); - const mockBin = installMockOpencode( - tmpDir, - JSON.stringify([ - { id: "ses_old", title: "AO:app-orchestrator", updated: "2025-01-01T00:00:00.000Z" }, - ]), - deleteLogPath, - ); - process.env.PATH = `${mockBin}:${originalPath ?? ""}`; - - const opencodeAgent: Agent = { - ...mockAgent, - name: "opencode", - }; - const registryWithOpenCode: PluginRegistry = { - ...mockRegistry, - get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; - if (slot === "agent") return opencodeAgent; - if (slot === "workspace") return mockWorkspace; - return null; - }), - }; - - const configWithIgnoreNew: OrchestratorConfig = { - ...config, - defaults: { ...config.defaults, agent: "opencode" }, - projects: { - ...config.projects, - "my-app": { - ...config.projects["my-app"], - agent: "opencode", - orchestratorSessionStrategy: "ignore", - }, - }, - }; - - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(makeHandle("rt-existing")), - createdAt: new Date().toISOString(), - }); - - vi.mocked(mockRuntime.isAlive).mockResolvedValueOnce(true); - - const sm = createSessionManager({ - config: configWithIgnoreNew, - registry: registryWithOpenCode, - }); - await sm.spawnOrchestrator({ projectId: "my-app" }); - - expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("rt-existing")); - expect(mockRuntime.create).toHaveBeenCalled(); - expect(existsSync(deleteLogPath)).toBe(false); - }); - - it("skips workspace creation", async () => { - const sm = createSessionManager({ config, registry: mockRegistry }); - - await sm.spawnOrchestrator({ projectId: "my-app" }); - - expect(mockWorkspace.create).not.toHaveBeenCalled(); - }); - - it("calls agent.setupWorkspaceHooks on project path", async () => { + it("calls agent.setupWorkspaceHooks on worktree path", async () => { const agentWithHooks: Agent = { ...mockAgent, setupWorkspaceHooks: vi.fn().mockResolvedValue(undefined), @@ -1574,7 +1422,7 @@ describe("spawn", () => { await sm.spawnOrchestrator({ projectId: "my-app" }); expect(agentWithHooks.setupWorkspaceHooks).toHaveBeenCalledWith( - join(tmpDir, "my-app"), + "/tmp/ws", expect.objectContaining({ dataDir: sessionsDir }), ); }); @@ -1586,7 +1434,7 @@ describe("spawn", () => { expect(mockRuntime.create).toHaveBeenCalledWith( expect.objectContaining({ - workspacePath: join(tmpDir, "my-app"), + workspacePath: "/tmp/ws", launchCommand: "mock-agent --start", }), ); @@ -1597,31 +1445,10 @@ describe("spawn", () => { await sm.spawnOrchestrator({ projectId: "my-app" }); - const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1"); expect(meta?.["orchestratorSessionReused"]).toBeUndefined(); }); - it("respawns the orchestrator when stale metadata exists but the runtime is dead", async () => { - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - project: "my-app", - role: "orchestrator", - runtimeHandle: JSON.stringify(makeHandle("rt-stale")), - createdAt: new Date().toISOString(), - }); - - vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); - - const sm = createSessionManager({ config, registry: mockRegistry }); - await sm.spawnOrchestrator({ projectId: "my-app" }); - - expect(mockRuntime.create).toHaveBeenCalledTimes(1); - const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); - expect(meta?.["runtimeHandle"]).toBe(JSON.stringify(makeHandle("rt-1"))); - }); - it("uses orchestratorModel when configured", async () => { const configWithOrchestratorModel: OrchestratorConfig = { ...config, @@ -1720,7 +1547,7 @@ describe("spawn", () => { expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled(); expect(mockAgent.getLaunchCommand).not.toHaveBeenCalled(); - expect(readMetadataRaw(sessionsDir, "app-orchestrator")?.["agent"]).toBe("codex"); + expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")?.["agent"]).toBe("codex"); }); it("uses defaults orchestrator agent when project agent is not set", async () => { @@ -1767,7 +1594,7 @@ describe("spawn", () => { await sm.spawnOrchestrator({ projectId: "my-app" }); expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled(); - expect(readMetadataRaw(sessionsDir, "app-orchestrator")?.["agent"]).toBe("codex"); + expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")?.["agent"]).toBe("codex"); }); it("keeps shared worker permissions when role-specific config only overrides model", async () => { @@ -1869,8 +1696,8 @@ describe("spawn", () => { // Should pass systemPromptFile (not inline systemPrompt) to avoid tmux truncation expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: "app-orchestrator", - systemPromptFile: expect.stringContaining("orchestrator-prompt.md"), + sessionId: "app-orchestrator-1", + systemPromptFile: expect.stringContaining("orchestrator-prompt-app-orchestrator-1.md"), }), ); @@ -1909,101 +1736,5 @@ describe("spawn", () => { expect(session.runtimeHandle).toEqual(makeHandle("rt-1")); }); - it("reuses existing orchestrator on reservation conflict when strategy is reuse", async () => { - const opencodeAgent: Agent = { - ...mockAgent, - name: "opencode", - }; - const registryWithOpenCode: PluginRegistry = { - ...mockRegistry, - get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; - if (slot === "agent") return opencodeAgent; - if (slot === "workspace") return mockWorkspace; - return null; - }), - }; - - const configWithReuse: OrchestratorConfig = { - ...config, - defaults: { ...config.defaults, agent: "opencode" }, - projects: { - ...config.projects, - "my-app": { - ...config.projects["my-app"], - agent: "opencode", - orchestratorSessionStrategy: "reuse", - }, - }, - }; - - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(makeHandle("rt-concurrent")), - opencodeSessionId: "ses_concurrent", - createdAt: new Date().toISOString(), - }); - - vi.mocked(mockRuntime.isAlive).mockResolvedValue(true); - - const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); - const session = await sm.spawnOrchestrator({ projectId: "my-app" }); - - expect(session.metadata["orchestratorSessionReused"]).toBe("true"); - expect(mockRuntime.create).not.toHaveBeenCalled(); - }); - - it("recovers reservation conflict when existing session is not usable", async () => { - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "killed", - role: "orchestrator", - project: "my-app", - runtimeHandle: JSON.stringify(makeHandle("rt-dead")), - createdAt: new Date().toISOString(), - }); - - vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); - - const sm = createSessionManager({ config, registry: mockRegistry }); - await expect(sm.spawnOrchestrator({ projectId: "my-app" })).resolves.toBeDefined(); - expect(mockRuntime.create).toHaveBeenCalledTimes(1); - }); - - it("creates only one runtime on reservation conflict", async () => { - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - runtimeHandle: JSON.stringify(makeHandle("rt-existing")), - createdAt: new Date().toISOString(), - }); - - vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); - - const sm = createSessionManager({ config, registry: mockRegistry }); - await expect(sm.spawnOrchestrator({ projectId: "my-app" })).resolves.toBeDefined(); - expect(mockRuntime.create).toHaveBeenCalledTimes(1); - }); - - it("does not delete an in-progress reservation file without runtime metadata", async () => { - expect(reserveSessionId(sessionsDir, "app-orchestrator")).toBe(true); - - const sm = createSessionManager({ config, registry: mockRegistry }); - - await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow( - "already exists but is not in a reusable state", - ); - expect(mockRuntime.create).not.toHaveBeenCalled(); - expect(readMetadataRaw(sessionsDir, "app-orchestrator")).toEqual({}); - }); }); }); diff --git a/packages/core/src/__tests__/types.test.ts b/packages/core/src/__tests__/types.test.ts index bf54e4b0c..911d36be0 100644 --- a/packages/core/src/__tests__/types.test.ts +++ b/packages/core/src/__tests__/types.test.ts @@ -4,19 +4,35 @@ import { isOrchestratorSession, isIssueNotFoundError } from "../types.js"; describe("isOrchestratorSession", () => { it("detects orchestrators by explicit role metadata", () => { expect( - isOrchestratorSession({ - id: "app-control", - metadata: { role: "orchestrator" }, - }), + isOrchestratorSession({ id: "app-control", metadata: { role: "orchestrator" } }, "app"), ).toBe(true); }); it("falls back to orchestrator naming for legacy sessions", () => { - expect(isOrchestratorSession({ id: "app-orchestrator", metadata: {} })).toBe(true); + expect(isOrchestratorSession({ id: "app-orchestrator", metadata: {} }, "app")).toBe(true); }); - it("does not classify worker sessions as orchestrators", () => { - expect(isOrchestratorSession({ id: "app-7", metadata: { role: "worker" } })).toBe(false); + it("detects numbered worktree orchestrators by prefix pattern", () => { + expect(isOrchestratorSession({ id: "app-orchestrator-1", metadata: {} }, "app")).toBe(true); + expect(isOrchestratorSession({ id: "app-orchestrator-42", metadata: {} }, "app")).toBe(true); + }); + + it("does not false-positive on worker sessions", () => { + expect(isOrchestratorSession({ id: "app-7", metadata: { role: "worker" } }, "app")).toBe(false); + }); + + it("does not false-positive when prefix ends with -orchestrator", () => { + // my-orchestrator-1 is a worker when prefix is "my-orchestrator" + expect( + isOrchestratorSession({ id: "my-orchestrator-1", metadata: {} }, "my-orchestrator"), + ).toBe(false); + // my-orchestrator-orchestrator-1 is the real worktree orchestrator + expect( + isOrchestratorSession( + { id: "my-orchestrator-orchestrator-1", metadata: {} }, + "my-orchestrator", + ), + ).toBe(true); }); }); diff --git a/packages/core/src/agent-selection.ts b/packages/core/src/agent-selection.ts index de8a76d77..158cb280e 100644 --- a/packages/core/src/agent-selection.ts +++ b/packages/core/src/agent-selection.ts @@ -20,9 +20,10 @@ export interface ResolvedAgentSelection { export function resolveSessionRole( sessionId: string, - metadata?: Record, + metadata: Record | undefined, + sessionPrefix: string, ): SessionRole { - return isOrchestratorSession({ id: sessionId, metadata }) ? "orchestrator" : "worker"; + return isOrchestratorSession({ id: sessionId, metadata }, sessionPrefix) ? "orchestrator" : "worker"; } export function resolveAgentSelection(params: { diff --git a/packages/core/src/config-generator.ts b/packages/core/src/config-generator.ts index 250871d92..d0ad9c78a 100644 --- a/packages/core/src/config-generator.ts +++ b/packages/core/src/config-generator.ts @@ -243,7 +243,7 @@ export function generateConfigFromUrl(options: GenerateConfigOptions): Record validatePluginConfigFields(value, ctx, "Tracker")); const SCMConfigSchema = z .object({ - plugin: z.string(), + plugin: z.string().optional(), + package: z.string().optional(), + path: z.string().optional(), webhook: z .object({ enabled: z.boolean().default(true), @@ -82,13 +113,17 @@ const SCMConfigSchema = z }) .optional(), }) - .passthrough(); + .passthrough() + .superRefine((value, ctx) => validatePluginConfigFields(value, ctx, "SCM")); const NotifierConfigSchema = z .object({ - plugin: z.string(), + plugin: z.string().optional(), + package: z.string().optional(), + path: z.string().optional(), }) - .passthrough(); + .passthrough() + .superRefine((value, ctx) => validatePluginConfigFields(value, ctx, "Notifier")); const AgentPermissionSchema = z .enum(["permissionless", "default", "auto-edit", "suggest", "skip"]) @@ -176,7 +211,7 @@ const DefaultPluginsSchema = z.object({ runtime: z.string().default("tmux"), agent: z.string().default("claude-code"), workspace: z.string().default("worktree"), - notifiers: z.array(z.string()).default(["composio", "desktop"]), + notifiers: z.array(z.string()).default([]), orchestrator: RoleAgentDefaultsSchema, worker: RoleAgentDefaultsSchema, }); @@ -215,14 +250,12 @@ const OrchestratorConfigSchema = z.object({ readyThresholdMs: z.number().nonnegative().default(300_000), defaults: DefaultPluginsSchema.default({}), plugins: z.array(InstalledPluginConfigSchema).default([]), - projects: z.record(ProjectConfigSchema), + projects: z.record( + z.string().regex(/^[a-zA-Z0-9_-]+$/, "Project ID must match [a-zA-Z0-9_-]+ (no dots, slashes, or special characters)"), + ProjectConfigSchema, + ), notifiers: z.record(NotifierConfigSchema).default({}), - notificationRouting: z.record(z.array(z.string())).default({ - urgent: ["desktop", "composio"], - action: ["desktop", "composio"], - warning: ["composio"], - info: ["composio"], - }), + notificationRouting: z.record(z.array(z.string())).default({}), reactions: z.record(ReactionConfigSchema).default({}), }); @@ -253,6 +286,181 @@ function expandPaths(config: OrchestratorConfig): OrchestratorConfig { return config; } +/** + * Generate a temporary plugin name from a package or path specifier. + * This name is used until the actual manifest.name is discovered during plugin loading. + * Format: extract the plugin name from the package/path, removing common prefixes. + * e.g., "@acme/ao-plugin-tracker-jira" -> "jira" + * e.g., "@acme/ao-plugin-tracker-jira-cloud" -> "jira-cloud" + * e.g., "./plugins/my-tracker" -> "my-tracker" + * e.g., "my-tracker" (local path without slashes) -> "my-tracker" + */ +function generateTempPluginName(pkg?: string, path?: string): string { + if (pkg) { + // Extract package name without scope: "@acme/ao-plugin-tracker-jira" -> "ao-plugin-tracker-jira" + const slashParts = pkg.split("/"); + const packageName = slashParts[slashParts.length - 1] ?? pkg; + + // Extract plugin name after ao-plugin-{slot}- prefix, preserving multi-word names like "jira-cloud" + const prefixMatch = packageName.match(/^ao-plugin-(?:runtime|agent|workspace|tracker|scm|notifier|terminal)-(.+)$/); + if (prefixMatch?.[1]) { + return prefixMatch[1]; + } + + // Non-standard package name (doesn't follow ao-plugin convention): use the full package name + // to avoid collisions. "plugin" from "custom-tracker-plugin" would collide with other packages + // that also end in "-plugin". The temp name is replaced with manifest.name after loading anyway. + return packageName; + } + + // Handle local paths: use the basename + // ./plugins/my-tracker -> my-tracker + // my-tracker -> my-tracker (no slashes is still a valid path) + if (path) { + const segments = path.split("/").filter((s) => s && s !== "." && s !== ".."); + return segments[segments.length - 1] ?? path; + } + + return "unknown"; +} + +/** + * Helper to process a single external plugin config entry. + * Expands home paths, generates temp plugin name if needed, and returns the entry ref. + */ +function processExternalPluginConfig( + pluginConfig: { plugin?: string; package?: string; path?: string }, + source: string, + location: ExternalPluginEntryRef["location"], + slot: ExternalPluginEntryRef["slot"], +): ExternalPluginEntryRef | null { + if (!pluginConfig.package && !pluginConfig.path) return null; + + // Expand home paths (~/...) for consistency with config.plugins + if (pluginConfig.path) { + pluginConfig.path = expandHome(pluginConfig.path); + } + + // Track if user explicitly specified plugin name (for validation) + const userSpecifiedPlugin = pluginConfig.plugin; + + // If plugin name not specified, generate a temporary one from package/path + if (!pluginConfig.plugin) { + pluginConfig.plugin = generateTempPluginName(pluginConfig.package, pluginConfig.path); + } + + return { + source, + location, + slot, + package: pluginConfig.package, + path: pluginConfig.path, + expectedPluginName: userSpecifiedPlugin, + }; +} + +/** + * Collect external plugin configs from tracker, scm, and notifier inline configs. + * These will be auto-added to config.plugins for loading. + * + * Also sets a temporary plugin name on configs that only have package/path, + * so that resolvePlugins() can look up the plugin by name. + * + * IMPORTANT: Only sets expectedPluginName when user explicitly specified `plugin`. + * When plugin is auto-generated, expectedPluginName is left undefined so that + * any manifest.name is accepted and the config is updated with it. + */ +export function collectExternalPluginConfigs(config: OrchestratorConfig): ExternalPluginEntryRef[] { + const entries: ExternalPluginEntryRef[] = []; + + // Collect from project tracker and scm configs + for (const [projectId, project] of Object.entries(config.projects)) { + if (project.tracker) { + const entry = processExternalPluginConfig( + project.tracker, + `projects.${projectId}.tracker`, + { kind: "project", projectId, configType: "tracker" }, + "tracker", + ); + if (entry) entries.push(entry); + } + + if (project.scm) { + const entry = processExternalPluginConfig( + project.scm, + `projects.${projectId}.scm`, + { kind: "project", projectId, configType: "scm" }, + "scm", + ); + if (entry) entries.push(entry); + } + } + + // Collect from global notifier configs + for (const [notifierId, notifierConfig] of Object.entries(config.notifiers ?? {})) { + if (notifierConfig) { + const entry = processExternalPluginConfig( + notifierConfig, + `notifiers.${notifierId}`, + { kind: "notifier", notifierId }, + "notifier", + ); + if (entry) entries.push(entry); + } + } + + return entries; +} + +/** + * Generate InstalledPluginConfig entries from external plugin entries. + * Merges with existing plugins, avoiding duplicates by package/path. + */ +function mergeExternalPlugins( + existingPlugins: OrchestratorConfig["plugins"], + externalEntries: ExternalPluginEntryRef[], +): OrchestratorConfig["plugins"] { + const plugins = [...(existingPlugins ?? [])]; + const seen = new Set(); + + // Track existing plugins by package/path + for (const plugin of plugins) { + if (plugin.package) seen.add(`package:${plugin.package}`); + if (plugin.path) seen.add(`path:${plugin.path}`); + } + + // Add external entries that aren't already present, or enable if disabled + for (const entry of externalEntries) { + const key = entry.package ? `package:${entry.package}` : `path:${entry.path}`; + if (seen.has(key)) { + // If the existing plugin is disabled but there's an inline reference, enable it + const existingPlugin = plugins.find( + (p) => + (entry.package && p.package === entry.package) || + (entry.path && p.path === entry.path), + ); + if (existingPlugin && existingPlugin.enabled === false) { + existingPlugin.enabled = true; + } + continue; + } + seen.add(key); + + // Generate a temporary name - will be replaced with manifest.name during loading + const tempName = entry.expectedPluginName ?? generateTempPluginName(entry.package, entry.path); + + plugins.push({ + name: tempName, + source: entry.package ? "npm" : "local", + package: entry.package, + path: entry.path, + enabled: true, + }); + } + + return plugins; +} + /** Apply defaults to project configs */ function applyProjectDefaults(config: OrchestratorConfig): OrchestratorConfig { for (const [id, project] of Object.entries(config.projects)) { @@ -544,6 +752,15 @@ export function validateConfig(raw: unknown): OrchestratorConfig { config = applyProjectDefaults(config); config = applyDefaultReactions(config); + // Collect external plugin configs from inline tracker/scm/notifier configs + // and merge them into config.plugins for loading + const externalPluginEntries = collectExternalPluginConfigs(config); + if (externalPluginEntries.length > 0) { + config.plugins = mergeExternalPlugins(config.plugins, externalPluginEntries); + // Store entries for manifest validation during plugin loading + config._externalPluginEntries = externalPluginEntries; + } + // Validate project uniqueness and prefix collisions validateProjectUniqueness(config); diff --git a/packages/core/src/global-pause.ts b/packages/core/src/global-pause.ts deleted file mode 100644 index e8baed66e..000000000 --- a/packages/core/src/global-pause.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const GLOBAL_PAUSE_UNTIL_KEY = "globalPauseUntil"; -export const GLOBAL_PAUSE_REASON_KEY = "globalPauseReason"; -export const GLOBAL_PAUSE_SOURCE_KEY = "globalPauseSource"; - -export function parsePauseUntil(raw: string | undefined): Date | null { - if (!raw) return null; - const parsed = new Date(raw); - if (Number.isNaN(parsed.getTime())) return null; - return parsed; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ef028390a..9bd10086c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -84,15 +84,6 @@ export type { export { generateOrchestratorPrompt } from "./orchestrator-prompt.js"; export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js"; - -// Global pause constants and utilities -export { - GLOBAL_PAUSE_UNTIL_KEY, - GLOBAL_PAUSE_REASON_KEY, - GLOBAL_PAUSE_SOURCE_KEY, - parsePauseUntil, -} from "./global-pause.js"; - // Shared utilities export { shellEscape, diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 678619b43..86323a169 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -15,6 +15,7 @@ import { SESSION_STATUS, PR_STATE, CI_STATUS, + TERMINAL_STATUSES, type LifecycleManager, type SessionManager, type SessionId, @@ -207,6 +208,16 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan */ const prEnrichmentCache = new Map(); + /** + * Per-session timestamp of last review backlog API check. + * Used to throttle getPendingComments/getAutomatedComments to at most once per 2 minutes. + * In-memory only — resets on restart (acceptable since it's a rate-limit hint, not state). + */ + const lastReviewBacklogCheckAt = new Map(); + + /** Throttle interval for review backlog API calls (2 minutes). */ + const REVIEW_BACKLOG_THROTTLE_MS = 2 * 60 * 1000; + /** * Populate the PR enrichment cache using batch GraphQL queries. * This is called once per poll cycle to fetch data for all PRs efficiently. @@ -235,7 +246,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const [owner, repo] = p.repo.split("/"); return owner === pr.owner && repo === pr.repo; }); - if (!project?.scm) continue; + if (!project?.scm?.plugin) continue; const pluginKey = project.scm.plugin; if (!prsByPlugin.has(pluginKey)) { @@ -344,13 +355,13 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan if (!project) return session.status; const agentName = resolveAgentSelection({ - role: resolveSessionRole(session.id, session.metadata), + role: resolveSessionRole(session.id, session.metadata, project.sessionPrefix), project, defaults: config.defaults, persistedAgent: session.metadata["agent"], }).agentName; const agent = registry.get("agent", agentName); - const scm = project.scm ? registry.get("scm", project.scm.plugin) : null; + const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; // Track activity state across steps so stuck detection can run after PR checks let detectedIdleTimestamp: Date | null = null; @@ -693,7 +704,10 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return reactionConfig ? (reactionConfig as ReactionConfig) : null; } - function updateSessionMetadata(session: Session, updates: Partial>): void { + function updateSessionMetadata( + session: Session, + updates: Partial>, + ): void { const project = config.projects[session.projectId]; if (!project) return; @@ -726,15 +740,16 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const project = config.projects[session.projectId]; if (!project || !session.pr) return; - const scm = project.scm ? registry.get("scm", project.scm.plugin) : null; + const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; if (!scm) return; const humanReactionKey = "changes-requested"; const automatedReactionKey = "bugbot-comments"; - if (newStatus === "merged" || newStatus === "killed") { + if (TERMINAL_STATUSES.has(newStatus)) { clearReactionTracker(session.id, humanReactionKey); clearReactionTracker(session.id, automatedReactionKey); + lastReviewBacklogCheckAt.delete(session.id); updateSessionMetadata(session, { lastPendingReviewFingerprint: "", lastPendingReviewDispatchHash: "", @@ -746,6 +761,27 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return; } + // Throttle review backlog API calls to at most once per 2 minutes. + // Comments don't change faster than this in practice, and the SCM calls + // (getPendingComments + getAutomatedComments) consume API quota on every poll. + // + // Exception: bypass throttle when a transition reaction just fired for a + // review reaction key. The transitionReaction branch records + // lastPendingReviewDispatchHash, which requires the current fingerprint from + // the API. If we throttle here, that metadata never gets written and the + // next unthrottled poll sees a "new" fingerprint, clears the reaction tracker, + // and fires a duplicate dispatch. + const hasRelevantTransition = + transitionReaction?.key === humanReactionKey || + transitionReaction?.key === automatedReactionKey; + if (!hasRelevantTransition) { + const lastCheckAt = lastReviewBacklogCheckAt.get(session.id) ?? 0; + if (Date.now() - lastCheckAt < REVIEW_BACKLOG_THROTTLE_MS) { + return; + } + } + lastReviewBacklogCheckAt.set(session.id, Date.now()); + const [pendingResult, automatedResult] = await Promise.allSettled([ scm.getPendingComments(session.pr), scm.getAutomatedComments(session.pr), @@ -826,9 +862,12 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // --- Automated (bot) review comments --- if (automatedComments !== null) { - const automatedFingerprint = makeFingerprint(automatedComments.map((comment) => comment.id)); + const automatedFingerprint = makeFingerprint( + automatedComments.map((comment) => comment.id), + ); const lastAutomatedFingerprint = session.metadata["lastAutomatedReviewFingerprint"] ?? ""; - const lastAutomatedDispatchHash = session.metadata["lastAutomatedReviewDispatchHash"] ?? ""; + const lastAutomatedDispatchHash = + session.metadata["lastAutomatedReviewDispatchHash"] ?? ""; if (automatedFingerprint !== lastAutomatedFingerprint) { clearReactionTracker(session.id, automatedReactionKey); @@ -903,7 +942,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const project = config.projects[session.projectId]; if (!project || !session.pr) return; - const scm = project.scm ? registry.get("scm", project.scm.plugin) : null; + const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; if (!scm) return; const ciReactionKey = "ci-failed"; @@ -934,13 +973,22 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return; } - // Fetch individual CI checks for failure details + // Fetch individual CI checks for failure details. + // Use batch enrichment data when available to avoid an extra REST call; + // fall back to getCIChecks() when the batch didn't run this cycle. + const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; + const cachedEnrichment = prEnrichmentCache.get(prKey); + let checks: CICheck[]; - try { - checks = await scm.getCIChecks(session.pr); - } catch { - // Failed to fetch checks — skip this cycle - return; + if (cachedEnrichment?.ciChecks !== undefined) { + checks = cachedEnrichment.ciChecks; + } else { + try { + checks = await scm.getCIChecks(session.pr); + } catch { + // Failed to fetch checks — skip this cycle + return; + } } const failedChecks = checks.filter( @@ -1025,7 +1073,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const project = config.projects[session.projectId]; if (!project || !session.pr) return; - const scm = project.scm ? registry.get("scm", project.scm.plugin) : null; + const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; if (!scm) return; const conflictReactionKey = "merge-conflicts"; @@ -1051,14 +1099,19 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return; } - // Check for conflicts using cached enrichment data or fallback to individual call + // Check for conflicts using cached enrichment data or fallback to individual call. + // When batch enrichment ran (cachedData is present), use its hasConflicts value + // to avoid 3 redundant REST calls from getMergeability() — the batch already + // fetched the mergeable/mergeStateStatus fields via GraphQL. const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; const cachedData = prEnrichmentCache.get(prKey); let hasConflicts: boolean; - if (cachedData && cachedData.hasConflicts !== undefined) { - hasConflicts = cachedData.hasConflicts; + if (cachedData) { + // Batch ran — trust its data (undefined means CONFLICTING wasn't set → no conflicts) + hasConflicts = cachedData.hasConflicts ?? false; } else { + // Batch didn't run this cycle — fall back to individual API call try { const mergeReadiness = await scm.getMergeability(session.pr); hasConflicts = !mergeReadiness.noConflicts; @@ -1155,7 +1208,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan }); // Reset allCompleteEmitted when any session becomes active again - if (newStatus !== "merged" && newStatus !== "killed") { + if (!TERMINAL_STATUSES.has(newStatus)) { allCompleteEmitted = false; } @@ -1215,6 +1268,22 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan states.set(session.id, newStatus); } + // Pin first quality summary for title stability + if ( + session.agentInfo?.summary && + !session.agentInfo.summaryIsFallback && + !session.metadata["pinnedSummary"] + ) { + const trimmed = session.agentInfo.summary.replace(/[\n\r]/g, " ").trim(); + if (trimmed.length >= 5) { + try { + updateSessionMetadata(session, { pinnedSummary: trimmed }); + } catch { + // Non-critical: title just won't be pinned this cycle + } + } + } + await Promise.allSettled([ maybeDispatchReviewBacklog(session, oldStatus, newStatus, transitionReaction), maybeDispatchCIFailureDetails(session, oldStatus, newStatus, transitionReaction), @@ -1237,7 +1306,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // (e.g., list() detected a dead runtime and marked it "killed" — we need to // process that transition even though the new status is terminal) const sessionsToCheck = sessions.filter((s) => { - if (s.status !== "merged" && s.status !== "killed") return true; + if (!TERMINAL_STATUSES.has(s.status)) return true; const tracked = states.get(s.id); return tracked !== undefined && tracked !== s.status; }); @@ -1249,8 +1318,8 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // Poll all sessions concurrently await Promise.allSettled(sessionsToCheck.map((s) => checkSession(s))); - // Prune stale entries from states and reactionTrackers for sessions - // that no longer appear in the session list (e.g., after kill/cleanup) + // Prune stale entries from states, reactionTrackers, and lastReviewBacklogCheckAt + // for sessions that no longer appear in the session list (e.g., after kill/cleanup) const currentSessionIds = new Set(sessions.map((s) => s.id)); for (const trackedId of states.keys()) { if (!currentSessionIds.has(trackedId)) { @@ -1263,9 +1332,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan reactionTrackers.delete(trackerKey); } } + for (const sessionId of lastReviewBacklogCheckAt.keys()) { + if (!currentSessionIds.has(sessionId)) { + lastReviewBacklogCheckAt.delete(sessionId); + } + } // Check if all sessions are complete (trigger reaction only once) - const activeSessions = sessions.filter((s) => s.status !== "merged" && s.status !== "killed"); + const activeSessions = sessions.filter((s) => !TERMINAL_STATUSES.has(s.status)); if (sessions.length > 0 && activeSessions.length === 0 && !allCompleteEmitted) { allCompleteEmitted = true; diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 7010e3f88..de5bda0b1 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -93,6 +93,7 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta ? Number(raw["directTerminalWsPort"]) : undefined, opencodeSessionId: raw["opencodeSessionId"], + pinnedSummary: raw["pinnedSummary"], }; } @@ -142,6 +143,7 @@ export function writeMetadata( if (metadata.directTerminalWsPort !== undefined) data["directTerminalWsPort"] = String(metadata.directTerminalWsPort); if (metadata.opencodeSessionId) data["opencodeSessionId"] = metadata.opencodeSessionId; + if (metadata.pinnedSummary) data["pinnedSummary"] = metadata.pinnedSummary; atomicWriteFileSync(path, serializeMetadata(data)); } diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index d807d956d..e05e395ff 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -11,6 +11,7 @@ import { existsSync, readFileSync, statSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import type { + ExternalPluginEntryRef, InstalledPluginConfig, PluginSlot, PluginManifest, @@ -60,30 +61,178 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> = { slot: "terminal", name: "web", pkg: "@composio/ao-plugin-terminal-web" }, ]; -/** Extract plugin-specific config from orchestrator config */ function extractPluginConfig( slot: PluginSlot, name: string, config: OrchestratorConfig, ): Record | undefined { - // Notifiers are configured under config.notifiers.. - // Match by key (e.g. "openclaw") or explicit plugin field. + // 1. Handle Notifier Slot if (slot === "notifier") { - for (const [notifierName, notifierConfig] of Object.entries(config.notifiers ?? {})) { + for (const [notifierId, notifierConfig] of Object.entries(config.notifiers ?? {})) { if (!notifierConfig || typeof notifierConfig !== "object") continue; const configuredPlugin = (notifierConfig as Record)["plugin"]; const hasExplicitPlugin = typeof configuredPlugin === "string" && configuredPlugin.length > 0; - const matches = hasExplicitPlugin ? configuredPlugin === name : notifierName === name; + const matches = hasExplicitPlugin ? configuredPlugin === name : notifierId === name; + if (matches) { - const { plugin: _plugin, ...rest } = notifierConfig as Record; - return config.configPath ? { ...rest, configPath: config.configPath } : rest; + return prepareConfig(slot, name, notifierId, notifierConfig, config.configPath); } } } + // 2. Handle Tracker and SCM Slots (Project-level) + // Tracker and SCM plugins are typically stateless singletons that receive + // project-specific config per-call (via ProjectConfig argument), not at create() time. + // This applies to BOTH built-in and external plugins to avoid order-dependent + // behavior when multiple projects share the same plugin but have different configs. + // Return undefined so plugins are initialized without project-specific config. + if (slot === "tracker" || slot === "scm") { + return undefined; + } + return undefined; } +/** + * Internal helper to validate and strip loading metadata from a plugin configuration. + * Reserved fields (plugin, package, path) are used for plugin resolution and stripped. + */ +function prepareConfig( + slot: string, + name: string, + sourceId: string, + rawConfig: Record, + configPath?: string, +): Record { + // Explicitly check for reserved fields to prevent silent stripping/collision. + // 'path' is reserved for local resolution; 'package' is reserved for npm resolution. + if ("package" in rawConfig && "path" in rawConfig) { + throw new Error( + `In ${slot} "${sourceId}": both "package" and "path" are specified. ` + + `Use "package" for npm plugins or "path" for local plugins, not both.`, + ); + } + + // If loading via built-in name or npm package, having a 'path' field is ambiguous: + // it could be a local plugin path (for loading) or a plugin config value. + // We reject this to avoid silently stripping a config value the user intended to pass. + const isBuiltin = BUILTIN_PLUGINS.some((b) => b.slot === slot && b.name === name); + if ((rawConfig.package || isBuiltin) && "path" in rawConfig) { + const loadingMethod = rawConfig.package ? `npm package "${rawConfig.package}"` : `built-in plugin "${name}"`; + throw new Error( + `In ${slot} "${sourceId}": "path" field conflicts with reserved plugin loading field. ` + + `You're loading via ${loadingMethod}, but also have a "path" field which would be stripped. ` + + `Rename your configuration field to something else (e.g., "apiPath", "webhookPath").`, + ); + } + + // Strip loading metadata fields (plugin, package, path) from config passed to plugin. + const { plugin: _plugin, package: _package, path: _path, ...rest } = rawConfig; + return configPath ? { ...rest, configPath } : rest; +} + +/** + * Build an index of external plugin entries by package/path for O(1) lookups. + * Multiple entries can share the same package/path (e.g., multiple projects using same plugin). + */ +function buildExternalPluginIndex( + externalEntries: ExternalPluginEntryRef[] | undefined, +): Map { + const index = new Map(); + if (!externalEntries) return index; + + for (const entry of externalEntries) { + const key = entry.package ? `package:${entry.package}` : `path:${entry.path}`; + const existing = index.get(key); + if (existing) { + existing.push(entry); + } else { + index.set(key, [entry]); + } + } + + return index; +} + +/** + * Find ALL external plugin entries that match a given plugin config. + * Used for manifest.name validation when loading inline tracker/scm/notifier plugins. + * + * Returns all matching entries because multiple projects may share the same + * external plugin (same package/path), and all their configs need to be updated + * with the actual manifest.name. + */ +function findAllExternalPluginEntries( + plugin: InstalledPluginConfig, + externalIndex: Map, +): ExternalPluginEntryRef[] { + if (plugin.package) { + return externalIndex.get(`package:${plugin.package}`) ?? []; + } + if (plugin.path) { + return externalIndex.get(`path:${plugin.path}`) ?? []; + } + return []; +} + +/** + * Validate that a plugin's manifest.name matches the expected name (if specified). + * Throws an error if there's a mismatch. + */ +function validateManifestName( + manifest: PluginManifest, + entry: ExternalPluginEntryRef, + specifier: string, +): void { + // If the user specified an explicit plugin name, validate it matches the manifest + if (entry.expectedPluginName && entry.expectedPluginName !== manifest.name) { + const specifierType = entry.package ? "package" : "path"; + throw new Error( + `Plugin manifest.name mismatch at ${entry.source}: ` + + `expected "${entry.expectedPluginName}" but ${specifierType} "${specifier}" has manifest.name "${manifest.name}". ` + + `Either update the 'plugin' field to match the actual manifest.name, or remove it to auto-infer.`, + ); + } +} + +/** + * Update the config with the actual plugin name after loading an external plugin. + * This ensures resolvePlugins() can look up the plugin by its manifest.name. + * + * Uses structured location data to avoid ambiguity from parsing dotted strings + * (project/notifier keys can legally contain dots). + */ +function updateConfigWithManifestName( + manifest: PluginManifest, + entry: ExternalPluginEntryRef, + config: OrchestratorConfig, +): void { + const { location, slot, source } = entry; + + // Use structured location to update the config + if (location.kind === "project") { + const { projectId, configType } = location; + const project = config.projects[projectId]; + if (project?.[configType]) { + project[configType]!.plugin = manifest.name; + } + } else if (location.kind === "notifier") { + const { notifierId } = location; + const notifierConfig = config.notifiers[notifierId]; + if (notifierConfig) { + notifierConfig.plugin = manifest.name; + } + } + + // Also validate slot matches + if (manifest.slot !== slot) { + process.stderr.write( + `[plugin-registry] Plugin at ${source} has slot "${manifest.slot}" but was configured as "${slot}". ` + + `The plugin will be registered under its declared slot "${manifest.slot}".\n`, + ); + } +} + export function isPluginModule(value: unknown): value is PluginModule { if (!value || typeof value !== "object") return false; const candidate = value as Partial; @@ -239,16 +388,25 @@ export function createPluginRegistry(): PluginRegistry { ): Promise { const doImport = importFn ?? ((pkg: string) => import(pkg)); for (const builtin of BUILTIN_PLUGINS) { + let mod; try { - const mod = normalizeImportedPluginModule(await doImport(builtin.pkg)); - if (mod) { + mod = normalizeImportedPluginModule(await doImport(builtin.pkg)); + } catch { + // Plugin not installed — that's fine, only load what's available + continue; + } + + if (mod) { + try { const pluginConfig = orchestratorConfig ? extractPluginConfig(builtin.slot, builtin.name, orchestratorConfig) : undefined; this.register(mod, pluginConfig); + } catch (error) { + process.stderr.write( + `[plugin-registry] Failed to load built-in plugin "${builtin.name}": ${error}\n`, + ); } - } catch { - // Plugin not installed — that's fine, only load what's available } } }, @@ -261,13 +419,15 @@ export function createPluginRegistry(): PluginRegistry { await this.loadBuiltins(config, importFn); const doImport = importFn ?? ((pkg: string) => import(pkg)); + // Build index once for O(1) lookups when matching plugins to external entries + const externalIndex = buildExternalPluginIndex(config._externalPluginEntries); for (const plugin of config.plugins ?? []) { if (plugin.enabled === false) continue; const specifier = resolvePluginSpecifier(plugin, config); if (!specifier) { - console.warn(`[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})`); + process.stderr.write(`[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})\n`); continue; } @@ -275,10 +435,34 @@ export function createPluginRegistry(): PluginRegistry { const mod = normalizeImportedPluginModule(await doImport(specifier)); if (!mod) continue; + // Check if this plugin was auto-added from inline tracker/scm/notifier config. + // Multiple projects may share the same external plugin, so find ALL matching entries. + // We validate and update configs FIRST, before extracting plugin config, because + // extractPluginConfig looks up by manifest.name which may differ from the temp name. + const matchingEntries = findAllExternalPluginEntries(plugin, externalIndex); + for (const externalEntry of matchingEntries) { + try { + // Validate manifest.name matches expectedPluginName (if specified) + validateManifestName(mod.manifest, externalEntry, specifier); + // Update the config with the actual manifest.name + updateConfigWithManifestName(mod.manifest, externalEntry, config); + } catch (validationError) { + // Log validation errors but don't abort - other projects can still use the plugin. + // The misconfigured project will fail later when it tries to use the plugin + // with the wrong name, giving a clearer error at point of use. + process.stderr.write( + `[plugin-registry] Config validation failed for ${externalEntry.source}: ${validationError}\n`, + ); + } + } + + // Extract plugin config AFTER updating configs with manifest.name. + // This ensures extractPluginConfig can find the config by manifest.name + // (e.g., manifest "ms-teams" after config was updated from temp "teams"). const pluginConfig = extractPluginConfig(mod.manifest.slot, mod.manifest.name, config); this.register(mod, pluginConfig); } catch (error) { - console.warn(`[plugin-registry] Failed to load plugin "${specifier}":`, error); + process.stderr.write(`[plugin-registry] Failed to load plugin "${specifier}": ${error}\n`); } } }, diff --git a/packages/core/src/recovery/validator.ts b/packages/core/src/recovery/validator.ts index 3a896e06f..410e0afbe 100644 --- a/packages/core/src/recovery/validator.ts +++ b/packages/core/src/recovery/validator.ts @@ -31,7 +31,7 @@ export async function validateSession( const runtimeName = project.runtime ?? config.defaults.runtime; const agentName = resolveAgentSelection({ - role: resolveSessionRole(sessionId, rawMetadata), + role: resolveSessionRole(sessionId, rawMetadata, project.sessionPrefix), project, defaults: config.defaults, persistedAgent: rawMetadata["agent"], diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index d23333cc4..7284baba7 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -11,7 +11,7 @@ * Reference: scripts/claude-ao-session, scripts/send-to-session */ -import { statSync, existsSync, readdirSync, writeFileSync, mkdirSync, utimesSync } from "node:fs"; +import { statSync, existsSync, readdirSync, writeFileSync, mkdirSync, utimesSync, unlinkSync } from "node:fs"; import { execFile } from "node:child_process"; import { basename, join, resolve } from "node:path"; import { homedir } from "node:os"; @@ -41,7 +41,6 @@ import { type PluginRegistry, type RuntimeHandle, type Issue, - isOrchestratorSession, PR_STATE, } from "./types.js"; import { @@ -60,17 +59,10 @@ import { getWorktreesDir, getProjectBaseDir, generateTmuxName, - generateConfigHash, validateAndStoreOrigin, } from "./paths.js"; import { asValidOpenCodeSessionId } from "./opencode-session-id.js"; import { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js"; -import { - GLOBAL_PAUSE_REASON_KEY, - GLOBAL_PAUSE_SOURCE_KEY, - GLOBAL_PAUSE_UNTIL_KEY, - parsePauseUntil, -} from "./global-pause.js"; import { sessionFromMetadata } from "./utils/session-from-metadata.js"; import { safeJsonParse } from "./utils/validation.js"; import { resolveAgentSelection, resolveSessionRole } from "./agent-selection.js"; @@ -286,27 +278,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return getSessionsDir(config.configPath, project.path); } - function getProjectPause(project: ProjectConfig): { - until: Date; - reason: string; - sourceSessionId: string; - } | null { - const sessionsDir = getProjectSessionsDir(project); - const orchestratorId = `${project.sessionPrefix}-orchestrator`; - const orchestratorRaw = readMetadataRaw(sessionsDir, orchestratorId); - if (!orchestratorRaw) return null; - - const until = parsePauseUntil(orchestratorRaw[GLOBAL_PAUSE_UNTIL_KEY]); - if (!until) return null; - if (until.getTime() <= Date.now()) return null; - - return { - until, - reason: orchestratorRaw[GLOBAL_PAUSE_REASON_KEY] ?? "Model rate limit reached", - sourceSessionId: orchestratorRaw[GLOBAL_PAUSE_SOURCE_KEY] ?? "unknown", - }; - } - function normalizePath(path: string): string { return resolve(path).replace(/\/$/, ""); } @@ -358,9 +329,17 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM function isOrchestratorSessionRecord( sessionId: string, raw: Record | null | undefined, + sessionPrefix?: string, ): boolean { if (!raw) return false; - return raw["role"] === "orchestrator" || sessionId.endsWith("-orchestrator"); + if (raw["role"] === "orchestrator" || sessionId.endsWith("-orchestrator")) return true; + // Check the -orchestrator-N pattern only when the prefix is known so the + // regex is anchored to the project prefix, preventing false-positives when + // the user-configured sessionPrefix itself ends with "-orchestrator". + if (sessionPrefix) { + return new RegExp(`^${escapeRegex(sessionPrefix)}-orchestrator-\\d+$`).test(sessionId); + } + return false; } function isCleanupProtectedSession( @@ -368,11 +347,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM sessionId: string, metadata?: Record | null, ): boolean { - const canonicalOrchestratorId = `${project.sessionPrefix}-orchestrator`; - return ( - sessionId === canonicalOrchestratorId || - isOrchestratorSession({ id: sessionId, metadata: metadata ?? undefined }) - ); + return isOrchestratorSessionRecord(sessionId, metadata ?? {}, project.sessionPrefix); } function applyMetadataUpdatesToRaw( @@ -422,9 +397,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM function repairSingleSessionMetadataOnRead( sessionsDir: string, record: ActiveSessionRecord, + sessionPrefix?: string, ): ActiveSessionRecord { const repaired = { ...record, raw: { ...record.raw } }; - if (!isOrchestratorSessionRecord(repaired.sessionName, repaired.raw)) { + if (!isOrchestratorSessionRecord(repaired.sessionName, repaired.raw, sessionPrefix)) { return repaired; } @@ -464,13 +440,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM function repairSessionMetadataOnRead( sessionsDir: string, records: ActiveSessionRecord[], + sessionPrefix?: string, ): ActiveSessionRecord[] { const repaired = records.map((record) => ({ ...record, raw: { ...record.raw } })); const duplicatePRAttachments = new Map(); for (const record of repaired) { - if (isOrchestratorSessionRecord(record.sessionName, record.raw)) { - record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record).raw; + if (isOrchestratorSessionRecord(record.sessionName, record.raw, sessionPrefix)) { + record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record, sessionPrefix).raw; continue; } @@ -531,7 +508,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return [{ sessionName, raw, modifiedAt } satisfies ActiveSessionRecord]; }); - return repairSessionMetadataOnRead(sessionsDir, records); + return repairSessionMetadataOnRead(sessionsDir, records, project.sessionPrefix); } function markArchivedOpenCodeCleanup(sessionsDir: string, sessionId: SessionId): void { @@ -702,6 +679,66 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM ); } + /** + * Reserve a unique orchestrator identity ({prefix}-orchestrator-N) for a worktree-based orchestrator. + * Unlike worker sessions, orchestrator IDs are assigned locally without remote branch checks. + */ + function reserveNextOrchestratorIdentity( + project: ProjectConfig, + sessionsDir: string, + ): { num: number; sessionId: string; tmuxName: string | undefined } { + const orchestratorPrefix = `${project.sessionPrefix}-orchestrator`; + const usedNumbers = new Set(); + + const orchestratorPattern = new RegExp(`^${escapeRegex(orchestratorPrefix)}-(\\d+)$`); + for (const sessionName of [ + ...listMetadata(sessionsDir), + ...listArchivedSessionIds(sessionsDir), + ]) { + const match = sessionName.match(orchestratorPattern); + if (match) { + const parsed = Number.parseInt(match[1], 10); + if (!Number.isNaN(parsed)) usedNumbers.add(parsed); + } + } + + // Build worker-ID patterns for all other projects. If another project has + // sessionPrefix === orchestratorPrefix (e.g. project B has prefix "app-orchestrator"), + // then its workers are named "app-orchestrator-1", "app-orchestrator-2", etc. — which + // would collide with our orchestrator IDs. Detect this impossible configuration early. + for (const [otherProjectId, otherProject] of Object.entries(config.projects)) { + const otherPrefix = otherProject.sessionPrefix ?? otherProjectId; + if (otherPrefix === project.sessionPrefix) continue; + if (otherPrefix === orchestratorPrefix) { + // Another project's workers are "{otherPrefix}-\d+" which equals "{orchestratorPrefix}-\d+". + // Every candidate ID we would generate collides — fail immediately with a clear message. + throw new Error( + `Cannot spawn orchestrator for project "${project.sessionPrefix}": the orchestrator ID prefix "${orchestratorPrefix}" ` + + `conflicts with the session prefix of project "${otherProjectId}" ("${otherPrefix}"). ` + + `Rename one of the project sessionPrefix values to avoid this overlap.`, + ); + } + } + + let num = 1; + for (let attempts = 0; attempts < 10_000; attempts++) { + if (!usedNumbers.has(num)) { + const sessionId = `${orchestratorPrefix}-${num}`; + const tmuxName = config.configPath + ? generateTmuxName(config.configPath, orchestratorPrefix, num) + : undefined; + if (reserveSessionId(sessionsDir, sessionId)) { + return { num, sessionId, tmuxName }; + } + } + num += 1; + } + + throw new Error( + `Failed to reserve orchestrator session ID after 10000 attempts (prefix: ${orchestratorPrefix})`, + ); + } + /** Resolve which plugins to use for a project. */ function resolvePlugins(project: ProjectConfig, agentName?: string) { const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime); @@ -710,10 +747,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM "workspace", project.workspace ?? config.defaults.workspace, ); - const tracker = project.tracker - ? registry.get("tracker", project.tracker.plugin) - : null; - const scm = project.scm ? registry.get("scm", project.scm.plugin) : null; + // After config validation, plugin is always set if tracker/scm exists + // (either from user config or auto-generated from package/path) + const tracker = + project.tracker?.plugin ? registry.get("tracker", project.tracker.plugin) : null; + const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; return { runtime, agent, workspace, tracker, scm }; } @@ -724,7 +762,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM metadata: Record, ) { return resolveAgentSelection({ - role: resolveSessionRole(sessionId, metadata), + role: resolveSessionRole(sessionId, metadata, project.sessionPrefix), project, defaults: config.defaults, persistedAgent: metadata["agent"], @@ -765,11 +803,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM modifiedAt = undefined; } - const repaired = repairSingleSessionMetadataOnRead(sessionsDir, { - sessionName: sessionId, - raw, - modifiedAt, - }); + const repaired = repairSingleSessionMetadataOnRead( + sessionsDir, + { sessionName: sessionId, raw, modifiedAt }, + project.sessionPrefix, + ); return { raw: repaired.raw, sessionsDir, project, projectId }; } @@ -896,13 +934,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM throw new Error(`Unknown project: ${spawnConfig.projectId}`); } - const pause = getProjectPause(project); - if (pause) { - throw new Error( - `Project is paused due to model rate limit until ${pause.until.toISOString()} (${pause.reason}; source: ${pause.sourceSessionId})`, - ); - } - const selection = resolveAgentSelection({ role: "worker", project, @@ -1227,13 +1258,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM throw new Error(`Unknown project: ${orchestratorConfig.projectId}`); } - const pause = getProjectPause(project); - if (pause) { - throw new Error( - `Project is paused due to model rate limit until ${pause.until.toISOString()} (${pause.reason}; source: ${pause.sourceSessionId})`, - ); - } - const selection = resolveAgentSelection({ role: "orchestrator", project, @@ -1247,29 +1271,88 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM throw new Error(`Agent plugin '${selection.agentName}' not found`); } - const sessionId = `${project.sessionPrefix}-orchestrator`; const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy( project.orchestratorSessionStrategy, ); - // Generate tmux name if using new architecture - let tmuxName: string | undefined; - if (config.configPath) { - const hash = generateConfigHash(config.configPath); - tmuxName = `${hash}-${sessionId}`; - } - // Get the sessions directory for this project const sessionsDir = getProjectSessionsDir(project); - // Validate and store .origin file + // Validate and store .origin file before reserving any identity so that + // a validation failure does not leave an orphaned metadata entry. if (config.configPath) { validateAndStoreOrigin(config.configPath, project.path); } + // Reserve a new unique orchestrator identity (e.g. {prefix}-orchestrator-1, -2, …). + // Each spawnOrchestrator call gets its own numbered session and isolated worktree. + const identity = reserveNextOrchestratorIdentity(project, sessionsDir); + const sessionId = identity.sessionId; + const tmuxName = identity.tmuxName; + + // Each orchestrator gets an isolated worktree on its own branch. + const branch = `orchestrator/${sessionId}`; + + if (!plugins.workspace) { + try { + deleteMetadata(sessionsDir, sessionId, false); + } catch { + /* best effort */ + } + throw new Error( + `spawnOrchestrator requires a workspace plugin but none is configured for project '${orchestratorConfig.projectId}'`, + ); + } + + let workspacePath: string; + try { + const wsInfo = await plugins.workspace.create({ + projectId: orchestratorConfig.projectId, + project, + sessionId, + branch, + }); + workspacePath = wsInfo.path; + } catch (err) { + try { + deleteMetadata(sessionsDir, sessionId, false); + } catch { + /* best effort */ + } + throw err; + } + + // Helper: undo worktree + metadata if anything between workspace creation + // and a fully-written metadata record fails. + const cleanupWorktreeAndMetadata = async (promptFile?: string): Promise => { + try { + // plugins.workspace is guaranteed non-null here: we threw above if it was null + await plugins.workspace!.destroy(workspacePath); + } catch { + /* best effort */ + } + try { + deleteMetadata(sessionsDir, sessionId, false); + } catch { + /* best effort */ + } + if (promptFile) { + try { + unlinkSync(promptFile); + } catch { + /* best effort */ + } + } + }; + // Setup agent hooks for automatic metadata updates - if (plugins.agent.setupWorkspaceHooks) { - await plugins.agent.setupWorkspaceHooks(project.path, { dataDir: sessionsDir }); + try { + if (plugins.agent.setupWorkspaceHooks) { + await plugins.agent.setupWorkspaceHooks(workspacePath, { dataDir: sessionsDir }); + } + } catch (err) { + await cleanupWorktreeAndMetadata(); + throw err; } // Write system prompt to a file to avoid shell/tmux truncation. @@ -1277,93 +1360,38 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // via tmux send-keys or paste-buffer. File-based approach is reliable. let systemPromptFile: string | undefined; if (orchestratorConfig.systemPrompt) { - const baseDir = getProjectBaseDir(config.configPath, project.path); - mkdirSync(baseDir, { recursive: true }); - systemPromptFile = join(baseDir, "orchestrator-prompt.md"); - writeFileSync(systemPromptFile, orchestratorConfig.systemPrompt, "utf-8"); - } - - const existingRaw = readMetadataRaw(sessionsDir, sessionId); - const existingOrchestrator = existingRaw?.["runtimeHandle"] - ? metadataToSession(sessionId, existingRaw, orchestratorConfig.projectId) - : null; - if (existingOrchestrator?.runtimeHandle) { - const existingAlive = await plugins.runtime - .isAlive(existingOrchestrator.runtimeHandle) - .catch(() => false); - if (existingAlive && orchestratorSessionStrategy === "reuse") { - const persistedRaw = readMetadataRaw(sessionsDir, sessionId); - if (persistedRaw?.["runtimeHandle"]) { - const persisted = metadataToSession( - sessionId, - persistedRaw, - orchestratorConfig.projectId, - ); - persisted.metadata["orchestratorSessionReused"] = "true"; - return persisted; - } - await plugins.runtime.destroy(existingOrchestrator.runtimeHandle).catch(() => undefined); - deleteMetadata(sessionsDir, sessionId, false); - } - if (existingAlive && orchestratorSessionStrategy !== "reuse") { - await plugins.runtime.destroy(existingOrchestrator.runtimeHandle).catch(() => undefined); - // Destroy runtime and delete metadata without archive for ignore strategy - deleteMetadata(sessionsDir, sessionId, false); - } - // For dead runtime, delete metadata so reserveSessionId can succeed: - // - With reuse strategy + opencode: archive to preserve opencodeSessionId for reuse lookup - // - With non-reuse strategy: delete without archive to respawn fresh - if (!existingAlive) { - deleteMetadata(sessionsDir, sessionId, orchestratorSessionStrategy === "reuse"); + try { + const baseDir = getProjectBaseDir(config.configPath, project.path); + mkdirSync(baseDir, { recursive: true }); + systemPromptFile = join(baseDir, `orchestrator-prompt-${sessionId}.md`); + writeFileSync(systemPromptFile, orchestratorConfig.systemPrompt, "utf-8"); + } catch (err) { + await cleanupWorktreeAndMetadata(systemPromptFile); + throw err; } } - // Atomically reserve the session ID before creating any resources. - // This prevents race conditions where concurrent spawnOrchestrator calls - // both see no existing session and proceed to create duplicate runtimes. - let reserved = reserveSessionId(sessionsDir, sessionId); - if (!reserved) { - // Reservation failed - another process reserved it first. - // Check if the session now exists and is alive. - const concurrentRaw = readMetadataRaw(sessionsDir, sessionId); - const concurrentSession = concurrentRaw?.["runtimeHandle"] - ? metadataToSession(sessionId, concurrentRaw, orchestratorConfig.projectId) - : null; - if (concurrentSession?.runtimeHandle) { - const concurrentAlive = await plugins.runtime - .isAlive(concurrentSession.runtimeHandle) - .catch(() => false); - if (concurrentAlive && orchestratorSessionStrategy === "reuse") { - concurrentSession.metadata["orchestratorSessionReused"] = "true"; - return concurrentSession; - } - if (!concurrentAlive) { - deleteMetadata(sessionsDir, sessionId, orchestratorSessionStrategy === "reuse"); - reserved = reserveSessionId(sessionsDir, sessionId); - } - } else { - reserved = reserveSessionId(sessionsDir, sessionId); + let reusableOpenCodeSessionId: string | undefined; + try { + reusableOpenCodeSessionId = + plugins.agent.name === "opencode" && orchestratorSessionStrategy === "reuse" + ? await resolveOpenCodeSessionReuse({ + sessionsDir, + criteria: { sessionId }, + strategy: "reuse", + }) + : undefined; + if (plugins.agent.name === "opencode" && orchestratorSessionStrategy === "delete") { + await resolveOpenCodeSessionReuse({ + sessionsDir, + criteria: { sessionId }, + strategy: "delete", + includeTitleDiscoveryForSessionId: true, + }); } - if (!reserved) { - throw new Error(`Session ${sessionId} already exists but is not in a reusable state`); - } - } - - const reusableOpenCodeSessionId = - plugins.agent.name === "opencode" && orchestratorSessionStrategy === "reuse" - ? await resolveOpenCodeSessionReuse({ - sessionsDir, - criteria: { sessionId }, - strategy: "reuse", - }) - : undefined; - if (plugins.agent.name === "opencode" && orchestratorSessionStrategy === "delete") { - await resolveOpenCodeSessionReuse({ - sessionsDir, - criteria: { sessionId }, - strategy: "delete", - includeTitleDiscoveryForSessionId: true, - }); + } catch (err) { + await cleanupWorktreeAndMetadata(systemPromptFile); + throw err; } // Get agent launch config — uses systemPromptFile, no issue/tracker interaction. @@ -1387,22 +1415,29 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig); const environment = plugins.agent.getEnvironment(agentLaunchConfig); - const handle = await plugins.runtime.create({ - sessionId: tmuxName ?? sessionId, - workspacePath: project.path, - launchCommand, - environment: { - ...environment, - AO_SESSION: sessionId, - AO_DATA_DIR: sessionsDir, - AO_SESSION_NAME: sessionId, - ...(tmuxName && { AO_TMUX_NAME: tmuxName }), - AO_CALLER_TYPE: "orchestrator", - AO_PROJECT_ID: orchestratorConfig.projectId, - AO_CONFIG_PATH: config.configPath, - ...(config.port !== undefined && config.port !== null && { AO_PORT: String(config.port) }), - }, - }); + // Create runtime — clean up worktree and metadata on failure + let handle: RuntimeHandle; + try { + handle = await plugins.runtime.create({ + sessionId: tmuxName ?? sessionId, + workspacePath, + launchCommand, + environment: { + ...environment, + AO_SESSION: sessionId, + AO_DATA_DIR: sessionsDir, + AO_SESSION_NAME: sessionId, + ...(tmuxName && { AO_TMUX_NAME: tmuxName }), + AO_CALLER_TYPE: "orchestrator", + AO_PROJECT_ID: orchestratorConfig.projectId, + AO_CONFIG_PATH: config.configPath, + ...(config.port !== undefined && config.port !== null && { AO_PORT: String(config.port) }), + }, + }); + } catch (err) { + await cleanupWorktreeAndMetadata(systemPromptFile); + throw err; + } // Write metadata and run post-launch setup const session: Session = { @@ -1410,10 +1445,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM projectId: orchestratorConfig.projectId, status: "working", activity: "active", - branch: project.defaultBranch, + branch, issueId: null, pr: null, - workspacePath: project.path, + workspacePath, runtimeHandle: handle, agentInfo: null, createdAt: new Date(), @@ -1425,8 +1460,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM try { writeMetadata(sessionsDir, sessionId, { - worktree: project.path, - branch: project.defaultBranch, + worktree: workspacePath, + branch, status: "working", role: "orchestrator", tmuxName, @@ -1465,11 +1500,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } catch { /* best effort */ } - try { - deleteMetadata(sessionsDir, sessionId, false); - } catch { - /* best effort */ - } + await cleanupWorktreeAndMetadata(systemPromptFile); throw err; } @@ -1560,11 +1591,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // If stat fails, timestamps will fall back to current time } - const repaired = repairSingleSessionMetadataOnRead(sessionsDir, { - sessionName: sessionId, - raw, - modifiedAt, - }); + const repaired = repairSingleSessionMetadataOnRead( + sessionsDir, + { sessionName: sessionId, raw, modifiedAt }, + project.sessionPrefix, + ); const session = metadataToSession(sessionId, repaired.raw, projectId, createdAt, modifiedAt); @@ -1814,13 +1845,6 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM async function send(sessionId: SessionId, message: string): Promise { const { raw, sessionsDir, project } = requireSessionRecord(sessionId); - const pause = getProjectPause(project); - const orchestratorId = `${project.sessionPrefix}-orchestrator`; - if (pause && sessionId !== orchestratorId) { - throw new Error( - `Project is paused due to model rate limit until ${pause.until.toISOString()} (${pause.reason}; source: ${pause.sourceSessionId})`, - ); - } const selection = resolveSelectionForSession(project, sessionId, raw); const selectedAgent = selection.agentName; @@ -2108,7 +2132,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (!reference) throw new Error("PR reference is required"); const { raw, sessionsDir, project, projectId } = requireSessionRecord(sessionId); - if (isOrchestratorSessionRecord(sessionId, raw)) { + if (isOrchestratorSessionRecord(sessionId, raw, project.sessionPrefix)) { throw new Error(`Session ${sessionId} is an orchestrator session and cannot claim PRs`); } @@ -2135,7 +2159,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM ); for (const { sessionName, raw: otherRaw } of activeRecords) { - if (!otherRaw || isOrchestratorSessionRecord(sessionName, otherRaw)) continue; + if (!otherRaw || isOrchestratorSessionRecord(sessionName, otherRaw, project.sessionPrefix)) continue; const samePr = otherRaw["pr"] === pr.url; const sameBranch = @@ -2309,6 +2333,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM createdAt: raw["createdAt"], runtimeHandle: raw["runtimeHandle"], opencodeSessionId: raw["opencodeSessionId"], + pinnedSummary: raw["pinnedSummary"], }); } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a5c5f804a..463dc02ea 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -189,11 +189,37 @@ export interface Session { metadata: Record; } -export function isOrchestratorSession(session: { - id: SessionId; - metadata?: Record; -}): boolean { - return session.metadata?.["role"] === "orchestrator" || session.id.endsWith("-orchestrator"); +export function isOrchestratorSession( + session: { id: SessionId; metadata?: Record }, + sessionPrefix?: string, + allSessionPrefixes?: string[], +): boolean { + if (session.metadata?.["role"] === "orchestrator" || session.id.endsWith("-orchestrator")) { + return true; + } + if (!sessionPrefix) { + return false; + } + const escaped = sessionPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if (!new RegExp(`^${escaped}-orchestrator-\\d+$`).test(session.id)) { + return false; + } + // Guard against cross-project false positives: if the session ID is a plain + // numbered worker for any other known prefix (e.g. prefix "app-orchestrator" + // matches "app-orchestrator-1" as a worker), it is not an orchestrator. + if (allSessionPrefixes) { + for (const prefix of allSessionPrefixes) { + if (prefix === sessionPrefix) continue; + if ( + new RegExp( + `^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\d+$`, + ).test(session.id) + ) { + return false; + } + } + } + return true; } /** Config for creating a new session */ @@ -790,6 +816,8 @@ export interface PREnrichmentData { isBehind?: boolean; /** List of blockers preventing merge */ blockers?: string[]; + /** Individual CI check results (populated from batch enrichment when available) */ + ciChecks?: CICheck[]; } /** @@ -1013,6 +1041,43 @@ export interface OrchestratorConfig { /** Default reaction configs */ reactions: Record; + + /** + * Internal: External plugin entries collected from inline tracker/scm/notifier configs. + * Used by plugin-registry for manifest validation. Set automatically during config validation. + */ + _externalPluginEntries?: ExternalPluginEntryRef[]; +} + +/** + * Structured location of an external plugin config. + * Used to update config with manifest.name after loading (avoids parsing dotted strings). + */ +export type ExternalPluginLocation = + | { kind: "project"; projectId: string; configType: "tracker" | "scm" } + | { kind: "notifier"; notifierId: string }; + +/** + * Reference to an external plugin config (from inline tracker/scm/notifier configs). + * Used for manifest.name validation during plugin loading. + */ +export interface ExternalPluginEntryRef { + /** Where this config came from (for error messages) */ + source: string; + /** Structured location for updating config (avoids parsing source string) */ + location: ExternalPluginLocation; + /** The slot this plugin fills */ + slot: "tracker" | "scm" | "notifier"; + /** npm package name (if specified) */ + package?: string; + /** Local path (if specified) */ + path?: string; + /** + * Expected plugin name (manifest.name). + * Only set when user explicitly specified `plugin` field. + * When undefined, any manifest.name is accepted and config is updated with it. + */ + expectedPluginName?: string; } export interface DefaultPlugins { @@ -1135,13 +1200,41 @@ export interface ProjectConfig { } export interface TrackerConfig { - plugin: string; + /** + * Plugin name (manifest.name). Required when using built-in plugins. + * Optional when `package` or `path` is specified (will be inferred from manifest). + * When both plugin and package/path are specified, manifest.name must match plugin. + * + * POST-VALIDATION INVARIANT: After validateConfig(), this field is ALWAYS populated. + * Either from user input, inferred from repo (github/gitlab), or auto-generated from + * package/path via generateTempPluginName(). The optional typing exists for raw config + * input before validation. Downstream code can safely assume non-null after validation. + */ + plugin?: string; + /** npm package name for external plugins (e.g. "@acme/ao-plugin-tracker-jira") */ + package?: string; + /** Local filesystem path for external plugins (relative to config file or absolute) */ + path?: string; /** Plugin-specific config (e.g. teamId for Linear) */ [key: string]: unknown; } export interface SCMConfig { - plugin: string; + /** + * Plugin name (manifest.name). Required when using built-in plugins. + * Optional when `package` or `path` is specified (will be inferred from manifest). + * When both plugin and package/path are specified, manifest.name must match plugin. + * + * POST-VALIDATION INVARIANT: After validateConfig(), this field is ALWAYS populated. + * Either from user input, inferred from repo (github/gitlab), or auto-generated from + * package/path via generateTempPluginName(). The optional typing exists for raw config + * input before validation. Downstream code can safely assume non-null after validation. + */ + plugin?: string; + /** npm package name for external plugins (e.g. "@acme/ao-plugin-scm-bitbucket") */ + package?: string; + /** Local filesystem path for external plugins (relative to config file or absolute) */ + path?: string; webhook?: SCMWebhookConfig; [key: string]: unknown; } @@ -1157,7 +1250,21 @@ export interface SCMWebhookConfig { } export interface NotifierConfig { - plugin: string; + /** + * Plugin name (manifest.name). Required when using built-in plugins. + * Optional when `package` or `path` is specified (will be inferred from manifest). + * When both plugin and package/path are specified, manifest.name must match plugin. + * + * POST-VALIDATION INVARIANT: After validateConfig(), this field is ALWAYS populated. + * Either from user input or auto-generated from package/path via generateTempPluginName(). + * The optional typing exists for raw config input before validation. + * Downstream code can safely assume non-null after validation. + */ + plugin?: string; + /** npm package name for external plugins (e.g. "@acme/ao-plugin-notifier-teams") */ + package?: string; + /** Local filesystem path for external plugins (relative to config file or absolute) */ + path?: string; [key: string]: unknown; } @@ -1281,6 +1388,7 @@ export interface SessionMetadata { terminalWsPort?: number; directTerminalWsPort?: number; opencodeSessionId?: string; + pinnedSummary?: string; // First quality summary, pinned for display stability } // ============================================================================= diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts index b18563b6b..5abedf842 100644 --- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -136,19 +136,23 @@ describe("Claude Code Activity Detection", () => { // Fallback cases (no JSONL data available) // ----------------------------------------------------------------------- - it("returns null when no session file exists yet", async () => { - // projectDir exists but is empty — no .jsonl files - expect(await agent.getActivityState(makeSession())).toBeNull(); + it("returns 'idle' when no session file exists yet", async () => { + // projectDir exists but is empty — no .jsonl files yet (freshly spawned session) + const session = makeSession(); + const result = await agent.getActivityState(session); + expect(result?.state).toBe("idle"); + // timestamp must be session.createdAt so stuck-detection can fire eventually + expect(result?.timestamp).toBe(session.createdAt); }); it("returns null when no workspacePath", async () => { expect(await agent.getActivityState(makeSession({ workspacePath: null }))).toBeNull(); }); - it("returns null when project directory does not exist", async () => { - // Point to a workspace whose project dir doesn't exist + it("returns 'idle' when project directory does not exist", async () => { + // Process is running but no Claude project dir yet — treat as idle const badPath = join(fakeHome, "nonexistent-workspace"); - expect(await agent.getActivityState(makeSession({ workspacePath: badPath }))).toBeNull(); + expect((await agent.getActivityState(makeSession({ workspacePath: badPath })))?.state).toBe("idle"); }); // ----------------------------------------------------------------------- @@ -297,8 +301,8 @@ describe("Claude Code Activity Detection", () => { it("ignores agent- prefixed JSONL files", async () => { writeJsonl([{ type: "user" }], 0, "agent-toolkit.jsonl"); - // No real session file → returns null (cannot determine activity) - expect(await agent.getActivityState(makeSession())).toBeNull(); + // No real session file → process is running, treat as idle + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); }); it("reads last entry from multi-entry JSONL (not first)", async () => { diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 4f141415d..c10ed3b9f 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -733,8 +733,9 @@ function createClaudeCodeAgent(): Agent { const sessionFile = await findLatestSessionFile(projectDir); if (!sessionFile) { - // No session file found — cannot determine activity - return null; + // No session file yet — process is running but no conversation started. + // Treat as idle (waiting for first task). + return { state: "idle", timestamp: session.createdAt }; } const entry = await readLastJsonlEntry(sessionFile); diff --git a/packages/plugins/scm-github/src/graphql-batch.ts b/packages/plugins/scm-github/src/graphql-batch.ts index 783d207a0..f877e3d58 100644 --- a/packages/plugins/scm-github/src/graphql-batch.ts +++ b/packages/plugins/scm-github/src/graphql-batch.ts @@ -9,6 +9,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import type { BatchObserver, + CICheck, CIStatus, PREnrichmentData, PRInfo, @@ -466,6 +467,24 @@ const PR_FIELDS = ` commit { statusCheckRollup { state + contexts(first: 20) { + nodes { + ... on CheckRun { + name + status + conclusion + detailsUrl + } + ... on StatusContext { + context + state + targetUrl + } + } + pageInfo { + hasNextPage + } + } } } } @@ -574,12 +593,108 @@ async function executeBatchQuery( return (result.data ?? {}) as Record; } +/** + * Parse individual CI check contexts from statusCheckRollup.contexts.nodes. + * Handles both CheckRun (GitHub Actions) and StatusContext (legacy status checks). + */ +function parseCheckContexts(contexts: unknown): CICheck[] { + if (!contexts || typeof contexts !== "object") return []; + + const nodes = (contexts as Record)["nodes"]; + if (!Array.isArray(nodes)) return []; + + const checks: CICheck[] = []; + for (const node of nodes) { + if (!node || typeof node !== "object") continue; + const n = node as Record; + + // CheckRun node (GitHub Actions) + if (typeof n["name"] === "string" && typeof n["status"] === "string") { + const rawStatus = (n["status"] as string).toUpperCase(); + // Uppercase conclusion to match REST getCIChecks/getCIChecksFromStatusRollup format + // so fingerprints are consistent regardless of which data source is used. + const rawConclusion = + typeof n["conclusion"] === "string" ? (n["conclusion"] as string).toUpperCase() : null; + + let status: CICheck["status"]; + if (rawStatus === "COMPLETED") { + if (rawConclusion === "SUCCESS") { + status = "passed"; + } else if ( + rawConclusion === "SKIPPED" || + rawConclusion === "NEUTRAL" || + rawConclusion === "STALE" || + rawConclusion === "NOT_REQUIRED" || + rawConclusion === "NONE" + ) { + // Mirror mapRawCheckStateToStatus() in the REST path: all non-failure + // terminal conclusions that are not SUCCESS map to "skipped". + status = "skipped"; + } else if ( + rawConclusion === "FAILURE" || + rawConclusion === "TIMED_OUT" || + rawConclusion === "CANCELLED" || + rawConclusion === "ACTION_REQUIRED" || + rawConclusion === "ERROR" + ) { + // Explicit failure conclusions — mirrors the failure list in mapRawCheckStateToStatus() + status = "failed"; + } else { + // STARTUP_FAILURE and any other unrecognized conclusion → "skipped", + // matching mapRawCheckStateToStatus()'s default return "skipped" in the REST path. + status = "skipped"; + } + } else if (rawStatus === "IN_PROGRESS") { + // Only IN_PROGRESS maps to "running" — matches mapRawCheckStateToStatus() in REST path + status = "running"; + } else { + // QUEUED, WAITING, and any other non-COMPLETED status → "pending" + // (REST path maps QUEUED/WAITING to "pending", not "running") + status = "pending"; + } + + checks.push({ + name: n["name"] as string, + status, + // Store the uppercased conclusion to match REST format + conclusion: rawConclusion ?? undefined, + url: typeof n["detailsUrl"] === "string" ? (n["detailsUrl"] as string) : undefined, + }); + continue; + } + + // StatusContext node (legacy commit statuses) + if (typeof n["context"] === "string" && typeof n["state"] === "string") { + const rawState = (n["state"] as string).toUpperCase(); + let status: CICheck["status"]; + if (rawState === "SUCCESS") { + status = "passed"; + } else if (rawState === "FAILURE" || rawState === "ERROR") { + status = "failed"; + } else { + status = "pending"; + } + + // Set conclusion to match the REST getCIChecksFromStatusRollup format + // (which sets conclusion = rawState.toUpperCase()) so fingerprints are + // consistent regardless of which data source is used. + checks.push({ + name: n["context"] as string, + status, + conclusion: rawState, + url: typeof n["targetUrl"] === "string" ? (n["targetUrl"] as string) : undefined, + }); + } + } + + return checks; +} + /** * Parse raw CI state from status check rollup. * - * Uses only the top-level aggregate state, which is much cheaper - * than fetching individual check contexts. The top-level state - * provides the same semantic information (passing/failing/pending). + * Uses only the top-level aggregate state to determine overall CI status. + * Individual check details are parsed separately via parseCheckContexts(). */ function parseCIState( statusCheckRollup: unknown, @@ -679,13 +794,30 @@ function extractPREnrichment( // Extract review decision const reviewDecision = parseReviewDecision(pr["reviewDecision"]); - // Extract CI status from commits + // Extract CI status and individual checks from commits const commits = pr["commits"] as - | { nodes?: Array<{ commit?: { statusCheckRollup?: unknown } }> } + | { nodes?: Array<{ commit?: { statusCheckRollup?: Record } }> } | undefined; - const ciStatus = commits?.nodes?.[0]?.commit?.statusCheckRollup - ? parseCIState(commits.nodes[0].commit.statusCheckRollup) - : "none"; + const statusCheckRollup = commits?.nodes?.[0]?.commit?.statusCheckRollup; + const ciStatus = statusCheckRollup ? parseCIState(statusCheckRollup) : "none"; + + // Only include ciChecks when the list is complete (no truncation). + // contexts(first: 20) silently truncates PRs with >20 checks — when truncated, + // the failing check may be missing, so we set ciChecks to undefined to force + // the getCIChecks() REST fallback in maybeDispatchCIFailureDetails. + const contextsField = statusCheckRollup?.["contexts"] as + | Record + | undefined; + const pageInfo = contextsField?.["pageInfo"]; + const contextsHasNextPage = + pageInfo !== null && + pageInfo !== undefined && + typeof pageInfo === "object" && + (pageInfo as Record)["hasNextPage"] === true; + const ciChecks = + contextsField && !contextsHasNextPage + ? parseCheckContexts(contextsField) + : undefined; // Build blockers list const blockers: string[] = []; @@ -722,6 +854,7 @@ function extractPREnrichment( hasConflicts, isBehind, blockers, + ...(ciChecks !== undefined ? { ciChecks } : {}), }; return { data, headSha }; diff --git a/packages/plugins/scm-github/test/graphql-batch.test.ts b/packages/plugins/scm-github/test/graphql-batch.test.ts index a25417087..cdab3eea2 100644 --- a/packages/plugins/scm-github/test/graphql-batch.test.ts +++ b/packages/plugins/scm-github/test/graphql-batch.test.ts @@ -1172,3 +1172,576 @@ describe("shouldRefreshPREnrichment - ETag Guard Strategy", () => { }); }); }); + +describe("extractPREnrichment ciChecks", () => { + it("parses CheckRun contexts into ciChecks", () => { + const pullRequest = { + title: "Fix CI", + state: "OPEN", + additions: 10, + deletions: 5, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "NONE", + reviews: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: "FAILURE", + contexts: { + nodes: [ + { + name: "lint", + status: "COMPLETED", + conclusion: "FAILURE", + detailsUrl: "https://github.com/org/repo/actions/runs/123", + }, + { + name: "test", + status: "COMPLETED", + conclusion: "SUCCESS", + detailsUrl: "https://github.com/org/repo/actions/runs/124", + }, + { + name: "typecheck", + status: "IN_PROGRESS", + conclusion: null, + detailsUrl: "https://github.com/org/repo/actions/runs/125", + }, + ], + }, + }, + }, + }, + ], + }, + }; + + const extracted = extractPREnrichment(pullRequest); + const ciChecks = extracted?.data.ciChecks; + + expect(ciChecks).toBeDefined(); + expect(ciChecks).toHaveLength(3); + + const lint = ciChecks?.find((c) => c.name === "lint"); + expect(lint?.status).toBe("failed"); + expect(lint?.conclusion).toBe("FAILURE"); + expect(lint?.url).toBe("https://github.com/org/repo/actions/runs/123"); + + const test = ciChecks?.find((c) => c.name === "test"); + expect(test?.status).toBe("passed"); + expect(test?.conclusion).toBe("SUCCESS"); + + const typecheck = ciChecks?.find((c) => c.name === "typecheck"); + expect(typecheck?.status).toBe("running"); + }); + + it("maps QUEUED and WAITING to pending, not running (matches REST mapRawCheckStateToStatus)", () => { + const pullRequest = { + title: "Queued checks", + state: "OPEN", + additions: 1, + deletions: 0, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "NONE", + reviews: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: "PENDING", + contexts: { + nodes: [ + { name: "queued-check", status: "QUEUED", conclusion: null, detailsUrl: null }, + { name: "waiting-check", status: "WAITING", conclusion: null, detailsUrl: null }, + ], + }, + }, + }, + }, + ], + }, + }; + + const extracted = extractPREnrichment(pullRequest); + const ciChecks = extracted?.data.ciChecks; + expect(ciChecks?.find((c) => c.name === "queued-check")?.status).toBe("pending"); + expect(ciChecks?.find((c) => c.name === "waiting-check")?.status).toBe("pending"); + }); + + it("parses StatusContext nodes into ciChecks", () => { + const pullRequest = { + title: "Legacy CI", + state: "OPEN", + additions: 5, + deletions: 2, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "NONE", + reviews: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: "FAILURE", + contexts: { + nodes: [ + { + context: "ci/build", + state: "FAILURE", + targetUrl: "https://ci.example.com/build/1", + }, + { + context: "ci/test", + state: "SUCCESS", + targetUrl: "https://ci.example.com/test/1", + }, + { + context: "ci/deploy", + state: "PENDING", + targetUrl: null, + }, + ], + }, + }, + }, + }, + ], + }, + }; + + const extracted = extractPREnrichment(pullRequest); + const ciChecks = extracted?.data.ciChecks; + + expect(ciChecks).toBeDefined(); + expect(ciChecks).toHaveLength(3); + + const build = ciChecks?.find((c) => c.name === "ci/build"); + expect(build?.status).toBe("failed"); + expect(build?.conclusion).toBe("FAILURE"); // matches REST getCIChecksFromStatusRollup format + expect(build?.url).toBe("https://ci.example.com/build/1"); + + const test = ciChecks?.find((c) => c.name === "ci/test"); + expect(test?.status).toBe("passed"); + expect(test?.conclusion).toBe("SUCCESS"); + + const deploy = ciChecks?.find((c) => c.name === "ci/deploy"); + expect(deploy?.status).toBe("pending"); + expect(deploy?.conclusion).toBe("PENDING"); + expect(deploy?.url).toBeUndefined(); + }); + + it("returns empty array when contexts nodes is empty", () => { + const pullRequest = { + title: "Clean PR", + state: "OPEN", + additions: 5, + deletions: 2, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "NONE", + reviews: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: "SUCCESS", + contexts: { nodes: [] }, + }, + }, + }, + ], + }, + }; + + const extracted = extractPREnrichment(pullRequest); + expect(extracted?.data.ciChecks).toEqual([]); + }); + + it("returns undefined ciChecks when statusCheckRollup has no contexts field", () => { + const pullRequest = { + title: "No contexts", + state: "OPEN", + additions: 5, + deletions: 2, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "NONE", + reviews: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: "SUCCESS", + // No contexts field — older API response + }, + }, + }, + ], + }, + }; + + const extracted = extractPREnrichment(pullRequest); + expect(extracted?.data.ciChecks).toBeUndefined(); + }); + + it("maps COMPLETED+SKIPPED conclusion to skipped status", () => { + const pullRequest = { + title: "Skipped check", + state: "OPEN", + additions: 5, + deletions: 2, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "NONE", + reviews: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: "SUCCESS", + contexts: { + nodes: [ + { + name: "optional-check", + status: "COMPLETED", + conclusion: "SKIPPED", + detailsUrl: null, + }, + ], + }, + }, + }, + }, + ], + }, + }; + + const extracted = extractPREnrichment(pullRequest); + const check = extracted?.data.ciChecks?.[0]; + expect(check?.status).toBe("skipped"); + expect(check?.conclusion).toBe("SKIPPED"); // uppercased to match REST format + }); + + it("maps COMPLETED+NEUTRAL conclusion to skipped (matches REST mapRawCheckStateToStatus)", () => { + const pullRequest = { + title: "Neutral check", + state: "OPEN", + additions: 5, + deletions: 2, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "NONE", + reviews: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: "SUCCESS", + contexts: { + nodes: [ + { + name: "optional-check", + status: "COMPLETED", + conclusion: "NEUTRAL", + detailsUrl: null, + }, + ], + }, + }, + }, + }, + ], + }, + }; + + const extracted = extractPREnrichment(pullRequest); + const check = extracted?.data.ciChecks?.[0]; + // NEUTRAL maps to "skipped" in REST mapRawCheckStateToStatus — must match + expect(check?.status).toBe("skipped"); + expect(check?.conclusion).toBe("NEUTRAL"); + }); + + it("uppercases CheckRun conclusion to match REST getCIChecks format", () => { + const pullRequest = { + title: "Mixed case conclusion", + state: "OPEN", + additions: 5, + deletions: 2, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "NONE", + reviews: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: "FAILURE", + contexts: { + nodes: [ + { + name: "lint", + status: "COMPLETED", + conclusion: "failure", // lowercase from API + detailsUrl: null, + }, + ], + }, + }, + }, + }, + ], + }, + }; + + const extracted = extractPREnrichment(pullRequest); + const check = extracted?.data.ciChecks?.[0]; + expect(check?.status).toBe("failed"); + expect(check?.conclusion).toBe("FAILURE"); // must be uppercased + }); + + it("maps STALE/NOT_REQUIRED/NONE conclusions to skipped (matches REST mapRawCheckStateToStatus)", () => { + const makeContextsWithConclusion = (conclusion: string) => ({ + title: "Check", + state: "OPEN", + additions: 1, + deletions: 0, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "NONE", + reviews: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: "SUCCESS", + contexts: { + nodes: [{ name: "check", status: "COMPLETED", conclusion, detailsUrl: null }], + }, + }, + }, + }, + ], + }, + }); + + for (const conclusion of ["STALE", "NOT_REQUIRED", "NONE"]) { + const extracted = extractPREnrichment(makeContextsWithConclusion(conclusion)); + const check = extracted?.data.ciChecks?.[0]; + expect(check?.status, `${conclusion} should map to skipped`).toBe("skipped"); + } + }); + + it("returns undefined ciChecks when contexts list is truncated (hasNextPage=true)", () => { + const pullRequest = { + title: "Many CI checks", + state: "OPEN", + additions: 5, + deletions: 2, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "NONE", + reviews: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: "FAILURE", + contexts: { + nodes: [ + { name: "check-1", status: "COMPLETED", conclusion: "FAILURE", detailsUrl: null }, + // ... 19 more checks truncated + ], + pageInfo: { hasNextPage: true }, // list was truncated! + }, + }, + }, + }, + ], + }, + }; + + const extracted = extractPREnrichment(pullRequest); + // ciChecks should be undefined when truncated — forces getCIChecks() REST fallback + expect(extracted?.data.ciChecks).toBeUndefined(); + }); + + it("returns ciChecks when contexts list is complete (hasNextPage=false)", () => { + const pullRequest = { + title: "Few CI checks", + state: "OPEN", + additions: 5, + deletions: 2, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "NONE", + reviews: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: "FAILURE", + contexts: { + nodes: [ + { name: "lint", status: "COMPLETED", conclusion: "FAILURE", detailsUrl: "https://ci.example.com/lint" }, + ], + pageInfo: { hasNextPage: false }, // complete list + }, + }, + }, + }, + ], + }, + }; + + const extracted = extractPREnrichment(pullRequest); + expect(extracted?.data.ciChecks).toBeDefined(); + expect(extracted?.data.ciChecks).toHaveLength(1); + expect(extracted?.data.ciChecks?.[0]?.name).toBe("lint"); + }); + + it("maps COMPLETED with null conclusion to skipped (matches REST mapRawCheckStateToStatus empty-string branch)", () => { + // REST path: mapRawCheckStateToStatus(undefined) → state="" → "skipped" + // GraphQL path must match: COMPLETED + null conclusion → "skipped", not "passed" + const pullRequest = { + title: "Null conclusion check", + state: "OPEN", + additions: 1, + deletions: 0, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "NONE", + reviews: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: "SUCCESS", + contexts: { + nodes: [ + { + name: "some-check", + status: "COMPLETED", + conclusion: null, // null conclusion — shouldn't map to "passed" + detailsUrl: null, + }, + ], + }, + }, + }, + }, + ], + }, + }; + + const extracted = extractPREnrichment(pullRequest); + const check = extracted?.data.ciChecks?.[0]; + expect(check?.status).toBe("skipped"); + }); + + it("maps COMPLETED+STARTUP_FAILURE to skipped (matches REST mapRawCheckStateToStatus default fallback)", () => { + const pullRequest = { + title: "Startup failure check", + state: "OPEN", + additions: 1, + deletions: 0, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "NONE", + reviews: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: "FAILURE", + contexts: { + nodes: [ + { + name: "infra-check", + status: "COMPLETED", + conclusion: "STARTUP_FAILURE", + detailsUrl: null, + }, + ], + }, + }, + }, + }, + ], + }, + }; + + const extracted = extractPREnrichment(pullRequest); + const check = extracted?.data.ciChecks?.[0]; + // STARTUP_FAILURE is not in the explicit failure list → falls through to "skipped" + // matching mapRawCheckStateToStatus()'s default return "skipped" + expect(check?.status).toBe("skipped"); + expect(check?.conclusion).toBe("STARTUP_FAILURE"); + }); + + it("handles null pageInfo safely without TypeError (typeof null === 'object' quirk)", () => { + const pullRequest = { + title: "Null pageInfo", + state: "OPEN", + additions: 1, + deletions: 0, + isDraft: false, + mergeable: "MERGEABLE", + mergeStateStatus: "CLEAN", + reviewDecision: "NONE", + reviews: { nodes: [] }, + commits: { + nodes: [ + { + commit: { + statusCheckRollup: { + state: "SUCCESS", + contexts: { + nodes: [ + { name: "lint", status: "COMPLETED", conclusion: "SUCCESS", detailsUrl: null }, + ], + pageInfo: null, // null pageInfo — older API responses may omit this + }, + }, + }, + }, + ], + }, + }; + + // Must not throw TypeError: Cannot read properties of null + expect(() => extractPREnrichment(pullRequest)).not.toThrow(); + const extracted = extractPREnrichment(pullRequest); + // null pageInfo is treated as "not truncated" → ciChecks should be defined + expect(extracted?.data.ciChecks).toBeDefined(); + expect(extracted?.data.ciChecks).toHaveLength(1); + }); +}); diff --git a/packages/web/next.config.js b/packages/web/next.config.js index 654d91bc4..64baaa9ed 100644 --- a/packages/web/next.config.js +++ b/packages/web/next.config.js @@ -1,5 +1,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + serverExternalPackages: ["@composio/core"], transpilePackages: [ "@composio/ao-core", "@composio/ao-plugin-agent-claude-code", @@ -23,4 +24,11 @@ const nextConfig = { }, }; -export default nextConfig; +// Only load bundle analyzer when ANALYZE=true (dev-only dependency) +let config = nextConfig; +if (process.env.ANALYZE === "true") { + const { default: bundleAnalyzer } = await import("@next/bundle-analyzer"); + config = bundleAnalyzer({ enabled: true })(nextConfig); +} + +export default config; diff --git a/packages/web/package.json b/packages/web/package.json index 962df764c..a1e24ed76 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -19,6 +19,7 @@ "build": "next build && tsc -p tsconfig.server.json", "start": "next start", "start:all": "node dist-server/start-all.js", + "dev:optimized": "next build && tsc -p tsconfig.server.json && node dist-server/start-all.js", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", @@ -41,6 +42,7 @@ "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "server-only": "^0.0.1", "ws": "^8.19.0", "xterm": "^5.3.0" }, @@ -48,6 +50,7 @@ "node-pty": "^1.1.0" }, "devDependencies": { + "@next/bundle-analyzer": "^15.1.0", "@tailwindcss/postcss": "^4.0.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.1.0", @@ -55,6 +58,7 @@ "@types/react-dom": "^19.0.0", "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.0", + "@vitest/coverage-v8": "^2.1.0", "concurrently": "^9.2.1", "jsdom": "^25.0.0", "node-gyp": "^12.2.0", @@ -62,7 +66,6 @@ "tailwindcss": "^4.0.0", "tsx": "^4.19.0", "typescript": "^5.7.0", - "@vitest/coverage-v8": "^2.1.0", "vitest": "^2.1.0" } } diff --git a/packages/web/screenshots/dashboard.png b/packages/web/screenshots/dashboard.png index bd310cf3c..4a3113879 100644 Binary files a/packages/web/screenshots/dashboard.png and b/packages/web/screenshots/dashboard.png differ diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 9aba8ff87..70d5b5e84 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -195,7 +195,7 @@ vi.mock("@/lib/services", () => ({ // ── Import routes after mocking ─────────────────────────────────────── import { GET as sessionsGET } from "@/app/api/sessions/route"; -import { POST as orchestratorsPOST } from "@/app/api/orchestrators/route"; +import { POST as orchestratorsPOST, GET as orchestratorsGET } from "@/app/api/orchestrators/route"; import { POST as spawnPOST } from "@/app/api/spawn/route"; import { POST as sendPOST } from "@/app/api/sessions/[id]/send/route"; import { POST as messagePOST } from "@/app/api/sessions/[id]/message/route"; @@ -318,81 +318,6 @@ describe("API Routes", () => { expect(mockSessionManager.list).toHaveBeenCalledWith("docs-app"); }); - it("keeps global pause sourced from all projects even for project-scoped requests", async () => { - const pausedUntil = new Date(Date.now() + 60_000).toISOString(); - const pausedSessions = [ - makeSession({ - id: "docs-orchestrator", - projectId: "docs-app", - metadata: { - role: "orchestrator", - globalPauseUntil: pausedUntil, - globalPauseReason: "Rate limit hit", - globalPauseSource: "docs-orchestrator", - }, - }), - makeSession({ id: "docs-1", projectId: "docs-app", status: "working", activity: "active" }), - makeSession({ - id: "backend-3", - projectId: "my-app", - status: "working", - activity: "active", - }), - ]; - (mockSessionManager.list as ReturnType).mockImplementation( - async (projectId?: string) => - projectId - ? pausedSessions.filter((session) => session.projectId === projectId) - : pausedSessions, - ); - - const res = await sessionsGET( - makeRequest("http://localhost:3000/api/sessions?project=my-app"), - ); - expect(res.status).toBe(200); - const data = await res.json(); - - expect(data.globalPause).toMatchObject({ - pausedUntil, - reason: "Rate limit hit", - sourceSessionId: "docs-orchestrator", - }); - expect(mockSessionManager.list).toHaveBeenNthCalledWith(1, "my-app"); - expect(mockSessionManager.list).toHaveBeenNthCalledWith(2); - }); - - it("finds active global pause even when a metadata-role orchestrator appears first", async () => { - const pausedUntil = new Date(Date.now() + 60_000).toISOString(); - const sessions = [ - makeSession({ - id: "control-session", - projectId: "docs-app", - metadata: { role: "orchestrator" }, - }), - makeSession({ - id: "docs-orchestrator", - projectId: "docs-app", - metadata: { - role: "orchestrator", - globalPauseUntil: pausedUntil, - globalPauseReason: "Rate limit hit", - globalPauseSource: "docs-orchestrator", - }, - }), - ]; - (mockSessionManager.list as ReturnType).mockResolvedValueOnce(sessions); - - const res = await sessionsGET(makeRequest("http://localhost:3000/api/sessions")); - expect(res.status).toBe(200); - const data = await res.json(); - - expect(data.globalPause).toMatchObject({ - pausedUntil, - reason: "Rate limit hit", - sourceSessionId: "docs-orchestrator", - }); - }); - it("enriches all PRs concurrently, not sequentially", async () => { vi.useFakeTimers(); @@ -687,6 +612,52 @@ describe("API Routes", () => { }); }); + describe("GET /api/orchestrators", () => { + it("returns orchestrators for a project", async () => { + const orchestrator = makeSession({ + id: "my-app-orchestrator", + projectId: "my-app", + metadata: { role: "orchestrator" }, + }); + (mockSessionManager.list as ReturnType).mockResolvedValueOnce([orchestrator]); + + const res = await orchestratorsGET( + makeRequest("http://localhost:3000/api/orchestrators?project=my-app"), + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.orchestrators).toHaveLength(1); + expect(data.orchestrators[0].id).toBe("my-app-orchestrator"); + expect(data.projectName).toBe("My App"); + }); + + it("returns 400 when project parameter is missing", async () => { + const res = await orchestratorsGET(makeRequest("http://localhost:3000/api/orchestrators")); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/Missing project query parameter/); + }); + + it("returns 404 for unknown project", async () => { + const res = await orchestratorsGET( + makeRequest("http://localhost:3000/api/orchestrators?project=unknown-app"), + ); + expect(res.status).toBe(404); + const data = await res.json(); + expect(data.error).toMatch(/Unknown project/); + }); + + it("returns 500 when list fails", async () => { + (mockSessionManager.list as ReturnType).mockRejectedValueOnce(new Error("boom")); + const res = await orchestratorsGET( + makeRequest("http://localhost:3000/api/orchestrators?project=my-app"), + ); + expect(res.status).toBe(500); + const data = await res.json(); + expect(data.error).toBe("boom"); + }); + }); + // ── POST /api/sessions/:id/send ──────────────────────────────────── describe("POST /api/sessions/:id/send", () => { diff --git a/packages/web/src/__tests__/components.test.tsx b/packages/web/src/__tests__/components.test.tsx index e747cf6d1..49fd3cad6 100644 --- a/packages/web/src/__tests__/components.test.tsx +++ b/packages/web/src/__tests__/components.test.tsx @@ -184,8 +184,8 @@ describe("PRStatus", () => { // ── SessionCard ────────────────────────────────────────────────────── describe("SessionCard", () => { - it("renders session id and summary", () => { - const session = makeSession({ id: "backend-1", summary: "Fixing auth" }); + it("renders summary when no PR, issue title, or branch title exists", () => { + const session = makeSession({ id: "backend-1", summary: "Fixing auth", branch: null }); render(); expect(screen.getByText("backend-1")).toBeInTheDocument(); expect(screen.getByText("Fixing auth")).toBeInTheDocument(); @@ -607,3 +607,46 @@ describe("AttentionZone", () => { expect(onRestore).toHaveBeenCalledWith("s1"); }); }); + +// ── Unenriched PR shimmer ───────────────────────────────────────────── + +describe("Unenriched PR shimmer", () => { + it("SessionCard shows shimmer for unenriched PR size", () => { + const pr = makePR({ enriched: false }); + const session = makeSession({ pr }); + const { container } = render(); + const shimmers = container.querySelectorAll(".animate-pulse"); + expect(shimmers.length).toBeGreaterThan(0); + }); + + it("SessionCard shows actual size for enriched PR", () => { + const pr = makePR({ enriched: true, additions: 50, deletions: 10 }); + const session = makeSession({ pr }); + render(); + expect(screen.getByText("+50 -10 S")).toBeInTheDocument(); + }); + + it("SessionCard suppresses alerts for unenriched PR", () => { + const pr = makePR({ + enriched: false, + ciStatus: "failing", + ciChecks: [{ name: "build", status: "failed" }], + }); + const session = makeSession({ pr }); + const { container } = render(); + expect(container.querySelector(".session-card__alert-pill")).toBeNull(); + }); + + it("PRStatus shows shimmer for unenriched PR", () => { + const pr = makePR({ enriched: false }); + const { container } = render(); + const shimmers = container.querySelectorAll(".animate-pulse"); + expect(shimmers.length).toBeGreaterThan(0); + }); + + it("PRStatus shows actual data for enriched PR", () => { + const pr = makePR({ enriched: true, additions: 50, deletions: 10 }); + render(); + expect(screen.getByText("+50 -10 S")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/__tests__/helpers.ts b/packages/web/src/__tests__/helpers.ts index 579c2f895..275cbb79e 100644 --- a/packages/web/src/__tests__/helpers.ts +++ b/packages/web/src/__tests__/helpers.ts @@ -50,6 +50,7 @@ export function makePR(overrides: Partial = {}): DashboardPR { }, unresolvedThreads: 0, unresolvedComments: [], + enriched: true, ...overrides, }; } diff --git a/packages/web/src/__tests__/orchestrators.test.tsx b/packages/web/src/__tests__/orchestrators.test.tsx new file mode 100644 index 000000000..eb83c4c42 --- /dev/null +++ b/packages/web/src/__tests__/orchestrators.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import OrchestratorsRoute from "@/app/orchestrators/page"; +import { getServices } from "@/lib/services"; +import { getAllProjects } from "@/lib/project-name"; + +// ── Mocks ───────────────────────────────────────────────────────────── + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + }), +})); + +vi.mock("next/link", () => ({ + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})); + +vi.mock("@/lib/services", () => ({ + getServices: vi.fn(), +})); + +vi.mock("@/lib/project-name", () => ({ + getAllProjects: vi.fn(), +})); + +global.fetch = vi.fn(); + +// ── Tests ───────────────────────────────────────────────────────────── + +describe("Orchestrators Page (OrchestratorsRoute)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the page with searchParams and listed orchestrators", async () => { + const mockSessionManager = { + list: vi.fn().mockResolvedValue([ + { + id: "app-orchestrator", + projectId: "my-app", + status: "working", + activity: "active", + createdAt: new Date(), + lastActivityAt: new Date(), + }, + ]), + }; + + (getServices as any).mockResolvedValue({ + config: { + projects: { + "my-app": { name: "My App", sessionPrefix: "app" }, + }, + }, + sessionManager: mockSessionManager, + }); + + (getAllProjects as any).mockReturnValue([{ id: "my-app", name: "My App" }]); + + const searchParams = Promise.resolve({ project: "my-app" }); + const jsx = await OrchestratorsRoute({ searchParams }); + render(jsx); + + expect(screen.getByText("My App")).toBeInTheDocument(); + expect(screen.getByText("app-orchestrator")).toBeInTheDocument(); + }); + + it("shows error when project is missing in searchParams", async () => { + const searchParams = Promise.resolve({}); + const jsx = await OrchestratorsRoute({ searchParams }); + render(jsx); + + expect(screen.getByText("Missing Project")).toBeInTheDocument(); + }); + + it("shows error when project is not found in config", async () => { + (getServices as any).mockResolvedValue({ + config: { projects: {} }, + sessionManager: { list: vi.fn() }, + }); + (getAllProjects as any).mockReturnValue([]); + + const searchParams = Promise.resolve({ project: "ghost" }); + const jsx = await OrchestratorsRoute({ searchParams }); + render(jsx); + + expect(screen.getByText('Project "ghost" not found')).toBeInTheDocument(); + }); + + it("handles service errors gracefully", async () => { + (getServices as any).mockRejectedValue(new Error("Database down")); + + const searchParams = Promise.resolve({ project: "my-app" }); + const jsx = await OrchestratorsRoute({ searchParams }); + render(jsx); + + expect(screen.getByText("Database down")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/__tests__/server-only-mock.ts b/packages/web/src/__tests__/server-only-mock.ts new file mode 100644 index 000000000..f83932659 --- /dev/null +++ b/packages/web/src/__tests__/server-only-mock.ts @@ -0,0 +1,3 @@ +// Stub for "server-only" package in vitest — the real module throws when +// imported outside a React Server Component context. +export {}; diff --git a/packages/web/src/app/api/issues/route.ts b/packages/web/src/app/api/issues/route.ts index 892d3f6c8..3212afd34 100644 --- a/packages/web/src/app/api/issues/route.ts +++ b/packages/web/src/app/api/issues/route.ts @@ -21,7 +21,7 @@ export async function GET(request: NextRequest) { for (const [projectId, project] of Object.entries(config.projects)) { if (projectFilter && projectId !== projectFilter) continue; - if (!project.tracker) continue; + if (!project.tracker?.plugin) continue; const tracker = registry.get("tracker", project.tracker.plugin); if (!tracker?.listIssues) continue; @@ -76,7 +76,7 @@ export async function POST(request: NextRequest) { } const project = config.projects[projectId]; - if (!project.tracker) { + if (!project.tracker?.plugin) { return NextResponse.json({ error: "No tracker configured for this project" }, { status: 422 }); } diff --git a/packages/web/src/app/api/orchestrators/route.ts b/packages/web/src/app/api/orchestrators/route.ts index 64f5d6901..d3c0b2359 100644 --- a/packages/web/src/app/api/orchestrators/route.ts +++ b/packages/web/src/app/api/orchestrators/route.ts @@ -1,7 +1,48 @@ import { type NextRequest, NextResponse } from "next/server"; -import { generateOrchestratorPrompt } from "@composio/ao-core"; +import { generateOrchestratorPrompt, generateSessionPrefix } from "@composio/ao-core"; import { getServices } from "@/lib/services"; import { validateIdentifier, validateConfiguredProject } from "@/lib/validation"; +import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils"; + +/** + * GET /api/orchestrators?project= + * List existing orchestrator sessions for a project. + */ +export async function GET(request: NextRequest) { + const projectId = request.nextUrl.searchParams.get("project"); + + if (!projectId) { + return NextResponse.json({ error: "Missing project query parameter" }, { status: 400 }); + } + + const projectErr = validateIdentifier(projectId, "projectId"); + if (projectErr) { + return NextResponse.json({ error: projectErr }, { status: 400 }); + } + + try { + const { config, sessionManager } = await getServices(); + const configProjectErr = validateConfiguredProject(config.projects, projectId); + if (configProjectErr) { + return NextResponse.json({ error: configProjectErr }, { status: 404 }); + } + const project = config.projects[projectId]; + const sessionPrefix = project.sessionPrefix ?? projectId; + + const allSessions = await sessionManager.list(projectId); + const allSessionPrefixes = Object.entries(config.projects).map( + ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""), + ); + const orchestrators = mapSessionsToOrchestrators(allSessions, sessionPrefix, project.name, allSessionPrefixes); + + return NextResponse.json({ orchestrators, projectName: project.name }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to list orchestrators" }, + { status: 500 }, + ); + } +} export async function POST(request: NextRequest) { const body = (await request.json().catch(() => null)) as Record | null; @@ -17,9 +58,9 @@ export async function POST(request: NextRequest) { try { const { config, sessionManager } = await getServices(); const projectId = body.projectId as string; - const projectErr = validateConfiguredProject(config.projects, projectId); - if (projectErr) { - return NextResponse.json({ error: projectErr }, { status: 404 }); + const configProjectErr = validateConfiguredProject(config.projects, projectId); + if (configProjectErr) { + return NextResponse.json({ error: configProjectErr }, { status: 404 }); } const project = config.projects[projectId]; diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 3485e7231..0156a105a 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -9,28 +9,13 @@ import { listDashboardOrchestrators, } from "@/lib/serialize"; import { getCorrelationId, jsonWithCorrelation, recordApiObservation } from "@/lib/observability"; -import { resolveGlobalPause } from "@/lib/global-pause"; import { filterProjectSessions } from "@/lib/project-utils"; +import { settlesWithin } from "@/lib/async-utils"; const METADATA_ENRICH_TIMEOUT_MS = 3_000; const PR_ENRICH_TIMEOUT_MS = 4_000; const PER_PR_ENRICH_TIMEOUT_MS = 1_500; -async function settlesWithin(promise: Promise, timeoutMs: number): Promise { - let timeoutId: ReturnType | null = null; - const timeoutPromise = new Promise((resolve) => { - timeoutId = setTimeout(() => resolve(false), timeoutMs); - }); - - try { - return await Promise.race([promise.then(() => true).catch(() => true), timeoutPromise]); - } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } - } -} - export async function GET(request: Request) { const correlationId = getCorrelationId(request); const startedAt = Date.now(); @@ -73,9 +58,17 @@ export async function GET(request: Request) { ); } - const allSessions = requestedProjectId ? await sessionManager.list() : coreSessions; - - let workerSessions = visibleSessions.filter((session) => !isOrchestratorSession(session)); + const allSessionPrefixes = Object.entries(config.projects).map( + ([projectId, p]) => p.sessionPrefix ?? projectId, + ); + let workerSessions = visibleSessions.filter( + (session) => + !isOrchestratorSession( + session, + config.projects[session.projectId]?.sessionPrefix ?? session.projectId, + allSessionPrefixes, + ), + ); // Convert to dashboard format let dashboardSessions = workerSessions.map(sessionToDashboard); @@ -134,7 +127,6 @@ export async function GET(request: Request) { stats: computeStats(dashboardSessions), orchestratorId, orchestrators, - globalPause: resolveGlobalPause(allSessions), }, { status: 200 }, correlationId, diff --git a/packages/web/src/app/api/verify/route.ts b/packages/web/src/app/api/verify/route.ts index f0c00e847..44c0ef627 100644 --- a/packages/web/src/app/api/verify/route.ts +++ b/packages/web/src/app/api/verify/route.ts @@ -64,7 +64,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: projectErr }, { status: 404 }); } const project = config.projects[projectId]; - if (!project.tracker) { + if (!project.tracker?.plugin) { return NextResponse.json({ error: `Project ${projectId} has no tracker` }, { status: 422 }); } diff --git a/packages/web/src/app/dev/terminal-test/page.tsx b/packages/web/src/app/dev/terminal-test/page.tsx index 8323ae23e..da56936aa 100644 --- a/packages/web/src/app/dev/terminal-test/page.tsx +++ b/packages/web/src/app/dev/terminal-test/page.tsx @@ -1,10 +1,22 @@ "use client"; -import { DirectTerminal } from "@/components/DirectTerminal"; -import { Terminal } from "@/components/Terminal"; +import nextDynamic from "next/dynamic"; import { useSearchParams } from "next/navigation"; import { useState, useEffect, Suspense } from "react"; +const DirectTerminal = nextDynamic( + () => + import("@/components/DirectTerminal").then((m) => ({ + default: m.DirectTerminal, + })), + { ssr: false }, +); + +const Terminal = nextDynamic( + () => import("@/components/Terminal").then((m) => ({ default: m.Terminal })), + { ssr: false }, +); + // Force dynamic rendering (required for useSearchParams) export const dynamic = "force-dynamic"; diff --git a/packages/web/src/app/error.test.tsx b/packages/web/src/app/error.test.tsx new file mode 100644 index 000000000..d28115410 --- /dev/null +++ b/packages/web/src/app/error.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const refresh = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh }), +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + ...props + }: React.PropsWithChildren>) => ( + {children} + ), +})); + +import ErrorPage from "./error"; + +describe("Route error boundary", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("retries with reset and router refresh", () => { + const reset = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + + expect(reset).toHaveBeenCalledTimes(1); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("renders a dashboard escape hatch", () => { + render(); + + expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute("href", "/"); + }); +}); diff --git a/packages/web/src/app/error.tsx b/packages/web/src/app/error.tsx new file mode 100644 index 000000000..ba96be99a --- /dev/null +++ b/packages/web/src/app/error.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { ErrorDisplay } from "@/components/ErrorDisplay"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const router = useRouter(); + + useEffect(() => { + console.error(error); + }, [error]); + + return ( + { + reset(); + router.refresh(); + }, + }} + secondaryAction={{ label: "Back to dashboard", href: "/" }} + error={error} + compact + chrome="card" + /> + ); +} diff --git a/packages/web/src/app/global-error.test.tsx b/packages/web/src/app/global-error.test.tsx new file mode 100644 index 000000000..461d1c744 --- /dev/null +++ b/packages/web/src/app/global-error.test.tsx @@ -0,0 +1,72 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const errorDisplaySpy = vi.fn(); + +vi.mock("@/components/ErrorDisplay", () => ({ + ErrorDisplay: (props: { + title: string; + message: string; + primaryAction?: { label: string; onClick?: () => void }; + secondaryAction?: { label: string; onClick?: () => void }; + }) => { + errorDisplaySpy(props); + return ( +
+
{props.title}
+
{props.message}
+ {props.primaryAction ? ( + + ) : null} + {props.secondaryAction ? ( + + ) : null} +
+ ); + }, +})); + +import GlobalError from "./global-error"; + +describe("Global error boundary", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + errorDisplaySpy.mockClear(); + Object.defineProperty(window, "location", { + configurable: true, + value: { reload: vi.fn() }, + }); + }); + + it("calls reset when retrying", () => { + const reset = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(reset).toHaveBeenCalledTimes(1); + }); + + it("reloads the page on demand", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Reload page" })); + expect(window.location.reload).toHaveBeenCalledTimes(1); + }); + + it("passes the expected shell copy to ErrorDisplay", () => { + render(); + + expect(errorDisplaySpy).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Something broke at the app shell", + message: + "The dashboard could not recover from this error at the layout level. Try again first, then reload the page if it still fails.", + }), + ); + }); +}); diff --git a/packages/web/src/app/global-error.tsx b/packages/web/src/app/global-error.tsx new file mode 100644 index 000000000..fa1784e5f --- /dev/null +++ b/packages/web/src/app/global-error.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useEffect } from "react"; +import { ErrorDisplay } from "@/components/ErrorDisplay"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( + + + window.location.reload() }} + error={error} + /> + + + ); +} diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index 113d6251a..9e57b0d2a 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -2000,6 +2000,68 @@ html.light .xterm .xterm-viewport:hover::-webkit-scrollbar-thumb:active { border-radius: 2px; } +/* ── Done / Terminated collapsible bar ────────────────────────────────── */ + +.done-bar__toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 0; + background: none; + border: none; + cursor: pointer; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-text-muted); + transition: color 0.15s ease; +} + +.done-bar__toggle:hover { + color: var(--color-text-secondary); +} + +.done-bar__chevron { + width: 14px; + height: 14px; + flex-shrink: 0; + transition: transform 0.15s ease; +} + +.done-bar__chevron--open { + transform: rotate(90deg); +} + +.done-bar__count { + flex-shrink: 0; + min-width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + border-radius: 999px; + background: var(--color-border-subtle); + color: var(--color-text-muted); + padding: 0 5px; +} + +.done-bar__rule { + flex: 1; + height: 1px; + background: var(--color-border-subtle); +} + +.done-bar__cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 8px; + margin-top: 10px; +} + /* ── Session card zone glow (hover) ──────────────────────────────────── */ .kanban-column__empty { diff --git a/packages/web/src/app/loading.test.tsx b/packages/web/src/app/loading.test.tsx new file mode 100644 index 000000000..ef0dff5ba --- /dev/null +++ b/packages/web/src/app/loading.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import Loading from "./loading"; + +describe("Loading (global)", () => { + it("renders the loading text", () => { + render(); + expect(screen.getByText("Loading…")).toBeInTheDocument(); + }); + + it("renders a spinning indicator", () => { + const { container } = render(); + const spinner = container.querySelector(".animate-spin"); + expect(spinner).toBeInTheDocument(); + expect(spinner?.tagName.toLowerCase()).toBe("svg"); + }); +}); diff --git a/packages/web/src/app/loading.tsx b/packages/web/src/app/loading.tsx new file mode 100644 index 000000000..77f71eb27 --- /dev/null +++ b/packages/web/src/app/loading.tsx @@ -0,0 +1,20 @@ +export default function Loading() { + return ( +
+
+ + + +

+ Loading… +

+
+
+ ); +} diff --git a/packages/web/src/app/not-found.test.tsx b/packages/web/src/app/not-found.test.tsx new file mode 100644 index 000000000..c2678acbc --- /dev/null +++ b/packages/web/src/app/not-found.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; + +vi.mock("next/link", () => ({ + default: ({ + children, + ...props + }: React.PropsWithChildren>) => ( + {children} + ), +})); + +import NotFound from "./not-found"; + +describe("NotFound (global)", () => { + it("renders the page-not-found message", () => { + render(); + expect(screen.getByText("Page not found")).toBeInTheDocument(); + }); + + it("renders descriptive copy", () => { + render(); + expect( + screen.getByText( + "This route does not exist in the dashboard. Return to the main view to pick an active project or session.", + ), + ).toBeInTheDocument(); + }); + + it("renders a link back to the dashboard", () => { + render(); + const link = screen.getByText("Back to dashboard"); + expect(link).toBeInTheDocument(); + expect(link.closest("a")).toHaveAttribute("href", "/"); + }); + + it("renders the not-found icon", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/app/not-found.tsx b/packages/web/src/app/not-found.tsx new file mode 100644 index 000000000..8b817324d --- /dev/null +++ b/packages/web/src/app/not-found.tsx @@ -0,0 +1,12 @@ +import { ErrorDisplay } from "@/components/ErrorDisplay"; + +export default function NotFound() { + return ( + + ); +} diff --git a/packages/web/src/app/orchestrators/page.tsx b/packages/web/src/app/orchestrators/page.tsx new file mode 100644 index 000000000..ba8b47d3f --- /dev/null +++ b/packages/web/src/app/orchestrators/page.tsx @@ -0,0 +1,78 @@ +import type { Metadata } from "next"; +import { OrchestratorSelector, type Orchestrator } from "@/components/OrchestratorSelector"; +import { getServices } from "@/lib/services"; +import { getAllProjects } from "@/lib/project-name"; +import { generateSessionPrefix } from "@composio/ao-core"; +import { mapSessionsToOrchestrators } from "@/lib/orchestrator-utils"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(props: { + searchParams: Promise<{ project?: string }>; +}): Promise { + const searchParams = await props.searchParams; + const projectId = searchParams.project; + let projectName = "Orchestrator"; + if (projectId) { + const projects = getAllProjects(); + const project = projects.find((p) => p.id === projectId); + if (project) { + projectName = project.name; + } + } + return { title: { absolute: `ao | ${projectName} - Select Orchestrator` } }; +} + +export default async function OrchestratorsRoute(props: { + searchParams: Promise<{ project?: string }>; +}) { + const searchParams = await props.searchParams; + const projectId = searchParams.project; + + if (!projectId) { + return ( +
+
+

+ Missing Project +

+

+ No project specified. Please provide a project parameter. +

+
+
+ ); + } + + let orchestrators: Orchestrator[] = []; + let projectName = projectId; + let error: string | null = null; + + try { + const { config, sessionManager } = await getServices(); + const project = config.projects[projectId]; + + if (!project) { + error = `Project "${projectId}" not found`; + } else { + projectName = project.name; + const sessionPrefix = project.sessionPrefix ?? projectId; + const allSessions = await sessionManager.list(projectId); + const allSessionPrefixes = Object.entries(config.projects).map( + ([, p]) => p.sessionPrefix ?? generateSessionPrefix(p.name ?? ""), + ); + orchestrators = mapSessionsToOrchestrators(allSessions, sessionPrefix, project.name, allSessionPrefixes); + } + } catch (err) { + error = err instanceof Error ? err.message : "Failed to load orchestrators"; + } + + return ( + + ); +} diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index f791b063c..27a7149d0 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -28,7 +28,6 @@ export default async function Home(props: { searchParams: Promise<{ project?: st projectId={pageData.selectedProjectId} projectName={pageData.projectName} projects={pageData.projects} - initialGlobalPause={pageData.globalPause} orchestrators={pageData.orchestrators} /> ); diff --git a/packages/web/src/app/sessions/[id]/error.test.tsx b/packages/web/src/app/sessions/[id]/error.test.tsx new file mode 100644 index 000000000..253fa11c2 --- /dev/null +++ b/packages/web/src/app/sessions/[id]/error.test.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const refresh = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh }), +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + ...props + }: React.PropsWithChildren>) => ( + {children} + ), +})); + +import SessionError from "./error"; + +describe("Session error boundary", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("retries with reset and router refresh", () => { + const reset = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + + expect(reset).toHaveBeenCalledTimes(1); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("shows a session-specific message", () => { + render(); + + expect( + screen.getByText( + "The session request failed before the dashboard got a response. Check the server connection and try again.", + ), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/app/sessions/[id]/error.tsx b/packages/web/src/app/sessions/[id]/error.tsx new file mode 100644 index 000000000..f9a0011c1 --- /dev/null +++ b/packages/web/src/app/sessions/[id]/error.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { ErrorDisplay } from "@/components/ErrorDisplay"; + +function getSessionErrorMessage(error: Error): string { + const normalized = error.message.toLowerCase(); + if (normalized.includes("network")) { + return "The session request failed before the dashboard got a response. Check the server connection and try again."; + } + if (normalized.includes("403")) { + return "The dashboard could not access this session. Permissions or auth may have changed."; + } + if (normalized.includes("404")) { + return "This session is no longer available. It may have been removed while the page was open."; + } + if (normalized.includes("500")) { + return "The server returned an internal error while loading this session. Try re-fetching the session data."; + } + return "The dashboard could not load this session cleanly. Try again to re-fetch the latest state."; +} + +export default function SessionError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const router = useRouter(); + + useEffect(() => { + console.error(error); + }, [error]); + + return ( + { + reset(); + router.refresh(); + }, + }} + secondaryAction={{ label: "Back to dashboard", href: "/" }} + error={error} + compact + chrome="card" + /> + ); +} diff --git a/packages/web/src/app/sessions/[id]/loading.test.tsx b/packages/web/src/app/sessions/[id]/loading.test.tsx new file mode 100644 index 000000000..da87ae0f6 --- /dev/null +++ b/packages/web/src/app/sessions/[id]/loading.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import SessionLoading from "./loading"; + +describe("SessionLoading", () => { + it("renders the session loading text", () => { + render(); + expect(screen.getByText("Loading session…")).toBeInTheDocument(); + }); + + it("renders a spinning indicator", () => { + const { container } = render(); + const spinner = container.querySelector(".animate-spin"); + expect(spinner).toBeInTheDocument(); + expect(spinner?.tagName.toLowerCase()).toBe("svg"); + }); +}); diff --git a/packages/web/src/app/sessions/[id]/loading.tsx b/packages/web/src/app/sessions/[id]/loading.tsx new file mode 100644 index 000000000..d81ee937a --- /dev/null +++ b/packages/web/src/app/sessions/[id]/loading.tsx @@ -0,0 +1,20 @@ +export default function SessionLoading() { + return ( +
+
+ + + +

+ Loading session… +

+
+
+ ); +} diff --git a/packages/web/src/app/sessions/[id]/not-found.test.tsx b/packages/web/src/app/sessions/[id]/not-found.test.tsx new file mode 100644 index 000000000..614ec96de --- /dev/null +++ b/packages/web/src/app/sessions/[id]/not-found.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; + +vi.mock("next/link", () => ({ + default: ({ + children, + ...props + }: React.PropsWithChildren>) => ( + {children} + ), +})); + +import SessionNotFound from "./not-found"; + +describe("SessionNotFound", () => { + it("renders the session-not-found message", () => { + render(); + expect(screen.getByText("Session not found")).toBeInTheDocument(); + }); + + it("renders descriptive subtext", () => { + render(); + expect( + screen.getByText( + "The session you’re looking for does not exist anymore, or the link is stale.", + ), + ).toBeInTheDocument(); + }); + + it("renders a link back to the dashboard", () => { + render(); + const link = screen.getByText("Back to dashboard"); + expect(link).toBeInTheDocument(); + expect(link.closest("a")).toHaveAttribute("href", "/"); + }); + + it("renders the terminal icon", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/app/sessions/[id]/not-found.tsx b/packages/web/src/app/sessions/[id]/not-found.tsx new file mode 100644 index 000000000..4fb654b60 --- /dev/null +++ b/packages/web/src/app/sessions/[id]/not-found.tsx @@ -0,0 +1,14 @@ +import { ErrorDisplay } from "@/components/ErrorDisplay"; + +export default function SessionNotFound() { + return ( + + ); +} diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx index 45eb15790..3b2243e5a 100644 --- a/packages/web/src/app/sessions/[id]/page.test.tsx +++ b/packages/web/src/app/sessions/[id]/page.test.tsx @@ -1,11 +1,17 @@ -import { act, render } from "@testing-library/react"; +import React, { type ReactNode } from "react"; +import { act, render, screen } from "@testing-library/react"; import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import type { DashboardSession } from "@/lib/types"; const sessionDetailSpy = vi.fn(); +const notFoundError = new Error("NEXT_NOT_FOUND"); +const notFoundSpy = vi.fn(() => { + throw notFoundError; +}); vi.mock("next/navigation", () => ({ useParams: () => ({ id: "worker-1" }), + notFound: notFoundSpy, })); vi.mock("@/components/SessionDetail", () => ({ @@ -40,10 +46,32 @@ async function flushAsyncWork(): Promise { }); } +class TestErrorBoundary extends React.Component< + { children: ReactNode }, + { error: Error | null } +> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + render() { + if (this.state.error) { + return
{this.state.error.message}
; + } + return this.props.children; + } +} + describe("SessionPage project polling", () => { beforeEach(() => { vi.useFakeTimers(); sessionDetailSpy.mockClear(); + vi.spyOn(console, "error").mockImplementation(() => {}); const eventSourceMock = { addEventListener: vi.fn(), @@ -59,6 +87,7 @@ describe("SessionPage project polling", () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); + notFoundSpy.mockReset(); }); it("resolves orchestrator nav once for non-orchestrator pages and skips repeated project polling", async () => { @@ -125,4 +154,70 @@ describe("SessionPage project polling", () => { ), ).toHaveLength(1); }); + + it("routes 404 responses through notFound()", async () => { + global.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/projects") { + return { + ok: true, + status: 200, + json: async () => ({ projects: [] }), + } as Response; + } + + if (url === "/api/sessions/worker-1") { + return { + ok: false, + status: 404, + json: async () => ({}), + } as Response; + } + + throw new Error(`Unexpected fetch: ${url}`); + }) as typeof fetch; + + const { default: SessionPage } = await import("./page"); + + render(); + await flushAsyncWork(); + + expect(notFoundSpy).toHaveBeenCalled(); + expect(screen.queryByTestId("session-detail")).not.toBeInTheDocument(); + }); + + it("throws non-404 session fetch failures to the route error boundary", async () => { + global.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/projects") { + return { + ok: true, + status: 200, + json: async () => ({ projects: [] }), + } as Response; + } + + if (url === "/api/sessions/worker-1") { + return { + ok: false, + status: 500, + json: async () => ({}), + } as Response; + } + + throw new Error(`Unexpected fetch: ${url}`); + }) as typeof fetch; + + const { default: SessionPage } = await import("./page"); + + render( + + + , + ); + + await flushAsyncWork(); + + expect(screen.getByTestId("route-error")).toHaveTextContent("HTTP 500"); + }); }); diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index fc8c33b00..ce1380472 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -1,11 +1,13 @@ "use client"; import { useEffect, useState, useCallback, useRef } from "react"; -import { useParams } from "next/navigation"; +import { notFound, useParams } from "next/navigation"; import { isOrchestratorSession } from "@composio/ao-core/types"; import { SessionDetail } from "@/components/SessionDetail"; import { type DashboardSession, type ActivityState, getAttentionLevel, type AttentionLevel } from "@/lib/types"; import { activityIcon } from "@/lib/activity-icons"; +import type { ProjectInfo } from "@/lib/project-name"; +import { getSessionTitle } from "@/lib/format"; import { useSSESessionActivity } from "@/hooks/useSSESessionActivity"; function truncate(s: string, max: number): string { @@ -15,23 +17,21 @@ function truncate(s: string, max: number): string { /** Build a descriptive tab title from session data. */ function buildSessionTitle( session: DashboardSession, + prefixByProject: Map, activityOverride?: ActivityState | null, ): string { const id = session.id; const activity = activityOverride !== undefined ? activityOverride : session.activity; const emoji = activity ? (activityIcon[activity] ?? "") : ""; - const isOrchestrator = isOrchestratorSession(session); + const allPrefixes = [...prefixByProject.values()]; + const isOrchestrator = isOrchestratorSession(session, prefixByProject.get(session.projectId), allPrefixes); let detail: string; if (isOrchestrator) { detail = "Orchestrator Terminal"; - } else if (session.pr) { - detail = `#${session.pr.number} ${truncate(session.pr.branch, 30)}`; - } else if (session.branch) { - detail = truncate(session.branch, 30); } else { - detail = "Session Detail"; + detail = truncate(getSessionTitle(session), 40); } return emoji ? `${emoji} ${id} | ${detail}` : `${id} | ${detail}`; @@ -60,12 +60,38 @@ export default function SessionPage() { const [zoneCounts, setZoneCounts] = useState(null); const [projectOrchestratorId, setProjectOrchestratorId] = useState(undefined); const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [routeError, setRouteError] = useState(null); + const [sessionMissing, setSessionMissing] = useState(false); + const [prefixByProject, setPrefixByProject] = useState>(new Map()); const sessionProjectId = session?.projectId ?? null; - const sessionIsOrchestrator = session ? isOrchestratorSession(session) : false; + const allPrefixes = [...prefixByProject.values()]; + const sessionIsOrchestrator = session + ? isOrchestratorSession(session, prefixByProject.get(session.projectId), allPrefixes) + : false; const sessionProjectIdRef = useRef(null); const sessionIsOrchestratorRef = useRef(false); const resolvedProjectSessionsKeyRef = useRef(null); + const prefixByProjectRef = useRef>(new Map()); + const hasLoadedSessionRef = useRef(false); + + // Keep prefixByProjectRef in sync so fetchProjectSessions (stable [] dep) reads latest map + useEffect(() => { + prefixByProjectRef.current = prefixByProject; + }, [prefixByProject]); + + // Fetch project prefix map once on mount so isOrchestratorSession can use the correct prefix + useEffect(() => { + fetch("/api/projects") + .then((res) => res.ok ? res.json() : null) + .then((data: { projects?: ProjectInfo[] } | null) => { + if (data?.projects) { + setPrefixByProject( + new Map(data.projects.map((p) => [p.id, p.sessionPrefix ?? p.id])), + ); + } + }) + .catch(() => {/* non-critical — falls back to role metadata check */}); + }, []); // Subscribe to SSE for real-time activity updates (title emoji) const sseActivity = useSSESessionActivity(id); @@ -73,11 +99,11 @@ export default function SessionPage() { // Update document title based on session data + SSE activity override useEffect(() => { if (session) { - document.title = buildSessionTitle(session, sseActivity?.activity); + document.title = buildSessionTitle(session, prefixByProject, sseActivity?.activity); } else { document.title = `${id} | Session Detail`; } - }, [session, id, sseActivity]); + }, [session, id, prefixByProject, sseActivity]); useEffect(() => { sessionProjectIdRef.current = sessionProjectId; @@ -92,17 +118,23 @@ export default function SessionPage() { try { const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`); if (res.status === 404) { - setError("Session not found"); + if (!hasLoadedSessionRef.current) { + setSessionMissing(true); + } setLoading(false); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = (await res.json()) as DashboardSession; setSession(data); - setError(null); + setRouteError(null); + setSessionMissing(false); + hasLoadedSessionRef.current = true; } catch (err) { console.error("Failed to fetch session:", err); - setError("Failed to load session"); + if (!hasLoadedSessionRef.current) { + setRouteError(err instanceof Error ? err : new Error("Failed to load session")); + } } finally { setLoading(false); } @@ -141,8 +173,9 @@ export default function SessionPage() { working: 0, done: 0, }; + const allPrefixes = [...prefixByProjectRef.current.values()]; for (const s of sessions) { - if (!isOrchestratorSession(s)) { + if (!isOrchestratorSession(s, prefixByProjectRef.current.get(s.projectId), allPrefixes)) { counts[getAttentionLevel(s) as AttentionLevel]++; } } @@ -183,17 +216,17 @@ export default function SessionPage() { ); } - if (error || !session) { - return ( -
-
- {error ?? "Session not found"} -
- - ← Back to dashboard - -
- ); + if (sessionMissing) { + notFound(); + return null; + } + + if (routeError) { + throw routeError; + } + + if (!session) { + throw new Error("Session data was unavailable after loading completed"); } return ( diff --git a/packages/web/src/app/test-direct/page.test.tsx b/packages/web/src/app/test-direct/page.test.tsx index d4ed45dff..b12d86405 100644 --- a/packages/web/src/app/test-direct/page.test.tsx +++ b/packages/web/src/app/test-direct/page.test.tsx @@ -7,18 +7,28 @@ vi.mock("next/navigation", () => ({ useSearchParams: () => searchParams, })); +const MockDirectTerminal = ({ + sessionId, + startFullscreen, +}: { + sessionId: string; + startFullscreen: boolean; +}) => ( +
+ {sessionId}:{String(startFullscreen)} +
+); + vi.mock("@/components/DirectTerminal", () => ({ - DirectTerminal: ({ - sessionId, - startFullscreen, - }: { - sessionId: string; - startFullscreen: boolean; - }) => ( -
- {sessionId}:{String(startFullscreen)} -
- ), + DirectTerminal: MockDirectTerminal, +})); + +// next/dynamic wraps lazy imports; in tests, bypass the dynamic loader and +// return the mock component directly. +vi.mock("next/dynamic", () => ({ + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + default: (_loader: any) => MockDirectTerminal, })); describe("TestDirectPage", () => { diff --git a/packages/web/src/app/test-direct/page.tsx b/packages/web/src/app/test-direct/page.tsx index 9a8b14911..06a8ab4ee 100644 --- a/packages/web/src/app/test-direct/page.tsx +++ b/packages/web/src/app/test-direct/page.tsx @@ -1,9 +1,17 @@ "use client"; -import { DirectTerminal } from "@/components/DirectTerminal"; +import nextDynamic from "next/dynamic"; import { useSearchParams } from "next/navigation"; import { Suspense } from "react"; +const DirectTerminal = nextDynamic( + () => + import("@/components/DirectTerminal").then((m) => ({ + default: m.DirectTerminal, + })), + { ssr: false }, +); + // Force dynamic rendering (required for useSearchParams) export const dynamic = "force-dynamic"; diff --git a/packages/web/src/components/AttentionZone.tsx b/packages/web/src/components/AttentionZone.tsx index c5e6416a4..0ae427fd4 100644 --- a/packages/web/src/components/AttentionZone.tsx +++ b/packages/web/src/components/AttentionZone.tsx @@ -54,7 +54,7 @@ const zoneConfig: Record< pending: { label: "Pending", color: "var(--color-status-attention)", - caption: "Blocked on system state", + caption: "Waiting on external state", }, working: { label: "Working", diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 3dbb05adf..287086622 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -7,13 +7,13 @@ import { type DashboardSession, type DashboardStats, type AttentionLevel, - type GlobalPauseState, type DashboardOrchestratorLink, getAttentionLevel, isPRRateLimited, isPRMergeReady, } from "@/lib/types"; import { AttentionZone } from "./AttentionZone"; +import { SessionCard } from "./SessionCard"; import { DynamicFavicon, countNeedingAttention } from "./DynamicFavicon"; import { useSessionEvents } from "@/hooks/useSessionEvents"; import { ProjectSidebar } from "./ProjectSidebar"; @@ -31,7 +31,6 @@ interface DashboardProps { projectId?: string; projectName?: string; projects?: ProjectInfo[]; - initialGlobalPause?: GlobalPauseState | null; orchestrators?: DashboardOrchestratorLink[]; } @@ -68,7 +67,6 @@ function DashboardInner({ projectId, projectName, projects = [], - initialGlobalPause = null, orchestrators, }: DashboardProps) { const orchestratorLinks = orchestrators ?? EMPTY_ORCHESTRATORS; @@ -79,16 +77,14 @@ function DashboardInner({ } return levels; }, [initialSessions]); - const { sessions, globalPause, connectionStatus, sseAttentionLevels } = useSessionEvents( + const { sessions, connectionStatus, sseAttentionLevels } = useSessionEvents( initialSessions, - initialGlobalPause, projectId, initialAttentionLevels, ); const searchParams = useSearchParams(); const activeSessionId = searchParams.get("session") ?? undefined; const [rateLimitDismissed, setRateLimitDismissed] = useState(false); - const [globalPauseDismissed, setGlobalPauseDismissed] = useState(false); const [activeOrchestrators, setActiveOrchestrators] = useState(orchestratorLinks); const [spawningProjectIds, setSpawningProjectIds] = useState([]); @@ -106,6 +102,7 @@ function DashboardInner({ mode: "preview" | "confirm-kill"; } | null>(null); const [sheetSessionOverride, setSheetSessionOverride] = useState(null); + const [doneExpanded, setDoneExpanded] = useState(false); const sessionsRef = useRef(sessions); const hasSeededMobileExpansionRef = useRef(false); sessionsRef.current = sessions; @@ -482,15 +479,6 @@ function DashboardInner({ [sessions], ); - const resumeAtLabel = useMemo(() => { - if (!globalPause) return null; - return new Date(globalPause.pausedUntil).toLocaleString(); - }, [globalPause]); - - useEffect(() => { - setGlobalPauseDismissed(false); - }, [globalPause?.pausedUntil, globalPause?.reason, globalPause?.sourceSessionId]); - return ( <> @@ -538,7 +526,7 @@ function DashboardInner({ {projectName ?? "Orchestrator"}

- Live sessions, review pressure, and merge readiness. + Live agent sessions, pull requests, and merge status.

@@ -600,45 +588,6 @@ function DashboardInner({ ) : null} - {globalPause && !globalPauseDismissed && ( -
- - - - - - Orchestrator paused: {globalPause.reason} - {resumeAtLabel && ( - Resume after {resumeAtLabel} - )} - {globalPause.sourceSessionId && ( - (Source: {globalPause.sourceSessionId}) - )} - - -
- )} - {anyRateLimited && !rateLimitDismissed && (

Attention Board

- Triage by required intervention, not by chronology. + Sessions sorted by what needs your attention.

@@ -735,7 +684,46 @@ function DashboardInner({
)} - {!allProjectsView && !hasAnySessions && } + {!allProjectsView && grouped.done.length > 0 && ( +
+ + {doneExpanded && ( +
+ {grouped.done.map((session) => ( + + ))} +
+ )} +
+ )} + + {!allProjectsView && !hasAnySessions && grouped.done.length === 0 && } @@ -1072,3 +1060,4 @@ function BoardLegendItem({ label, tone }: { label: string; tone: string }) { ); } + diff --git a/packages/web/src/components/ErrorDisplay.tsx b/packages/web/src/components/ErrorDisplay.tsx new file mode 100644 index 000000000..e520ab979 --- /dev/null +++ b/packages/web/src/components/ErrorDisplay.tsx @@ -0,0 +1,204 @@ +"use client"; + +import Link from "next/link"; +import { type ReactNode } from "react"; + +type ErrorTone = "error" | "warning" | "not-found"; + +interface ErrorAction { + label: string; + onClick?: () => void; + href?: string; +} + +interface ErrorDisplayProps { + title: string; + message: string; + tone?: ErrorTone; + detailsTitle?: string; + error?: Error & { digest?: string }; + primaryAction?: ErrorAction; + secondaryAction?: ErrorAction; + compact?: boolean; + chrome?: "page" | "card"; + children?: ReactNode; +} + +const toneMeta: Record = { + error: { + accent: "var(--color-status-error)", + bg: "var(--color-tint-red)", + border: "color-mix(in srgb, var(--color-status-error) 24%, transparent)", + label: "error", + }, + warning: { + accent: "var(--color-status-attention)", + bg: "var(--color-tint-yellow)", + border: "color-mix(in srgb, var(--color-status-attention) 24%, transparent)", + label: "warning", + }, + "not-found": { + accent: "var(--color-accent)", + bg: "var(--color-tint-blue)", + border: "color-mix(in srgb, var(--color-accent) 24%, transparent)", + label: "missing", + }, +}; + +function TerminalIcon({ accent }: { accent: string }) { + return ( +
+ + + + +
+ ); +} + +function ActionButton({ action, primary = false }: { action: ErrorAction; primary?: boolean }) { + const className = primary + ? "inline-flex items-center justify-center rounded-full border px-4 py-2 text-[12px] font-semibold transition-colors hover:no-underline" + : "inline-flex items-center justify-center rounded-full border px-4 py-2 text-[12px] font-medium transition-colors hover:no-underline"; + const style = primary + ? { + background: "var(--color-accent)", + color: "var(--color-text-inverse)", + borderColor: "color-mix(in srgb, var(--color-accent) 72%, transparent)", + } + : { + background: "var(--color-bg-surface)", + color: "var(--color-text-secondary)", + borderColor: "var(--color-border-default)", + }; + + if (action.href) { + return ( + + {action.label} + + ); + } + + return ( + + ); +} + +export function ErrorDisplay({ + title, + message, + tone = "error", + detailsTitle = "Technical details", + error, + primaryAction, + secondaryAction, + compact = false, + chrome = "page", + children, +}: ErrorDisplayProps) { + const meta = toneMeta[tone]; + const hasDetails = Boolean(error?.digest || error?.message || error?.stack); + + return ( +
+
+
+
+ +
+
+ {meta.label} +
+

+ {title} +

+

+ {message} +

+
+
+ + {(primaryAction || secondaryAction) && ( +
+ {primaryAction ? : null} + {secondaryAction ? : null} +
+ )} + + {children} + + {hasDetails ? ( +
+ + {detailsTitle} + +
+ {error?.digest ? ( +

+ digest: {error.digest} +

+ ) : null} + {error?.message ? ( +

+ {error.message} +

+ ) : null} + {error?.stack ? ( +
+                    {error.stack}
+                  
+ ) : null} +
+
+ ) : null} +
+
+
+ ); +} diff --git a/packages/web/src/components/OrchestratorSelector.tsx b/packages/web/src/components/OrchestratorSelector.tsx new file mode 100644 index 000000000..c80fa015b --- /dev/null +++ b/packages/web/src/components/OrchestratorSelector.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { useState, useRef } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { cn } from "@/lib/cn"; + +export interface Orchestrator { + id: string; + projectId: string; + projectName: string; + status: string; + activity: string | null; + createdAt: string | null; + lastActivityAt: string | null; +} + +interface OrchestratorSelectorProps { + orchestrators: Orchestrator[]; + projectId: string; + projectName: string; + error: string | null; +} + +function formatRelativeTime(isoDate: string | null): string { + if (!isoDate) return "Unknown"; + const date = new Date(isoDate); + const timestamp = date.getTime(); + // Guard against invalid dates (NaN) and future timestamps + if (!Number.isFinite(timestamp)) return "Unknown"; + const now = new Date(); + const diffMs = now.getTime() - timestamp; + // Handle future timestamps + if (diffMs < 0) return "Just now"; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return `${diffDays}d ago`; +} + +function getStatusColor(status: string): string { + switch (status) { + case "working": + return "var(--color-status-working)"; + case "spawning": + return "var(--color-status-attention)"; + case "pr_open": + case "review_pending": + case "approved": + case "mergeable": + return "var(--color-status-ready)"; + case "ci_failed": + case "changes_requested": + return "var(--color-status-error)"; + case "merged": + case "done": + case "killed": + case "terminated": + return "var(--color-text-tertiary)"; + default: + return "var(--color-text-secondary)"; + } +} + +function getActivityLabel(activity: string | null): string { + if (!activity) return ""; + switch (activity) { + case "active": + return "Active"; + case "ready": + return "Ready"; + case "idle": + return "Idle"; + case "waiting_input": + return "Waiting"; + case "blocked": + return "Blocked"; + case "exited": + return "Exited"; + default: + return activity; + } +} + +export function OrchestratorSelector({ + orchestrators, + projectId, + projectName, + error, +}: OrchestratorSelectorProps) { + const router = useRouter(); + const [isSpawning, setIsSpawning] = useState(false); + const [spawnError, setSpawnError] = useState(null); + const spawnLockRef = useRef(false); + + const handleSpawnNew = async () => { + // Synchronous re-entrancy guard: React state updates are async, + // so two clicks before rerender would fire two POSTs without this. + if (spawnLockRef.current) return; + spawnLockRef.current = true; + setIsSpawning(true); + setSpawnError(null); + + try { + const response = await fetch("/api/orchestrators", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to spawn orchestrator"); + } + + const data = await response.json(); + router.push(`/sessions/${data.orchestrator.id}`); + } catch (err) { + setSpawnError(err instanceof Error ? err.message : "Failed to spawn orchestrator"); + } finally { + setIsSpawning(false); + spawnLockRef.current = false; + } + }; + + if (error) { + return ( +
+
+

Error

+

{error}

+ + Go to Dashboard + +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+

+ {projectName} +

+

Select an orchestrator

+
+ + Dashboard + +
+
+ + {/* Content */} +
+
+ {/* Info banner */} +
+

+ Found{" "} + + {orchestrators.length} + {" "} + existing orchestrator session{orchestrators.length !== 1 ? "s" : ""}. You can resume + an existing session or start a new one. +

+
+ + {/* Existing orchestrators */} +
+

+ Existing Sessions +

+
+ {orchestrators.map((orch) => ( + +
+
+
+
{orch.id}
+
+ {orch.status.replace(/_/g, " ")} + {orch.activity && ( + <> + · + {getActivityLabel(orch.activity)} + + )} +
+
+
+
+
Created {formatRelativeTime(orch.createdAt)}
+ {orch.lastActivityAt && ( +
Active {formatRelativeTime(orch.lastActivityAt)}
+ )} +
+ + ))} +
+
+ + {/* Start new section */} +
+

+ Or Start Fresh +

+ + {spawnError && ( +

{spawnError}

+ )} +
+
+
+
+ ); +} diff --git a/packages/web/src/components/PRStatus.tsx b/packages/web/src/components/PRStatus.tsx index 26242c32a..0d9b48d6f 100644 --- a/packages/web/src/components/PRStatus.tsx +++ b/packages/web/src/components/PRStatus.tsx @@ -1,6 +1,6 @@ "use client"; -import { type DashboardPR, isPRRateLimited } from "@/lib/types"; +import { type DashboardPR, isPRRateLimited, isPRUnenriched } from "@/lib/types"; import { CIBadge } from "./CIBadge"; export function getSizeLabel(additions: number, deletions: number): string { @@ -15,6 +15,7 @@ interface PRStatusProps { export function PRStatus({ pr }: PRStatusProps) { const sizeLabel = getSizeLabel(pr.additions, pr.deletions); const rateLimited = isPRRateLimited(pr); + const unenriched = isPRUnenriched(pr); return (
@@ -29,12 +30,14 @@ export function PRStatus({ pr }: PRStatusProps) { #{pr.number} - {/* Size — hide when rate limited (would show +0 -0 XS) */} - {!rateLimited && ( + {/* Size — shimmer when unenriched, hide when rate limited */} + {!rateLimited && (unenriched ? ( + + ) : ( +{pr.additions} -{pr.deletions} {sizeLabel} - )} + ))} {/* Merged badge */} {pr.state === "merged" && ( @@ -50,13 +53,15 @@ export function PRStatus({ pr }: PRStatusProps) { )} - {/* CI status — only when we have real data */} - {pr.state === "open" && !pr.isDraft && !rateLimited && ( + {/* CI status — shimmer when unenriched */} + {pr.state === "open" && !pr.isDraft && !rateLimited && (unenriched ? ( + + ) : ( - )} + ))} {/* Review decision (only for open PRs with real data) */} - {pr.state === "open" && pr.reviewDecision === "approved" && !rateLimited && ( + {pr.state === "open" && pr.reviewDecision === "approved" && !rateLimited && !unenriched && ( approved @@ -72,8 +77,10 @@ interface PRTableRowProps { export function PRTableRow({ pr }: PRTableRowProps) { const sizeLabel = getSizeLabel(pr.additions, pr.deletions); const rateLimited = isPRRateLimited(pr); + const unenriched = isPRUnenriched(pr); + const hideData = rateLimited || unenriched; - const reviewLabel = rateLimited + const reviewLabel = hideData ? "—" : pr.isDraft ? "draft" @@ -83,7 +90,7 @@ export function PRTableRow({ pr }: PRTableRowProps) { ? "changes requested" : "needs review"; - const reviewClass = rateLimited + const reviewClass = hideData ? "text-[var(--color-text-tertiary)]" : pr.isDraft ? "text-[var(--color-text-muted)]" @@ -93,6 +100,8 @@ export function PRTableRow({ pr }: PRTableRowProps) { ? "text-[var(--color-accent-red)]" : "text-[var(--color-accent-yellow)]"; + const shimmer = ; + return ( @@ -102,7 +111,7 @@ export function PRTableRow({ pr }: PRTableRowProps) { {pr.title} - {rateLimited ? ( + {unenriched ? shimmer : rateLimited ? ( ) : ( <> @@ -113,17 +122,19 @@ export function PRTableRow({ pr }: PRTableRowProps) { )} - {rateLimited ? ( + {unenriched ? shimmer : rateLimited ? ( ) : ( )} - {reviewLabel} + + {unenriched ? shimmer : reviewLabel} + 0 ? "text-[var(--color-accent-red)]" : "text-[var(--color-border-default)]"}`} + className={`px-3 py-2.5 text-center text-sm font-bold ${unenriched ? "" : pr.unresolvedThreads > 0 ? "text-[var(--color-accent-red)]" : "text-[var(--color-border-default)]"}`} > - {pr.unresolvedThreads} + {unenriched ? shimmer : pr.unresolvedThreads} ); @@ -132,9 +143,10 @@ export function PRTableRow({ pr }: PRTableRowProps) { export function PRCard({ pr }: PRTableRowProps) { const sizeLabel = getSizeLabel(pr.additions, pr.deletions); const rateLimited = isPRRateLimited(pr); + const unenriched = isPRUnenriched(pr); - const reviewLabel = rateLimited - ? "stale" + const reviewLabel = rateLimited || unenriched + ? "—" : pr.isDraft ? "draft" : pr.reviewDecision === "approved" @@ -143,14 +155,16 @@ export function PRCard({ pr }: PRTableRowProps) { ? "changes" : "review"; - const ciLabel = rateLimited - ? "CI stale" + const ciLabel = rateLimited || unenriched + ? "—" : pr.ciStatus === "passing" ? "CI passing" : pr.ciStatus === "failing" ? "CI failing" : "CI pending"; + const shimmer = ; + return ( #{pr.number} {pr.title} - {!rateLimited ? {sizeLabel} : null} + {!rateLimited && !unenriched ? {sizeLabel} : null}
- {ciLabel} - {reviewLabel} - {pr.unresolvedThreads} threads + {unenriched ? shimmer : ciLabel} + {unenriched ? shimmer : reviewLabel} + {unenriched ? shimmer : `${pr.unresolvedThreads} threads`}
); diff --git a/packages/web/src/components/ProjectSidebar.tsx b/packages/web/src/components/ProjectSidebar.tsx index f2f5b5fed..036e73d75 100644 --- a/packages/web/src/components/ProjectSidebar.tsx +++ b/packages/web/src/components/ProjectSidebar.tsx @@ -21,8 +21,14 @@ interface ProjectSidebarProps { type ProjectHealth = "red" | "yellow" | "green" | "gray"; -function computeProjectHealth(sessions: DashboardSession[]): ProjectHealth { - const workers = sessions.filter((s) => !isOrchestratorSession(s)); +function computeProjectHealth( + sessions: DashboardSession[], + prefixByProject: Map, + allPrefixes: string[], +): ProjectHealth { + const workers = sessions.filter( + (s) => !isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes), + ); if (workers.length === 0) return "gray"; for (const s of workers) { if (getAttentionLevel(s) === "respond") return "red"; @@ -131,6 +137,16 @@ function ProjectSidebarInner({ router.push(pathname + `?project=${encodeURIComponent(projectId)}`); }; + const prefixByProject = useMemo( + () => new Map(projects.map((p) => [p.id, p.sessionPrefix ?? p.id])), + [projects], + ); + + const allPrefixes = useMemo( + () => projects.map((p) => p.sessionPrefix ?? p.id), + [projects], + ); + const sessionsByProject = useMemo(() => { const map = new Map(); let totalWorkers = 0; @@ -144,7 +160,7 @@ function ProjectSidebarInner({ map.set(s.projectId, entry); } entry.all.push(s); - if (!isOrchestratorSession(s)) { + if (!isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes)) { entry.workers.push(s); totalWorkers++; } @@ -154,7 +170,7 @@ function ProjectSidebarInner({ } return { map, totalWorkers, needsInput, reviewLoad }; - }, [sessions]); + }, [sessions, prefixByProject, allPrefixes]); const { totalWorkers: totalWorkerSessions, needsInput: needsInputCount, reviewLoad: reviewLoadCount } = sessionsByProject; @@ -168,7 +184,7 @@ function ProjectSidebarInner({
{projects.map((project) => { const entry = sessionsByProject.map.get(project.id); - const health = entry ? computeProjectHealth(entry.all) : ("gray" as ProjectHealth); + const health = entry ? computeProjectHealth(entry.all, prefixByProject, allPrefixes) : ("gray" as ProjectHealth); const isActive = activeProjectId === project.id; const initial = project.name.charAt(0).toUpperCase(); return ( @@ -287,7 +303,7 @@ function ProjectSidebarInner({ const entry = sessionsByProject.map.get(project.id); const projectSessions = entry?.all ?? []; const workerSessions = entry?.workers ?? []; - const health = computeProjectHealth(projectSessions); + const health = computeProjectHealth(projectSessions, prefixByProject, allPrefixes); const isExpanded = expandedProjects.has(project.id); const isActive = activeProjectId === project.id; diff --git a/packages/web/src/components/PullRequestsPage.tsx b/packages/web/src/components/PullRequestsPage.tsx index 027ad8757..c7fdb4d20 100644 --- a/packages/web/src/components/PullRequestsPage.tsx +++ b/packages/web/src/components/PullRequestsPage.tsx @@ -43,7 +43,7 @@ export function PullRequestsPage({ } return levels; }, [initialSessions]); - const { sessions, sseAttentionLevels } = useSessionEvents(initialSessions, null, projectId, initialAttentionLevels); + const { sessions, sseAttentionLevels } = useSessionEvents(initialSessions, projectId, initialAttentionLevels); const searchParams = useSearchParams(); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); @@ -117,7 +117,7 @@ export function PullRequestsPage({

{projectName ? `${projectName} PRs` : "Pull Requests"}

- Review active pull requests without the dashboard board chrome. + Open pull requests created by agents{allProjectsView ? " across all projects" : " in this project"}.

diff --git a/packages/web/src/components/SessionCard.tsx b/packages/web/src/components/SessionCard.tsx index 50061acf9..dc86cb295 100644 --- a/packages/web/src/components/SessionCard.tsx +++ b/packages/web/src/components/SessionCard.tsx @@ -5,6 +5,7 @@ import { type DashboardSession, getAttentionLevel, isPRRateLimited, + isPRUnenriched, TERMINAL_STATUSES, TERMINAL_ACTIVITIES, CI_STATUS, @@ -156,6 +157,7 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio }; const rateLimited = pr ? isPRRateLimited(pr) : false; + const prUnenriched = pr ? isPRUnenriched(pr) : false; const alerts = getAlerts(session); const isReadyToMerge = !rateLimited && pr?.mergeability.mergeable && pr.state === "open"; const isTerminal = @@ -265,13 +267,15 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio #{pr.number} )} - {pr && !rateLimited && ( + {pr && !rateLimited && (prUnenriched ? ( + + ) : ( +{pr.additions}{" "} -{pr.deletions}{" "} {getSizeLabel(pr.additions, pr.deletions)} - )} + ))} {/* Expandable detail panel */} @@ -345,21 +349,33 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio > {pr.title} -
- - - +{pr.additions}{" "} - -{pr.deletions} - - · - - mergeable: {pr.mergeability.mergeable ? "yes" : "no"} - - · - - review: {pr.reviewDecision} - - + {prUnenriched ? ( + <> +
+ + + PR details loading... + + + ) : ( + <> +
+ + + +{pr.additions}{" "} + -{pr.deletions} + + · + + mergeable: {pr.mergeability.mergeable ? "yes" : "no"} + + · + + review: {pr.reviewDecision} + + + + )}

)} @@ -474,11 +490,13 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio #{pr.number} )} - {pr && !rateLimited && ( + {pr && !rateLimited && (prUnenriched ? ( + + ) : ( +{pr.additions} -{pr.deletions} {getSizeLabel(pr.additions, pr.deletions)} - )} + ))} {secondaryText && ( @@ -725,6 +743,7 @@ function getAlerts(session: DashboardSession): Alert[] { const pr = session.pr; if (!pr || pr.state !== "open") return []; if (isPRRateLimited(pr)) return []; + if (isPRUnenriched(pr)) return []; const meta = session.metadata; const alerts: Alert[] = []; diff --git a/packages/web/src/components/SessionDetail.tsx b/packages/web/src/components/SessionDetail.tsx index 567b73d12..debc055cb 100644 --- a/packages/web/src/components/SessionDetail.tsx +++ b/packages/web/src/components/SessionDetail.tsx @@ -6,10 +6,21 @@ import { useMediaQuery, MOBILE_BREAKPOINT } from "@/hooks/useMediaQuery"; import { type DashboardSession, type DashboardPR, isPRMergeReady } from "@/lib/types"; import { CI_STATUS } from "@composio/ao-core/types"; import { cn } from "@/lib/cn"; +import dynamic from "next/dynamic"; +import { getSessionTitle } from "@/lib/format"; import { CICheckList } from "./CIBadge"; -import { DirectTerminal } from "./DirectTerminal"; import { MobileBottomNav } from "./MobileBottomNav"; +const DirectTerminal = dynamic( + () => import("./DirectTerminal").then((m) => ({ default: m.DirectTerminal })), + { + ssr: false, + loading: () => ( +
+ ), + }, +); + interface OrchestratorZones { merge: number; respond: number; @@ -37,10 +48,6 @@ const activityMeta: Record = { exited: { label: "Exited", color: "var(--color-status-error)" }, }; -function getSessionHeadline(session: DashboardSession): string { - return session.issueTitle ?? session.summary ?? session.id; -} - function cleanBugbotComment(body: string): { title: string; description: string } { const isBugbot = body.includes("") || body.includes("### "); if (isBugbot) { @@ -329,7 +336,7 @@ export function SessionDetail({ label: session.activity ?? "unknown", color: "var(--color-text-muted)", }; - const headline = getSessionHeadline(session); + const headline = getSessionTitle(session); const accentColor = "var(--color-accent)"; const terminalVariant = isOrchestrator ? "orchestrator" : "agent"; diff --git a/packages/web/src/components/Skeleton.tsx b/packages/web/src/components/Skeleton.tsx index d61c430a5..2d2c30f93 100644 --- a/packages/web/src/components/Skeleton.tsx +++ b/packages/web/src/components/Skeleton.tsx @@ -7,7 +7,6 @@ interface EmptyStateProps { export function EmptyState({ message, }: EmptyStateProps) { - const isDefault = !message; return (
{/* Terminal icon */} @@ -22,16 +21,7 @@ export function EmptyState({

- {isDefault ? ( - <> - No sessions running. Start one with{" "} - - ao start - - - ) : ( - message - )} + {message ?? "No active sessions"}

); diff --git a/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx b/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx new file mode 100644 index 000000000..1bfd2bcb7 --- /dev/null +++ b/packages/web/src/components/__tests__/Dashboard.doneBar.test.tsx @@ -0,0 +1,62 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Dashboard } from "../Dashboard"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), + usePathname: () => "/", + useSearchParams: () => new URLSearchParams(), +})); + +beforeEach(() => { + const eventSourceMock = { + onmessage: null, + onerror: null, + close: vi.fn(), + }; + const eventSourceConstructor = vi.fn(() => eventSourceMock as unknown as EventSource); + global.EventSource = Object.assign(eventSourceConstructor, { + CONNECTING: 0, + OPEN: 1, + CLOSED: 2, + }) as unknown as typeof EventSource; + global.fetch = vi.fn(); +}); + +const DONE_SESSION = { + id: "done-1", + projectId: "proj", + status: "merged" as const, + activity: "exited" as const, + branch: "feat/done", + issueId: null, + issueUrl: null, + issueLabel: null, + issueTitle: null, + summary: "Finished task", + summaryIsFallback: false, + createdAt: new Date().toISOString(), + lastActivityAt: new Date().toISOString(), + pr: null, + metadata: {}, +}; + +describe("Dashboard done bar", () => { + it("shows the done bar when done sessions exist", () => { + render(); + expect(screen.getByText("Done / Terminated")).toBeInTheDocument(); + }); + + it("expands to show session cards when clicked", () => { + const { container } = render(); + const toggle = screen.getByText("Done / Terminated").closest("button")!; + expect(container.querySelector(".done-bar__cards")).toBeNull(); + fireEvent.click(toggle); + expect(container.querySelector(".done-bar__cards")).toBeInTheDocument(); + }); + + it("does not show empty state when only done sessions exist", () => { + render(); + expect(screen.queryByText(/No active sessions/i)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx b/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx index 6eddff75c..2e05c66a1 100644 --- a/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.emptyState.test.tsx @@ -26,7 +26,7 @@ beforeEach(() => { describe("Dashboard empty state", () => { it("shows empty state when there are no sessions (single-project view)", () => { render(); - expect(screen.getByText(/No sessions running/i)).toBeInTheDocument(); + expect(screen.getByText(/No active sessions/i)).toBeInTheDocument(); }); it("does not show empty state when sessions exist", () => { @@ -53,6 +53,6 @@ describe("Dashboard empty state", () => { ]} />, ); - expect(queryByText(/No sessions running/i)).not.toBeInTheDocument(); + expect(queryByText(/No active sessions/i)).not.toBeInTheDocument(); }); }); diff --git a/packages/web/src/components/__tests__/Dashboard.globalPause.test.tsx b/packages/web/src/components/__tests__/Dashboard.globalPause.test.tsx deleted file mode 100644 index 5b46489ea..000000000 --- a/packages/web/src/components/__tests__/Dashboard.globalPause.test.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; -import { Dashboard } from "@/components/Dashboard"; -import type { GlobalPauseState } from "@/lib/types"; -import { makeSession } from "@/__tests__/helpers"; - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), - usePathname: () => "/", - useSearchParams: () => new URLSearchParams(), -})); - -describe("Dashboard globalPause banner", () => { - let eventSourceMock: { - onmessage: ((event: MessageEvent) => void) | null; - onerror: (() => void) | null; - close: () => void; - }; - - const makeGlobalPause = (overrides: Partial = {}): GlobalPauseState => ({ - pausedUntil: new Date(Date.now() + 3600000).toISOString(), - reason: "Model rate limit reached", - sourceSessionId: "session-1", - ...overrides, - }); - - beforeEach(() => { - eventSourceMock = { - onmessage: null, - onerror: null, - close: vi.fn(), - }; - global.EventSource = vi.fn(() => eventSourceMock as unknown as EventSource); - global.fetch = vi.fn(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("shows banner when initialGlobalPause is set", () => { - const sessions = [makeSession()]; - const globalPause = makeGlobalPause(); - - render(); - - expect(screen.getByText(/Orchestrator paused:/)).toBeInTheDocument(); - expect(screen.getByText(/Model rate limit reached/)).toBeInTheDocument(); - }); - - it("hides banner when initialGlobalPause is null", () => { - const sessions = [makeSession()]; - - render(); - - expect(screen.queryByText(/Orchestrator paused:/)).not.toBeInTheDocument(); - }); - - it("shows banner with custom reason from any provider", () => { - const sessions = [makeSession()]; - const globalPause = makeGlobalPause({ reason: "Custom provider limit exceeded" }); - - render(); - - expect(screen.getByText(/Custom provider limit exceeded/)).toBeInTheDocument(); - }); - - it("shows the automatic resume time", () => { - const sessions = [makeSession()]; - const globalPause = makeGlobalPause({ pausedUntil: "2026-03-10T12:30:00.000Z" }); - - render(); - - expect(screen.getByText(/Resume after/)).toBeInTheDocument(); - }); - - it("displays source session ID when provided", () => { - const sessions = [makeSession()]; - const globalPause = makeGlobalPause({ sourceSessionId: "my-worker-42" }); - - render(); - - expect(screen.getByText(/Source: my-worker-42/)).toBeInTheDocument(); - }); - - it("banner appears from state update via SSE (provider-agnostic)", async () => { - const sessions = [makeSession()]; - - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - sessions: [...sessions, makeSession({ id: "session-new" })], - globalPause: makeGlobalPause({ reason: "Rate limit from any agent" }), - }), - } as Response); - - render(); - - expect(screen.queryByText(/Orchestrator paused:/)).not.toBeInTheDocument(); - - await waitFor(() => expect(eventSourceMock.onmessage).not.toBeNull()); - - await act(async () => { - eventSourceMock.onmessage!({ - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { - id: "session-0", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - { - id: "session-new", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - ], - }), - } as MessageEvent); - }); - - await waitFor(() => { - expect(screen.getByText(/Orchestrator paused:/)).toBeInTheDocument(); - expect(screen.getByText(/Rate limit from any agent/)).toBeInTheDocument(); - }); - }); - - it("banner disappears from state update via SSE (pause expires)", async () => { - const sessions = [makeSession()]; - const globalPause = makeGlobalPause(); - - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - sessions: [...sessions, makeSession({ id: "session-new" })], - globalPause: null, - }), - } as Response); - - render(); - - expect(screen.getByText(/Orchestrator paused:/)).toBeInTheDocument(); - - await waitFor(() => expect(eventSourceMock.onmessage).not.toBeNull()); - - await act(async () => { - eventSourceMock.onmessage!({ - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { - id: "session-0", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - { - id: "session-new", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - ], - }), - } as MessageEvent); - }); - - await waitFor(() => { - expect(screen.queryByText(/Orchestrator paused:/)).not.toBeInTheDocument(); - }); - }); - - it("shows a new pause after dismissing a previous one", () => { - const sessions = [makeSession()]; - const firstPause = makeGlobalPause({ - reason: "First pause", - pausedUntil: "2026-03-10T12:00:00.000Z", - }); - const secondPause = makeGlobalPause({ - reason: "Second pause", - pausedUntil: "2026-03-10T13:00:00.000Z", - }); - - const { rerender } = render( - , - ); - - expect(screen.getByText(/First pause/)).toBeInTheDocument(); - - const dismissButtons = screen.getAllByLabelText("Dismiss"); - act(() => { - fireEvent.click(dismissButtons[0]); - }); - expect(screen.queryByText(/Orchestrator paused:/)).not.toBeInTheDocument(); - - rerender(); - - expect(screen.getByText(/Second pause/)).toBeInTheDocument(); - }); -}); diff --git a/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx b/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx index 104915659..2faff2fa6 100644 --- a/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx +++ b/packages/web/src/components/__tests__/Dashboard.mobile.test.tsx @@ -55,6 +55,7 @@ describe("Dashboard mobile layout", () => { makeSession({ id: `needs-input-${index + 1}`, summary: `Need approval ${index + 1}`, + branch: null, status: "needs_input", activity: "waiting_input", }), @@ -83,18 +84,18 @@ describe("Dashboard mobile layout", () => { render(); - expect(screen.getByRole("link", { name: /go to need approval to proceed/i })).toHaveAttribute( + expect(screen.getByRole("link", { name: /go to mobile density/i })).toHaveAttribute( "href", "/sessions/respond-1", ); await act(async () => { - fireEvent.click(screen.getByRole("button", { name: /open need approval to proceed/i })); + fireEvent.click(screen.getByRole("button", { name: /open mobile density/i })); }); expect(screen.getByRole("link", { name: "Open session" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Terminate" })).toBeInTheDocument(); - expect(screen.getAllByText("Need approval to proceed").length).toBeGreaterThan(1); + expect(screen.getAllByText("Mobile Density").length).toBeGreaterThan(1); expect(screen.getAllByText("respond").length).toBeGreaterThan(0); expect(screen.getAllByText("needs input").length).toBeGreaterThan(0); expect(screen.getByText("waiting input")).toBeInTheDocument(); @@ -116,7 +117,7 @@ describe("Dashboard mobile layout", () => { const { rerender } = render(); await act(async () => { - fireEvent.click(screen.getByRole("button", { name: /open need approval to proceed/i })); + fireEvent.click(screen.getByRole("button", { name: /open mobile density/i })); }); expect(screen.getByRole("button", { name: "Terminate" })).toBeInTheDocument(); @@ -219,12 +220,14 @@ describe("Dashboard mobile layout", () => { status: "needs_input", activity: "waiting_input", summary: "Need approval to proceed", + branch: null, }), makeSession({ id: "working-1", status: "running", activity: "active", summary: "Implement dashboard filters", + branch: null, }), ]} />, @@ -245,6 +248,7 @@ describe("Dashboard mobile layout", () => { status: "needs_input", activity: "waiting_input", summary: "Need approval to proceed", + branch: null, }), ]} />, @@ -264,12 +268,14 @@ describe("Dashboard mobile layout", () => { status: "needs_input", activity: "waiting_input", summary: "Need approval to proceed", + branch: null, }), makeSession({ id: "working-1", status: "running", activity: "active", summary: "Implement dashboard filters", + branch: null, }), ]} />, @@ -289,6 +295,7 @@ describe("Dashboard mobile layout", () => { status: "needs_input", activity: "waiting_input", summary: "Need approval to proceed", + branch: null, lastActivityAt: new Date(Date.now() + 1_000).toISOString(), }), makeSession({ @@ -296,6 +303,7 @@ describe("Dashboard mobile layout", () => { status: "running", activity: "active", summary: "Implement dashboard filters", + branch: null, lastActivityAt: new Date(Date.now() + 2_000).toISOString(), }), ]} diff --git a/packages/web/src/components/__tests__/ErrorDisplay.test.tsx b/packages/web/src/components/__tests__/ErrorDisplay.test.tsx new file mode 100644 index 000000000..1c86bff0e --- /dev/null +++ b/packages/web/src/components/__tests__/ErrorDisplay.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next/link", () => ({ + default: ({ + children, + ...props + }: React.PropsWithChildren>) => ( + {children} + ), +})); + +import { ErrorDisplay } from "../ErrorDisplay"; + +describe("ErrorDisplay", () => { + it("renders title, message, and actions", () => { + const onRetry = vi.fn(); + + render( + , + ); + + expect(screen.getByText("Failed to load session")).toBeInTheDocument(); + expect(screen.getByText("Try again.")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(onRetry).toHaveBeenCalledTimes(1); + + expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute("href", "/"); + }); + + it("renders technical details when provided", () => { + render( + , + ); + + expect(screen.getByText("Technical details")).toBeInTheDocument(); + expect(screen.getByText(/digest: abc123/)).toBeInTheDocument(); + expect(screen.getByText("HTTP 500")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/components/__tests__/OrchestratorSelector.test.tsx b/packages/web/src/components/__tests__/OrchestratorSelector.test.tsx new file mode 100644 index 000000000..986de386d --- /dev/null +++ b/packages/web/src/components/__tests__/OrchestratorSelector.test.tsx @@ -0,0 +1,272 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { OrchestratorSelector } from "../OrchestratorSelector"; + +const mockPush = vi.fn(); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockPush }), +})); + +const mockOrchestrators = [ + { + id: "app-orchestrator-1", + projectId: "my-project", + projectName: "My Project", + status: "working", + activity: "active", + createdAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago + lastActivityAt: new Date(Date.now() - 300000).toISOString(), // 5 min ago + }, + { + id: "app-orchestrator-2", + projectId: "my-project", + projectName: "My Project", + status: "spawning", + activity: null, + createdAt: new Date(Date.now() - 7200000).toISOString(), // 2 hours ago + lastActivityAt: null, + }, +]; + +const defaultProps = { + orchestrators: mockOrchestrators, + projectId: "my-project", + projectName: "My Project", + error: null, +}; + +describe("OrchestratorSelector", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPush.mockClear(); + global.fetch = vi.fn(); + }); + + it("renders orchestrator list", () => { + render(); + + expect(screen.getByText("app-orchestrator-1")).toBeInTheDocument(); + expect(screen.getByText("app-orchestrator-2")).toBeInTheDocument(); + }); + + it("displays project name in header", () => { + render(); + + expect(screen.getByText("My Project")).toBeInTheDocument(); + expect(screen.getByText("Select an orchestrator")).toBeInTheDocument(); + }); + + it("shows count of existing sessions", () => { + render(); + + expect(screen.getByText(/existing orchestrator sessions/)).toBeInTheDocument(); + // The count "2" appears in multiple places, so we check the full info banner text + expect(screen.getByText(/Found/)).toBeInTheDocument(); + }); + + it("shows error state", () => { + render( + , + ); + + expect(screen.getByText("Error")).toBeInTheDocument(); + expect(screen.getByText("Project not found")).toBeInTheDocument(); + }); + + it("shows start new orchestrator button", () => { + render(); + + expect(screen.getByRole("button", { name: /start new orchestrator/i })).toBeInTheDocument(); + }); + + it("spawns new orchestrator on button click and navigates", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + orchestrator: { id: "app-orchestrator-3" }, + }), + }); + global.fetch = mockFetch; + + render(); + + const button = screen.getByRole("button", { name: /start new orchestrator/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith("/api/orchestrators", expect.any(Object)); + }); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith("/sessions/app-orchestrator-3"); + }); + }); + + it("shows loading state while spawning", async () => { + const mockFetch = vi.fn().mockImplementation( + () => new Promise(() => {}), // Never resolves + ); + global.fetch = mockFetch; + + render(); + + const button = screen.getByRole("button", { name: /start new orchestrator/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText(/creating new orchestrator/i)).toBeInTheDocument(); + }); + }); + + it("shows error when spawn fails", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ error: "Failed to spawn" }), + }); + global.fetch = mockFetch; + + render(); + + const button = screen.getByRole("button", { name: /start new orchestrator/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText("Failed to spawn")).toBeInTheDocument(); + }); + }); + + it("links to orchestrator session page", () => { + render(); + + const link = screen.getByRole("link", { name: /app-orchestrator-1/i }); + expect(link).toHaveAttribute("href", "/sessions/app-orchestrator-1"); + }); + + it("displays status and activity for each orchestrator", () => { + render(); + + expect(screen.getByText("working")).toBeInTheDocument(); + expect(screen.getByText("Active")).toBeInTheDocument(); + expect(screen.getByText("spawning")).toBeInTheDocument(); + }); + + it("covers relative time for days and status colors/labels", () => { + const wideOrchestrators = [ + { + id: "orch-2", + projectId: "my-project", + projectName: "My Project", + status: "ci_failed", + activity: "waiting_input", + createdAt: new Date(Date.now() - 3600000 * 50).toISOString(), // 2d ago + lastActivityAt: null, + }, + { + id: "orch-3", + projectId: "my-project", + projectName: "My Project", + status: "killed", + activity: "ready", + createdAt: new Date(Date.now() - 1000).toISOString(), // Just now + lastActivityAt: null, + }, + { + id: "orch-4", + projectId: "my-project", + projectName: "My Project", + status: "unknown", + activity: "blocked", + createdAt: new Date().toISOString(), + lastActivityAt: null, + }, + { + id: "orch-5", + projectId: "my-project", + projectName: "My Project", + status: "mergeable", + activity: "exited", + createdAt: new Date().toISOString(), + lastActivityAt: null, + }, + ]; + + render( + , + ); + + expect(screen.getByText(/2d ago/)).toBeInTheDocument(); + expect(screen.getAllByText(/Just now/i).length).toBeGreaterThan(0); + expect(screen.getByText(/Waiting/)).toBeInTheDocument(); + expect(screen.getByText(/Ready/)).toBeInTheDocument(); + expect(screen.getByText(/Blocked/)).toBeInTheDocument(); + expect(screen.getByText(/Exited/)).toBeInTheDocument(); + expect(screen.getByText(/ci failed/i)).toBeInTheDocument(); + }); + + describe("formatRelativeTime edge cases", () => { + it("shows Unknown for invalid date strings", () => { + const orchestratorsWithInvalidDate = [ + { + ...mockOrchestrators[0], + createdAt: "not-a-valid-date", + lastActivityAt: null, + }, + ]; + render( + , + ); + + // The "Created Unknown" text should appear for invalid dates + expect(screen.getByText(/Created Unknown/)).toBeInTheDocument(); + }); + + it("shows Just now for future timestamps", () => { + const futureDate = new Date(Date.now() + 60000).toISOString(); // 1 minute in future + const orchestratorsWithFutureDate = [ + { + ...mockOrchestrators[0], + createdAt: futureDate, + lastActivityAt: null, + }, + ]; + render( + , + ); + + // Future timestamps should show "Just now" instead of negative values + expect(screen.getByText(/Created Just now/)).toBeInTheDocument(); + }); + + it("shows Unknown for null dates", () => { + const orchestratorsWithNullDate = [ + { + ...mockOrchestrators[0], + createdAt: null, + lastActivityAt: null, + }, + ]; + render( + , + ); + + expect(screen.getByText(/Created Unknown/)).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/web/src/components/__tests__/PRStatus.coverage.test.tsx b/packages/web/src/components/__tests__/PRStatus.coverage.test.tsx new file mode 100644 index 000000000..4e2a2366e --- /dev/null +++ b/packages/web/src/components/__tests__/PRStatus.coverage.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { PRCard } from "../PRStatus"; +import { makePR } from "../../__tests__/helpers"; + +describe("PRCard diff coverage", () => { + it("shows fallback review and CI labels for unenriched PRs", () => { + const { container } = render( + , + ); + + expect(screen.getByRole("link", { name: /#635 hydrate pr details later/i })).toBeTruthy(); + expect(screen.queryByText("approved")).toBeNull(); + expect(screen.queryByText("CI passing")).toBeNull(); + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/packages/web/src/components/__tests__/SessionCard.coverage.test.tsx b/packages/web/src/components/__tests__/SessionCard.coverage.test.tsx new file mode 100644 index 000000000..8f08c78de --- /dev/null +++ b/packages/web/src/components/__tests__/SessionCard.coverage.test.tsx @@ -0,0 +1,89 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SessionCard } from "../SessionCard"; +import { makePR, makeSession } from "../../__tests__/helpers"; + +describe("SessionCard diff coverage", () => { + it("shows the done-card size shimmer for terminal sessions with unenriched PRs", () => { + const { container } = render( + , + ); + + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0); + }); + + it("does not show placeholder PR metrics in the done-card detail panel before enrichment", () => { + render( + , + ); + + fireEvent.click(screen.getByText("Cold-cache terminal PR")); + + expect(screen.getByText("PR details loading...")).not.toBeNull(); + expect(screen.queryByText("mergeable: no")).toBeNull(); + expect(screen.queryByText("review: none")).toBeNull(); + }); + + it("shows enriched PR metrics in the done-card detail panel when data is available", () => { + render( + , + ); + + // Click to expand the done card + fireEvent.click(screen.getByText("fix: auth token refresh")); + + // Enriched PR detail lines 361-377 should render + expect(screen.getByText("mergeable: yes")).not.toBeNull(); + expect(screen.getByText("review: approved")).not.toBeNull(); + // +42 appears in both meta chips and expanded detail + expect(screen.getAllByText("+42").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("-7").length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx b/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx index 4875f9841..db732c779 100644 --- a/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx +++ b/packages/web/src/components/__tests__/SessionDetail.mobile.test.tsx @@ -40,6 +40,7 @@ describe("SessionDetail mobile navbar", () => { projectId: "my-app", metadata: { role: "orchestrator" }, summary: "Orchestrator session title", + branch: null, }); render( @@ -123,7 +124,7 @@ describe("SessionDetail mobile navbar", () => { />, ); - expect(screen.getByText("Compact mobile header")).toBeInTheDocument(); + expect(screen.getByText("Compact header polish")).toBeInTheDocument(); expect(screen.getByText("feat/compact-header")).toBeInTheDocument(); expect(screen.getByRole("link", { name: "PR #77" })).toHaveClass( "session-detail-link-pill--link", @@ -133,6 +134,24 @@ describe("SessionDetail mobile navbar", () => { ); }); + it("prefers issue title over changing summary text", () => { + render( + , + ); + + expect(screen.getByText("Fix stable session titles")).toBeInTheDocument(); + expect(screen.queryByText("Responding to latest review comment")).not.toBeInTheDocument(); + }); + it("preserves CI and unresolved review comment detail on mobile session pages", () => { render( { - let eventSourceMock: { - onmessage: ((event: MessageEvent) => void) | null; - onopen: (() => void) | null; - onerror: (() => void) | null; - readyState: number; - close: () => void; - }; - let eventSourceInstances: (typeof eventSourceMock)[]; - - beforeEach(() => { - eventSourceInstances = []; - const eventSourceConstructor = vi.fn(() => { - const instance = { - onmessage: null as ((event: MessageEvent) => void) | null, - onopen: null as (() => void) | null, - onerror: null as (() => void) | null, - readyState: 0, - close: vi.fn(), - }; - eventSourceInstances.push(instance); - eventSourceMock = instance; - return instance as unknown as EventSource; - }); - global.EventSource = Object.assign(eventSourceConstructor, { - CONNECTING: 0, - OPEN: 1, - CLOSED: 2, - }) as unknown as typeof EventSource; - global.fetch = vi.fn(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - const makeSessions = (count: number): DashboardSession[] => - Array.from({ length: count }, (_, i) => makeSession({ id: `session-${i}` })); - - const makeGlobalPause = (overrides: Partial = {}): GlobalPauseState => ({ - pausedUntil: new Date(Date.now() + 3600000).toISOString(), - reason: "Model rate limit reached", - sourceSessionId: "session-1", - ...overrides, - }); - - describe("initial state", () => { - it("returns initial sessions and globalPause", () => { - const sessions = makeSessions(2); - const globalPause = makeGlobalPause(); - - const { result } = renderHook(() => useSessionEvents(sessions, globalPause)); - - expect(result.current.sessions).toEqual(sessions); - expect(result.current.globalPause).toEqual(globalPause); - }); - - it("accepts null globalPause", () => { - const sessions = makeSessions(1); - - const { result } = renderHook(() => useSessionEvents(sessions, null)); - - expect(result.current.sessions).toEqual(sessions); - expect(result.current.globalPause).toBeNull(); - }); - }); - - describe("globalPause state updates from /api/sessions", () => { - it("updates globalPause when membership changes and /api/sessions returns new pause state", async () => { - const initialSessions = makeSessions(2); - const initialPause: GlobalPauseState = makeGlobalPause({ reason: "Initial pause" }); - - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - sessions: [...initialSessions, makeSession({ id: "session-new" })], - globalPause: makeGlobalPause({ reason: "Updated pause from different provider" }), - }), - } as unknown as Response); - - const { result } = renderHook(() => useSessionEvents(initialSessions, initialPause)); - - expect(result.current.globalPause?.reason).toBe("Initial pause"); - - await act(async () => { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { - id: "session-0", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - { - id: "session-1", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - { - id: "session-new", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - ], - }), - } as MessageEvent); - }); - - await waitFor(() => { - expect(result.current.globalPause?.reason).toBe("Updated pause from different provider"); - }); - }); - - it("clears globalPause when /api/sessions returns null pause", async () => { - const initialSessions = makeSessions(2); - const initialPause = makeGlobalPause(); - - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - sessions: [...initialSessions, makeSession({ id: "session-new" })], - globalPause: null, - }), - } as unknown as Response); - - const { result } = renderHook(() => useSessionEvents(initialSessions, initialPause)); - - expect(result.current.globalPause).not.toBeNull(); - - await act(async () => { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { - id: "session-0", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - { - id: "session-1", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - { - id: "session-new", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - ], - }), - } as MessageEvent); - }); - - await waitFor(() => { - expect(result.current.globalPause).toBeNull(); - }); - }); - - it("sets globalPause when initially null and /api/sessions returns pause", async () => { - const initialSessions = makeSessions(2); - - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - sessions: [...initialSessions, makeSession({ id: "session-new" })], - globalPause: makeGlobalPause({ reason: "New rate limit detected" }), - }), - } as Response); - - const { result } = renderHook(() => useSessionEvents(initialSessions, null)); - - expect(result.current.globalPause).toBeNull(); - - await act(async () => { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { - id: "session-0", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - { - id: "session-1", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - { - id: "session-new", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - ], - }), - } as MessageEvent); - }); - - await waitFor(() => { - expect(result.current.globalPause?.reason).toBe("New rate limit detected"); - }); - }); - - it("refreshes globalPause on stale same-membership snapshots without waiting for membership churn", async () => { - vi.useFakeTimers(); - const initialSessions = makeSessions(2); - - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - sessions: initialSessions, - globalPause: makeGlobalPause({ reason: "Pause updated during steady-state SSE" }), - }), - } as Response); - - const { result } = renderHook(() => useSessionEvents(initialSessions, null)); - - await act(async () => { - await vi.advanceTimersByTimeAsync(15000); - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: initialSessions.map((session) => ({ - id: session.id, - status: session.status, - activity: session.activity, - lastActivityAt: session.lastActivityAt, - })), - }), - } as MessageEvent); - await vi.advanceTimersByTimeAsync(120); - }); - - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith("/api/sessions", { signal: expect.any(AbortSignal) }); - expect(result.current.globalPause?.reason).toBe("Pause updated during steady-state SSE"); - }); - }); - - describe("provider-agnostic behavior", () => { - it("handles globalPause from Claude Code agent without provider-specific logic", async () => { - const sessions = makeSessions(1); - - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - sessions, - globalPause: { - pausedUntil: new Date(Date.now() + 7200000).toISOString(), - reason: "usage limit reached for 2 hours", - sourceSessionId: "claude-session-1", - }, - }), - } as Response); - - const { result } = renderHook(() => useSessionEvents(sessions, null)); - - await act(async () => { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { - id: "session-new", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - ], - }), - } as MessageEvent); - }); - - await waitFor(() => { - expect(result.current.globalPause?.reason).toBe("usage limit reached for 2 hours"); - expect(result.current.globalPause?.sourceSessionId).toBe("claude-session-1"); - }); - }); - - it("handles globalPause from OpenCode agent without provider-specific logic", async () => { - const sessions = makeSessions(1); - - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - sessions, - globalPause: { - pausedUntil: new Date(Date.now() + 1800000).toISOString(), - reason: "Model capacity exceeded", - sourceSessionId: "opencode-session-42", - }, - }), - } as Response); - - const { result } = renderHook(() => useSessionEvents(sessions, null)); - - await act(async () => { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { - id: "session-new", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - ], - }), - } as MessageEvent); - }); - - await waitFor(() => { - expect(result.current.globalPause?.reason).toBe("Model capacity exceeded"); - expect(result.current.globalPause?.sourceSessionId).toBe("opencode-session-42"); - }); - }); - - it("handles globalPause from Codex agent without provider-specific logic", async () => { - const sessions = makeSessions(1); - - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => ({ - sessions, - globalPause: { - pausedUntil: new Date(Date.now() + 3600000).toISOString(), - reason: "API quota exhausted", - sourceSessionId: "codex-worker-99", - }, - }), - } as Response); - - const { result } = renderHook(() => useSessionEvents(sessions, null)); - - await act(async () => { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { - id: "session-new", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - ], - }), - } as MessageEvent); - }); - - await waitFor(() => { - expect(result.current.globalPause?.reason).toBe("API quota exhausted"); - expect(result.current.globalPause?.sourceSessionId).toBe("codex-worker-99"); - }); - }); - }); - - describe("session state updates", () => { - it("falls back to disconnected after a prolonged EventSource outage", async () => { - vi.useFakeTimers(); - const sessions = makeSessions(1); - - const { result } = renderHook(() => useSessionEvents(sessions, null)); - - expect(result.current.connectionStatus).toBe("connected"); - - await act(async () => { - eventSourceMock.readyState = EventSource.CONNECTING; - eventSourceMock.onerror?.(); - }); - - expect(result.current.connectionStatus).toBe("reconnecting"); - - await act(async () => { - await vi.advanceTimersByTimeAsync(4000); - }); - - expect(result.current.connectionStatus).toBe("disconnected"); - }); - - it("clears the disconnect timer when EventSource reconnects", async () => { - vi.useFakeTimers(); - const sessions = makeSessions(1); - - const { result } = renderHook(() => useSessionEvents(sessions, null)); - - await act(async () => { - eventSourceMock.readyState = EventSource.CONNECTING; - eventSourceMock.onerror?.(); - await vi.advanceTimersByTimeAsync(2000); - eventSourceMock.readyState = EventSource.OPEN; - eventSourceMock.onopen?.(); - await vi.advanceTimersByTimeAsync(3000); - }); - - expect(result.current.connectionStatus).toBe("connected"); - }); - - it("applies snapshot patches to sessions", async () => { - const sessions = makeSessions(2); - - const { result } = renderHook(() => useSessionEvents(sessions, null)); - - await act(async () => { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { - id: "session-0", - status: "pr_open", - activity: "idle", - lastActivityAt: new Date().toISOString(), - }, - ], - }), - } as MessageEvent); - }); - - expect(result.current.sessions[0].status).toBe("pr_open"); - expect(result.current.sessions[0].activity).toBe("idle"); - expect(result.current.sessions[1].status).toBe("working"); - }); - - it("preserves untouched session references across snapshot patches", async () => { - const sessions = makeSessions(2); - - const { result } = renderHook(() => useSessionEvents(sessions, null)); - const untouchedSession = result.current.sessions[1]; - - await act(async () => { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { - id: "session-0", - status: "pr_open", - activity: "idle", - lastActivityAt: new Date().toISOString(), - }, - ], - }), - } as MessageEvent); - }); - - expect(result.current.sessions[0]).not.toBe(sessions[0]); - expect(result.current.sessions[1]).toBe(untouchedSession); - }); - - it("coalesces bursty membership changes into a single refresh fetch", async () => { - vi.useFakeTimers(); - const initialSessions = makeSessions(1); - - vi.mocked(fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - sessions: [ - ...initialSessions, - makeSession({ id: "session-1" }), - makeSession({ id: "session-2" }), - makeSession({ id: "session-3" }), - ], - globalPause: null, - }), - } as Response); - - renderHook(() => useSessionEvents(initialSessions, null)); - - await act(async () => { - for (const sessionId of ["session-1", "session-2", "session-3"]) { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { - id: "session-0", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - { - id: sessionId, - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - ], - }), - } as MessageEvent); - } - - await vi.advanceTimersByTimeAsync(120); - await Promise.resolve(); - }); - - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith("/api/sessions", { signal: expect.any(AbortSignal) }); - }); - - it("ignores stale refresh completions after project changes", async () => { - vi.useFakeTimers(); - const alphaSessions = [makeSession({ id: "alpha-0", projectId: "alpha" })]; - const betaSessions = [makeSession({ id: "beta-0", projectId: "beta" })]; - let resolveAlphaFetch: ((value: Response) => void) | null = null; - - vi.mocked(fetch).mockImplementation((input) => { - const url = typeof input === "string" ? input : input.toString(); - if (url.includes("project=alpha")) { - return new Promise((resolve) => { - resolveAlphaFetch = resolve; - }); - } - - return Promise.resolve({ - ok: true, - json: async () => ({ - sessions: [...betaSessions, makeSession({ id: "beta-1", projectId: "beta" })], - globalPause: null, - }), - } as unknown as Response); - }); - - const { result, rerender } = renderHook( - ({ sessions, project }) => useSessionEvents(sessions, null, project), - { - initialProps: { sessions: alphaSessions, project: "alpha" }, - }, - ); - - await act(async () => { - eventSourceInstances[0].onmessage?.({ - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { - id: "alpha-0", - status: "working", - activity: "active", - lastActivityAt: alphaSessions[0].lastActivityAt, - }, - { - id: "alpha-1", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - ], - }), - } as MessageEvent); - await vi.advanceTimersByTimeAsync(120); - }); - - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenNthCalledWith(1, "/api/sessions?project=alpha", { - signal: expect.any(AbortSignal), - }); - - rerender({ sessions: betaSessions, project: "beta" }); - - await act(async () => { - resolveAlphaFetch?.({ - ok: true, - json: async () => ({ - sessions: [...alphaSessions, makeSession({ id: "alpha-1", projectId: "alpha" })], - globalPause: null, - }), - } as unknown as Response); - await Promise.resolve(); - }); - - expect(result.current.sessions).toEqual(betaSessions); - - await act(async () => { - eventSourceInstances[1].onmessage?.({ - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { - id: "beta-0", - status: "working", - activity: "active", - lastActivityAt: betaSessions[0].lastActivityAt, - }, - { - id: "beta-1", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - ], - }), - } as MessageEvent); - await vi.advanceTimersByTimeAsync(120); - }); - - expect(fetch).toHaveBeenCalledTimes(2); - expect(fetch).toHaveBeenNthCalledWith(2, "/api/sessions?project=beta", { - signal: expect.any(AbortSignal), - }); - }); - - it("swallows refresh fetch JSON failures without resetting sessions", async () => { - const sessions = makeSessions(1); - - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - json: async () => { - throw new Error("bad json"); - }, - } as unknown as Response); - - const { result } = renderHook(() => useSessionEvents(sessions, null)); - - await act(async () => { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { - id: "session-0", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - { - id: "session-new", - status: "working", - activity: "active", - lastActivityAt: new Date().toISOString(), - }, - ], - }), - } as MessageEvent); - }); - - await waitFor(() => { - expect(fetch).toHaveBeenCalledWith("/api/sessions", { - signal: expect.any(AbortSignal), - }); - }); - - expect(result.current.sessions).toHaveLength(1); - expect(result.current.sessions[0].id).toBe("session-0"); - }); - }); - - describe("sseAttentionLevels", () => { - it("starts with empty attention levels when no initial levels provided", () => { - const sessions = makeSessions(2); - const { result } = renderHook(() => useSessionEvents(sessions)); - expect(result.current.sseAttentionLevels).toEqual({}); - }); - - it("seeds attention levels from initialAttentionLevels parameter", () => { - const sessions = makeSessions(2); - const initialLevels = { "session-0": "working" as const, "session-1": "respond" as const }; - const { result } = renderHook(() => useSessionEvents(sessions, null, undefined, initialLevels)); - expect(result.current.sseAttentionLevels).toEqual({ - "session-0": "working", - "session-1": "respond", - }); - }); - - it("populates attention levels from SSE snapshot", () => { - const sessions = makeSessions(2); - const { result } = renderHook(() => useSessionEvents(sessions)); - - act(() => { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() }, - { id: "session-1", status: "needs_input", activity: "waiting_input", attentionLevel: "respond", lastActivityAt: new Date().toISOString() }, - ], - }), - } as MessageEvent); - }); - - expect(result.current.sseAttentionLevels).toEqual({ - "session-0": "working", - "session-1": "respond", - }); - }); - - it("updates attention levels when they change", () => { - const sessions = makeSessions(1); - const { result } = renderHook(() => useSessionEvents(sessions)); - - act(() => { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() }, - ], - }), - } as MessageEvent); - }); - - expect(result.current.sseAttentionLevels["session-0"]).toBe("working"); - - act(() => { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { id: "session-0", status: "needs_input", activity: "waiting_input", attentionLevel: "respond", lastActivityAt: new Date().toISOString() }, - ], - }), - } as MessageEvent); - }); - - expect(result.current.sseAttentionLevels["session-0"]).toBe("respond"); - }); - - it("preserves referential stability when attention levels do not change", () => { - const sessions = makeSessions(1); - const { result } = renderHook(() => useSessionEvents(sessions)); - - const ts = new Date().toISOString(); - - act(() => { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: ts }, - ], - }), - } as MessageEvent); - }); - - const firstLevels = result.current.sseAttentionLevels; - - act(() => { - eventSourceMock!.onmessage!.call(eventSourceMock, { - data: JSON.stringify({ - type: "snapshot", - sessions: [ - { id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: ts }, - ], - }), - } as MessageEvent); - }); - - expect(result.current.sseAttentionLevels).toBe(firstLevels); - }); - - it("resets sseAttentionLevels to new initialAttentionLevels when initialSessions changes", () => { - const sessions1 = makeSessions(1); - let currentSessions = sessions1; - let currentLevels: Record | undefined = { "session-0": "respond" as const }; - - const { result, rerender } = renderHook(() => - useSessionEvents(currentSessions, null, undefined, currentLevels), - ); - - expect(result.current.sseAttentionLevels).toEqual({ "session-0": "respond" }); - - const sessions2 = makeSessions(1).map((s) => ({ ...s, id: "session-new" })); - const levels2 = { "session-new": "working" as const }; - currentSessions = sessions2; - currentLevels = levels2; - rerender(); - - expect(result.current.sseAttentionLevels).toEqual({ "session-new": "working" }); - expect(result.current.sseAttentionLevels["session-0"]).toBeUndefined(); - }); - }); -}); diff --git a/packages/web/src/hooks/useSessionEvents.ts b/packages/web/src/hooks/useSessionEvents.ts index 7eae5e8c7..524e748aa 100644 --- a/packages/web/src/hooks/useSessionEvents.ts +++ b/packages/web/src/hooks/useSessionEvents.ts @@ -1,11 +1,11 @@ "use client"; import { useEffect, useReducer, useRef } from "react"; -import type { - AttentionLevel, - DashboardSession, - GlobalPauseState, - SSESnapshotEvent, +import { + getAttentionLevel, + type AttentionLevel, + type DashboardSession, + type SSESnapshotEvent, } from "@/lib/types"; /** Debounce before fetching full session list after membership change. */ @@ -23,14 +23,13 @@ export type SSEAttentionMap = Readonly>; interface State { sessions: DashboardSession[]; - globalPause: GlobalPauseState | null; connectionStatus: ConnectionStatus; /** Attention levels from the latest SSE snapshot (server-computed, includes PR state). */ sseAttentionLevels: SSEAttentionMap; } type Action = - | { type: "reset"; sessions: DashboardSession[]; globalPause: GlobalPauseState | null; sseAttentionLevels?: SSEAttentionMap } + | { type: "reset"; sessions: DashboardSession[]; sseAttentionLevels?: SSEAttentionMap } | { type: "snapshot"; patches: SSESnapshotEvent["sessions"] } | { type: "setConnection"; status: ConnectionStatus }; @@ -40,7 +39,6 @@ function reducer(state: State, action: Action): State { return { ...state, sessions: action.sessions, - globalPause: action.globalPause, ...(action.sseAttentionLevels !== undefined ? { sseAttentionLevels: action.sseAttentionLevels } : {}), @@ -61,12 +59,7 @@ function reducer(state: State, action: Action): State { return s; } changed = true; - return { - ...s, - status: patch.status, - activity: patch.activity, - lastActivityAt: patch.lastActivityAt, - }; + return { ...s, status: patch.status, activity: patch.activity, lastActivityAt: patch.lastActivityAt }; }); // Build attention level map from server-computed values @@ -102,13 +95,11 @@ function createMembershipKey( export function useSessionEvents( initialSessions: DashboardSession[], - initialGlobalPause?: GlobalPauseState | null, project?: string, initialAttentionLevels?: SSEAttentionMap, ): State { const [state, dispatch] = useReducer(reducer, { sessions: initialSessions, - globalPause: initialGlobalPause ?? null, connectionStatus: "connected" as ConnectionStatus, sseAttentionLevels: initialAttentionLevels ?? ({} as SSEAttentionMap), }); @@ -118,9 +109,10 @@ export function useSessionEvents( const refreshingRef = useRef(false); const refreshTimerRef = useRef | null>(null); const pendingMembershipKeyRef = useRef(null); - const lastRefreshAtRef = useRef(Date.now()); + const lastRefreshAtRef = useRef(0); const disconnectedTimerRef = useRef | null>(null); + // Reset state when server-rendered props change (e.g. full page refresh) useEffect(() => { sessionsRef.current = state.sessions; }, [state.sessions]); @@ -129,12 +121,14 @@ export function useSessionEvents( dispatch({ type: "reset", sessions: initialSessions, - globalPause: initialGlobalPause ?? null, sseAttentionLevels: initialAttentionLevelsRef.current ?? ({} as SSEAttentionMap), }); - }, [initialSessions, initialGlobalPause]); + }, [initialSessions]); useEffect(() => { + // Reset so the new project gets an immediate first refresh on its first SSE snapshot + lastRefreshAtRef.current = 0; + const url = project ? `/api/events?project=${encodeURIComponent(project)}` : "/api/events"; const es = new EventSource(url); let disposed = false; @@ -172,20 +166,31 @@ export function useSessionEvents( void fetch(sessionsUrl, { signal: refreshController.signal }) .then((res) => (res.ok ? res.json() : null)) .then( - (updated: { sessions?: DashboardSession[]; globalPause?: GlobalPauseState } | null) => { - if (disposed || refreshController.signal.aborted || !updated?.sessions) return; + (updated: { sessions?: DashboardSession[] } | null) => { + if (disposed || refreshController.signal.aborted || !updated?.sessions) { + // Update timestamp even for non-OK responses to prevent retry storms + if (!disposed && !refreshController.signal.aborted) { + lastRefreshAtRef.current = Date.now(); + } + return; + } lastRefreshAtRef.current = Date.now(); + const sseAttentionLevels = Object.fromEntries( + updated.sessions.map((s) => [s.id, getAttentionLevel(s)]), + ) as SSEAttentionMap; dispatch({ type: "reset", sessions: updated.sessions, - globalPause: updated.globalPause ?? null, + sseAttentionLevels, }); }, ) .catch((err: unknown) => { if (err instanceof DOMException && err.name === "AbortError") return; console.warn("[useSessionEvents] refresh failed:", err); + // Update timestamp on failure to prevent retry loops on every SSE snapshot + lastRefreshAtRef.current = Date.now(); }) .finally(() => { if (activeRefreshController === refreshController) { diff --git a/packages/web/src/lib/__tests__/dashboard-page-data.fast-path.test.ts b/packages/web/src/lib/__tests__/dashboard-page-data.fast-path.test.ts new file mode 100644 index 000000000..7ceb794d8 --- /dev/null +++ b/packages/web/src/lib/__tests__/dashboard-page-data.fast-path.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const hoisted = vi.hoisted(() => ({ + getServicesMock: vi.fn(), + getSCMMock: vi.fn(), + sessionToDashboardMock: vi.fn(), + resolveProjectMock: vi.fn(), + enrichSessionPRMock: vi.fn(), + enrichSessionsMetadataFastMock: vi.fn(), + listDashboardOrchestratorsMock: vi.fn(), + filterProjectSessionsMock: vi.fn(), + filterWorkerSessionsMock: vi.fn(), + resolveGlobalPauseMock: vi.fn(), + getAllProjectsMock: vi.fn(), + getPrimaryProjectIdMock: vi.fn(), + getProjectNameMock: vi.fn(), +})); + +vi.mock("@/lib/services", () => ({ + getServices: hoisted.getServicesMock, + getSCM: hoisted.getSCMMock, +})); + +vi.mock("@/lib/serialize", () => ({ + sessionToDashboard: hoisted.sessionToDashboardMock, + resolveProject: hoisted.resolveProjectMock, + enrichSessionPR: hoisted.enrichSessionPRMock, + enrichSessionsMetadataFast: hoisted.enrichSessionsMetadataFastMock, + listDashboardOrchestrators: hoisted.listDashboardOrchestratorsMock, +})); + +vi.mock("@/lib/project-utils", () => ({ + filterProjectSessions: hoisted.filterProjectSessionsMock, + filterWorkerSessions: hoisted.filterWorkerSessionsMock, +})); + +vi.mock("@/lib/global-pause", () => ({ + resolveGlobalPause: hoisted.resolveGlobalPauseMock, +})); + +vi.mock("@/lib/project-name", () => ({ + getAllProjects: hoisted.getAllProjectsMock, + getPrimaryProjectId: hoisted.getPrimaryProjectIdMock, + getProjectName: hoisted.getProjectNameMock, +})); + +import { getDashboardPageData } from "@/lib/dashboard-page-data"; + +describe("getDashboardPageData fast path", () => { + beforeEach(() => { + vi.clearAllMocks(); + hoisted.getAllProjectsMock.mockReturnValue([ + { id: "docs", name: "Docs" }, + { id: "mono", name: "Mono" }, + ]); + hoisted.getPrimaryProjectIdMock.mockReturnValue("docs"); + hoisted.getProjectNameMock.mockReturnValue("Docs"); + hoisted.resolveGlobalPauseMock.mockReturnValue({ reason: "paused" }); + hoisted.listDashboardOrchestratorsMock.mockReturnValue([{ id: "orch-1", projectId: "docs", projectName: "Docs" }]); + hoisted.enrichSessionsMetadataFastMock.mockResolvedValue(undefined); + }); + + it("runs fast enrichment, uses cache-only PR hydration, and infers merged/closed state for terminal cache misses even without SCM", async () => { + const noPrCore = { id: "session-no-pr", status: "working", pr: null }; + const closedCore = { id: "session-closed", status: "killed", pr: { number: 2 } }; + const mergedCore = { id: "session-merged", status: "merged", pr: { number: 3 } }; + const allSessions = [noPrCore, closedCore, mergedCore]; + + const dashboardNoPr = { id: "session-no-pr", pr: null }; + const dashboardClosed = { id: "session-closed", pr: { state: "open", enriched: false } }; + const dashboardMerged = { id: "session-merged", pr: { state: "open", enriched: false } }; + + hoisted.getServicesMock.mockResolvedValue({ + config: { projects: { docs: { id: "docs" } } }, + registry: { scm: "registry" }, + sessionManager: { list: vi.fn().mockResolvedValue(allSessions) }, + }); + hoisted.filterProjectSessionsMock.mockReturnValue(allSessions); + hoisted.filterWorkerSessionsMock.mockReturnValue(allSessions); + hoisted.sessionToDashboardMock + .mockReturnValueOnce(dashboardNoPr) + .mockReturnValueOnce(dashboardClosed) + .mockReturnValueOnce(dashboardMerged); + hoisted.resolveProjectMock.mockImplementation((core) => ({ id: core.id })); + hoisted.getSCMMock + .mockReturnValueOnce(undefined) + .mockReturnValueOnce({ provider: "github" }); + + const pageData = await getDashboardPageData("docs"); + + expect(hoisted.enrichSessionsMetadataFastMock).toHaveBeenCalledWith( + allSessions, + [dashboardNoPr, dashboardClosed, dashboardMerged], + { projects: { docs: { id: "docs" } } }, + { scm: "registry" }, + ); + expect(hoisted.enrichSessionPRMock).toHaveBeenCalledTimes(1); + expect(hoisted.enrichSessionPRMock).toHaveBeenCalledWith( + dashboardMerged, + { provider: "github" }, + mergedCore.pr, + { cacheOnly: true }, + ); + expect(dashboardClosed.pr.state).toBe("closed"); + expect(dashboardMerged.pr.state).toBe("merged"); + expect(pageData.sessions).toEqual([dashboardNoPr, dashboardClosed, dashboardMerged]); + }); + + it("does not block SSR indefinitely when fast metadata enrichment hangs", async () => { + vi.useFakeTimers(); + + try { + const core = { id: "session-hung", status: "working", pr: null }; + const dashboard = { id: "session-hung", pr: null }; + + hoisted.getServicesMock.mockResolvedValue({ + config: { projects: { mono: { id: "mono" } } }, + registry: { scm: "registry" }, + sessionManager: { list: vi.fn().mockResolvedValue([core]) }, + }); + hoisted.filterProjectSessionsMock.mockReturnValue([core]); + hoisted.filterWorkerSessionsMock.mockReturnValue([core]); + hoisted.sessionToDashboardMock.mockReturnValue(dashboard); + hoisted.enrichSessionsMetadataFastMock.mockImplementation( + () => new Promise(() => {}), + ); + + const pageDataPromise = getDashboardPageData("mono"); + await vi.advanceTimersByTimeAsync(3_000); + const pageData = await pageDataPromise; + + expect(pageData.sessions).toEqual([dashboard]); + expect(hoisted.enrichSessionPRMock).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/web/src/lib/__tests__/format.test.ts b/packages/web/src/lib/__tests__/format.test.ts index c1d42260c..c32d746a8 100644 --- a/packages/web/src/lib/__tests__/format.test.ts +++ b/packages/web/src/lib/__tests__/format.test.ts @@ -121,16 +121,14 @@ describe("getSessionTitle", () => { expect(getSessionTitle(session)).toBe("feat: add auth"); }); - it("returns agent summary over issue title", () => { + it("returns issue title over agent summary", () => { const session = makeSession({ summary: "Implementing OAuth2 authentication with JWT tokens", summaryIsFallback: false, issueTitle: "Add user authentication", branch: "feat/auth", }); - expect(getSessionTitle(session)).toBe( - "Implementing OAuth2 authentication with JWT tokens", - ); + expect(getSessionTitle(session)).toBe("Add user authentication"); }); it("skips fallback summaries in favor of issue title", () => { @@ -143,16 +141,14 @@ describe("getSessionTitle", () => { expect(getSessionTitle(session)).toBe("Add authentication to API"); }); - it("uses fallback summary when no issue title is available", () => { + it("uses branch before fallback summary when no issue title is available", () => { const session = makeSession({ summary: "You are working on GitHub issue #42: Add authentication to API...", summaryIsFallback: true, issueTitle: null, branch: "feat/issue-42", }); - expect(getSessionTitle(session)).toBe( - "You are working on GitHub issue #42: Add authentication to API...", - ); + expect(getSessionTitle(session)).toBe("Issue 42"); }); it("returns issue title when no summary exists", () => { @@ -182,15 +178,56 @@ describe("getSessionTitle", () => { expect(getSessionTitle(session)).toBe("working"); }); - it("prefers fallback summary over branch when no issue title", () => { + it("returns branch before summary when no issue title exists", () => { const session = makeSession({ summary: "You are working on Linear ticket INT-1327: Refactor session manager", summaryIsFallback: true, issueTitle: null, branch: "feat/INT-1327", }); - expect(getSessionTitle(session)).toBe( - "You are working on Linear ticket INT-1327: Refactor session manager", - ); + expect(getSessionTitle(session)).toBe("INT 1327"); + }); + + it("returns quality summary when neither issue title nor branch exists", () => { + const session = makeSession({ + summary: "Investigating flaky PR enrichment", + summaryIsFallback: false, + issueTitle: null, + branch: null, + }); + expect(getSessionTitle(session)).toBe("Investigating flaky PR enrichment"); + }); + + it("uses pinnedSummary from metadata before live summary when no branch or issue title", () => { + const session = makeSession({ + summary: "Drifting live summary from latest agent output", + summaryIsFallback: false, + issueTitle: null, + branch: null, + metadata: { pinnedSummary: "Stable pinned title" }, + }); + expect(getSessionTitle(session)).toBe("Stable pinned title"); + }); + + it("skips pinnedSummary when branch is present (branch takes priority)", () => { + const session = makeSession({ + summary: "Live summary", + summaryIsFallback: false, + issueTitle: null, + branch: "feat/my-feature", + metadata: { pinnedSummary: "Pinned summary" }, + }); + expect(getSessionTitle(session)).toBe("My Feature"); + }); + + it("falls through to live summary when pinnedSummary is empty", () => { + const session = makeSession({ + summary: "Live quality summary", + summaryIsFallback: false, + issueTitle: null, + branch: null, + metadata: { pinnedSummary: "" }, + }); + expect(getSessionTitle(session)).toBe("Live quality summary"); }); }); diff --git a/packages/web/src/lib/__tests__/serialize.test.ts b/packages/web/src/lib/__tests__/serialize.test.ts index 7742aaf1e..4e1e9892c 100644 --- a/packages/web/src/lib/__tests__/serialize.test.ts +++ b/packages/web/src/lib/__tests__/serialize.test.ts @@ -20,6 +20,7 @@ import { enrichSessionAgentSummary, enrichSessionIssueTitle, enrichSessionsMetadata, + enrichSessionsMetadataFast, computeStats, } from "../serialize"; import { prCache, prCacheKey } from "../cache"; @@ -190,6 +191,51 @@ describe("sessionToDashboard", () => { expect(dashboard.summaryIsFallback).toBe(false); }); + it("should use live agentInfo summary even when pinnedSummary is set in metadata", () => { + const coreSession = createCoreSession({ + agentInfo: { + summary: "Latest live summary from agent", + summaryIsFallback: false, + agentSessionId: "abc123", + }, + metadata: { pinnedSummary: "First pinned summary" }, + }); + const dashboard = sessionToDashboard(coreSession); + + // pinnedSummary must NOT replace dashboard.summary — live summary wins + expect(dashboard.summary).toBe("Latest live summary from agent"); + expect(dashboard.summaryIsFallback).toBe(false); + // pinnedSummary remains accessible via metadata for title selection + expect(dashboard.metadata["pinnedSummary"]).toBe("First pinned summary"); + }); + + it("should use metadata summary when pinnedSummary is also set (pinnedSummary only for titles)", () => { + const coreSession = createCoreSession({ + agentInfo: null, + metadata: { pinnedSummary: "Pinned summary", summary: "Metadata summary" }, + }); + const dashboard = sessionToDashboard(coreSession); + + // pinnedSummary must NOT override the regular metadata summary + expect(dashboard.summary).toBe("Metadata summary"); + expect(dashboard.summaryIsFallback).toBe(false); + }); + + it("should use agentInfo summary regardless of pinnedSummary value", () => { + const coreSession = createCoreSession({ + agentInfo: { + summary: "Agent summary", + summaryIsFallback: false, + agentSessionId: "abc123", + }, + metadata: { pinnedSummary: "" }, + }); + const dashboard = sessionToDashboard(coreSession); + + expect(dashboard.summary).toBe("Agent summary"); + expect(dashboard.summaryIsFallback).toBe(false); + }); + it("should convert PRInfo to DashboardPR with defaults", () => { const pr = createPRInfo(); const coreSession = createCoreSession({ pr }); @@ -204,7 +250,8 @@ describe("sessionToDashboard", () => { expect(dashboard.pr?.deletions).toBe(0); expect(dashboard.pr?.ciStatus).toBe("none"); expect(dashboard.pr?.reviewDecision).toBe("none"); - expect(dashboard.pr?.mergeability.blockers).toContain("Data not loaded"); + expect(dashboard.pr?.mergeability.blockers).toEqual([]); + expect(dashboard.pr?.enriched).toBe(false); }); it("should set pr to null when session has no PR", () => { @@ -808,6 +855,41 @@ describe("enrichSessionsMetadata", () => { expect(dashboard.issueTitle).toBe("Fix auth bug"); }); + it("starts issue-title fetches before agent summaries finish", async () => { + let resolveSummary: ((value: { summary: string; summaryIsFallback: false; agentSessionId: string }) => void) | null = null; + + const tracker = mockTracker("Fix auth bug"); + const agent = { + ...mockAgent(), + getSessionInfo: vi.fn().mockImplementation( + () => new Promise((resolve) => { + resolveSummary = resolve; + }), + ), + } as Agent; + const registry = mockRegistry(tracker, agent); + + const core = createCoreSession({ issueId: `${urlBase}-parallel` }); + const dashboard = sessionToDashboard(core); + + const enrichmentPromise = enrichSessionsMetadata([core], [dashboard], testConfig, registry); + await Promise.resolve(); + + expect(tracker.getIssue).toHaveBeenCalledTimes(1); + expect(dashboard.issueLabel).toBe("#42"); + expect(dashboard.summary).toBeNull(); + + resolveSummary?.({ + summary: "Implementing auth fix", + summaryIsFallback: false, + agentSessionId: "abc", + }); + await enrichmentPromise; + + expect(dashboard.summary).toBe("Implementing auth fix"); + expect(dashboard.issueTitle).toBe("Fix auth bug"); + }); + it("should skip sessions without issue URLs", async () => { const tracker = mockTracker(); const agent = mockAgent(); @@ -948,6 +1030,61 @@ describe("enrichSessionsMetadata", () => { }); }); +describe("enrichSessionsMetadataFast", () => { + it("should enrich issue labels and agent summaries but NOT issue titles", async () => { + const urlBase = "https://github.com/test/repo/issues/fast"; + const tracker: Tracker = { + name: "mock-tracker", + getIssue: vi.fn().mockResolvedValue({ id: "99", title: "Should not be called", url: urlBase }), + issueLabel: vi.fn().mockReturnValue("#99"), + } as unknown as Tracker; + const agent: Agent = { + getSessionInfo: vi.fn().mockResolvedValue({ + summary: "Fast summary", + summaryIsFallback: false, + agentSessionId: "abc", + }), + } as unknown as Agent; + const registry = { + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "tracker") return tracker; + if (slot === "agent") return agent; + return null; + }), + } as unknown as PluginRegistry; + + const config: OrchestratorConfig = { + projects: { + test: { + path: "/test", + repo: "test/repo", + defaultBranch: "main", + sessionPrefix: "test-", + tracker: { plugin: "mock-tracker" }, + }, + } as Record, + defaults: { agent: "mock-agent", runtime: "tmux" }, + configPath: "/test/config.yaml", + } as OrchestratorConfig; + + const core = createCoreSession({ + issueId: `${urlBase}-fast`, + agentInfo: undefined, + }); + const dashboard = sessionToDashboard(core); + + await enrichSessionsMetadataFast([core], [dashboard], config, registry); + + // Issue label should be set (synchronous) + expect(dashboard.issueLabel).toBe("#99"); + // Agent summary should be set (local I/O) + expect(dashboard.summary).toBe("Fast summary"); + // Issue title should NOT be set (tracker API is not called by fast path) + expect(dashboard.issueTitle).toBeNull(); + expect(tracker.getIssue).not.toHaveBeenCalled(); + }); +}); + describe("computeStats", () => { function makeDashboard(overrides: Partial = {}): DashboardSession { return { @@ -1030,11 +1167,12 @@ describe("basicPRToDashboard defaults", () => { expect(dashboard.pr?.reviewDecision).not.toBe("changes_requested"); }); - it("should have explicit blocker indicating data not loaded", () => { + it("should mark unenriched PR with enriched: false", () => { const pr = createPRInfo(); const coreSession = createCoreSession({ pr }); const dashboard = sessionToDashboard(coreSession); - expect(dashboard.pr?.mergeability.blockers).toContain("Data not loaded"); + expect(dashboard.pr?.enriched).toBe(false); + expect(dashboard.pr?.mergeability.blockers).toEqual([]); }); }); diff --git a/packages/web/src/lib/async-utils.ts b/packages/web/src/lib/async-utils.ts new file mode 100644 index 000000000..9224169b9 --- /dev/null +++ b/packages/web/src/lib/async-utils.ts @@ -0,0 +1,19 @@ +/** + * Race a promise against a timeout. Returns `true` if the promise settled + * (resolved or rejected) before the deadline, `false` if the timeout won. + * The timer is always cleaned up regardless of outcome. + */ +export async function settlesWithin(promise: Promise, timeoutMs: number): Promise { + let timeoutId: ReturnType | null = null; + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => resolve(false), timeoutMs); + }); + + try { + return await Promise.race([promise.then(() => true).catch(() => true), timeoutPromise]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } +} diff --git a/packages/web/src/lib/dashboard-page-data.ts b/packages/web/src/lib/dashboard-page-data.ts index 830b6e168..efb6e2419 100644 --- a/packages/web/src/lib/dashboard-page-data.ts +++ b/packages/web/src/lib/dashboard-page-data.ts @@ -1,21 +1,24 @@ +import "server-only"; + import { cache } from "react"; -import type { DashboardSession, DashboardOrchestratorLink } from "@/lib/types"; +import { TERMINAL_STATUSES, type DashboardSession, type DashboardOrchestratorLink } from "@/lib/types"; import { getServices, getSCM } from "@/lib/services"; import { sessionToDashboard, resolveProject, enrichSessionPR, - enrichSessionsMetadata, + enrichSessionsMetadataFast, listDashboardOrchestrators, } from "@/lib/serialize"; -import { prCache, prCacheKey } from "@/lib/cache"; import { getPrimaryProjectId, getProjectName, getAllProjects, type ProjectInfo } from "@/lib/project-name"; import { filterProjectSessions, filterWorkerSessions } from "@/lib/project-utils"; -import { resolveGlobalPause, type GlobalPauseState } from "@/lib/global-pause"; +import { settlesWithin } from "@/lib/async-utils"; + +const FAST_METADATA_ENRICH_TIMEOUT_MS = 3_000; + interface DashboardPageData { sessions: DashboardSession[]; - globalPause: GlobalPauseState | null; orchestrators: DashboardOrchestratorLink[]; projectName: string; projects: ProjectInfo[]; @@ -47,7 +50,6 @@ export const getDashboardPageData = cache(async function getDashboardPageData(pr const projectFilter = resolveDashboardProjectFilter(project); const pageData: DashboardPageData = { sessions: [], - globalPause: null, orchestrators: [], projectName: getDashboardProjectName(projectFilter), projects: getAllProjects(), @@ -58,73 +60,43 @@ export const getDashboardPageData = cache(async function getDashboardPageData(pr const { config, registry, sessionManager } = await getServices(); const allSessions = await sessionManager.list(); - pageData.globalPause = resolveGlobalPause(allSessions); - const visibleSessions = filterProjectSessions(allSessions, projectFilter, config.projects); pageData.orchestrators = listDashboardOrchestrators(visibleSessions, config.projects); const coreSessions = filterWorkerSessions(allSessions, projectFilter, config.projects); pageData.sessions = coreSessions.map(sessionToDashboard); - const metaTimeout = new Promise((resolve) => setTimeout(resolve, 3_000)); - await Promise.race([ - enrichSessionsMetadata(coreSessions, pageData.sessions, config, registry), - metaTimeout, - ]); - - const terminalStatuses = new Set(["merged", "killed", "cleanup", "done", "terminated"]); - const enrichPromises = coreSessions.map((core, index) => { - if (!core.pr) return Promise.resolve(); - - const cacheKey = prCacheKey(core.pr.owner, core.pr.repo, core.pr.number); - const cached = prCache.get(cacheKey); - - if (cached) { - const sessionPR = pageData.sessions[index]?.pr; - if (sessionPR) { - sessionPR.state = cached.state; - sessionPR.title = cached.title; - sessionPR.additions = cached.additions; - sessionPR.deletions = cached.deletions; - sessionPR.ciStatus = cached.ciStatus as - | "none" - | "pending" - | "passing" - | "failing"; - sessionPR.reviewDecision = cached.reviewDecision as - | "none" - | "pending" - | "approved" - | "changes_requested"; - sessionPR.ciChecks = cached.ciChecks.map((check) => ({ - name: check.name, - status: check.status as "pending" | "running" | "passed" | "failed" | "skipped", - url: check.url, - })); - sessionPR.mergeability = cached.mergeability; - sessionPR.unresolvedThreads = cached.unresolvedThreads; - sessionPR.unresolvedComments = cached.unresolvedComments; - } - - if ( - terminalStatuses.has(core.status) || - cached.state === "merged" || - cached.state === "closed" - ) { - return Promise.resolve(); - } - } + // Fast enrichment: issue labels (sync) + agent summaries (local disk I/O). + // Keep a hard cap here so a slow local agent plugin can't stall SSR indefinitely. + await settlesWithin( + enrichSessionsMetadataFast(coreSessions, pageData.sessions, config, registry), + FAST_METADATA_ENRICH_TIMEOUT_MS, + ); + // PR cache hits only (in-memory lookup, no SCM API calls) + // TERMINAL_STATUSES includes merged, killed, cleanup, done, terminated, errored + for (let i = 0; i < coreSessions.length; i++) { + const core = coreSessions[i]; + if (!core.pr) continue; const projectConfig = resolveProject(core, config.projects); const scm = getSCM(registry, projectConfig); - if (!scm) return Promise.resolve(); - return enrichSessionPR(pageData.sessions[index], scm, core.pr); - }); - const enrichTimeout = new Promise((resolve) => setTimeout(resolve, 4_000)); - await Promise.race([Promise.allSettled(enrichPromises), enrichTimeout]); + if (scm) { + await enrichSessionPR(pageData.sessions[i], scm, core.pr, { cacheOnly: true }); + } + + // For cache-miss PRs, infer terminal PR state from lifecycle status + // to avoid showing merged/closed PRs as "open" until client refresh. + const sessionPR = pageData.sessions[i].pr; + if (sessionPR && !sessionPR.enriched && TERMINAL_STATUSES.has(core.status)) { + if (core.status === "merged") { + sessionPR.state = "merged"; + } else if (core.status === "killed") { + sessionPR.state = "closed"; + } + } + } } catch { pageData.sessions = []; - pageData.globalPause = null; pageData.orchestrators = []; } diff --git a/packages/web/src/lib/format.ts b/packages/web/src/lib/format.ts index 8f81a890e..a2928a2c3 100644 --- a/packages/web/src/lib/format.ts +++ b/packages/web/src/lib/format.ts @@ -29,30 +29,35 @@ export function humanizeBranch(branch: string): string { * * Fallback chain (ordered by signal quality): * 1. PR title — human-visible deliverable name - * 2. Quality summary — real agent-generated summary (not a fallback) - * 3. Issue title — human-written task description - * 4. Any summary — even a fallback excerpt is better than nothing - * 5. Humanized branch — last resort with semantic content - * 6. Status text — absolute fallback + * 2. Issue title — human-written task description + * 3. Humanized branch — stable task identifier when no explicit title exists + * 4. Pinned summary — first quality summary, stable across agent updates + * 5. Quality summary — live summary, but can drift as the session evolves + * 6. Any summary — even a fallback excerpt is better than nothing + * 7. Status text — absolute fallback */ export function getSessionTitle(session: DashboardSession): string { // 1. PR title — always best if (session.pr?.title) return session.pr.title; - // 2. Quality summary — skip fallback summaries (truncated spawn prompts) + // 2. Issue title — human-written task description + if (session.issueTitle) return session.issueTitle; + + // 3. Humanized branch — stable semantic fallback + if (session.branch) return humanizeBranch(session.branch); + + // 4. Pinned summary — first quality summary, stable across agent updates + const pinnedSummary = session.metadata["pinnedSummary"]; + if (pinnedSummary) return pinnedSummary; + + // 5. Quality summary — skip fallback summaries (truncated spawn prompts) if (session.summary && !session.summaryIsFallback) { return session.summary; } - // 3. Issue title — human-written task description - if (session.issueTitle) return session.issueTitle; - - // 4. Any summary — even fallback excerpts beat branch names + // 6. Any summary — even fallback excerpts beat raw status text if (session.summary) return session.summary; - // 5. Humanized branch - if (session.branch) return humanizeBranch(session.branch); - - // 6. Status + // 7. Status return session.status; } diff --git a/packages/web/src/lib/global-pause.ts b/packages/web/src/lib/global-pause.ts deleted file mode 100644 index 2811aada6..000000000 --- a/packages/web/src/lib/global-pause.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - GLOBAL_PAUSE_REASON_KEY, - GLOBAL_PAUSE_SOURCE_KEY, - GLOBAL_PAUSE_UNTIL_KEY, - isOrchestratorSession, - parsePauseUntil, -} from "@composio/ao-core"; - -export interface GlobalPauseState { - pausedUntil: string; - reason: string; - sourceSessionId: string | null; -} - -export function resolveGlobalPause( - sessions: Array<{ id: string; metadata: Record }>, -): GlobalPauseState | null { - for (const session of sessions) { - if (!isOrchestratorSession(session)) continue; - const parsed = parsePauseUntil(session.metadata[GLOBAL_PAUSE_UNTIL_KEY]); - if (!parsed || parsed.getTime() <= Date.now()) continue; - - return { - pausedUntil: parsed.toISOString(), - reason: session.metadata[GLOBAL_PAUSE_REASON_KEY] ?? "Model rate limit reached", - sourceSessionId: session.metadata[GLOBAL_PAUSE_SOURCE_KEY] ?? null, - }; - } - - return null; -} diff --git a/packages/web/src/lib/observability.ts b/packages/web/src/lib/observability.ts index da33da03d..284273188 100644 --- a/packages/web/src/lib/observability.ts +++ b/packages/web/src/lib/observability.ts @@ -1,3 +1,5 @@ +import "server-only"; + import { createCorrelationId, createProjectObserver, diff --git a/packages/web/src/lib/orchestrator-utils.ts b/packages/web/src/lib/orchestrator-utils.ts new file mode 100644 index 000000000..ce4efeb6b --- /dev/null +++ b/packages/web/src/lib/orchestrator-utils.ts @@ -0,0 +1,26 @@ +import type { Session } from "@composio/ao-core"; +import { isOrchestratorSession, isTerminalSession } from "@composio/ao-core/types"; +import type { Orchestrator } from "@/components/OrchestratorSelector"; + +/** + * Filter and map sessions to orchestrator DTOs. + * Shared between page.tsx and API route to ensure consistent orchestrator listing. + */ +export function mapSessionsToOrchestrators( + sessions: Session[], + sessionPrefix: string, + projectName: string, + allSessionPrefixes?: string[], +): Orchestrator[] { + return sessions + .filter((s) => isOrchestratorSession(s, sessionPrefix, allSessionPrefixes) && !isTerminalSession(s)) + .map((s) => ({ + id: s.id, + projectId: s.projectId, + projectName, + status: s.status, + activity: s.activity, + createdAt: s.createdAt?.toISOString() ?? null, + lastActivityAt: s.lastActivityAt?.toISOString() ?? null, + })); +} diff --git a/packages/web/src/lib/project-name.ts b/packages/web/src/lib/project-name.ts index d6fdae75a..0aaf920c5 100644 --- a/packages/web/src/lib/project-name.ts +++ b/packages/web/src/lib/project-name.ts @@ -1,9 +1,12 @@ +import "server-only"; + import { cache } from "react"; import { loadConfig } from "@composio/ao-core"; export interface ProjectInfo { id: string; name: string; + sessionPrefix?: string; } export const getProjectName = cache((): string => { @@ -37,6 +40,7 @@ export const getAllProjects = cache((): ProjectInfo[] => { return Object.entries(config.projects).map(([id, project]) => ({ id, name: project.name ?? id, + sessionPrefix: project.sessionPrefix ?? id, })); } catch { return []; diff --git a/packages/web/src/lib/project-utils.ts b/packages/web/src/lib/project-utils.ts index 179d00eec..1807034a9 100644 --- a/packages/web/src/lib/project-utils.ts +++ b/packages/web/src/lib/project-utils.ts @@ -44,6 +44,16 @@ export function filterWorkerSessions( projectFilter: string | null | undefined, projects: Record, ): T[] { - const workers = sessions.filter((s) => !isOrchestratorSession(s)); + const allSessionPrefixes = Object.entries(projects).map( + ([projectId, p]) => p.sessionPrefix ?? projectId, + ); + const workers = sessions.filter( + (s) => + !isOrchestratorSession( + s, + projects[s.projectId]?.sessionPrefix ?? s.projectId, + allSessionPrefixes, + ), + ); return filterProjectSessions(workers, projectFilter, projects); } diff --git a/packages/web/src/lib/scm-webhooks.ts b/packages/web/src/lib/scm-webhooks.ts index 105dca848..5e2472f92 100644 --- a/packages/web/src/lib/scm-webhooks.ts +++ b/packages/web/src/lib/scm-webhooks.ts @@ -1,3 +1,5 @@ +import "server-only"; + import { TERMINAL_STATUSES, type OrchestratorConfig, @@ -34,7 +36,7 @@ export function findWebhookProjects( pathname: string, ): WebhookProjectMatch[] { return Object.entries(config.projects).flatMap(([projectId, project]) => { - if (!project.scm) return []; + if (!project.scm?.plugin) return []; const webhookPath = getProjectWebhookPath(project); if (!webhookPath || webhookPath !== pathname) return []; const scm = registry.get("scm", project.scm.plugin); diff --git a/packages/web/src/lib/serialize.ts b/packages/web/src/lib/serialize.ts index d8668cb49..d24372b6a 100644 --- a/packages/web/src/lib/serialize.ts +++ b/packages/web/src/lib/serialize.ts @@ -1,3 +1,5 @@ +import "server-only"; + /** * Core Session → DashboardSession serialization. * @@ -73,8 +75,17 @@ export function listDashboardOrchestrators( sessions: Session[], projects: Record, ): DashboardOrchestratorLink[] { + const allSessionPrefixes = Object.entries(projects).map( + ([projectId, p]) => p.sessionPrefix ?? projectId, + ); return sessions - .filter((session) => isOrchestratorSession(session)) + .filter((session) => + isOrchestratorSession( + session, + projects[session.projectId]?.sessionPrefix ?? session.projectId, + allSessionPrefixes, + ), + ) .map((session) => ({ id: session.id, projectId: session.projectId, @@ -109,10 +120,11 @@ function basicPRToDashboard(pr: PRInfo): DashboardPR { ciPassing: false, // Conservative default approved: false, noConflicts: true, // Optimistic default (conflicts are rare) - blockers: ["Data not loaded"], // Explicit blocker + blockers: [], }, unresolvedThreads: 0, unresolvedComments: [], + enriched: false, }; } @@ -143,6 +155,7 @@ export async function enrichSessionPR( dashboard.pr.mergeability = cached.mergeability; dashboard.pr.unresolvedThreads = cached.unresolvedThreads; dashboard.pr.unresolvedComments = cached.unresolvedComments; + dashboard.pr.enriched = true; return true; } @@ -225,6 +238,9 @@ export async function enrichSessionPR( })); } + // Mark as enriched — we attempted SCM API calls and applied whatever succeeded + dashboard.pr.enriched = true; + // Add rate-limit warning blocker if most requests failed // (but we still applied any successful results above) if ( @@ -349,28 +365,45 @@ export async function enrichSessionIssueTitle( } /** - * Enrich dashboard sessions with metadata (issue labels, agent summaries, issue titles). - * Orchestrates sync + async enrichment in parallel. Does NOT enrich PR data — callers - * handle that separately since strategies differ (e.g. terminal-session cache optimization). + * Fast-path metadata enrichment: issue labels (sync) + agent summaries (local I/O). + * Does NOT call tracker API for issue titles — use enrichSessionsMetadata() for full enrichment. */ -export async function enrichSessionsMetadata( +export async function enrichSessionsMetadataFast( coreSessions: Session[], dashboardSessions: DashboardSession[], config: OrchestratorConfig, registry: PluginRegistry, ): Promise { - // Resolve projects once per session (avoids repeated Object.entries lookups) + const { summaryPromises } = prepareSessionMetadataEnrichment( + coreSessions, + dashboardSessions, + config, + registry, + ); + + await Promise.allSettled(summaryPromises); +} + +function prepareSessionMetadataEnrichment( + coreSessions: Session[], + dashboardSessions: DashboardSession[], + config: OrchestratorConfig, + registry: PluginRegistry, +): { + projects: Array; + summaryPromises: Promise[]; +} { const projects = coreSessions.map((core) => resolveProject(core, config.projects)); - // Enrich issue labels (synchronous — must run before async title enrichment) + // Issue labels (synchronous string parsing, no API calls) projects.forEach((project, i) => { - if (!dashboardSessions[i].issueUrl || !project?.tracker) return; + if (!dashboardSessions[i].issueUrl || !project?.tracker?.plugin) return; const tracker = registry.get("tracker", project.tracker.plugin); if (!tracker) return; enrichSessionIssue(dashboardSessions[i], tracker, project); }); - // Enrich agent summaries (reads agent's JSONL — local I/O, not an API call) + // Agent summaries (local disk I/O — reads agent JSONL) const summaryPromises = coreSessions.map((core, i) => { if (dashboardSessions[i].summary) return Promise.resolve(); const agentName = projects[i]?.agent ?? config.defaults.agent; @@ -380,12 +413,32 @@ export async function enrichSessionsMetadata( return enrichSessionAgentSummary(dashboardSessions[i], core, agent); }); - // Enrich issue titles (fetches from tracker API, cached with TTL) + return { projects, summaryPromises }; +} + +/** + * Full metadata enrichment: issue labels, agent summaries, AND issue titles (tracker API). + * Used by /api/sessions for complete data. For SSR fast path, use enrichSessionsMetadataFast(). + */ +export async function enrichSessionsMetadata( + coreSessions: Session[], + dashboardSessions: DashboardSession[], + config: OrchestratorConfig, + registry: PluginRegistry, +): Promise { + const { projects, summaryPromises } = prepareSessionMetadataEnrichment( + coreSessions, + dashboardSessions, + config, + registry, + ); + + // Issue-title fetches depend on labels being set, but can run in parallel with summary I/O. const issueTitlePromises = projects.map((project, i) => { if (!dashboardSessions[i].issueUrl || !dashboardSessions[i].issueLabel) { return Promise.resolve(); } - if (!project?.tracker) return Promise.resolve(); + if (!project?.tracker?.plugin) return Promise.resolve(); const tracker = registry.get("tracker", project.tracker.plugin); if (!tracker) return Promise.resolve(); return enrichSessionIssueTitle(dashboardSessions[i], tracker, project); diff --git a/packages/web/src/lib/services.ts b/packages/web/src/lib/services.ts index e815bf69f..7715136f8 100644 --- a/packages/web/src/lib/services.ts +++ b/packages/web/src/lib/services.ts @@ -1,3 +1,5 @@ +import "server-only"; + /** * Server-side singleton for core services. * @@ -137,7 +139,7 @@ async function labelIssuesForVerification( for (const session of mergedSessions) { const key = `${session.projectId}:${session.issueId}`; const project = config.projects[session.projectId]; - if (!project?.tracker) { + if (!project?.tracker?.plugin) { processedIssues.add(key); continue; } @@ -180,7 +182,7 @@ async function relabelReopenedIssues( registry: PluginRegistry, ): Promise { for (const [, project] of Object.entries(config.projects)) { - if (!project.tracker) continue; + if (!project.tracker?.plugin) continue; const tracker = registry.get("tracker", project.tracker.plugin); if (!tracker?.listIssues || !tracker.updateIssue) continue; @@ -225,8 +227,16 @@ export async function pollBacklog(): Promise { // Detect reopened issues: open state + agent:done label → relabel as agent:backlog await relabelReopenedIssues(config, registry); + const allSessionPrefixes = Object.entries(config.projects).map( + ([id, p]) => p.sessionPrefix ?? id, + ); const workerSessions = allSessions.filter( - (session) => !isOrchestratorSession(session) && !TERMINAL_STATUSES.has(session.status), + (session) => + !isOrchestratorSession( + session, + config.projects[session.projectId]?.sessionPrefix ?? session.projectId, + allSessionPrefixes, + ) && !TERMINAL_STATUSES.has(session.status), ); const activeIssueIds = new Set( workerSessions @@ -240,7 +250,7 @@ export async function pollBacklog(): Promise { for (const [projectId, project] of Object.entries(config.projects)) { if (availableSlots <= 0) break; - if (!project.tracker) continue; + if (!project.tracker?.plugin) continue; const tracker = registry.get("tracker", project.tracker.plugin); if (!tracker?.listIssues) continue; @@ -352,7 +362,7 @@ export async function getBacklogIssues(): Promise("tracker", project.tracker.plugin); if (!tracker?.listIssues) continue; @@ -380,7 +390,7 @@ export async function getVerifyIssues(): Promise("tracker", project.tracker.plugin); if (!tracker?.listIssues) continue; @@ -404,6 +414,6 @@ export async function getVerifyIssues(): Promise("scm", project.scm.plugin); } diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index db820c221..47a7e8594 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -17,9 +17,6 @@ export type { PRState, } from "@composio/ao-core/types"; -// Re-export global pause state from shared lib (provider-agnostic state contract) -export type { GlobalPauseState } from "./global-pause"; - import { ACTIVITY_STATE, SESSION_STATUS, @@ -99,6 +96,9 @@ export interface DashboardPR { mergeability: DashboardMergeability; unresolvedThreads: number; unresolvedComments: DashboardUnresolvedComment[]; + /** Whether this PR has been enriched with live SCM data (or cache hit). + * `false` means only basic data from the session metadata is available. */ + enriched?: boolean; } /** @@ -169,6 +169,12 @@ export function isPRRateLimited(pr: DashboardPR): boolean { return pr.mergeability.blockers.includes("API rate limited or unavailable"); } +/** Returns true when a PR has not yet been enriched with live SCM data. + * Only returns true for explicit `false` — undefined (legacy data) is treated as enriched. */ +export function isPRUnenriched(pr: DashboardPR): boolean { + return pr.enriched === false; +} + /** * Returns true when a PR is open and all merge criteria are met. * Does NOT return true for merged or closed PRs — those are already done. @@ -207,7 +213,7 @@ export function getAttentionLevel(session: DashboardSession): AttentionLevel { if (session.status === "mergeable" || session.status === "approved") { return "merge"; } - if (session.pr?.mergeability.mergeable) { + if (session.pr && !isPRUnenriched(session.pr) && session.pr.mergeability.mergeable) { return "merge"; } @@ -236,7 +242,7 @@ export function getAttentionLevel(session: DashboardSession): AttentionLevel { if (session.status === "ci_failed" || session.status === "changes_requested") { return "review"; } - if (session.pr && !isPRRateLimited(session.pr)) { + if (session.pr && !isPRRateLimited(session.pr) && !isPRUnenriched(session.pr)) { const pr = session.pr; if (pr.ciStatus === CI_STATUS.FAILING) return "review"; if (pr.reviewDecision === "changes_requested") return "review"; @@ -247,7 +253,7 @@ export function getAttentionLevel(session: DashboardSession): AttentionLevel { if (session.status === "review_pending") { return "pending"; } - if (session.pr && !isPRRateLimited(session.pr)) { + if (session.pr && !isPRRateLimited(session.pr) && !isPRUnenriched(session.pr)) { const pr = session.pr; if (!pr.isDraft && pr.unresolvedThreads > 0) return "pending"; if (!pr.isDraft && (pr.reviewDecision === "pending" || pr.reviewDecision === "none")) { diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts index d0f48db74..8e6585d6b 100644 --- a/packages/web/vitest.config.ts +++ b/packages/web/vitest.config.ts @@ -16,8 +16,42 @@ export default defineConfig({ }, }, resolve: { - alias: { - "@": resolve(__dirname, "./src"), - }, + alias: [ + { find: "@composio/ao-core/types", replacement: resolve(__dirname, "../core/src/types.ts") }, + { + find: "@composio/ao-core", + replacement: resolve(__dirname, "../core/src/index.ts"), + }, + { + find: "@composio/ao-plugin-runtime-tmux", + replacement: resolve(__dirname, "../plugins/runtime-tmux/src/index.ts"), + }, + { + find: "@composio/ao-plugin-agent-claude-code", + replacement: resolve(__dirname, "../plugins/agent-claude-code/src/index.ts"), + }, + { + find: "@composio/ao-plugin-agent-opencode", + replacement: resolve(__dirname, "../plugins/agent-opencode/src/index.ts"), + }, + { + find: "@composio/ao-plugin-workspace-worktree", + replacement: resolve(__dirname, "../plugins/workspace-worktree/src/index.ts"), + }, + { + find: "@composio/ao-plugin-scm-github", + replacement: resolve(__dirname, "../plugins/scm-github/src/index.ts"), + }, + { + find: "@composio/ao-plugin-tracker-github", + replacement: resolve(__dirname, "../plugins/tracker-github/src/index.ts"), + }, + { + find: "@composio/ao-plugin-tracker-linear", + replacement: resolve(__dirname, "../plugins/tracker-linear/src/index.ts"), + }, + { find: "server-only", replacement: resolve(__dirname, "./src/__tests__/server-only-mock.ts") }, + { find: "@", replacement: resolve(__dirname, "./src") }, + ], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 830107e4a..ba10b54e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -620,6 +620,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.4(react@19.2.4) + server-only: + specifier: ^0.0.1 + version: 0.0.1 ws: specifier: ^8.19.0 version: 8.19.0 @@ -631,6 +634,9 @@ importers: specifier: ^1.1.0 version: 1.1.0 devDependencies: + '@next/bundle-analyzer': + specifier: ^15.1.0 + version: 15.5.14 '@tailwindcss/postcss': specifier: ^4.0.0 version: 4.1.18 @@ -915,6 +921,10 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@discoveryjs/json-ext@0.5.7': + resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} + engines: {node: '>=10.0.0'} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -1558,6 +1568,9 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@next/bundle-analyzer@15.5.14': + resolution: {integrity: sha512-Q8rK9QXAqnJ2Ct1XXRJALBccINg9rX0Zl8QSPrYCuL/J1c+k7cfGcRTUNDAP9WIvhYk8JfxZEm1JpcHi+MUKiw==} + '@next/env@15.5.12': resolution: {integrity: sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==} @@ -1637,6 +1650,9 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -2139,6 +2155,10 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -2344,6 +2364,10 @@ packages: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + composio-core@0.5.39: resolution: {integrity: sha512-7BeSFlfRzr1cbIfGYJW4jQ3BHwaObOaFKiRJIFuWOmvOrTABl1hbxGkWPA3C+uFw9CFXbZhrLWNyD7lhYy2Scg==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. @@ -2386,6 +2410,9 @@ packages: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} + debounce@1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2443,6 +2470,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -2742,6 +2772,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2850,6 +2884,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -3186,6 +3224,10 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3281,6 +3323,10 @@ packages: zod: optional: true + opener@1.5.2: + resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} + hasBin: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3570,6 +3616,9 @@ packages: engines: {node: '>=10'} hasBin: true + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3596,6 +3645,10 @@ packages: simple-wcswidth@1.1.2: resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==} + sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -3767,6 +3820,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -4027,6 +4084,11 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + webpack-bundle-analyzer@4.10.1: + resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==} + engines: {node: '>= 10.13.0'} + hasBin: true + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -4071,6 +4133,18 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -4481,6 +4555,8 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@discoveryjs/json-ext@0.5.7': {} + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -4995,6 +5071,13 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@next/bundle-analyzer@15.5.14': + dependencies: + webpack-bundle-analyzer: 4.10.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@next/env@15.5.12': {} '@next/swc-darwin-arm64@15.5.12': @@ -5052,6 +5135,8 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@polka/url@1.0.0-next.29': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.57.1': @@ -5587,6 +5672,10 @@ snapshots: dependencies: acorn: 8.15.0 + acorn-walk@8.3.5: + dependencies: + acorn: 8.15.0 + acorn@8.15.0: {} agent-base@7.1.4: {} @@ -5776,6 +5865,8 @@ snapshots: commander@13.1.0: {} + commander@7.2.0: {} + composio-core@0.5.39(@ai-sdk/openai@3.0.29(zod@3.25.76))(@cloudflare/workers-types@4.20260214.0)(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(@langchain/openai@1.2.7(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(ws@8.19.0))(ai@6.0.86(zod@3.25.76))(langchain@1.2.24(@langchain/core@1.1.24(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76)))(@opentelemetry/api@1.9.0)(openai@6.22.0(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod-to-json-schema@3.25.1(zod@3.25.76)))(openai@6.22.0(ws@8.19.0)(zod@3.25.76)): dependencies: '@ai-sdk/openai': 3.0.29(zod@3.25.76) @@ -5836,6 +5927,8 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + debounce@1.2.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -5872,6 +5965,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexer@0.1.2: {} + eastasianwidth@0.2.0: {} electron-to-chromium@1.5.286: {} @@ -6225,6 +6320,10 @@ snapshots: graceful-fs@4.2.11: {} + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -6312,6 +6411,8 @@ snapshots: is-number@7.0.0: {} + is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} is-subdir@1.2.0: @@ -6645,6 +6746,8 @@ snapshots: mri@1.2.0: {} + mrmime@2.0.1: {} + ms@2.1.3: {} mustache@4.2.0: {} @@ -6734,6 +6837,8 @@ snapshots: ws: 8.19.0 zod: 3.25.76 + opener@1.5.2: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -7008,6 +7113,8 @@ snapshots: semver@7.7.4: {} + server-only@0.0.1: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -7054,6 +7161,12 @@ snapshots: simple-wcswidth@1.1.2: {} + sirv@2.0.4: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sisteransi@1.0.5: {} slash@3.0.0: {} @@ -7202,6 +7315,8 @@ snapshots: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -7462,6 +7577,25 @@ snapshots: webidl-conversions@7.0.0: {} + webpack-bundle-analyzer@4.10.1: + dependencies: + '@discoveryjs/json-ext': 0.5.7 + acorn: 8.15.0 + acorn-walk: 8.3.5 + commander: 7.2.0 + debounce: 1.2.1 + escape-string-regexp: 4.0.0 + gzip-size: 6.0.0 + html-escaper: 2.0.2 + is-plain-object: 5.0.0 + opener: 1.5.2 + picocolors: 1.1.1 + sirv: 2.0.4 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 @@ -7506,6 +7640,8 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 + ws@7.5.10: {} + ws@8.19.0: {} xml-name-validator@5.0.0: {}