Merge remote-tracking branch 'origin/main' into ci/coverage-report-913
This commit is contained in:
commit
cfc87877a1
|
|
@ -67,3 +67,4 @@ agent-orchestrator.yaml
|
|||
# OS-specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.gstack/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
[](https://github.com/ComposioHQ/agent-orchestrator/stargazers)
|
||||
[](https://www.npmjs.com/package/@composio/ao)
|
||||
[](LICENSE)
|
||||
[](https://github.com/ComposioHQ/agent-orchestrator/pulls?q=is%3Amerged)
|
||||
[](https://github.com/ComposioHQ/agent-orchestrator/releases/tag/metrics-v1)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 */
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.`));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ describe("generateConfigFromUrl", () => {
|
|||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: ["desktop"],
|
||||
notifiers: [],
|
||||
});
|
||||
|
||||
// Check project config
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ describe("kill", () => {
|
|||
await sm.kill("app-1", { purgeOpenCode: true });
|
||||
|
||||
expect(existsSync(deleteLogPath)).toBe(false);
|
||||
});
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ export function generateConfigFromUrl(options: GenerateConfigOptions): Record<st
|
|||
runtime: "tmux",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: ["desktop"],
|
||||
notifiers: [],
|
||||
},
|
||||
projects: {
|
||||
[projectId]: projectConfig,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export function makePR(overrides: Partial<DashboardPR> = {}): DashboardPR {
|
|||
},
|
||||
unresolvedThreads: 0,
|
||||
unresolvedComments: [],
|
||||
enriched: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// Stub for "server-only" package in vitest — the real module throws when
|
||||
// imported outside a React Server Component context.
|
||||
export {};
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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", "/");
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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: "/" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 you’re 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
|
||||
export default function SessionNotFound() {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
title="Session not found"
|
||||
message="The session you’re looking for does not exist anymore, or the link is stale."
|
||||
tone="not-found"
|
||||
primaryAction={{ label: "Back to dashboard", href: "/" }}
|
||||
compact
|
||||
chrome="card"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)]">·</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
}),
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
Loading…
Reference in New Issue