mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(ida): align workspace discovery with readiness (#7863)
This commit is contained in:
1 parent
37827af591
commit
00bd92c56e
5 files changed
+228
-25
No files matched your search
+3
-20
@@ -8,10 +8,7 @@ import {
|
||||
} from "./IdaConfiguration.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { privateRuntimeRootCapability } from "../process/PrivateRuntimeRoot.js";
|
||||
import { constants } from "node:fs";
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { resolveIdaWorkspaceParent } from "./IdaWorkspace.js";
|
||||
import { inspectIdaWorkspaceReadiness } from "./IdaWorkspaceReadiness.js";
|
||||
import type { RetainedResourceScope } from "../application/AnalysisProvider.js";
|
||||
|
||||
/** Check the registration without launching IDA, and retain only the configuration file reference. */
|
||||
@@ -69,8 +66,8 @@ export const inspectIdaRegistration = async (
|
||||
const inspectPrivateWorkspace = async (
|
||||
workspaceRoot: string | undefined,
|
||||
): Promise<DoctorProviderCheck> => {
|
||||
const privacy = privateRuntimeRootCapability();
|
||||
const unusable = await workspaceRootFailure(workspaceRoot);
|
||||
const { privacy, unusable } =
|
||||
await inspectIdaWorkspaceReadiness(workspaceRoot);
|
||||
return {
|
||||
name: "private_workspace",
|
||||
ok: privacy.available && unusable === undefined,
|
||||
@@ -98,17 +95,3 @@ const inspectPrivateWorkspace = async (
|
||||
: "unsupported_host",
|
||||
};
|
||||
};
|
||||
|
||||
const workspaceRootFailure = async (
|
||||
root: string | undefined,
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const parent = await resolveIdaWorkspaceParent(root);
|
||||
if (!(await stat(parent)).isDirectory())
|
||||
return `IDA workspaceRoot ${root ?? "the system temporary directory"} is not a directory.`;
|
||||
await access(parent, constants.W_OK | constants.X_OK);
|
||||
return undefined;
|
||||
} catch (cause: unknown) {
|
||||
return `IDA workspaceRoot ${root ?? "the system temporary directory"} is not a usable directory: ${cause instanceof Error ? cause.message : String(cause)}`;
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { launchRegexMatchingLimitation } from "../process/RegexMatcher.js";
|
||||
import { AnalysisSettings } from "../application/AnalysisSettings.js";
|
||||
import { runtimeAnalysisConfig } from "../config/runtimeAnalysisSettings.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
|
||||
@@ -86,6 +86,60 @@ it("keeps a selected valid registration distinct from a live IDA connection", as
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["missing", "file", "directory", "omitted"] as const)(
|
||||
"reports headless workspace readiness without launching IDA: %s",
|
||||
async (kind) => {
|
||||
const root = await createTestTempDirectory("rea-ida-discovery-workspace-");
|
||||
const workspace = join(root, "workspace");
|
||||
if (kind === "file") await writeFile(workspace, "caller data");
|
||||
if (kind === "directory") await mkdir(workspace);
|
||||
const registration = join(root, "registration.json");
|
||||
await writeFile(
|
||||
registration,
|
||||
JSON.stringify({
|
||||
mode: "headless",
|
||||
command: "must-not-launch",
|
||||
...(kind === "omitted" ? {} : { workspaceRoot: workspace }),
|
||||
}),
|
||||
);
|
||||
const config = parseConfig({ REA_IDA_MCP_CONFIG: registration });
|
||||
if (!config.ok) throw config.error;
|
||||
const connect = vi.fn(() => {
|
||||
throw new Error("Readiness must not connect to IDA");
|
||||
});
|
||||
const provider = new IdaProvider(config.value, {}, connect);
|
||||
const before = await readdir(root);
|
||||
try {
|
||||
const snapshot = await provider.inspectReadinessSnapshot();
|
||||
if (kind === "missing" || kind === "file") {
|
||||
expect(snapshot.availability).toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "open_options_invalid",
|
||||
reason: expect.stringContaining(workspace),
|
||||
diagnostics: {
|
||||
workspace_root: workspace,
|
||||
live_connection_probed: false,
|
||||
configuration_remediation: expect.stringContaining("workspaceRoot"),
|
||||
},
|
||||
});
|
||||
expect(snapshot.capabilities.every(({ available }) => !available)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
snapshot.capabilities.find(
|
||||
({ operation }) => operation === "procedure_callers",
|
||||
)?.reason,
|
||||
).toContain("direct call edges");
|
||||
} else expect(snapshot.availability.status).toBe("available");
|
||||
expect(await readdir(root)).toEqual(before);
|
||||
expect(connect).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
const closed = await provider.closeOwnedResources();
|
||||
if (!closed.ok) throw closed.error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
describe("IDA provider target support", () => {
|
||||
it("declines caller-selected gamecube-dol targets", () => {
|
||||
const config = parseConfig({});
|
||||
|
||||
+46
-4
@@ -46,11 +46,12 @@ import {
|
||||
IDA_PROVIDER_IDENTITY,
|
||||
} from "./IdaProviderCapabilities.js";
|
||||
import { IdaSessionClient } from "./IdaSessionClient.js";
|
||||
import { privateRuntimeRootCapability } from "../process/PrivateRuntimeRoot.js";
|
||||
import { inspectIdaWorkspaceReadiness } from "./IdaWorkspaceReadiness.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import type { ProviderInspectionScope } from "../application/ProviderInspectionScope.js";
|
||||
import type { ProviderRejectionCode } from "../domain/providerSelection.js";
|
||||
|
||||
/** Bring-your-own IDA MCP candidate; discovery performs no provider startup. */
|
||||
export class IdaProvider implements AnalysisProviderCandidate {
|
||||
@@ -191,6 +192,20 @@ export class IdaProvider implements AnalysisProviderCandidate {
|
||||
registration_path: this.config.idaMcpConfigPath ?? null,
|
||||
live_connection_probed: false,
|
||||
};
|
||||
const unavailableCapabilities = (
|
||||
reason: string,
|
||||
code: ProviderRejectionCode,
|
||||
) =>
|
||||
capabilities.map((capability) =>
|
||||
capability.available
|
||||
? {
|
||||
...capability,
|
||||
available: false as const,
|
||||
reason,
|
||||
availabilityCode: code,
|
||||
}
|
||||
: capability,
|
||||
);
|
||||
if (!config.ok) {
|
||||
const failure = projectAnalysisError(config.error);
|
||||
return {
|
||||
@@ -207,11 +222,18 @@ export class IdaProvider implements AnalysisProviderCandidate {
|
||||
configuration_remediation: failure.remediation.action,
|
||||
},
|
||||
},
|
||||
capabilities,
|
||||
capabilities: unavailableCapabilities(
|
||||
failure.message,
|
||||
this.config.idaMcpConfigPath === undefined
|
||||
? "not_configured"
|
||||
: "open_options_invalid",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (config.value.mode === "headless") {
|
||||
const privacy = privateRuntimeRootCapability();
|
||||
const { privacy, unusable } = await inspectIdaWorkspaceReadiness(
|
||||
config.value.workspaceRoot,
|
||||
);
|
||||
if (!privacy.available)
|
||||
return {
|
||||
availability: {
|
||||
@@ -228,7 +250,27 @@ export class IdaProvider implements AnalysisProviderCandidate {
|
||||
},
|
||||
},
|
||||
},
|
||||
capabilities,
|
||||
capabilities: unavailableCapabilities(privacy.reason, privacy.code),
|
||||
};
|
||||
if (unusable !== undefined)
|
||||
return {
|
||||
availability: {
|
||||
status: "unavailable" as const,
|
||||
code: "open_options_invalid" as const,
|
||||
reason: unusable,
|
||||
diagnostics: {
|
||||
...registrationDiagnostics,
|
||||
mode: "headless",
|
||||
workspace_root: config.value.workspaceRoot ?? null,
|
||||
private_workspace_available: false,
|
||||
configuration_remediation:
|
||||
"Set workspaceRoot to an existing directory REA can create entries in, or omit it to use the system temporary directory; see docs/ida-provider.md.",
|
||||
},
|
||||
},
|
||||
capabilities: unavailableCapabilities(
|
||||
unusable,
|
||||
"open_options_invalid",
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { constants } from "node:fs";
|
||||
import { access, stat } from "node:fs/promises";
|
||||
|
||||
import { privateRuntimeRootCapability } from "../process/PrivateRuntimeRoot.js";
|
||||
import { resolveIdaWorkspaceParent } from "./IdaWorkspace.js";
|
||||
|
||||
/** Inspect local prerequisites without creating a workspace or launching IDA. */
|
||||
export const inspectIdaWorkspaceReadiness = async (
|
||||
root: string | undefined,
|
||||
) => {
|
||||
const privacy = privateRuntimeRootCapability();
|
||||
let unusable: string | undefined;
|
||||
try {
|
||||
const parent = await resolveIdaWorkspaceParent(root);
|
||||
if (!(await stat(parent)).isDirectory())
|
||||
unusable = `IDA workspaceRoot ${root ?? "the system temporary directory"} is not a directory.`;
|
||||
else await access(parent, constants.W_OK | constants.X_OK);
|
||||
} catch (cause: unknown) {
|
||||
unusable = `IDA workspaceRoot ${root ?? "the system temporary directory"} is not a usable directory: ${cause instanceof Error ? cause.message : String(cause)}`;
|
||||
}
|
||||
return { privacy, unusable };
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { access, chmod, mkdir, readdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.skipIf(process.platform === "win32")(
|
||||
"reports the same headless workspace readiness through discovery and doctor",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-ida-discovery-cli-");
|
||||
const writable = join(root, "writable");
|
||||
const file = join(root, "file");
|
||||
const marker = join(root, "launched");
|
||||
const command = join(root, "fixture-ida");
|
||||
await mkdir(writable);
|
||||
await writeFile(file, "caller data");
|
||||
await writeFile(command, `#!/bin/sh\nprintf launched > '${marker}'\n`);
|
||||
await chmod(command, 0o700);
|
||||
const registration = join(root, "ida.json");
|
||||
const before = await readdir(root);
|
||||
for (const [workspaceRoot, usable] of [
|
||||
[join(root, "missing"), false],
|
||||
[file, false],
|
||||
[writable, true],
|
||||
[undefined, true],
|
||||
] as const) {
|
||||
await writeFile(
|
||||
registration,
|
||||
JSON.stringify({
|
||||
mode: "headless",
|
||||
command,
|
||||
...(workspaceRoot === undefined ? {} : { workspaceRoot }),
|
||||
}),
|
||||
);
|
||||
const environment = {
|
||||
HOME: root,
|
||||
REA_IDA_MCP_CONFIG: registration,
|
||||
};
|
||||
for (const command of ["providers", "capabilities"] as const) {
|
||||
const result = await cli.run({
|
||||
arguments: [command, "--json"],
|
||||
environment,
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.json).toMatchObject({
|
||||
analysis_provider_candidates: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
provider: expect.objectContaining({ id: "ida" }),
|
||||
availability: usable
|
||||
? expect.objectContaining({ status: "available" })
|
||||
: expect.objectContaining({
|
||||
status: "unavailable",
|
||||
code: "open_options_invalid",
|
||||
reason: expect.stringContaining(workspaceRoot),
|
||||
}),
|
||||
...(usable
|
||||
? {}
|
||||
: {
|
||||
capabilities: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
groups: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
operations: expect.arrayContaining([
|
||||
"list_procedures",
|
||||
]),
|
||||
available: false,
|
||||
reason: expect.stringContaining(workspaceRoot),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
operations: expect.arrayContaining([
|
||||
"procedure_callers",
|
||||
]),
|
||||
available: false,
|
||||
reason:
|
||||
expect.stringContaining("direct call edges"),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
}
|
||||
const doctor = await cli.run({
|
||||
arguments: ["doctor", "--provider", "ida", "--json"],
|
||||
environment,
|
||||
});
|
||||
expect(doctor.exitCode, doctor.stderr).toBe(usable ? 0 : 1);
|
||||
expect(doctor.json).toMatchObject({
|
||||
providerInspections: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "ida", available: usable }),
|
||||
]),
|
||||
});
|
||||
}
|
||||
expect((await readdir(root)).filter((name) => name !== "ida.json")).toEqual(
|
||||
before,
|
||||
);
|
||||
await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user