Merge remote-tracking branch 'origin/main' into ci/coverage-report-913

This commit is contained in:
fireddd 2026-04-09 01:30:06 +05:30
commit cfc87877a1
122 changed files with 7054 additions and 2528 deletions

1
.gitignore vendored
View File

@ -67,3 +67,4 @@ agent-orchestrator.yaml
# OS-specific files
.DS_Store
Thumbs.db
.gstack/

View File

@ -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)

View File

@ -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 <pid> -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");
});
});

View File

@ -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", () => {

View File

@ -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 () => {

View File

@ -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<typeof import("node:process")>();
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<string, Record<string, unknown>>): Record<s
runtime: "tmux",
agent: "claude-code",
workspace: "worktree",
notifiers: ["desktop"],
notifiers: [],
},
projects,
notifiers: {},
@ -859,6 +881,144 @@ describe("start command — orchestrator session strategy display", () => {
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([]);
});
});

View File

@ -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();
});
});

View File

@ -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", () => {

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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>", "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 */
}
/**

View File

@ -343,7 +343,12 @@ async function sendTestNotifications(
const targets = new Map<string, NotifierTarget>();
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) {

View File

@ -125,23 +125,19 @@ export function registerSession(program: Command): void {
.command("kill")
.description("Kill a session and remove its worktree")
.argument("<session>", "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")

View File

@ -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];
}

View File

@ -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<OrchestratorConfig>
runtime: "tmux",
agent,
workspace: "worktree",
notifiers: ["desktop"],
notifiers: [],
},
projects: {
[projectId]: {
@ -747,22 +749,29 @@ export async function createConfigOnly(): Promise<void> {
* 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<ChildProcess> {
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<number> {
// 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 <issue-number>\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 <issue-number>\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);
}
},
);
});
}

View File

@ -74,7 +74,11 @@ async function gatherSessionInfo(
scm: SCM,
projectConfig: ReturnType<typeof loadConfig>,
): Promise<SessionInfo> {
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>("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
try {

View File

@ -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>("tracker", project.tracker.plugin);
if (!tracker) {
console.error(chalk.red(`Tracker plugin "${project.tracker.plugin}" not found.`));

View File

@ -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();

View File

@ -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<string | nu
return pid;
}
/**
* Find the working directory of a process by PID.
* Returns null if the cwd can't be determined.
*/
export async function findProcessWebDir(pid: string): Promise<string | null> {
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<void> {
const nextDir = resolve(webDir, ".next");
@ -72,3 +69,27 @@ export async function cleanNextCache(webDir: string): Promise<void> {
}
}
/**
* 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<void> {
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 },
);
}
}

View File

@ -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<void> {
* installs (hoisted to a parent node_modules).
*/
async function checkBuilt(webDir: string): Promise<void> {
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}`);

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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"),
},
],
},
});

View File

@ -255,7 +255,7 @@ describe("generateConfigFromUrl", () => {
runtime: "tmux",
agent: "claude-code",
workspace: "worktree",
notifiers: ["desktop"],
notifiers: [],
});
// Check project config

View File

@ -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");
});
});

View File

@ -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<string, unknown> | undefined;
const data = event?.data as Record<string, unknown> | 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();
});
});

View File

@ -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", () => {

View File

@ -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();
});
});

View File

@ -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");

View File

@ -225,7 +225,7 @@ describe("kill", () => {
await sm.kill("app-1", { purgeOpenCode: true });
expect(existsSync(deleteLogPath)).toBe(false);
});
}, 15000);
});
describe("cleanup", () => {

View File

@ -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");

View File

@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
path: worktreePath,
branch: "orchestrator/app-orchestrator-1",
sessionId: "app-orchestrator-1",
projectId: "my-app",
});
(mockRuntime.create as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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({});
});
});
});

View File

@ -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);
});
});

View File

@ -20,9 +20,10 @@ export interface ResolvedAgentSelection {
export function resolveSessionRole(
sessionId: string,
metadata?: Record<string, string>,
metadata: Record<string, string> | undefined,
sessionPrefix: string,
): SessionRole {
return isOrchestratorSession({ id: sessionId, metadata }) ? "orchestrator" : "worker";
return isOrchestratorSession({ id: sessionId, metadata }, sessionPrefix) ? "orchestrator" : "worker";
}
export function resolveAgentSelection(params: {

View File

@ -243,7 +243,7 @@ export function generateConfigFromUrl(options: GenerateConfigOptions): Record<st
runtime: "tmux",
agent: "claude-code",
workspace: "worktree",
notifiers: ["desktop"],
notifiers: [],
},
projects: {
[projectId]: projectConfig,

View File

@ -15,7 +15,7 @@ import { resolve, join, basename } from "node:path";
import { homedir } from "node:os";
import { parse as parseYaml } from "yaml";
import { z } from "zod";
import { ConfigNotFoundError, type OrchestratorConfig } from "./types.js";
import { ConfigNotFoundError, type ExternalPluginEntryRef, type OrchestratorConfig } from "./types.js";
import { generateSessionPrefix } from "./paths.js";
function inferScmPlugin(project: {
@ -50,6 +50,32 @@ function inferScmPlugin(project: {
// ZOD SCHEMAS
// =============================================================================
/**
* Common validation for plugin config fields (tracker, scm, notifier).
* Must have either plugin (for built-ins) or package/path (for external plugins).
* Cannot have both package and path.
*/
function validatePluginConfigFields(
value: { plugin?: string; package?: string; path?: string },
ctx: z.RefinementCtx,
configType: string,
): void {
// Must have either plugin or package/path
if (!value.plugin && !value.package && !value.path) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${configType} config requires either 'plugin' (for built-ins) or 'package'/'path' (for external plugins)`,
});
}
// Cannot have both package and path
if (value.package && value.path) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${configType} config cannot have both 'package' and 'path' - use one or the other`,
});
}
}
const ReactionConfigSchema = z.object({
auto: z.boolean().default(true),
action: z.enum(["send-to-agent", "notify", "auto-merge"]).default("notify"),
@ -63,13 +89,18 @@ const ReactionConfigSchema = z.object({
const TrackerConfigSchema = 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, "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<string>();
// 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);

View File

@ -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;
}

View File

@ -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,

View File

@ -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<string, PREnrichmentData>();
/**
* 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<SessionId, number>();
/** 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>("agent", agentName);
const scm = project.scm ? registry.get<SCM>("scm", project.scm.plugin) : null;
const scm = project.scm?.plugin ? registry.get<SCM>("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<Record<string, string>>): void {
function updateSessionMetadata(
session: Session,
updates: Partial<Record<string, string>>,
): 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>("scm", project.scm.plugin) : null;
const scm = project.scm?.plugin ? registry.get<SCM>("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>("scm", project.scm.plugin) : null;
const scm = project.scm?.plugin ? registry.get<SCM>("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>("scm", project.scm.plugin) : null;
const scm = project.scm?.plugin ? registry.get<SCM>("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;

View File

@ -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));
}

View File

@ -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<string, unknown> | undefined {
// Notifiers are configured under config.notifiers.<id>.
// 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<string, unknown>)["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<string, unknown>;
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<string, unknown>,
configPath?: string,
): Record<string, unknown> {
// 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<string, ExternalPluginEntryRef[]> {
const index = new Map<string, ExternalPluginEntryRef[]>();
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<string, ExternalPluginEntryRef[]>,
): 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<PluginModule>;
@ -239,16 +388,25 @@ export function createPluginRegistry(): PluginRegistry {
): Promise<void> {
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`);
}
}
},

View File

@ -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"],

View File

@ -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<string, string> | 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<string, string> | 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<string, ActiveSessionRecord[]>();
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<number>();
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>("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>("tracker", project.tracker.plugin)
: null;
const scm = project.scm ? registry.get<SCM>("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>("tracker", project.tracker.plugin) : null;
const scm = project.scm?.plugin ? registry.get<SCM>("scm", project.scm.plugin) : null;
return { runtime, agent, workspace, tracker, scm };
}
@ -724,7 +762,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
metadata: Record<string, string>,
) {
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<void> => {
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<void> {
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"],
});
}

View File

@ -189,11 +189,37 @@ export interface Session {
metadata: Record<string, string>;
}
export function isOrchestratorSession(session: {
id: SessionId;
metadata?: Record<string, string>;
}): boolean {
return session.metadata?.["role"] === "orchestrator" || session.id.endsWith("-orchestrator");
export function isOrchestratorSession(
session: { id: SessionId; metadata?: Record<string, string> },
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<string, ReactionConfig>;
/**
* 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
}
// =============================================================================

View File

@ -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 () => {

View File

@ -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);

View File

@ -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<string, unknown>;
}
/**
* 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<string, unknown>)["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<string, unknown>;
// 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<string, unknown> } }> }
| 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<string, unknown>
| undefined;
const pageInfo = contextsField?.["pageInfo"];
const contextsHasNextPage =
pageInfo !== null &&
pageInfo !== undefined &&
typeof pageInfo === "object" &&
(pageInfo as Record<string, unknown>)["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 };

View File

@ -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);
});
});

View File

@ -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;

View File

@ -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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 103 KiB

View File

@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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", () => {

View File

@ -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(<SessionCard session={session} />);
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(<SessionCard session={session} />);
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(<SessionCard session={session} />);
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(<SessionCard session={session} />);
expect(container.querySelector(".session-card__alert-pill")).toBeNull();
});
it("PRStatus shows shimmer for unenriched PR", () => {
const pr = makePR({ enriched: false });
const { container } = render(<PRStatus pr={pr} />);
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(<PRStatus pr={pr} />);
expect(screen.getByText("+50 -10 S")).toBeInTheDocument();
});
});

View File

@ -50,6 +50,7 @@ export function makePR(overrides: Partial<DashboardPR> = {}): DashboardPR {
},
unresolvedThreads: 0,
unresolvedComments: [],
enriched: true,
...overrides,
};
}

View File

@ -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 }) => (
<a href={href}>{children}</a>
),
}));
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();
});
});

View File

@ -0,0 +1,3 @@
// Stub for "server-only" package in vitest — the real module throws when
// imported outside a React Server Component context.
export {};

View File

@ -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>("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 });
}

View File

@ -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=<projectId>
* 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<string, unknown> | 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];

View File

@ -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<unknown>, timeoutMs: number): Promise<boolean> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<boolean>((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,

View File

@ -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 });
}

View File

@ -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";

View File

@ -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<React.AnchorHTMLAttributes<HTMLAnchorElement>>) => (
<a {...props}>{children}</a>
),
}));
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(<ErrorPage error={new Error("boom")} reset={reset} />);
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(reset).toHaveBeenCalledTimes(1);
expect(refresh).toHaveBeenCalledTimes(1);
});
it("renders a dashboard escape hatch", () => {
render(<ErrorPage error={new Error("boom")} reset={vi.fn()} />);
expect(screen.getByRole("link", { name: "Back to dashboard" })).toHaveAttribute("href", "/");
});
});

View File

@ -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 (
<ErrorDisplay
title="Something went wrong"
message="The dashboard hit an unexpected error. Try reloading the route data or head back to the main dashboard."
tone="warning"
primaryAction={{
label: "Try again",
onClick: () => {
reset();
router.refresh();
},
}}
secondaryAction={{ label: "Back to dashboard", href: "/" }}
error={error}
compact
chrome="card"
/>
);
}

View File

@ -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 (
<div>
<div>{props.title}</div>
<div>{props.message}</div>
{props.primaryAction ? (
<button type="button" onClick={props.primaryAction.onClick}>
{props.primaryAction.label}
</button>
) : null}
{props.secondaryAction ? (
<button type="button" onClick={props.secondaryAction.onClick}>
{props.secondaryAction.label}
</button>
) : null}
</div>
);
},
}));
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(<GlobalError error={new Error("layout failed")} reset={reset} />);
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(reset).toHaveBeenCalledTimes(1);
});
it("reloads the page on demand", () => {
render(<GlobalError error={new Error("layout failed")} reset={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Reload page" }));
expect(window.location.reload).toHaveBeenCalledTimes(1);
});
it("passes the expected shell copy to ErrorDisplay", () => {
render(<GlobalError error={new Error("layout failed")} reset={vi.fn()} />);
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.",
}),
);
});
});

View File

@ -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 (
<html lang="en" className="dark">
<body className="bg-[var(--color-bg-base)] text-[var(--color-text-primary)] antialiased">
<ErrorDisplay
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."
tone="error"
primaryAction={{ label: "Try again", onClick: reset }}
secondaryAction={{ label: "Reload page", onClick: () => window.location.reload() }}
error={error}
/>
</body>
</html>
);
}

View File

@ -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 {

View File

@ -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(<Loading />);
expect(screen.getByText("Loading…")).toBeInTheDocument();
});
it("renders a spinning indicator", () => {
const { container } = render(<Loading />);
const spinner = container.querySelector(".animate-spin");
expect(spinner).toBeInTheDocument();
expect(spinner?.tagName.toLowerCase()).toBe("svg");
});
});

View File

@ -0,0 +1,20 @@
export default function Loading() {
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--color-bg-base)]">
<div className="flex flex-col items-center gap-3">
<svg
className="h-5 w-5 animate-spin text-[var(--color-text-tertiary)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
<p className="text-[13px] text-[var(--color-text-tertiary)]">
Loading
</p>
</div>
</div>
);
}

View File

@ -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<React.AnchorHTMLAttributes<HTMLAnchorElement>>) => (
<a {...props}>{children}</a>
),
}));
import NotFound from "./not-found";
describe("NotFound (global)", () => {
it("renders the page-not-found message", () => {
render(<NotFound />);
expect(screen.getByText("Page not found")).toBeInTheDocument();
});
it("renders descriptive copy", () => {
render(<NotFound />);
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(<NotFound />);
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(<NotFound />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
});
});

View File

@ -0,0 +1,12 @@
import { ErrorDisplay } from "@/components/ErrorDisplay";
export default function NotFound() {
return (
<ErrorDisplay
title="Page not found"
message="This route does not exist in the dashboard. Return to the main view to pick an active project or session."
tone="not-found"
primaryAction={{ label: "Back to dashboard", href: "/" }}
/>
);
}

View File

@ -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<Metadata> {
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 (
<div className="flex h-screen items-center justify-center bg-[var(--color-bg-base)]">
<div className="text-center">
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">
Missing Project
</h1>
<p className="mt-2 text-[var(--color-text-secondary)]">
No project specified. Please provide a project parameter.
</p>
</div>
</div>
);
}
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 (
<OrchestratorSelector
orchestrators={orchestrators}
projectId={projectId}
projectName={projectName}
error={error}
/>
);
}

View File

@ -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}
/>
);

View File

@ -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<React.AnchorHTMLAttributes<HTMLAnchorElement>>) => (
<a {...props}>{children}</a>
),
}));
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(<SessionError error={new Error("HTTP 500")} reset={reset} />);
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(reset).toHaveBeenCalledTimes(1);
expect(refresh).toHaveBeenCalledTimes(1);
});
it("shows a session-specific message", () => {
render(<SessionError error={new Error("Network request failed")} reset={vi.fn()} />);
expect(
screen.getByText(
"The session request failed before the dashboard got a response. Check the server connection and try again.",
),
).toBeInTheDocument();
});
});

View File

@ -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 (
<ErrorDisplay
title="Failed to load session"
message={getSessionErrorMessage(error)}
tone="error"
primaryAction={{
label: "Try again",
onClick: () => {
reset();
router.refresh();
},
}}
secondaryAction={{ label: "Back to dashboard", href: "/" }}
error={error}
compact
chrome="card"
/>
);
}

View File

@ -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(<SessionLoading />);
expect(screen.getByText("Loading session…")).toBeInTheDocument();
});
it("renders a spinning indicator", () => {
const { container } = render(<SessionLoading />);
const spinner = container.querySelector(".animate-spin");
expect(spinner).toBeInTheDocument();
expect(spinner?.tagName.toLowerCase()).toBe("svg");
});
});

View File

@ -0,0 +1,20 @@
export default function SessionLoading() {
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--color-bg-base)]">
<div className="flex flex-col items-center gap-3">
<svg
className="h-5 w-5 animate-spin text-[var(--color-text-tertiary)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
<p className="text-[13px] text-[var(--color-text-tertiary)]">
Loading session
</p>
</div>
</div>
);
}

View File

@ -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<React.AnchorHTMLAttributes<HTMLAnchorElement>>) => (
<a {...props}>{children}</a>
),
}));
import SessionNotFound from "./not-found";
describe("SessionNotFound", () => {
it("renders the session-not-found message", () => {
render(<SessionNotFound />);
expect(screen.getByText("Session not found")).toBeInTheDocument();
});
it("renders descriptive subtext", () => {
render(<SessionNotFound />);
expect(
screen.getByText(
"The session youre looking for does not exist anymore, or the link is stale.",
),
).toBeInTheDocument();
});
it("renders a link back to the dashboard", () => {
render(<SessionNotFound />);
const link = screen.getByText("Back to dashboard");
expect(link).toBeInTheDocument();
expect(link.closest("a")).toHaveAttribute("href", "/");
});
it("renders the terminal icon", () => {
const { container } = render(<SessionNotFound />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
});
});

View File

@ -0,0 +1,14 @@
import { ErrorDisplay } from "@/components/ErrorDisplay";
export default function SessionNotFound() {
return (
<ErrorDisplay
title="Session not found"
message="The session youre looking for does not exist anymore, or the link is stale."
tone="not-found"
primaryAction={{ label: "Back to dashboard", href: "/" }}
compact
chrome="card"
/>
);
}

View File

@ -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<void> {
});
}
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 <div data-testid="route-error">{this.state.error.message}</div>;
}
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(<SessionPage />);
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(
<TestErrorBoundary>
<SessionPage />
</TestErrorBoundary>,
);
await flushAsyncWork();
expect(screen.getByTestId("route-error")).toHaveTextContent("HTTP 500");
});
});

View File

@ -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<string, string>,
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<ZoneCounts | null>(null);
const [projectOrchestratorId, setProjectOrchestratorId] = useState<string | null | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [routeError, setRouteError] = useState<Error | null>(null);
const [sessionMissing, setSessionMissing] = useState(false);
const [prefixByProject, setPrefixByProject] = useState<Map<string, string>>(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<string | null>(null);
const sessionIsOrchestratorRef = useRef(false);
const resolvedProjectSessionsKeyRef = useRef<string | null>(null);
const prefixByProjectRef = useRef<Map<string, string>>(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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-[var(--color-bg-base)]">
<div className="text-[13px] text-[var(--color-status-error)]">
{error ?? "Session not found"}
</div>
<a href="/" className="text-[12px] text-[var(--color-accent)] hover:underline">
Back to dashboard
</a>
</div>
);
if (sessionMissing) {
notFound();
return null;
}
if (routeError) {
throw routeError;
}
if (!session) {
throw new Error("Session data was unavailable after loading completed");
}
return (

View File

@ -7,18 +7,28 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => searchParams,
}));
const MockDirectTerminal = ({
sessionId,
startFullscreen,
}: {
sessionId: string;
startFullscreen: boolean;
}) => (
<div data-testid="direct-terminal">
{sessionId}:{String(startFullscreen)}
</div>
);
vi.mock("@/components/DirectTerminal", () => ({
DirectTerminal: ({
sessionId,
startFullscreen,
}: {
sessionId: string;
startFullscreen: boolean;
}) => (
<div data-testid="direct-terminal">
{sessionId}:{String(startFullscreen)}
</div>
),
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", () => {

View File

@ -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";

View File

@ -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",

View File

@ -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<DashboardOrchestratorLink[]>(orchestratorLinks);
const [spawningProjectIds, setSpawningProjectIds] = useState<string[]>([]);
@ -106,6 +102,7 @@ function DashboardInner({
mode: "preview" | "confirm-kill";
} | null>(null);
const [sheetSessionOverride, setSheetSessionOverride] = useState<DashboardSession | null>(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 (
<>
<ConnectionBar status={connectionStatus} />
@ -538,7 +526,7 @@ function DashboardInner({
{projectName ?? "Orchestrator"}
</h1>
<p className="dashboard-subtitle">
Live sessions, review pressure, and merge readiness.
Live agent sessions, pull requests, and merge status.
</p>
</div>
</div>
@ -600,45 +588,6 @@ function DashboardInner({
</section>
) : null}
{globalPause && !globalPauseDismissed && (
<div className="dashboard-alert mb-6 flex items-center gap-2.5 border border-[color-mix(in_srgb,var(--color-status-error)_25%,transparent)] bg-[var(--color-tint-red)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-error)]">
<svg
className="h-3.5 w-3.5 shrink-0"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<span className="flex-1">
<strong>Orchestrator paused:</strong> {globalPause.reason}
{resumeAtLabel && (
<span className="ml-2 opacity-75">Resume after {resumeAtLabel}</span>
)}
{globalPause.sourceSessionId && (
<span className="ml-2 opacity-75">(Source: {globalPause.sourceSessionId})</span>
)}
</span>
<button
onClick={() => setGlobalPauseDismissed(true)}
className="ml-1 shrink-0 opacity-60 hover:opacity-100"
aria-label="Dismiss"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
)}
{anyRateLimited && !rateLimitDismissed && (
<div className="dashboard-alert mb-6 flex items-center gap-2.5 border border-[color-mix(in_srgb,var(--color-status-attention)_25%,transparent)] bg-[var(--color-tint-yellow)] px-3.5 py-2.5 text-[11px] text-[var(--color-status-attention)]">
<svg
@ -688,7 +637,7 @@ function DashboardInner({
<div>
<h2 className="board-section-head__title">Attention Board</h2>
<p className="board-section-head__subtitle">
Triage by required intervention, not by chronology.
Sessions sorted by what needs your attention.
</p>
</div>
<div className="board-section-head__legend">
@ -735,7 +684,46 @@ function DashboardInner({
</div>
)}
{!allProjectsView && !hasAnySessions && <EmptyState />}
{!allProjectsView && grouped.done.length > 0 && (
<div className="done-bar mt-6">
<button
type="button"
className="done-bar__toggle"
onClick={() => setDoneExpanded((v) => !v)}
aria-expanded={doneExpanded}
>
<svg
className={`done-bar__chevron${doneExpanded ? " done-bar__chevron--open" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="m9 18 6-6-6-6" />
</svg>
<span>Done / Terminated</span>
<span className="done-bar__count">{grouped.done.length}</span>
<span className="done-bar__rule" aria-hidden="true" />
</button>
{doneExpanded && (
<div className="done-bar__cards">
{grouped.done.map((session) => (
<SessionCard
key={session.id}
session={session}
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
/>
))}
</div>
)}
</div>
)}
{!allProjectsView && !hasAnySessions && grouped.done.length === 0 && <EmptyState />}
</div>
</div>
@ -1072,3 +1060,4 @@ function BoardLegendItem({ label, tone }: { label: string; tone: string }) {
</span>
);
}

View File

@ -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<ErrorTone, { accent: string; bg: string; border: string; label: string }> = {
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 (
<div
className="flex h-14 w-14 items-center justify-center rounded-2xl border"
style={{
background: `linear-gradient(180deg, color-mix(in srgb, ${accent} 16%, var(--color-bg-elevated)) 0%, var(--color-bg-surface) 100%)`,
borderColor: `color-mix(in srgb, ${accent} 18%, var(--color-border-default))`,
boxShadow: "var(--detail-card-shadow)",
}}
>
<svg
className="h-7 w-7"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
style={{ color: accent }}
viewBox="0 0 24 24"
>
<rect x="2.5" y="4.5" width="19" height="15" rx="3" />
<path d="M6.5 9.5l3.25 2.5-3.25 2.5M12.75 15h4.75" />
</svg>
</div>
);
}
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 (
<Link href={action.href} className={className} style={style}>
{action.label}
</Link>
);
}
return (
<button type="button" onClick={action.onClick} className={className} style={style}>
{action.label}
</button>
);
}
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 (
<div
className={`flex w-full items-center justify-center px-6 py-10 ${compact ? "min-h-[calc(100vh-4rem)]" : "min-h-screen"}`}
style={{
background:
chrome === "page"
? "radial-gradient(circle at top, var(--color-body-gradient-blue), transparent 35%), var(--color-bg-base)"
: "transparent",
}}
>
<div
className="w-full max-w-[36rem] rounded-[28px] border p-6 sm:p-8"
style={{
background:
"linear-gradient(180deg, color-mix(in srgb, var(--color-bg-elevated) 88%, transparent) 0%, var(--color-bg-surface) 100%)",
borderColor: "var(--color-border-default)",
boxShadow: "var(--detail-card-shadow)",
}}
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<TerminalIcon accent={meta.accent} />
<div className="min-w-0 flex-1">
<div
className="mb-3 inline-flex items-center rounded-full border px-2.5 py-1 font-[var(--font-mono)] text-[10px] font-medium uppercase tracking-[0.22em]"
style={{
color: meta.accent,
background: meta.bg,
borderColor: meta.border,
}}
>
{meta.label}
</div>
<h1 className="text-[24px] font-semibold tracking-[-0.04em] text-[var(--color-text-primary)]">
{title}
</h1>
<p className="mt-2 max-w-[34rem] text-[14px] leading-6 text-[var(--color-text-secondary)]">
{message}
</p>
</div>
</div>
{(primaryAction || secondaryAction) && (
<div className="flex flex-wrap gap-3">
{primaryAction ? <ActionButton action={primaryAction} primary /> : null}
{secondaryAction ? <ActionButton action={secondaryAction} /> : null}
</div>
)}
{children}
{hasDetails ? (
<details
className="rounded-2xl border"
style={{
background: "color-mix(in srgb, var(--color-bg-elevated) 84%, transparent)",
borderColor: "var(--color-border-subtle)",
}}
>
<summary className="cursor-pointer list-none px-4 py-3 text-[12px] font-medium text-[var(--color-text-secondary)]">
{detailsTitle}
</summary>
<div className="border-t px-4 py-4" style={{ borderColor: "var(--color-border-subtle)" }}>
{error?.digest ? (
<p className="mb-3 font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
digest: {error.digest}
</p>
) : null}
{error?.message ? (
<p className="mb-3 text-[12px] leading-5 text-[var(--color-text-secondary)]">
{error.message}
</p>
) : null}
{error?.stack ? (
<pre className="overflow-x-auto whitespace-pre-wrap font-[var(--font-mono)] text-[11px] leading-5 text-[var(--color-text-tertiary)]">
{error.stack}
</pre>
) : null}
</div>
</details>
) : null}
</div>
</div>
</div>
);
}

View File

@ -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<string | null>(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 (
<div className="flex h-screen items-center justify-center bg-[var(--color-bg-base)]">
<div className="text-center">
<h1 className="text-xl font-semibold text-[var(--color-text-primary)]">Error</h1>
<p className="mt-2 text-[var(--color-text-secondary)]">{error}</p>
<Link
href="/"
className="orchestrator-btn mt-4 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium"
>
Go to Dashboard
</Link>
</div>
</div>
);
}
return (
<div className="flex min-h-screen flex-col bg-[var(--color-bg-base)]">
{/* Header */}
<header className="nav-glass sticky top-0 z-10 border-b border-[var(--color-border-subtle)] px-6 py-4">
<div className="mx-auto flex max-w-3xl items-center justify-between">
<div>
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
{projectName}
</h1>
<p className="text-sm text-[var(--color-text-secondary)]">Select an orchestrator</p>
</div>
<Link
href={`/?project=${projectId}`}
className="text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
>
Dashboard
</Link>
</div>
</header>
{/* Content */}
<main className="flex-1 px-6 py-8">
<div className="mx-auto max-w-3xl">
{/* Info banner */}
<div className="mb-6 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] p-4">
<p className="text-sm text-[var(--color-text-secondary)]">
Found{" "}
<span className="font-medium text-[var(--color-text-primary)]">
{orchestrators.length}
</span>{" "}
existing orchestrator session{orchestrators.length !== 1 ? "s" : ""}. You can resume
an existing session or start a new one.
</p>
</div>
{/* Existing orchestrators */}
<div className="mb-6">
<h2 className="mb-3 text-sm font-medium text-[var(--color-text-secondary)]">
Existing Sessions
</h2>
<div className="space-y-2">
{orchestrators.map((orch) => (
<Link
key={orch.id}
href={`/sessions/${orch.id}`}
className={cn(
"flex items-center justify-between rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] p-4",
"transition-all hover:border-[var(--color-border-default)] hover:shadow-sm",
)}
>
<div className="flex items-center gap-3">
<div
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: getStatusColor(orch.status) }}
/>
<div>
<div className="font-medium text-[var(--color-text-primary)]">{orch.id}</div>
<div className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<span className="capitalize">{orch.status.replace(/_/g, " ")}</span>
{orch.activity && (
<>
<span className="text-[var(--color-text-tertiary)]">&middot;</span>
<span>{getActivityLabel(orch.activity)}</span>
</>
)}
</div>
</div>
</div>
<div className="text-right text-xs text-[var(--color-text-tertiary)]">
<div>Created {formatRelativeTime(orch.createdAt)}</div>
{orch.lastActivityAt && (
<div>Active {formatRelativeTime(orch.lastActivityAt)}</div>
)}
</div>
</Link>
))}
</div>
</div>
{/* Start new section */}
<div className="border-t border-[var(--color-border-subtle)] pt-6">
<h2 className="mb-3 text-sm font-medium text-[var(--color-text-secondary)]">
Or Start Fresh
</h2>
<button
type="button"
onClick={handleSpawnNew}
disabled={isSpawning}
className={cn(
"orchestrator-btn w-full px-4 py-3 text-sm font-medium",
"disabled:cursor-wait disabled:opacity-70",
)}
>
{isSpawning ? (
<span className="flex items-center justify-center gap-2">
<svg
className="h-4 w-4 animate-spin"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Creating new orchestrator...
</span>
) : (
"Start New Orchestrator"
)}
</button>
{spawnError && (
<p className="mt-2 text-sm text-[var(--color-status-error)]">{spawnError}</p>
)}
</div>
</div>
</main>
</div>
);
}

View File

@ -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 (
<div className="flex flex-wrap items-center gap-1.5">
@ -29,12 +30,14 @@ export function PRStatus({ pr }: PRStatusProps) {
#{pr.number}
</a>
{/* Size — hide when rate limited (would show +0 -0 XS) */}
{!rateLimited && (
{/* Size — shimmer when unenriched, hide when rate limited */}
{!rateLimited && (unenriched ? (
<span className="inline-block h-[14px] w-16 animate-pulse rounded-full bg-[var(--color-bg-subtle)]" />
) : (
<span className="inline-flex items-center rounded-full bg-[rgba(125,133,144,0.08)] px-2 py-0.5 text-[10px] font-semibold text-[var(--color-text-muted)]">
+{pr.additions} -{pr.deletions} {sizeLabel}
</span>
)}
))}
{/* Merged badge */}
{pr.state === "merged" && (
@ -50,13 +53,15 @@ export function PRStatus({ pr }: PRStatusProps) {
</span>
)}
{/* 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 ? (
<span className="inline-block h-[14px] w-14 animate-pulse rounded-full bg-[var(--color-bg-subtle)]" />
) : (
<CIBadge status={pr.ciStatus} checks={pr.ciChecks} />
)}
))}
{/* Review decision (only for open PRs with real data) */}
{pr.state === "open" && pr.reviewDecision === "approved" && !rateLimited && (
{pr.state === "open" && pr.reviewDecision === "approved" && !rateLimited && !unenriched && (
<span className="inline-flex items-center rounded-full bg-[rgba(63,185,80,0.1)] px-2 py-0.5 text-[10px] font-semibold text-[var(--color-accent-green)]">
approved
</span>
@ -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 = <span className="inline-block h-3 w-12 animate-pulse rounded bg-[var(--color-bg-subtle)]" />;
return (
<tr className="border-b border-[var(--color-border-muted)] hover:bg-[rgba(88,166,255,0.03)]">
<td className="px-3 py-2.5 text-sm">
@ -102,7 +111,7 @@ export function PRTableRow({ pr }: PRTableRowProps) {
</td>
<td className="max-w-[420px] truncate px-3 py-2.5 text-sm font-medium">{pr.title}</td>
<td className="px-3 py-2.5 text-sm">
{rateLimited ? (
{unenriched ? shimmer : rateLimited ? (
<span className="text-[var(--color-text-tertiary)]"></span>
) : (
<>
@ -113,17 +122,19 @@ export function PRTableRow({ pr }: PRTableRowProps) {
)}
</td>
<td className="px-3 py-2.5">
{rateLimited ? (
{unenriched ? shimmer : rateLimited ? (
<span className="text-[var(--color-text-tertiary)]"></span>
) : (
<CIBadge status={pr.ciStatus} checks={pr.ciChecks} compact />
)}
</td>
<td className={`px-3 py-2.5 text-xs font-semibold ${reviewClass}`}>{reviewLabel}</td>
<td className={`px-3 py-2.5 text-xs font-semibold ${reviewClass}`}>
{unenriched ? shimmer : reviewLabel}
</td>
<td
className={`px-3 py-2.5 text-center text-sm font-bold ${pr.unresolvedThreads > 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}
</td>
</tr>
);
@ -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 = <span className="inline-block h-3 w-10 animate-pulse rounded bg-[var(--color-bg-subtle)]" />;
return (
<a
href={pr.url}
@ -161,12 +175,12 @@ export function PRCard({ pr }: PRTableRowProps) {
<div className="mobile-pr-card__line">
<span className="mobile-pr-card__number">#{pr.number}</span>
<span className="mobile-pr-card__title">{pr.title}</span>
{!rateLimited ? <span className="mobile-pr-card__size">{sizeLabel}</span> : null}
{!rateLimited && !unenriched ? <span className="mobile-pr-card__size">{sizeLabel}</span> : null}
</div>
<div className="mobile-pr-card__meta">
<span>{ciLabel}</span>
<span>{reviewLabel}</span>
<span>{pr.unresolvedThreads} threads</span>
<span>{unenriched ? shimmer : ciLabel}</span>
<span>{unenriched ? shimmer : reviewLabel}</span>
<span>{unenriched ? shimmer : `${pr.unresolvedThreads} threads`}</span>
</div>
</a>
);

View File

@ -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<string, string>,
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<string, { all: DashboardSession[]; workers: DashboardSession[] }>();
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({
<div className="flex flex-1 flex-col items-center gap-2">
{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;

View File

@ -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({
<div>
<h1 className="dashboard-title">{projectName ? `${projectName} PRs` : "Pull Requests"}</h1>
<p className="dashboard-subtitle">
Review active pull requests without the dashboard board chrome.
Open pull requests created by agents{allProjectsView ? " across all projects" : " in this project"}.
</p>
</div>
</div>

View File

@ -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}
</a>
)}
{pr && !rateLimited && (
{pr && !rateLimited && (prUnenriched ? (
<span className="inline-block h-[14px] w-16 animate-pulse rounded-full bg-[var(--color-bg-subtle)]" />
) : (
<span className="done-meta-chip font-[var(--font-mono)]">
<span className="text-[var(--color-status-ready)]">+{pr.additions}</span>{" "}
<span className="text-[var(--color-status-error)]">-{pr.deletions}</span>{" "}
{getSizeLabel(pr.additions, pr.deletions)}
</span>
)}
))}
</div>
{/* Expandable detail panel */}
@ -345,21 +349,33 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
>
{pr.title}
</a>
<br />
<span className="mt-1 inline-flex items-center gap-2">
<span className="done-meta-chip font-[var(--font-mono)]">
<span className="text-[var(--color-status-ready)]">+{pr.additions}</span>{" "}
<span className="text-[var(--color-status-error)]">-{pr.deletions}</span>
</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="text-[10px] text-[var(--color-text-muted)]">
mergeable: {pr.mergeability.mergeable ? "yes" : "no"}
</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="text-[10px] text-[var(--color-text-muted)]">
review: {pr.reviewDecision}
</span>
</span>
{prUnenriched ? (
<>
<br />
<span className="mt-1 inline-flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<span className="inline-block h-3 w-12 animate-pulse rounded bg-[var(--color-bg-subtle)]" />
<span>PR details loading...</span>
</span>
</>
) : (
<>
<br />
<span className="mt-1 inline-flex items-center gap-2">
<span className="done-meta-chip font-[var(--font-mono)]">
<span className="text-[var(--color-status-ready)]">+{pr.additions}</span>{" "}
<span className="text-[var(--color-status-error)]">-{pr.deletions}</span>
</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="text-[10px] text-[var(--color-text-muted)]">
mergeable: {pr.mergeability.mergeable ? "yes" : "no"}
</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="text-[10px] text-[var(--color-text-muted)]">
review: {pr.reviewDecision}
</span>
</span>
</>
)}
</p>
</div>
)}
@ -474,11 +490,13 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
#{pr.number}
</a>
)}
{pr && !rateLimited && (
{pr && !rateLimited && (prUnenriched ? (
<span className="inline-block h-[14px] w-16 animate-pulse rounded-full bg-[var(--color-bg-subtle)]" />
) : (
<span className="inline-flex items-center rounded-full bg-[var(--color-chip-bg)] px-2 py-0.5 font-[var(--font-mono)] text-[10px] font-semibold text-[var(--color-text-muted)]">
+{pr.additions} -{pr.deletions} {getSizeLabel(pr.additions, pr.deletions)}
</span>
)}
))}
</div>
{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[] = [];

View File

@ -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: () => (
<div className="h-[440px] animate-pulse rounded bg-[var(--color-bg-primary)]" />
),
},
);
interface OrchestratorZones {
merge: number;
respond: number;
@ -37,10 +48,6 @@ const activityMeta: Record<string, { label: string; color: string }> = {
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("<!-- DESCRIPTION START -->") || 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";

View File

@ -7,7 +7,6 @@ interface EmptyStateProps {
export function EmptyState({
message,
}: EmptyStateProps) {
const isDefault = !message;
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
{/* Terminal icon */}
@ -22,16 +21,7 @@ export function EmptyState({
<path d="M6 9l4 3-4 3M13 15h5" />
</svg>
<p className="text-[13px] text-[var(--color-text-muted)]">
{isDefault ? (
<>
No sessions running. Start one with{" "}
<code className="font-[var(--font-mono)] text-[var(--color-text-secondary)]">
ao start
</code>
</>
) : (
message
)}
{message ?? "No active sessions"}
</p>
</div>
);

View File

@ -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(<Dashboard initialSessions={[DONE_SESSION]} />);
expect(screen.getByText("Done / Terminated")).toBeInTheDocument();
});
it("expands to show session cards when clicked", () => {
const { container } = render(<Dashboard initialSessions={[DONE_SESSION]} />);
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(<Dashboard initialSessions={[DONE_SESSION]} />);
expect(screen.queryByText(/No active sessions/i)).not.toBeInTheDocument();
});
});

View File

@ -26,7 +26,7 @@ beforeEach(() => {
describe("Dashboard empty state", () => {
it("shows empty state when there are no sessions (single-project view)", () => {
render(<Dashboard initialSessions={[]} />);
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();
});
});

View File

@ -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> = {}): 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(<Dashboard initialSessions={sessions} initialGlobalPause={globalPause} />);
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(<Dashboard initialSessions={sessions} initialGlobalPause={null} />);
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(<Dashboard initialSessions={sessions} initialGlobalPause={globalPause} />);
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(<Dashboard initialSessions={sessions} initialGlobalPause={globalPause} />);
expect(screen.getByText(/Resume after/)).toBeInTheDocument();
});
it("displays source session ID when provided", () => {
const sessions = [makeSession()];
const globalPause = makeGlobalPause({ sourceSessionId: "my-worker-42" });
render(<Dashboard initialSessions={sessions} initialGlobalPause={globalPause} />);
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(<Dashboard initialSessions={sessions} initialGlobalPause={null} />);
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(<Dashboard initialSessions={sessions} initialGlobalPause={globalPause} />);
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(
<Dashboard initialSessions={sessions} initialGlobalPause={firstPause} />,
);
expect(screen.getByText(/First pause/)).toBeInTheDocument();
const dismissButtons = screen.getAllByLabelText("Dismiss");
act(() => {
fireEvent.click(dismissButtons[0]);
});
expect(screen.queryByText(/Orchestrator paused:/)).not.toBeInTheDocument();
rerender(<Dashboard initialSessions={sessions} initialGlobalPause={secondPause} />);
expect(screen.getByText(/Second pause/)).toBeInTheDocument();
});
});

View File

@ -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(<Dashboard initialSessions={[session]} />);
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(<Dashboard initialSessions={[session]} />);
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(),
}),
]}

View File

@ -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<React.AnchorHTMLAttributes<HTMLAnchorElement>>) => (
<a {...props}>{children}</a>
),
}));
import { ErrorDisplay } from "../ErrorDisplay";
describe("ErrorDisplay", () => {
it("renders title, message, and actions", () => {
const onRetry = vi.fn();
render(
<ErrorDisplay
title="Failed to load session"
message="Try again."
primaryAction={{ label: "Try again", onClick: onRetry }}
secondaryAction={{ label: "Back to dashboard", href: "/" }}
/>,
);
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(
<ErrorDisplay
title="Something went wrong"
message="Details are available."
error={Object.assign(new Error("HTTP 500"), { digest: "abc123" })}
/>,
);
expect(screen.getByText("Technical details")).toBeInTheDocument();
expect(screen.getByText(/digest: abc123/)).toBeInTheDocument();
expect(screen.getByText("HTTP 500")).toBeInTheDocument();
});
});

View File

@ -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(<OrchestratorSelector {...defaultProps} />);
expect(screen.getByText("app-orchestrator-1")).toBeInTheDocument();
expect(screen.getByText("app-orchestrator-2")).toBeInTheDocument();
});
it("displays project name in header", () => {
render(<OrchestratorSelector {...defaultProps} />);
expect(screen.getByText("My Project")).toBeInTheDocument();
expect(screen.getByText("Select an orchestrator")).toBeInTheDocument();
});
it("shows count of existing sessions", () => {
render(<OrchestratorSelector {...defaultProps} />);
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(
<OrchestratorSelector
{...defaultProps}
orchestrators={[]}
error="Project not found"
/>,
);
expect(screen.getByText("Error")).toBeInTheDocument();
expect(screen.getByText("Project not found")).toBeInTheDocument();
});
it("shows start new orchestrator button", () => {
render(<OrchestratorSelector {...defaultProps} />);
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(<OrchestratorSelector {...defaultProps} />);
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(<OrchestratorSelector {...defaultProps} />);
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(<OrchestratorSelector {...defaultProps} />);
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(<OrchestratorSelector {...defaultProps} />);
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(<OrchestratorSelector {...defaultProps} />);
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(
<OrchestratorSelector
{...defaultProps}
orchestrators={wideOrchestrators}
/>,
);
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(
<OrchestratorSelector
{...defaultProps}
orchestrators={orchestratorsWithInvalidDate}
/>,
);
// 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(
<OrchestratorSelector
{...defaultProps}
orchestrators={orchestratorsWithFutureDate}
/>,
);
// 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(
<OrchestratorSelector
{...defaultProps}
orchestrators={orchestratorsWithNullDate}
/>,
);
expect(screen.getByText(/Created Unknown/)).toBeInTheDocument();
});
});
});

Some files were not shown because too many files have changed in this diff Show More