refactor(spawn): plugin-owned preflight + collapse project resolution (#1622)
* feat(core): add PreflightContext + optional preflight() to plugin interfaces Foundation for PR 2 of the ao spawn refactor: lets plugins own their own prerequisites instead of the CLI hardcoding 'if runtime === tmux check tmux' / 'if tracker === github check gh auth' switches. PreflightContext describes intent (willClaimExistingPR, role) rather than CLI flag names, so plugins never learn about flags. New flags map to new intent fields only when a plugin actually needs them. Adds preflight?(ctx) as an optional method on Runtime, Agent, Workspace, Tracker, SCM. Backwards-compatible: existing plugins keep working unchanged. Subsequent commits move checkTmux into runtime-tmux and checkGhAuth into the github plugins, then update spawn.ts to iterate selected plugins instead of switching on plugin names. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(plugins): implement preflight() in runtime-tmux + tracker-github + scm-github Each plugin now owns its own prerequisite checks (tmux binary, gh auth) behind the optional PluginModule preflight() contract added in the previous commit. The CLI no longer needs to know which plugin needs which tool — it just iterates the selected plugins. - runtime-tmux: checks 'tmux -V' and throws with platform-appropriate install hint (brew / apt / dnf / WSL) - tracker-github: checks 'gh --version' and 'gh auth status' unconditionally (tracker is exercised on every spawn that has an issueId AND on lifecycle polling for issue closure) - scm-github: same gh auth checks but only when the spawn will exercise PR-write paths — gates on context.intent.willClaimExistingPR Subsequent commit refactors the CLI to iterate plugins instead of hardcoded 'if runtime === tmux' switches. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(cli): make ao spawn iterate plugin preflight, collapse project resolution Three small changes bundled because they all touch spawn.ts: 1. Plugin-iterating preflight: replaces the hardcoded 'if runtime === tmux check tmux' / 'if tracker === github check gh auth' switches in runSpawnPreflight with a 4-line loop that walks the selected plugins and calls each one's optional preflight(). Plugin internals are no longer leaked into the CLI; new plugins only need to declare their own preflight. 2. Project-resolution collapse: the prefix/no-prefix and issue/no-issue paths previously had three near-duplicate code blocks each with its own try/catch around autoDetectProject. Replaced by one resolveProjectAndIssue() helper that uses resolveSpawnTarget's fallback parameter — caller wraps in a single try/catch. 3. Micro-deletes: drop the unused 'return session.id' in spawnSession (callers already ignore it; the SESSION=<id> stdout line is the scriptable contract). Drop checkTmux/checkGhAuth from lib/preflight.ts (now in their respective plugins) along with their orphaned tests. LOC: roughly net-zero. Wins are structural — adding runtime-podman / tracker-jira / scm-bitbucket no longer requires editing spawn.ts. Pre-existing start.test.ts 'stop command' failures are unrelated (verified on upstream/main bare). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * perf(plugins): dedupe gh-auth check across tracker-github + scm-github Address greptile P2 on PR #1622: when a project has both tracker: github and scm: github with --claim-pr, both plugin preflights ran 'gh --version' + 'gh auth status' independently — 4 execs where 2 suffice, and two identical error messages on failure. Add memoizeAsync(key, fn) to core (process-scoped Promise cache) and have both github plugins share the key 'gh-cli-auth'. Second caller hits the in-flight (or resolved) promise — zero extra subprocess overhead, one error on failure. Caches both successes and rejections: failed checks should never re-run within a process (cache dies with the CLI, user fixes the underlying issue and re-invokes). 5 unit tests for memoizeAsync covering: single-fire dedup, value identity, distinct keys, rejection caching, concurrent in-flight dedup. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(spawn): collect-all preflight + per-plugin tests + key-namespacing docs Address self-review feedback on PR #1622: 1. **Collect-all preflight** (spawn.ts): runSpawnPreflight previously aborted at the first plugin's failure, so a user with multiple broken prereqs (tmux missing AND gh logged out) had to fix-and-retry to discover the second one. Now collects every plugin's error and reports them together ("2 preflight checks failed:\n 1. ...\n 2. ..."). Single-failure path is unchanged — that error throws as-is without the wrapper. Test added: 'collects every plugin's preflight failure into one combined error'. 2. **Drop redundant workspace literal fallback** (spawn.ts): DefaultPluginsSchema in core/config.ts applies .default("worktree") to workspace, same as runtime/agent. The literal '?? "worktree"' was asymmetric defensive theater — dropped to match the runtime/agent form. 3. **memoizeAsync key-namespacing convention** (process-cache.ts): Added a JSDoc section documenting that two callers using the same key get shared state (intentional for cross-cutting checks like gh-cli-auth, dangerous for plugin-internal caching). Recommends namespacing plugin-internal keys as 'plugin-name:thing'. 4. **Per-plugin preflight unit tests**: - runtime-tmux: tmux-present resolves; tmux-missing throws with platform-specific install hint (verified per-platform branch) - tracker-github: happy path, gh-not-installed, gh-not-authenticated - scm-github: no-op when willClaimExistingPR=false (zero gh calls), full check when true, plus install/auth failure branches Process cache cleared in beforeEach so each test starts fresh. Required exporting _clearProcessCacheForTests from core/index.ts (matches existing _testUtils pattern in gh-trace.ts). Pre-existing start.test.ts 'stop command' failures unchanged (verified on bare upstream/main). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(test): collapse duplicate @aoagents/ao-core import in tracker-github test eslint no-duplicate-imports caught it on CI — combined the value and type-only imports into one statement. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
fad75b6323
commit
caa7f60a4b
|
|
@ -52,8 +52,18 @@ vi.mock("@aoagents/ao-core", async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
// Default registry returns no plugins → preflight loop is a no-op. Tests that
|
||||
// need a specific plugin's preflight to fire override mockRegistryGet.
|
||||
const mockRegistryGet = vi.fn().mockReturnValue(null);
|
||||
vi.mock("../../src/lib/create-session-manager.js", () => ({
|
||||
getSessionManager: async (): Promise<SessionManager> => mockSessionManager as SessionManager,
|
||||
getPluginRegistry: async () => ({
|
||||
register: vi.fn(),
|
||||
get: mockRegistryGet,
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn(),
|
||||
loadFromConfig: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/running-state.js", () => ({
|
||||
|
|
@ -124,6 +134,7 @@ beforeEach(() => {
|
|||
mockSessionManager.claimPR.mockReset();
|
||||
mockExec.mockReset();
|
||||
mockGetRunning.mockReset();
|
||||
mockRegistryGet.mockReset().mockReturnValue(null);
|
||||
mockGetRunning.mockResolvedValue({ pid: 1234, port: 3000, startedAt: "", projects: ["my-app"] });
|
||||
});
|
||||
|
||||
|
|
@ -673,23 +684,14 @@ describe("spawn command", () => {
|
|||
});
|
||||
|
||||
describe("spawn pre-flight checks", () => {
|
||||
it("fails with clear error when tmux is not installed (default runtime)", async () => {
|
||||
mockExec.mockRejectedValue(new Error("ENOENT"));
|
||||
// The spawn CLI now iterates the configured plugins and calls each one's
|
||||
// optional preflight(). Plugin-internal checks (e.g. checkTmux, gh auth
|
||||
// status) live in the plugin packages — see runtime-tmux / tracker-github /
|
||||
// scm-github tests for that coverage. These tests verify the orchestration:
|
||||
// the right plugins are iterated, and the intent context is forwarded.
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
|
||||
const errors = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(errors).toContain("tmux");
|
||||
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips tmux check when runtime is not tmux", async () => {
|
||||
const fakeSession: Session = {
|
||||
function makeFakeSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "spawning",
|
||||
|
|
@ -698,98 +700,77 @@ describe("spawn pre-flight checks", () => {
|
|||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: "/tmp/wt",
|
||||
runtimeHandle: { id: "proc-1", runtimeName: "process", data: {} },
|
||||
runtimeHandle: { id: "hash-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
mockSessionManager.spawn.mockResolvedValue(fakeSession);
|
||||
}
|
||||
|
||||
// Set runtime to "process"
|
||||
(mockConfigRef.current as Record<string, unknown>).defaults = {
|
||||
runtime: "process",
|
||||
agent: "claude-code",
|
||||
workspace: "worktree",
|
||||
notifiers: ["desktop"],
|
||||
};
|
||||
it("surfaces a plugin's preflight error and aborts before sm.spawn", async () => {
|
||||
mockRegistryGet.mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") {
|
||||
return {
|
||||
name: "tmux",
|
||||
preflight: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("tmux is not installed. Install it: brew install tmux")),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// exec would fail for tmux but should never be called
|
||||
mockExec.mockRejectedValue(new Error("ENOENT"));
|
||||
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)");
|
||||
|
||||
await program.parseAsync(["node", "test", "spawn"]);
|
||||
|
||||
expect(mockSessionManager.spawn).toHaveBeenCalled();
|
||||
const errors = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(errors).toContain("tmux is not installed");
|
||||
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks gh auth when tracker is github", async () => {
|
||||
it("skips scm.preflight when --claim-pr is not provided", async () => {
|
||||
const trackerPreflight = vi.fn().mockResolvedValue(undefined);
|
||||
const scmPreflight = vi.fn().mockResolvedValue(undefined);
|
||||
mockRegistryGet.mockImplementation((slot: string) => {
|
||||
if (slot === "tracker") return { name: "github", preflight: trackerPreflight };
|
||||
if (slot === "scm") return { name: "github", preflight: scmPreflight };
|
||||
return null;
|
||||
});
|
||||
|
||||
const projects = (mockConfigRef.current as Record<string, unknown>).projects as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
projects["my-app"].tracker = { plugin: "github" };
|
||||
projects["my-app"].scm = { plugin: "github" };
|
||||
|
||||
// tmux check passes, gh --version passes, gh auth status fails
|
||||
mockExec
|
||||
.mockResolvedValueOnce({ stdout: "tmux 3.3a", stderr: "" }) // tmux -V
|
||||
.mockResolvedValueOnce({ stdout: "gh version 2.40", stderr: "" }) // gh --version
|
||||
.mockRejectedValueOnce(new Error("not logged in")); // gh auth status
|
||||
mockSessionManager.spawn.mockResolvedValue(makeFakeSession());
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
await program.parseAsync(["node", "test", "spawn"]);
|
||||
|
||||
const errors = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(errors).toContain("not authenticated");
|
||||
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
|
||||
expect(trackerPreflight).toHaveBeenCalled();
|
||||
expect(scmPreflight).not.toHaveBeenCalled();
|
||||
expect(mockSessionManager.spawn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks gh auth when --claim-pr targets a github SCM project", async () => {
|
||||
it("calls scm.preflight with willClaimExistingPR=true when --claim-pr is provided", async () => {
|
||||
const scmPreflight = vi.fn().mockResolvedValue(undefined);
|
||||
mockRegistryGet.mockImplementation((slot: string) => {
|
||||
if (slot === "scm") return { name: "github", preflight: scmPreflight };
|
||||
return null;
|
||||
});
|
||||
|
||||
const projects = (mockConfigRef.current as Record<string, unknown>).projects as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
projects["my-app"].tracker = { plugin: "linear" };
|
||||
projects["my-app"].scm = { plugin: "github" };
|
||||
|
||||
mockExec
|
||||
.mockResolvedValueOnce({ stdout: "tmux 3.3a", stderr: "" })
|
||||
.mockResolvedValueOnce({ stdout: "gh version 2.40", stderr: "" })
|
||||
.mockRejectedValueOnce(new Error("not logged in"));
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["node", "test", "spawn", "--claim-pr", "123"]),
|
||||
).rejects.toThrow("process.exit(1)");
|
||||
|
||||
const errors = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(errors).toContain("not authenticated");
|
||||
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles tracker+scm github preflight when claiming during spawn", async () => {
|
||||
const fakeSession: Session = {
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "spawning",
|
||||
activity: null,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: "/tmp/wt",
|
||||
runtimeHandle: { id: "hash-app-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
mockSessionManager.spawn.mockResolvedValue(fakeSession);
|
||||
mockSessionManager.spawn.mockResolvedValue(makeFakeSession());
|
||||
mockSessionManager.claimPR.mockResolvedValue({
|
||||
sessionId: "app-1",
|
||||
projectId: "my-app",
|
||||
|
|
@ -808,86 +789,59 @@ describe("spawn pre-flight checks", () => {
|
|||
takenOverFrom: [],
|
||||
});
|
||||
|
||||
const projects = (mockConfigRef.current as Record<string, unknown>).projects as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
projects["my-app"].tracker = { plugin: "github" };
|
||||
projects["my-app"].scm = { plugin: "github" };
|
||||
|
||||
mockExec
|
||||
.mockResolvedValueOnce({ stdout: "tmux 3.3a", stderr: "" })
|
||||
.mockResolvedValueOnce({ stdout: "gh version 2.40", stderr: "" })
|
||||
.mockResolvedValueOnce({ stdout: "Logged in", stderr: "" });
|
||||
|
||||
await program.parseAsync(["node", "test", "spawn", "--claim-pr", "123"]);
|
||||
|
||||
expect(mockExec).toHaveBeenCalledWith("tmux", ["-V"]);
|
||||
const ghCalls = mockExec.mock.calls.filter(([command]) => command === "gh");
|
||||
expect(ghCalls).toHaveLength(2);
|
||||
expect(mockSessionManager.spawn).toHaveBeenCalled();
|
||||
expect(mockSessionManager.claimPR).toHaveBeenCalledWith("app-1", "123", {
|
||||
assignOnGithub: undefined,
|
||||
});
|
||||
expect(scmPreflight).toHaveBeenCalledTimes(1);
|
||||
const ctx = scmPreflight.mock.calls[0]?.[0] as { intent: { willClaimExistingPR: boolean } };
|
||||
expect(ctx.intent.willClaimExistingPR).toBe(true);
|
||||
});
|
||||
|
||||
it("skips gh auth check when tracker is not github", async () => {
|
||||
const fakeSession: Session = {
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "spawning",
|
||||
activity: null,
|
||||
branch: null,
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: "/tmp/wt",
|
||||
runtimeHandle: { id: "hash-1", runtimeName: "tmux", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
};
|
||||
mockSessionManager.spawn.mockResolvedValue(fakeSession);
|
||||
it("does not iterate the tracker slot when no tracker is configured", async () => {
|
||||
const trackerPreflight = vi.fn().mockResolvedValue(undefined);
|
||||
mockRegistryGet.mockImplementation((slot: string) => {
|
||||
if (slot === "tracker") return { name: "github", preflight: trackerPreflight };
|
||||
return null;
|
||||
});
|
||||
|
||||
const projects = (mockConfigRef.current as Record<string, unknown>).projects as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
projects["my-app"].tracker = { plugin: "linear" };
|
||||
|
||||
// tmux check passes — gh should never be called
|
||||
mockExec.mockResolvedValue({ stdout: "tmux 3.3a", stderr: "" });
|
||||
// Project intentionally has no tracker configured.
|
||||
mockSessionManager.spawn.mockResolvedValue(makeFakeSession());
|
||||
|
||||
await program.parseAsync(["node", "test", "spawn"]);
|
||||
|
||||
// Should only call tmux -V, not gh
|
||||
expect(mockExec).toHaveBeenCalledWith("tmux", ["-V"]);
|
||||
expect(mockExec).not.toHaveBeenCalledWith("gh", expect.anything());
|
||||
expect(mockSessionManager.spawn).toHaveBeenCalled();
|
||||
expect(trackerPreflight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("distinguishes gh not installed from gh not authenticated", async () => {
|
||||
it("collects every plugin's preflight failure into one combined error", async () => {
|
||||
const runtimePreflight = vi.fn().mockRejectedValue(new Error("tmux is not installed"));
|
||||
const trackerPreflight = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("GitHub CLI is not authenticated. Run: gh auth login"));
|
||||
mockRegistryGet.mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return { name: "tmux", preflight: runtimePreflight };
|
||||
if (slot === "tracker") return { name: "github", preflight: trackerPreflight };
|
||||
return null;
|
||||
});
|
||||
|
||||
const projects = (mockConfigRef.current as Record<string, unknown>).projects as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
projects["my-app"].tracker = { plugin: "github" };
|
||||
|
||||
// tmux passes, gh --version fails (not installed)
|
||||
mockExec
|
||||
.mockResolvedValueOnce({ stdout: "tmux 3.3a", stderr: "" }) // tmux -V
|
||||
.mockRejectedValueOnce(new Error("ENOENT")); // gh --version fails
|
||||
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow("process.exit(1)");
|
||||
|
||||
await expect(program.parseAsync(["node", "test", "spawn"])).rejects.toThrow(
|
||||
"process.exit(1)",
|
||||
);
|
||||
// Both preflights ran (collect-all, not fail-fast).
|
||||
expect(runtimePreflight).toHaveBeenCalled();
|
||||
expect(trackerPreflight).toHaveBeenCalled();
|
||||
|
||||
const errors = vi
|
||||
.mocked(console.error)
|
||||
.mock.calls.map((c) => String(c[0]))
|
||||
.join("\n");
|
||||
expect(errors).toContain("not installed");
|
||||
expect(errors).not.toContain("not authenticated");
|
||||
expect(errors).toContain("2 preflight checks failed");
|
||||
expect(errors).toContain("tmux is not installed");
|
||||
expect(errors).toContain("gh auth login");
|
||||
expect(mockSessionManager.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -175,7 +175,6 @@ vi.mock("../../src/lib/preflight.js", () => ({
|
|||
preflight: {
|
||||
checkPort: vi.fn(),
|
||||
checkBuilt: vi.fn(),
|
||||
checkTmux: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,10 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { mockExec, mockIsPortAvailable, mockExistsSync } = vi.hoisted(() => ({
|
||||
mockExec: vi.fn(),
|
||||
const { mockIsPortAvailable, mockExistsSync } = vi.hoisted(() => ({
|
||||
mockIsPortAvailable: vi.fn(),
|
||||
mockExistsSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/shell.js", () => ({
|
||||
exec: mockExec,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/lib/web-dir.js", () => ({
|
||||
isPortAvailable: mockIsPortAvailable,
|
||||
}));
|
||||
|
|
@ -26,7 +21,6 @@ vi.mock("../../src/lib/dashboard-rebuild.js", () => ({
|
|||
import { preflight } from "../../src/lib/preflight.js";
|
||||
|
||||
beforeEach(() => {
|
||||
mockExec.mockReset();
|
||||
mockIsPortAvailable.mockReset();
|
||||
mockExistsSync.mockReset();
|
||||
});
|
||||
|
|
@ -130,65 +124,5 @@ describe("preflight.checkBuilt", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("preflight.checkTmux", () => {
|
||||
it("passes when tmux is already installed", async () => {
|
||||
mockExec.mockResolvedValue({ stdout: "tmux 3.3a", stderr: "" });
|
||||
await expect(preflight.checkTmux()).resolves.toBeUndefined();
|
||||
expect(mockExec).toHaveBeenCalledWith("tmux", ["-V"]);
|
||||
});
|
||||
|
||||
it("throws with install instructions when tmux is missing", async () => {
|
||||
mockExec.mockRejectedValue(new Error("ENOENT"));
|
||||
const err = await preflight.checkTmux().catch((e: Error) => e);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toContain("tmux is not installed");
|
||||
expect(err.message).toContain("Install it:");
|
||||
expect(mockExec).toHaveBeenCalledTimes(1);
|
||||
expect(mockExec).toHaveBeenCalledWith("tmux", ["-V"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preflight.checkGhAuth", () => {
|
||||
it("passes when gh is installed and authenticated", async () => {
|
||||
mockExec.mockResolvedValue({ stdout: "ok", stderr: "" });
|
||||
await expect(preflight.checkGhAuth()).resolves.toBeUndefined();
|
||||
expect(mockExec).toHaveBeenCalledWith("gh", ["--version"]);
|
||||
expect(mockExec).toHaveBeenCalledWith("gh", ["auth", "status"]);
|
||||
});
|
||||
|
||||
it("throws 'not installed' when gh is missing (ENOENT)", async () => {
|
||||
mockExec.mockRejectedValue(new Error("ENOENT"));
|
||||
await expect(preflight.checkGhAuth()).rejects.toThrow(
|
||||
"GitHub CLI (gh) is not installed",
|
||||
);
|
||||
// Should only call --version, not auth status
|
||||
expect(mockExec).toHaveBeenCalledTimes(1);
|
||||
expect(mockExec).toHaveBeenCalledWith("gh", ["--version"]);
|
||||
});
|
||||
|
||||
it("throws 'not authenticated' when gh exists but auth fails", async () => {
|
||||
mockExec
|
||||
.mockResolvedValueOnce({ stdout: "gh version 2.40", stderr: "" }) // --version succeeds
|
||||
.mockRejectedValueOnce(new Error("not logged in")); // auth status fails
|
||||
await expect(preflight.checkGhAuth()).rejects.toThrow(
|
||||
"GitHub CLI is not authenticated",
|
||||
);
|
||||
expect(mockExec).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("includes correct fix instructions for each failure", async () => {
|
||||
// Not installed → install link
|
||||
mockExec.mockRejectedValue(new Error("ENOENT"));
|
||||
await expect(preflight.checkGhAuth()).rejects.toThrow(
|
||||
"https://cli.github.com/",
|
||||
);
|
||||
|
||||
mockExec.mockReset();
|
||||
|
||||
// Not authenticated → auth login
|
||||
mockExec
|
||||
.mockResolvedValueOnce({ stdout: "gh version 2.40", stderr: "" })
|
||||
.mockRejectedValueOnce(new Error("not logged in"));
|
||||
await expect(preflight.checkGhAuth()).rejects.toThrow("gh auth login");
|
||||
});
|
||||
});
|
||||
// checkTmux + checkGhAuth moved into the runtime-tmux / tracker-github / scm-github
|
||||
// plugins as their own preflight() methods. See those plugins' tests for coverage.
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import {
|
|||
resolveSpawnTarget,
|
||||
TERMINAL_STATUSES,
|
||||
type OrchestratorConfig,
|
||||
type PreflightContext,
|
||||
} from "@aoagents/ao-core";
|
||||
import { DEFAULT_PORT } from "../lib/constants.js";
|
||||
import { exec } from "../lib/shell.js";
|
||||
import { banner } from "../lib/format.js";
|
||||
import { getSessionManager } from "../lib/create-session-manager.js";
|
||||
import { preflight } from "../lib/preflight.js";
|
||||
import { getPluginRegistry, getSessionManager } from "../lib/create-session-manager.js";
|
||||
import { findProjectForDirectory } from "../lib/project-resolution.js";
|
||||
import { getRunning } from "../lib/running-state.js";
|
||||
import { projectSessionUrl } from "../lib/routes.js";
|
||||
|
|
@ -51,6 +51,49 @@ function autoDetectProject(config: OrchestratorConfig): string {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-throwing variant — returns null when the project can't be resolved
|
||||
* unambiguously. Used to feed `resolveSpawnTarget`'s fallback parameter so
|
||||
* the prefix/no-prefix and issue/no-issue paths share one code path.
|
||||
*/
|
||||
function tryAutoDetectProject(config: OrchestratorConfig): string | null {
|
||||
try {
|
||||
return autoDetectProject(config);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the project + issue from a single optional CLI argument.
|
||||
*
|
||||
* Single source of truth for the four cases:
|
||||
* - `ao spawn` → auto-detect project, no issue
|
||||
* - `ao spawn 42` → auto-detect project, issue=42
|
||||
* - `ao spawn xid/42` → prefix match, issue=42
|
||||
* - `ao spawn x402-identity/42` → exact projectId match, issue=42
|
||||
*
|
||||
* Throws (via autoDetectProject) when the project can't be resolved — the
|
||||
* caller wraps in one try/catch instead of duplicating it across branches.
|
||||
*/
|
||||
function resolveProjectAndIssue(
|
||||
config: OrchestratorConfig,
|
||||
issue: string | undefined,
|
||||
): { projectId: string; issueId?: string } {
|
||||
const fallback = tryAutoDetectProject(config);
|
||||
if (issue) {
|
||||
const target = resolveSpawnTarget(config.projects, issue, fallback ?? undefined);
|
||||
if (target) return { projectId: target.projectId, issueId: target.issueId };
|
||||
autoDetectProject(config); // throws with the real error message
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
if (!fallback) {
|
||||
autoDetectProject(config); // throws
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
return { projectId: fallback };
|
||||
}
|
||||
|
||||
interface SpawnClaimOptions {
|
||||
claimPr?: string;
|
||||
assignOnGithub?: boolean;
|
||||
|
|
@ -84,8 +127,16 @@ async function warnIfAONotRunning(projectId: string): Promise<void> {
|
|||
|
||||
/**
|
||||
* Run pre-flight checks for a project once, before any sessions are spawned.
|
||||
* Validates runtime and tracker prerequisites so failures surface immediately
|
||||
* rather than repeating per-session in a batch.
|
||||
*
|
||||
* Iterates the plugins selected for this spawn and calls each one's optional
|
||||
* `preflight()`. Plugins own their own prerequisites (binary present, auth
|
||||
* configured, etc.) so this CLI helper does not need to know which plugin
|
||||
* needs which tool. Adding a new runtime/tracker/scm plugin only requires
|
||||
* the plugin to declare its own preflight — no edits here.
|
||||
*
|
||||
* Collects every plugin's failure rather than aborting at the first one, so a
|
||||
* user with multiple broken prerequisites (e.g. tmux missing AND gh logged
|
||||
* out) sees both errors in a single run instead of fixing one and re-invoking.
|
||||
*/
|
||||
async function runSpawnPreflight(
|
||||
config: OrchestratorConfig,
|
||||
|
|
@ -93,15 +144,54 @@ async function runSpawnPreflight(
|
|||
options?: SpawnClaimOptions,
|
||||
): Promise<void> {
|
||||
const project = config.projects[projectId];
|
||||
const runtime = project?.runtime ?? config.defaults.runtime;
|
||||
if (runtime === "tmux") {
|
||||
await preflight.checkTmux();
|
||||
if (!project) return;
|
||||
|
||||
const ctx: PreflightContext = {
|
||||
project,
|
||||
intent: {
|
||||
role: "worker",
|
||||
willClaimExistingPR: !!options?.claimPr,
|
||||
},
|
||||
};
|
||||
|
||||
const registry = await getPluginRegistry(config);
|
||||
// DefaultPluginsSchema (config.ts) defaults runtime/agent/workspace via
|
||||
// .default(), so these are guaranteed strings — no literal fallback needed.
|
||||
const runtimeName = project.runtime ?? config.defaults.runtime;
|
||||
const agentName = project.agent ?? config.defaults.agent;
|
||||
const workspaceName = project.workspace ?? config.defaults.workspace;
|
||||
const trackerName = project.tracker?.plugin;
|
||||
const scmName = project.scm?.plugin;
|
||||
|
||||
// Only iterate plugins that the spawn will actually exercise. SCM is
|
||||
// skipped unless the user passed --claim-pr; otherwise an unconfigured
|
||||
// gh auth would block spawns that don't touch PRs.
|
||||
const candidates: Array<unknown> = [
|
||||
registry.get("runtime", runtimeName),
|
||||
registry.get("agent", agentName),
|
||||
registry.get("workspace", workspaceName),
|
||||
trackerName ? registry.get("tracker", trackerName) : null,
|
||||
options?.claimPr && scmName ? registry.get("scm", scmName) : null,
|
||||
];
|
||||
|
||||
const errors: Error[] = [];
|
||||
for (const plugin of candidates) {
|
||||
const preflight = (plugin as { preflight?: (ctx: PreflightContext) => Promise<void> } | null)
|
||||
?.preflight;
|
||||
if (!preflight) continue;
|
||||
try {
|
||||
await preflight.call(plugin, ctx);
|
||||
} catch (err) {
|
||||
errors.push(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
const needsGitHubAuth =
|
||||
project?.tracker?.plugin === "github" ||
|
||||
(options?.claimPr && project?.scm?.plugin === "github");
|
||||
if (needsGitHubAuth) {
|
||||
await preflight.checkGhAuth();
|
||||
|
||||
if (errors.length === 1) throw errors[0];
|
||||
if (errors.length > 1) {
|
||||
throw new Error(
|
||||
`${errors.length} preflight checks failed:\n` +
|
||||
errors.map((e, i) => ` ${i + 1}. ${e.message}`).join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +203,7 @@ async function spawnSession(
|
|||
agent?: string,
|
||||
claimOptions?: SpawnClaimOptions,
|
||||
prompt?: string,
|
||||
): Promise<string> {
|
||||
): Promise<void> {
|
||||
const spinner = ora("Creating session").start();
|
||||
|
||||
try {
|
||||
|
|
@ -181,7 +271,6 @@ async function spawnSession(
|
|||
|
||||
// Output for scripting
|
||||
console.log(`SESSION=${session.id}`);
|
||||
return session.id;
|
||||
} catch (err) {
|
||||
spinner.fail("Failed to create or initialize session");
|
||||
throw err;
|
||||
|
|
@ -228,29 +317,11 @@ export function registerSpawn(program: Command): void {
|
|||
const config = loadConfig();
|
||||
let projectId: string;
|
||||
let issueId: string | undefined;
|
||||
|
||||
if (issue) {
|
||||
const prefixed = resolveSpawnTarget(config.projects, issue);
|
||||
if (prefixed) {
|
||||
projectId = prefixed.projectId;
|
||||
issueId = prefixed.issueId;
|
||||
} else {
|
||||
issueId = issue;
|
||||
try {
|
||||
projectId = autoDetectProject(config);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No args: auto-detect project, no issue
|
||||
try {
|
||||
projectId = autoDetectProject(config);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
({ projectId, issueId } = resolveProjectAndIssue(config, issue));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!opts.claimPr && opts.assignOnGithub) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
/**
|
||||
* Pre-flight checks for `ao start` and `ao spawn`.
|
||||
* Pre-flight checks for `ao start` (port + dashboard build artifacts).
|
||||
*
|
||||
* Validates runtime prerequisites before entering the main command flow,
|
||||
* giving clear errors instead of cryptic failures.
|
||||
* Tool/auth checks for `ao spawn` live on each plugin's `preflight()` method.
|
||||
* Adding a new runtime/tracker/scm therefore doesn't require editing this
|
||||
* file — the plugin declares its own prereqs.
|
||||
*
|
||||
* All checks throw on failure so callers can catch and handle uniformly.
|
||||
*/
|
||||
|
|
@ -10,7 +11,6 @@
|
|||
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";
|
||||
|
||||
/**
|
||||
|
|
@ -76,49 +76,7 @@ function findPackageUp(startDir: string, ...segments: string[]): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that tmux is installed.
|
||||
* Throws with platform-appropriate manual install instructions when missing.
|
||||
*/
|
||||
async function checkTmux(): Promise<void> {
|
||||
try {
|
||||
await exec("tmux", ["-V"]);
|
||||
return;
|
||||
} catch {
|
||||
// tmux not found
|
||||
}
|
||||
|
||||
const hint =
|
||||
process.platform === "darwin"
|
||||
? "brew install tmux"
|
||||
: process.platform === "win32"
|
||||
? "tmux is not available on Windows. Use WSL: wsl --install, then: sudo apt install tmux"
|
||||
: "sudo apt install tmux (Debian/Ubuntu) or sudo dnf install tmux (Fedora)";
|
||||
throw new Error(`tmux is not installed. Install it: ${hint}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the GitHub CLI is installed and authenticated.
|
||||
* Distinguishes between "not installed" and "not authenticated"
|
||||
* so the user gets the right troubleshooting guidance.
|
||||
*/
|
||||
async function checkGhAuth(): Promise<void> {
|
||||
try {
|
||||
await exec("gh", ["--version"]);
|
||||
} catch {
|
||||
throw new Error("GitHub CLI (gh) is not installed. Install it: https://cli.github.com/");
|
||||
}
|
||||
|
||||
try {
|
||||
await exec("gh", ["auth", "status"]);
|
||||
} catch {
|
||||
throw new Error("GitHub CLI is not authenticated. Run: gh auth login");
|
||||
}
|
||||
}
|
||||
|
||||
export const preflight = {
|
||||
checkPort,
|
||||
checkBuilt,
|
||||
checkTmux,
|
||||
checkGhAuth,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { memoizeAsync, _clearProcessCacheForTests } from "../process-cache.js";
|
||||
|
||||
beforeEach(() => {
|
||||
_clearProcessCacheForTests();
|
||||
});
|
||||
|
||||
describe("memoizeAsync", () => {
|
||||
it("runs the underlying fn only once for a given key", async () => {
|
||||
const fn = vi.fn().mockResolvedValue("ok");
|
||||
|
||||
await memoizeAsync("k", fn);
|
||||
await memoizeAsync("k", fn);
|
||||
await memoizeAsync("k", fn);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns the same resolved value across calls", async () => {
|
||||
const fn = vi.fn().mockResolvedValue({ value: 42 });
|
||||
|
||||
const a = await memoizeAsync("k", fn);
|
||||
const b = await memoizeAsync("k", fn);
|
||||
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("uses different keys for different work", async () => {
|
||||
const fnA = vi.fn().mockResolvedValue("a");
|
||||
const fnB = vi.fn().mockResolvedValue("b");
|
||||
|
||||
expect(await memoizeAsync("a", fnA)).toBe("a");
|
||||
expect(await memoizeAsync("b", fnB)).toBe("b");
|
||||
expect(fnA).toHaveBeenCalledTimes(1);
|
||||
expect(fnB).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("caches rejections too — failed checks don't re-run within a process", async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(memoizeAsync("k", fn)).rejects.toThrow("boom");
|
||||
await expect(memoizeAsync("k", fn)).rejects.toThrow("boom");
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns the in-flight promise to concurrent callers (no double-fire)", async () => {
|
||||
let resolveFn: (v: string) => void = () => {};
|
||||
const fn = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveFn = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const a = memoizeAsync("k", fn);
|
||||
const b = memoizeAsync("k", fn);
|
||||
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFn("done");
|
||||
expect(await a).toBe("done");
|
||||
expect(await b).toBe("done");
|
||||
});
|
||||
});
|
||||
|
|
@ -121,6 +121,10 @@ export {
|
|||
export { createSessionManager } from "./session-manager.js";
|
||||
export type { SessionManagerDeps } from "./session-manager.js";
|
||||
|
||||
// Process-scoped async memoization — used by plugins to dedupe shared
|
||||
// prerequisite checks (e.g. multiple github plugins checking gh auth).
|
||||
export { memoizeAsync, _clearProcessCacheForTests } from "./process-cache.js";
|
||||
|
||||
// Lifecycle manager — state machine + reaction engine
|
||||
export { createLifecycleManager } from "./lifecycle-manager.js";
|
||||
export type { LifecycleManagerDeps } from "./lifecycle-manager.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Process-scoped async memoization for expensive checks shared across plugins.
|
||||
*
|
||||
* Use cases: prerequisite checks (binary present, auth valid) that multiple
|
||||
* plugins want to perform but only need to actually run once per CLI
|
||||
* invocation. Cache key chooses the dedup boundary — plugins that share a
|
||||
* key share the result.
|
||||
*
|
||||
* Both successes and failures are cached: if a check fails the user must fix
|
||||
* the underlying issue and re-run, so re-checking within the same process is
|
||||
* pointless and would muddy the error stream with duplicate messages.
|
||||
*
|
||||
* **Key namespacing convention:**
|
||||
*
|
||||
* The cache is shared across every caller in the process, so two plugins
|
||||
* passing the same key are explicitly opting into shared state. That is the
|
||||
* intended use for cross-cutting checks like the `gh` CLI auth status (used
|
||||
* by both `tracker-github` and `scm-github`).
|
||||
*
|
||||
* For plugin-internal caching (where you do *not* want sharing), namespace
|
||||
* the key with your plugin name to avoid silent collisions:
|
||||
* - shared cross-plugin check: `"gh-cli-auth"` (intentional sharing)
|
||||
* - plugin-internal check: `"tracker-github:rate-limit-check"`
|
||||
*
|
||||
* If two plugins use the same key for semantically different work, callers
|
||||
* will silently receive each other's resolved values — a debugging nightmare.
|
||||
* When in doubt, namespace.
|
||||
*/
|
||||
|
||||
const cache = new Map<string, Promise<unknown>>();
|
||||
|
||||
export function memoizeAsync<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
let cached = cache.get(key);
|
||||
if (!cached) {
|
||||
cached = fn();
|
||||
cache.set(key, cached);
|
||||
}
|
||||
return cached as Promise<T>;
|
||||
}
|
||||
|
||||
/** Test-only — clears the process cache. */
|
||||
export function _clearProcessCacheForTests(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
|
@ -409,6 +409,13 @@ export interface Runtime {
|
|||
|
||||
/** Get info needed to attach a human to this session (for Terminal plugin) */
|
||||
getAttachInfo?(handle: RuntimeHandle): Promise<AttachInfo>;
|
||||
|
||||
/**
|
||||
* Optional: validate that this runtime's prerequisites are present before
|
||||
* it is exercised by `ao spawn`. Throw with an actionable, human-readable
|
||||
* message; the CLI catches and formats the error.
|
||||
*/
|
||||
preflight?(context: PreflightContext): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RuntimeCreateConfig {
|
||||
|
|
@ -540,6 +547,12 @@ export interface Agent {
|
|||
* `getActivityState` already reads richer data from the agent's own session files.
|
||||
*/
|
||||
recordActivity?(session: Session, terminalOutput: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Optional: validate that this agent's prerequisites are present before
|
||||
* it is exercised by `ao spawn`. Throw with an actionable error message.
|
||||
*/
|
||||
preflight?(context: PreflightContext): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AgentLaunchConfig {
|
||||
|
|
@ -640,6 +653,12 @@ export interface Workspace {
|
|||
|
||||
/** Optional: restore a workspace (e.g. recreate a worktree for an existing branch) */
|
||||
restore?(config: WorkspaceCreateConfig, workspacePath: string): Promise<WorkspaceInfo>;
|
||||
|
||||
/**
|
||||
* Optional: validate that this workspace's prerequisites (e.g. git in PATH,
|
||||
* write access to the worktree root) are present before `ao spawn`.
|
||||
*/
|
||||
preflight?(context: PreflightContext): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WorkspaceCreateConfig {
|
||||
|
|
@ -694,6 +713,13 @@ export interface Tracker {
|
|||
|
||||
/** Optional: create a new issue */
|
||||
createIssue?(input: CreateIssueInput, project: ProjectConfig): Promise<Issue>;
|
||||
|
||||
/**
|
||||
* Optional: validate that this tracker's prerequisites (auth tokens, CLI
|
||||
* tools) are present before `ao spawn` runs. Throw with an actionable
|
||||
* error message.
|
||||
*/
|
||||
preflight?(context: PreflightContext): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
|
|
@ -834,6 +860,14 @@ export interface SCM {
|
|||
* @returns Map keyed by "${owner}/${repo}#${number}" containing enrichment data
|
||||
*/
|
||||
enrichSessionsPRBatch?(prs: PRInfo[], observer?: BatchObserver, repos?: string[]): Promise<Map<string, PREnrichmentData>>;
|
||||
|
||||
/**
|
||||
* Optional: validate that this SCM's prerequisites (auth, CLI tools) are
|
||||
* present before `ao spawn` runs. Plugins should consult
|
||||
* `context.intent.willClaimExistingPR` and skip PR-write prereqs when the
|
||||
* spawn won't exercise them.
|
||||
*/
|
||||
preflight?(context: PreflightContext): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1664,6 +1698,32 @@ export interface PluginModule<T = unknown> {
|
|||
detect?(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to a plugin's `preflight()` method.
|
||||
*
|
||||
* Describes the **intent** of the operation (what it will do), not the CLI
|
||||
* flags that triggered it. Plugins should never know about specific flag
|
||||
* names — translate flags into intent at the CLI boundary so adding a new
|
||||
* flag doesn't ripple into every plugin that cares about a related operation.
|
||||
*/
|
||||
export interface PreflightContext {
|
||||
/** The project the operation runs against. */
|
||||
project: ProjectConfig;
|
||||
|
||||
/** What the operation will do. Plugins decide whether their prereqs apply. */
|
||||
intent: {
|
||||
/** Whether the spawn is for a worker session or the orchestrator. */
|
||||
role: "worker" | "orchestrator";
|
||||
|
||||
/**
|
||||
* Whether the operation will exercise SCM PR-write paths
|
||||
* (e.g. claiming an existing PR for the new session). When false, an SCM
|
||||
* plugin's preflight can skip PR-write prereqs.
|
||||
*/
|
||||
willClaimExistingPR: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SESSION METADATA
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -578,3 +578,25 @@ describe("runtime.getAttachInfo()", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime.preflight()", () => {
|
||||
it("resolves when `tmux -V` succeeds", async () => {
|
||||
mockTmuxSuccess("tmux 3.4");
|
||||
const runtime = create();
|
||||
await expect(runtime.preflight!({} as never)).resolves.toBeUndefined();
|
||||
expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", ["-V"], expectedTmuxOptions);
|
||||
});
|
||||
|
||||
it("throws with platform-specific install hint when tmux is missing", async () => {
|
||||
mockTmuxError("ENOENT");
|
||||
const runtime = create();
|
||||
const err = (await runtime.preflight!({} as never).catch((e: unknown) => e)) as Error;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toContain("tmux is not installed");
|
||||
expect(err.message).toContain("Install it:");
|
||||
// Hint must include something runnable for the host platform.
|
||||
if (process.platform === "darwin") expect(err.message).toContain("brew install tmux");
|
||||
else if (process.platform === "win32") expect(err.message).toContain("WSL");
|
||||
else expect(err.message).toMatch(/apt install tmux|dnf install tmux/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -190,6 +190,20 @@ export function create(): Runtime {
|
|||
command: `tmux attach -t ${handle.id}`,
|
||||
};
|
||||
},
|
||||
|
||||
async preflight(): Promise<void> {
|
||||
try {
|
||||
await execFileAsync("tmux", ["-V"], { timeout: TMUX_COMMAND_TIMEOUT_MS });
|
||||
} catch {
|
||||
const hint =
|
||||
process.platform === "darwin"
|
||||
? "brew install tmux"
|
||||
: process.platform === "win32"
|
||||
? "tmux is not available on Windows. Use WSL: wsl --install, then: sudo apt install tmux"
|
||||
: "sudo apt install tmux (Debian/Ubuntu) or sudo dnf install tmux (Fedora)";
|
||||
throw new Error(`tmux is not installed. Install it: ${hint}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import { promisify } from "node:util";
|
|||
import {
|
||||
CI_STATUS,
|
||||
execGhObserved,
|
||||
memoizeAsync,
|
||||
type PluginModule,
|
||||
type PreflightContext,
|
||||
type SCM,
|
||||
type SCMWebhookEvent,
|
||||
type SCMWebhookRequest,
|
||||
|
|
@ -1251,6 +1253,28 @@ function createGitHubSCM(): SCM {
|
|||
const batchResult = await enrichSessionsPRBatchImpl(prs, observer, repos);
|
||||
return batchResult.enrichment;
|
||||
},
|
||||
|
||||
async preflight(context: PreflightContext): Promise<void> {
|
||||
// SCM is only exercised at spawn time when --claim-pr is set. Skip the
|
||||
// gh-auth check otherwise so spawns that don't touch PRs don't require
|
||||
// gh credentials. Lifecycle polling has its own auth handling.
|
||||
if (!context.intent.willClaimExistingPR) return;
|
||||
// Memoize across plugins: shares the "gh-cli-auth" cache key with
|
||||
// tracker-github so spawns that touch both only run gh --version + gh
|
||||
// auth status once total, not twice.
|
||||
await memoizeAsync("gh-cli-auth", async () => {
|
||||
try {
|
||||
await execFileAsync("gh", ["--version"]);
|
||||
} catch {
|
||||
throw new Error("GitHub CLI (gh) is not installed. Install it: https://cli.github.com/");
|
||||
}
|
||||
try {
|
||||
await execFileAsync("gh", ["auth", "status"]);
|
||||
} catch {
|
||||
throw new Error("GitHub CLI is not authenticated. Run: gh auth login");
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ vi.mock("node:child_process", () => {
|
|||
});
|
||||
|
||||
import { create, manifest } from "../src/index.js";
|
||||
import { createActivitySignal, type PRInfo, type SCMWebhookRequest, type Session, type ProjectConfig } from "@aoagents/ao-core";
|
||||
import { _clearProcessCacheForTests, createActivitySignal, type PreflightContext, type PRInfo, type SCMWebhookRequest, type Session, type ProjectConfig } from "@aoagents/ao-core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
|
|
@ -1427,6 +1427,41 @@ describe("scm-github plugin", () => {
|
|||
expect(ghMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preflight", () => {
|
||||
function ctxWith(willClaim: boolean): PreflightContext {
|
||||
return { project, intent: { role: "worker", willClaimExistingPR: willClaim } };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_clearProcessCacheForTests();
|
||||
});
|
||||
|
||||
it("is a no-op when willClaimExistingPR is false (no gh calls made)", async () => {
|
||||
await expect(scm.preflight!(ctxWith(false))).resolves.toBeUndefined();
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks gh installed + authenticated when willClaimExistingPR is true", async () => {
|
||||
mockGhRaw("gh version 2.40.0");
|
||||
mockGhRaw("Logged in to github.com as alice");
|
||||
await expect(scm.preflight!(ctxWith(true))).resolves.toBeUndefined();
|
||||
expect(ghMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws 'not installed' when `gh --version` fails", async () => {
|
||||
mockGhError("ENOENT");
|
||||
const err = (await scm.preflight!(ctxWith(true)).catch((e: unknown) => e)) as Error;
|
||||
expect(err.message).toContain("GitHub CLI (gh) is not installed");
|
||||
});
|
||||
|
||||
it("throws 'not authenticated' when `gh auth status` fails", async () => {
|
||||
mockGhRaw("gh version 2.40.0");
|
||||
mockGhError("not logged in");
|
||||
const err = (await scm.preflight!(ctxWith(true)).catch((e: unknown) => e)) as Error;
|
||||
expect(err.message).toContain("not authenticated");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockGhRaw(stdout: string) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import {
|
||||
execGhObserved,
|
||||
memoizeAsync,
|
||||
type PluginModule,
|
||||
type Tracker,
|
||||
type Issue,
|
||||
|
|
@ -29,6 +30,26 @@ async function gh(args: string[]): Promise<string> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-scoped gh auth check shared with scm-github via the same cache key
|
||||
* (`gh-cli-auth`). Both plugins call into this — the second caller hits the
|
||||
* cached promise and adds zero subprocess overhead.
|
||||
*/
|
||||
async function checkGhCliAuth(): Promise<void> {
|
||||
return memoizeAsync("gh-cli-auth", async () => {
|
||||
try {
|
||||
await gh(["--version"]);
|
||||
} catch {
|
||||
throw new Error("GitHub CLI (gh) is not installed. Install it: https://cli.github.com/");
|
||||
}
|
||||
try {
|
||||
await gh(["auth", "status"]);
|
||||
} catch {
|
||||
throw new Error("GitHub CLI is not authenticated. Run: gh auth login");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getErrorText(err: unknown): string {
|
||||
if (!(err instanceof Error)) return "";
|
||||
|
||||
|
|
@ -415,6 +436,15 @@ function createGitHubTracker(): Tracker {
|
|||
|
||||
return tracker.getIssue(number, project);
|
||||
},
|
||||
|
||||
async preflight(): Promise<void> {
|
||||
// Memoize across plugins: tracker-github and scm-github both check the
|
||||
// same gh CLI / auth state. Sharing key "gh-cli-auth" via process-cache
|
||||
// means both plugins' preflights resolve to the same in-flight check
|
||||
// (or cached result) — halving execs on the happy path and giving one
|
||||
// error message instead of two on the failure path.
|
||||
await checkGhCliAuth();
|
||||
},
|
||||
};
|
||||
|
||||
return tracker;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ vi.mock("node:child_process", () => {
|
|||
});
|
||||
|
||||
import { create, manifest } from "../src/index.js";
|
||||
import type { ProjectConfig } from "@aoagents/ao-core";
|
||||
import {
|
||||
_clearProcessCacheForTests,
|
||||
type PreflightContext,
|
||||
type ProjectConfig,
|
||||
} from "@aoagents/ao-core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
|
|
@ -565,4 +569,39 @@ describe("tracker-github plugin", () => {
|
|||
).rejects.toThrow("Failed to parse issue URL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("preflight", () => {
|
||||
const ctx: PreflightContext = {
|
||||
project,
|
||||
intent: { role: "worker", willClaimExistingPR: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Process cache spans tests; clear so each one starts fresh.
|
||||
_clearProcessCacheForTests();
|
||||
});
|
||||
|
||||
it("resolves when gh is installed and authenticated", async () => {
|
||||
mockGhRaw("gh version 2.40.0"); // gh --version
|
||||
mockGhRaw("Logged in to github.com as alice"); // gh auth status
|
||||
await expect(tracker.preflight!(ctx)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws 'not installed' when `gh --version` fails", async () => {
|
||||
mockGhError("ENOENT");
|
||||
const err = (await tracker.preflight!(ctx).catch((e: unknown) => e)) as Error;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toContain("GitHub CLI (gh) is not installed");
|
||||
expect(err.message).toContain("https://cli.github.com/");
|
||||
});
|
||||
|
||||
it("throws 'not authenticated' when `gh auth status` fails", async () => {
|
||||
mockGhRaw("gh version 2.40.0");
|
||||
mockGhError("not logged in");
|
||||
const err = (await tracker.preflight!(ctx).catch((e: unknown) => e)) as Error;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toContain("not authenticated");
|
||||
expect(err.message).toContain("gh auth login");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue