mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 10:12:50 +00:00
refactor(binary): group session selection and snapshot ownership (#886)
This commit is contained in:
1 parent
ab985c6a10
commit
dd114d3980
59 files changed
+220
-190
No files matched your search
@@ -0,0 +1,29 @@
|
||||
/** Sentinel returned when an abort signal wins an asynchronous race. */
|
||||
export const ABORTED = Symbol("aborted");
|
||||
|
||||
/**
|
||||
* Await a promise without depending on the producer to observe cancellation.
|
||||
* The producer still receives its signal separately and remains responsible for
|
||||
* releasing any resources that it owns.
|
||||
*/
|
||||
export const waitForAbortable = async <Value>(
|
||||
pending: Promise<Value>,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<Value | typeof ABORTED> => {
|
||||
if (signal === undefined) return pending;
|
||||
let onAbort: (() => void) | undefined;
|
||||
const cancelled = new Promise<typeof ABORTED>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve(ABORTED);
|
||||
return;
|
||||
}
|
||||
onAbort = () => resolve(ABORTED);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
try {
|
||||
// Even an already-cancelled wait must own the producer's eventual rejection.
|
||||
return await Promise.race([cancelled, pending]);
|
||||
} finally {
|
||||
if (onAbort !== undefined) signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { AnalysisClient, ExecutionOptions } from "../AnalysisProvider.js";
|
||||
|
||||
/** Close one provider client and normalize unexpected adapter rejection. */
|
||||
export const closeAnalysisClient = async (
|
||||
client: AnalysisClient,
|
||||
providerId: string,
|
||||
options: Pick<ExecutionOptions, "progress"> & {
|
||||
readonly retainDocument?: boolean;
|
||||
} = {},
|
||||
): Promise<Result<null, AnalysisError>> => {
|
||||
try {
|
||||
if (client.closeWithOutcome !== undefined)
|
||||
return await client.closeWithOutcome(options);
|
||||
await client.close();
|
||||
return ok(null);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
providerId,
|
||||
["provider-client"],
|
||||
{ reason: "provider client close rejected unexpectedly" },
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
analysisProfileSchema,
|
||||
type AnalysisProfileCommitment,
|
||||
} from "../../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type { AnalysisProviderCandidate } from "../AnalysisProvider.js";
|
||||
import type { AnalysisProviderCandidateStatus } from "./AnalysisProviderRegistry.js";
|
||||
import { ABORTED, waitForAbortable } from "./AbortablePromise.js";
|
||||
|
||||
/** Profile-resolution truth for one discovered provider candidate. */
|
||||
export interface AnalysisProviderCandidateEvaluation {
|
||||
readonly candidate: AnalysisProviderCandidate;
|
||||
readonly status: AnalysisProviderCandidateStatus;
|
||||
readonly profile?: AnalysisProfileCommitment;
|
||||
readonly compatibility?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Resolve and validate one provider profile without starting its client. */
|
||||
export const evaluateAnalysisProviderCandidate = async (
|
||||
candidate: AnalysisProviderCandidate,
|
||||
status: AnalysisProviderCandidateStatus,
|
||||
target: BinaryTarget,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<
|
||||
Result<AnalysisProviderCandidateEvaluation, AnalysisCancelledError>
|
||||
> => {
|
||||
if (signalIsAborted(signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
let resolved: Awaited<ReturnType<typeof candidate.resolveAnalysisProfile>>;
|
||||
try {
|
||||
const resolution = await waitForAbortable(
|
||||
candidate.resolveAnalysisProfile(
|
||||
target,
|
||||
signal === undefined ? undefined : { signal },
|
||||
),
|
||||
signal,
|
||||
);
|
||||
if (resolution === ABORTED)
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
resolved = resolution;
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
return ok(
|
||||
rejectedProfile(candidate, status, "Provider profile resolution threw"),
|
||||
);
|
||||
}
|
||||
if (signalIsAborted(signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (!resolved.ok && resolved.error instanceof AnalysisCancelledError)
|
||||
return err(resolved.error);
|
||||
if (!resolved.ok)
|
||||
return ok(
|
||||
rejectedProfile(candidate, status, resolved.error.message, {
|
||||
error_tag: resolved.error._tag,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
if (resolved.value.profile === null)
|
||||
return ok(
|
||||
rejectedProfile(
|
||||
candidate,
|
||||
status,
|
||||
"Provider version or analysis profile could not be resolved",
|
||||
),
|
||||
);
|
||||
const profile = analysisProfileSchema.parse(resolved.value.profile);
|
||||
const compatibility = jsonObjectSchema.parse(resolved.value.compatibility);
|
||||
const identity = candidate.identity();
|
||||
if (
|
||||
profile.provider.id !== identity.id ||
|
||||
profile.provider.name !== identity.name ||
|
||||
(identity.version !== null &&
|
||||
profile.provider.version !== identity.version)
|
||||
)
|
||||
return ok(
|
||||
rejectedProfile(
|
||||
candidate,
|
||||
status,
|
||||
"Resolved analysis profile has mismatched provider identity",
|
||||
),
|
||||
);
|
||||
return ok({ candidate, status, profile, compatibility });
|
||||
} catch (cause: unknown) {
|
||||
void cause;
|
||||
return ok(
|
||||
rejectedProfile(
|
||||
candidate,
|
||||
status,
|
||||
"Provider returned an invalid analysis profile",
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const signalIsAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
|
||||
const rejectedProfile = (
|
||||
candidate: AnalysisProviderCandidate,
|
||||
status: AnalysisProviderCandidateStatus,
|
||||
reason: string,
|
||||
diagnostics: Readonly<Record<string, JsonValue>> = {},
|
||||
): AnalysisProviderCandidateEvaluation => ({
|
||||
candidate,
|
||||
status: {
|
||||
...status,
|
||||
availability: {
|
||||
status: "unavailable",
|
||||
code: "version_unresolved",
|
||||
reason,
|
||||
diagnostics: jsonObjectSchema.parse({
|
||||
...status.availability.diagnostics,
|
||||
...diagnostics,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AnalysisProviderRegistry,
|
||||
type AnalysisProviderBinding,
|
||||
} from "./AnalysisProviderRegistry.js";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisClient,
|
||||
type AnalysisProviderCandidate,
|
||||
type CapabilityDescriptor,
|
||||
type ProviderAvailability,
|
||||
type ProviderIdentity,
|
||||
type ProviderTargetSupport,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { ProviderSelectionError } from "../../domain/providerSelectionError.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
|
||||
const DATABASE_TARGET: BinaryTarget = {
|
||||
path: "/tmp/fixture.hop",
|
||||
sha256: "a".repeat(64),
|
||||
kind: "database",
|
||||
format: "analysis-database",
|
||||
};
|
||||
const ARTIFACT_TARGET: BinaryTarget = {
|
||||
path: "/tmp/app.asar",
|
||||
sha256: "b".repeat(64),
|
||||
kind: "archive",
|
||||
format: "asar",
|
||||
};
|
||||
|
||||
describe("analysis provider registry: discovery and selection", () => {
|
||||
it("admits overlapping operations, sorts discovery, and rejects duplicate IDs", () => {
|
||||
const alpha = candidate("alpha");
|
||||
const beta = candidate("beta");
|
||||
const registry = new AnalysisProviderRegistry([
|
||||
beta.provider,
|
||||
alpha.provider,
|
||||
]);
|
||||
|
||||
expect(registry.identities().map(({ id }) => id)).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
]);
|
||||
expect(
|
||||
registry.candidates().map(({ provider, targetSupport }) => ({
|
||||
id: provider.id,
|
||||
support: targetSupport.status,
|
||||
})),
|
||||
).toEqual([
|
||||
{ id: "alpha", support: "unknown" },
|
||||
{ id: "beta", support: "unknown" },
|
||||
]);
|
||||
expect(alpha.created).toEqual([]);
|
||||
expect(beta.created).toEqual([]);
|
||||
expect(
|
||||
() => new AnalysisProviderRegistry([alpha.provider, alpha.provider]),
|
||||
).toThrow(/Duplicate analysis provider ID: alpha/u);
|
||||
expect(
|
||||
() =>
|
||||
new AnalysisProviderRegistry([
|
||||
{
|
||||
...alpha.provider,
|
||||
capabilities: () => {
|
||||
const descriptor = capability(alpha.provider.identity());
|
||||
return [descriptor, descriptor];
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toThrow(/declares operation address_name more than once/u);
|
||||
expect(
|
||||
() =>
|
||||
new AnalysisProviderRegistry([
|
||||
{
|
||||
...alpha.provider,
|
||||
capabilities: () => [
|
||||
capability({
|
||||
...alpha.provider.identity(),
|
||||
name: "mismatched provider",
|
||||
}),
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toThrow(/published mismatched capability provenance/u);
|
||||
expect(
|
||||
() => new AnalysisProviderRegistry([candidate("auto").provider]),
|
||||
).toThrow(/Invalid analysis provider ID: auto/u);
|
||||
});
|
||||
|
||||
it("selects the sole usable candidate without creating a client", async () => {
|
||||
const alpha = candidate("alpha");
|
||||
const beta = candidate("beta", { available: false });
|
||||
const registry = new AnalysisProviderRegistry([
|
||||
beta.provider,
|
||||
alpha.provider,
|
||||
]);
|
||||
|
||||
const selected = await registry.select(DATABASE_TARGET);
|
||||
expect(selected.ok).toBe(true);
|
||||
if (!selected.ok) throw new Error("provider selection failed");
|
||||
if (selected.value.binding === null)
|
||||
throw new Error("expected a bound provider");
|
||||
expect(bindingProjection(selected.value.binding)).toEqual({
|
||||
id: "alpha",
|
||||
source: "auto-single-candidate",
|
||||
version: "1",
|
||||
});
|
||||
expect(selected.value.candidates).toMatchObject([
|
||||
{ provider: { id: "alpha", version: "1" }, selected: true },
|
||||
{
|
||||
provider: { id: "beta" },
|
||||
selected: false,
|
||||
availability: { status: "unavailable", code: "runtime_missing" },
|
||||
},
|
||||
]);
|
||||
expect(alpha.created).toEqual([]);
|
||||
expect(beta.created).toEqual([]);
|
||||
});
|
||||
|
||||
it("makes auto ambiguity explicit and honors request over environment", async () => {
|
||||
const alpha = candidate("alpha");
|
||||
const beta = candidate("beta");
|
||||
const registry = new AnalysisProviderRegistry(
|
||||
[alpha.provider, beta.provider],
|
||||
"beta",
|
||||
);
|
||||
|
||||
const environment = await registry.select(DATABASE_TARGET);
|
||||
expect(environment.ok).toBe(true);
|
||||
if (environment.ok)
|
||||
expect(environment.value.binding?.selectionSource).toBe("environment");
|
||||
expect(environment.ok && environment.value.binding?.identity.id).toBe(
|
||||
"beta",
|
||||
);
|
||||
|
||||
const request = await registry.select(DATABASE_TARGET, "alpha");
|
||||
expect(request.ok && request.value.binding?.identity.id).toBe("alpha");
|
||||
if (request.ok)
|
||||
expect(request.value.binding?.selectionSource).toBe("request");
|
||||
|
||||
const automatic = await registry.select(DATABASE_TARGET, "auto");
|
||||
expect(automatic.ok).toBe(false);
|
||||
if (automatic.ok) return;
|
||||
expect(automatic.error).toMatchObject({
|
||||
reason: "ambiguous",
|
||||
requestedProviderId: "auto",
|
||||
candidateIds: ["alpha", "beta"],
|
||||
});
|
||||
expect(projectAnalysisError(automatic.error)).toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
details: {
|
||||
selection_reason: "ambiguous",
|
||||
candidate_ids: ["alpha", "beta"],
|
||||
},
|
||||
});
|
||||
expect(alpha.created).toEqual([]);
|
||||
expect(beta.created).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("analysis provider registry: binding failures and cancellation", () => {
|
||||
it("returns actionable unknown, unavailable, and unsupported failures", async () => {
|
||||
const unavailable = candidate("alpha", { available: false });
|
||||
const registry = new AnalysisProviderRegistry([unavailable.provider]);
|
||||
|
||||
const unknown = await registry.select(DATABASE_TARGET, "missing");
|
||||
expect(unknown.ok).toBe(false);
|
||||
if (!unknown.ok)
|
||||
expect(projectAnalysisError(unknown.error)).toMatchObject({
|
||||
details: {
|
||||
selection_reason: "unknown_provider",
|
||||
requested_provider_id: "missing",
|
||||
candidate_ids: ["alpha"],
|
||||
},
|
||||
});
|
||||
|
||||
const rejected = await registry.select(DATABASE_TARGET, "alpha");
|
||||
expect(rejected.ok).toBe(false);
|
||||
if (!rejected.ok)
|
||||
expect(projectAnalysisError(rejected.error)).toMatchObject({
|
||||
code: "provider_unavailable",
|
||||
details: {
|
||||
rejections: [
|
||||
{
|
||||
provider_id: "alpha",
|
||||
code: "runtime_missing",
|
||||
diagnostics: { executable_path: "/opt/alpha/bin/analyze" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const unsupported = await new AnalysisProviderRegistry([
|
||||
candidate("alpha", { available: false }).provider,
|
||||
]).select(ARTIFACT_TARGET, "alpha");
|
||||
expect(unsupported.ok).toBe(false);
|
||||
if (unsupported.ok)
|
||||
throw new Error("expected unsupported target rejection");
|
||||
expect(unsupported.error).toBeInstanceOf(ProviderSelectionError);
|
||||
if (
|
||||
!unsupported.ok &&
|
||||
unsupported.error instanceof ProviderSelectionError
|
||||
) {
|
||||
expect(unsupported.error).toMatchObject({
|
||||
reason: "target_unsupported",
|
||||
});
|
||||
expect(unsupported.error.rejections.map(({ code }) => code)).toEqual([
|
||||
"runtime_missing",
|
||||
"target_kind_unsupported",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps artifact-only targets unbound under auto or environment preference", async () => {
|
||||
const alpha = candidate("alpha");
|
||||
const environment = new AnalysisProviderRegistry([alpha.provider], "alpha");
|
||||
const automatic = new AnalysisProviderRegistry([alpha.provider]);
|
||||
|
||||
for (const registry of [environment, automatic]) {
|
||||
const selected = await registry.select(ARTIFACT_TARGET);
|
||||
expect(selected).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
binding: null,
|
||||
candidates: [
|
||||
{
|
||||
selected: false,
|
||||
targetSupport: {
|
||||
status: "unsupported",
|
||||
code: "target_kind_unsupported",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
const explicit = await automatic.select(ARTIFACT_TARGET, "alpha");
|
||||
expect(explicit.ok).toBe(false);
|
||||
if (explicit.ok) throw new Error("expected provider selection rejection");
|
||||
expect(explicit.error).toBeInstanceOf(ProviderSelectionError);
|
||||
if (explicit.error instanceof ProviderSelectionError)
|
||||
expect(explicit.error.reason).toBe("target_unsupported");
|
||||
});
|
||||
|
||||
it("does not bind a profile with unresolved version or adapter failure", async () => {
|
||||
const unresolved = candidate("unresolved", { profile: "missing" });
|
||||
const failed = candidate("failed", { profile: "error" });
|
||||
|
||||
const automatic = await new AnalysisProviderRegistry([
|
||||
unresolved.provider,
|
||||
]).select(DATABASE_TARGET);
|
||||
expect(automatic).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
binding: null,
|
||||
candidates: [
|
||||
{
|
||||
availability: {
|
||||
status: "unavailable",
|
||||
code: "version_unresolved",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const explicit = await new AnalysisProviderRegistry([
|
||||
failed.provider,
|
||||
]).select(DATABASE_TARGET, "failed");
|
||||
expect(explicit.ok).toBe(false);
|
||||
if (explicit.ok) throw new Error("expected provider selection rejection");
|
||||
expect(explicit.error).toBeInstanceOf(ProviderSelectionError);
|
||||
if (explicit.error instanceof ProviderSelectionError)
|
||||
expect(explicit.error.rejections).toMatchObject([
|
||||
{
|
||||
providerId: "failed",
|
||||
code: "version_unresolved",
|
||||
diagnostics: { error_tag: "ProviderAdapterError" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancels a pending profile probe even when the adapter ignores its signal", async () => {
|
||||
const alpha = candidate("alpha");
|
||||
let observedSignal: AbortSignal | undefined;
|
||||
const ignoresCancellation: AnalysisProviderCandidate = {
|
||||
...alpha.provider,
|
||||
resolveAnalysisProfile: (_target, options) => {
|
||||
observedSignal = options?.signal;
|
||||
return new Promise<never>(() => undefined);
|
||||
},
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const selecting = new AnalysisProviderRegistry([
|
||||
ignoresCancellation,
|
||||
]).select(DATABASE_TARGET, "alpha", { signal: controller.signal });
|
||||
expect(observedSignal).toBe(controller.signal);
|
||||
|
||||
controller.abort();
|
||||
|
||||
await expect(selecting).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError", operation: "open_binary" },
|
||||
});
|
||||
expect(alpha.created).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
interface CandidateFixture {
|
||||
readonly provider: AnalysisProviderCandidate;
|
||||
readonly created: string[];
|
||||
}
|
||||
|
||||
const candidate = (
|
||||
id: string,
|
||||
options: {
|
||||
readonly available?: boolean;
|
||||
readonly profile?: "resolved" | "missing" | "error";
|
||||
} = {},
|
||||
): CandidateFixture => {
|
||||
const identity: ProviderIdentity = {
|
||||
id,
|
||||
name: `${id} provider`,
|
||||
version: null,
|
||||
};
|
||||
const created: string[] = [];
|
||||
const available = options.available ?? true;
|
||||
const provider: AnalysisProviderCandidate = {
|
||||
identity: () => identity,
|
||||
capabilities: () => [capability(identity)],
|
||||
inspectAvailability: (): ProviderAvailability =>
|
||||
available
|
||||
? {
|
||||
status: "available",
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics: { executable_path: `/opt/${id}/bin/analyze` },
|
||||
}
|
||||
: {
|
||||
status: "unavailable",
|
||||
code: "runtime_missing",
|
||||
reason: `${id} runtime is unavailable`,
|
||||
diagnostics: { executable_path: `/opt/${id}/bin/analyze` },
|
||||
},
|
||||
inspectTargetSupport: (target): ProviderTargetSupport =>
|
||||
target.kind === "database"
|
||||
? {
|
||||
status: "supported",
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics: { target_kind: target.kind },
|
||||
}
|
||||
: {
|
||||
status: "unsupported",
|
||||
code: "target_kind_unsupported",
|
||||
reason: `${id} only accepts analysis databases in this fixture`,
|
||||
diagnostics: { target_kind: target.kind },
|
||||
},
|
||||
resolveAnalysisProfile: () => {
|
||||
if (options.profile === "error")
|
||||
return Promise.resolve(
|
||||
err(new ProviderAdapterError(id, "resolve_analysis_profile")),
|
||||
);
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
profile:
|
||||
options.profile === "missing"
|
||||
? null
|
||||
: createAnalysisProfile(
|
||||
{ id, name: identity.name, version: "1" },
|
||||
{ fixture: id },
|
||||
),
|
||||
compatibility: {},
|
||||
}),
|
||||
);
|
||||
},
|
||||
createClient: (_target, profile): AnalysisClient => {
|
||||
created.push(id);
|
||||
const executionIdentity = profile?.provider ?? identity;
|
||||
return {
|
||||
execute: (operation) =>
|
||||
Promise.resolve(
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
operation === "health" ? null : `${id}:${operation}`,
|
||||
executionIdentity,
|
||||
),
|
||||
),
|
||||
),
|
||||
close: () => Promise.resolve(),
|
||||
};
|
||||
},
|
||||
};
|
||||
return { provider, created };
|
||||
};
|
||||
|
||||
const capability = (provider: ProviderIdentity): CapabilityDescriptor => ({
|
||||
provider,
|
||||
operation: "address_name",
|
||||
available: true,
|
||||
reason: null,
|
||||
effects: {
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: true,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: false,
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
},
|
||||
limitations: [],
|
||||
});
|
||||
|
||||
const bindingProjection = (binding: AnalysisProviderBinding) => ({
|
||||
id: binding.identity.id,
|
||||
source: binding.selectionSource,
|
||||
version: binding.identity.version,
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
import {
|
||||
analysisProviderIdSchema,
|
||||
analysisProviderSelectorSchema,
|
||||
type AnalysisProviderSelector,
|
||||
} from "../../contracts/providerSelection.js";
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
ProviderSelectionError,
|
||||
type ProviderSelectionFailureReason,
|
||||
type ProviderSelectionRejection,
|
||||
} from "../../domain/providerSelectionError.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type {
|
||||
AnalysisProviderCandidate,
|
||||
CapabilityDescriptor,
|
||||
ProviderAvailability,
|
||||
ProviderIdentity,
|
||||
ProviderTargetSupport,
|
||||
} from "../AnalysisProvider.js";
|
||||
import {
|
||||
evaluateAnalysisProviderCandidate,
|
||||
type AnalysisProviderCandidateEvaluation,
|
||||
} from "./AnalysisProviderEvaluation.js";
|
||||
|
||||
/** Deterministic discovery truth for one configured deep provider. */
|
||||
export interface AnalysisProviderCandidateStatus {
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly availability: ProviderAvailability;
|
||||
readonly targetSupport:
|
||||
| ProviderTargetSupport
|
||||
| {
|
||||
/** No target has been parsed, so support has not been evaluated. */
|
||||
readonly status: "unknown";
|
||||
readonly code: null;
|
||||
readonly reason: string;
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
||||
};
|
||||
readonly selected: boolean;
|
||||
readonly capabilities: readonly CapabilityDescriptor[];
|
||||
}
|
||||
|
||||
/** One immutable deep-provider binding selected before client startup. */
|
||||
export interface AnalysisProviderBinding {
|
||||
readonly provider: AnalysisProviderCandidate;
|
||||
readonly identity: ProviderIdentity & { readonly version: string };
|
||||
readonly selectionSource: "request" | "environment" | "auto-single-candidate";
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
/** Complete result used for both a bound and intentionally unbound target. */
|
||||
export interface AnalysisProviderSelection {
|
||||
readonly requestedProviderId: string;
|
||||
readonly binding: AnalysisProviderBinding | null;
|
||||
readonly candidates: readonly AnalysisProviderCandidateStatus[];
|
||||
}
|
||||
|
||||
interface TargetSelectionContext {
|
||||
readonly target: BinaryTarget;
|
||||
readonly baseline: readonly AnalysisProviderCandidateStatus[];
|
||||
readonly signal: AbortSignal | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and select overlapping deep providers without starting their clients.
|
||||
* Registration order never affects selection or caller-visible output.
|
||||
*/
|
||||
export class AnalysisProviderRegistry {
|
||||
readonly #providers: readonly AnalysisProviderCandidate[];
|
||||
readonly #byId: ReadonlyMap<string, AnalysisProviderCandidate>;
|
||||
readonly #declaredOperations: readonly string[];
|
||||
readonly #defaultSelector: AnalysisProviderSelector;
|
||||
|
||||
constructor(
|
||||
providers: readonly AnalysisProviderCandidate[],
|
||||
defaultSelector: AnalysisProviderSelector = "auto",
|
||||
) {
|
||||
this.#defaultSelector =
|
||||
analysisProviderSelectorSchema.parse(defaultSelector);
|
||||
const byId = new Map<string, AnalysisProviderCandidate>();
|
||||
const declaredOperations = new Set<string>();
|
||||
for (const provider of providers) {
|
||||
const identity = provider.identity();
|
||||
if (!analysisProviderIdSchema.safeParse(identity.id).success)
|
||||
throw new TypeError(`Invalid analysis provider ID: ${identity.id}`);
|
||||
if (byId.has(identity.id))
|
||||
throw new TypeError(`Duplicate analysis provider ID: ${identity.id}`);
|
||||
const providerOperations = new Set<string>();
|
||||
for (const capability of provider.capabilities()) {
|
||||
if (
|
||||
capability.provider.id !== identity.id ||
|
||||
capability.provider.name !== identity.name ||
|
||||
capability.provider.version !== identity.version
|
||||
)
|
||||
throw new TypeError(
|
||||
`Provider ${identity.id} published mismatched capability provenance for ${capability.operation}`,
|
||||
);
|
||||
if (providerOperations.has(capability.operation))
|
||||
throw new TypeError(
|
||||
`Provider ${identity.id} declares operation ${capability.operation} more than once`,
|
||||
);
|
||||
providerOperations.add(capability.operation);
|
||||
declaredOperations.add(capability.operation);
|
||||
}
|
||||
byId.set(identity.id, provider);
|
||||
}
|
||||
this.#providers = Object.freeze(
|
||||
[...providers].sort((left, right) =>
|
||||
left.identity().id.localeCompare(right.identity().id),
|
||||
),
|
||||
);
|
||||
this.#byId = byId;
|
||||
this.#declaredOperations = Object.freeze(
|
||||
[...declaredOperations].sort((left, right) => left.localeCompare(right)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Return the configured default without interpreting it as priority. */
|
||||
defaultSelector(): AnalysisProviderSelector {
|
||||
return this.#defaultSelector;
|
||||
}
|
||||
|
||||
/** List configured candidate identities in canonical provider-ID order. */
|
||||
identities(): readonly ProviderIdentity[] {
|
||||
return this.#providers.map((provider) =>
|
||||
structuredClone(provider.identity()),
|
||||
);
|
||||
}
|
||||
|
||||
/** List the unique deep-operation declarations without probing candidates. */
|
||||
declaredOperations(): readonly string[] {
|
||||
return [...this.#declaredOperations];
|
||||
}
|
||||
|
||||
/** Discover candidates for status without resolving profiles or starting clients. */
|
||||
candidates(
|
||||
target?: BinaryTarget,
|
||||
): readonly AnalysisProviderCandidateStatus[] {
|
||||
return this.#providers.map((provider) => candidateStatus(provider, target));
|
||||
}
|
||||
|
||||
/** Select one whole deep-operation family for a parsed target. */
|
||||
async select(
|
||||
target: BinaryTarget,
|
||||
requestedSelector?: AnalysisProviderSelector,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
): Promise<
|
||||
Result<
|
||||
AnalysisProviderSelection,
|
||||
ProviderSelectionError | AnalysisCancelledError
|
||||
>
|
||||
> {
|
||||
if (signalIsAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const parsedRequest =
|
||||
requestedSelector === undefined
|
||||
? undefined
|
||||
: analysisProviderSelectorSchema.safeParse(requestedSelector);
|
||||
if (parsedRequest !== undefined && !parsedRequest.success)
|
||||
return err(
|
||||
selectionError(
|
||||
"invalid_options",
|
||||
typeof requestedSelector === "string"
|
||||
? requestedSelector
|
||||
: "<invalid>",
|
||||
this.candidates(target),
|
||||
),
|
||||
);
|
||||
const request = parsedRequest?.data;
|
||||
const selector = request ?? this.#defaultSelector;
|
||||
const baseline = this.candidates(target);
|
||||
if (signalIsAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (
|
||||
request === undefined &&
|
||||
selector !== "auto" &&
|
||||
baseline.length > 0 &&
|
||||
baseline.every(
|
||||
({ targetSupport }) => targetSupport.status === "unsupported",
|
||||
)
|
||||
)
|
||||
return ok({
|
||||
requestedProviderId: selector,
|
||||
binding: null,
|
||||
candidates: baseline,
|
||||
});
|
||||
if (selector !== "auto")
|
||||
return this.#selectConcrete(
|
||||
{ target, baseline, signal: options.signal },
|
||||
selector,
|
||||
request === undefined ? "environment" : "request",
|
||||
);
|
||||
return this.#selectAutomatically({
|
||||
target,
|
||||
baseline,
|
||||
signal: options.signal,
|
||||
});
|
||||
}
|
||||
|
||||
/** Explain why a deep operation is unavailable on an unbound target. */
|
||||
unboundOperationError(
|
||||
operation: string,
|
||||
selection: AnalysisProviderSelection,
|
||||
): ProviderSelectionError {
|
||||
const reason: ProviderSelectionFailureReason =
|
||||
selection.candidates.length > 0 &&
|
||||
selection.candidates.every(
|
||||
({ targetSupport }) => targetSupport.status === "unsupported",
|
||||
)
|
||||
? "target_unsupported"
|
||||
: "provider_unavailable";
|
||||
return selectionError(
|
||||
reason,
|
||||
selection.requestedProviderId,
|
||||
selection.candidates,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
|
||||
async #selectConcrete(
|
||||
context: TargetSelectionContext,
|
||||
providerId: string,
|
||||
source: AnalysisProviderBinding["selectionSource"],
|
||||
): Promise<
|
||||
Result<
|
||||
AnalysisProviderSelection,
|
||||
ProviderSelectionError | AnalysisCancelledError
|
||||
>
|
||||
> {
|
||||
const { baseline, signal, target } = context;
|
||||
const provider = this.#byId.get(providerId);
|
||||
if (provider === undefined)
|
||||
return err(selectionError("unknown_provider", providerId, baseline));
|
||||
const initial = baseline.find(
|
||||
({ provider: identity }) => identity.id === providerId,
|
||||
);
|
||||
if (initial === undefined)
|
||||
throw new TypeError(
|
||||
"Registered provider is absent from discovery output",
|
||||
);
|
||||
if (initial.targetSupport.status !== "supported")
|
||||
return err(selectionError("target_unsupported", providerId, baseline));
|
||||
if (initial.availability.status === "unavailable")
|
||||
return err(selectionError("provider_unavailable", providerId, baseline));
|
||||
const evaluation = await evaluateAnalysisProviderCandidate(
|
||||
provider,
|
||||
initial,
|
||||
target,
|
||||
signal,
|
||||
);
|
||||
if (!evaluation.ok) return evaluation;
|
||||
const evaluated = evaluation.value;
|
||||
const statuses = replaceCandidate(baseline, evaluated.status);
|
||||
if (
|
||||
evaluated.profile === undefined ||
|
||||
evaluated.compatibility === undefined
|
||||
)
|
||||
return err(selectionError("provider_unavailable", providerId, statuses));
|
||||
return ok(
|
||||
selectedResult(
|
||||
providerId,
|
||||
source,
|
||||
{
|
||||
...evaluated,
|
||||
profile: evaluated.profile,
|
||||
compatibility: evaluated.compatibility,
|
||||
},
|
||||
statuses,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async #selectAutomatically(
|
||||
context: TargetSelectionContext,
|
||||
): Promise<
|
||||
Result<
|
||||
AnalysisProviderSelection,
|
||||
ProviderSelectionError | AnalysisCancelledError
|
||||
>
|
||||
> {
|
||||
const { baseline, signal, target } = context;
|
||||
const results = await Promise.all(
|
||||
this.#providers.map(
|
||||
async (
|
||||
provider,
|
||||
): Promise<
|
||||
Result<AnalysisProviderCandidateEvaluation, AnalysisCancelledError>
|
||||
> => {
|
||||
const status = baseline.find(
|
||||
({ provider: identity }) => identity.id === provider.identity().id,
|
||||
);
|
||||
if (status === undefined)
|
||||
throw new TypeError(
|
||||
"Registered provider is absent from discovery output",
|
||||
);
|
||||
return status.availability.status === "available" &&
|
||||
status.targetSupport.status === "supported"
|
||||
? evaluateAnalysisProviderCandidate(
|
||||
provider,
|
||||
status,
|
||||
target,
|
||||
signal,
|
||||
)
|
||||
: ok({
|
||||
candidate: provider,
|
||||
status,
|
||||
} satisfies AnalysisProviderCandidateEvaluation);
|
||||
},
|
||||
),
|
||||
);
|
||||
const cancelled = results.find((result) => !result.ok);
|
||||
if (cancelled !== undefined && !cancelled.ok) return cancelled;
|
||||
const evaluations = results.map((result) => {
|
||||
if (!result.ok)
|
||||
throw new TypeError("Cancelled candidate evaluation disappeared");
|
||||
return result.value;
|
||||
});
|
||||
const statuses = evaluations.map(({ status }) => status);
|
||||
const usable = evaluations.filter(
|
||||
(
|
||||
evaluation,
|
||||
): evaluation is AnalysisProviderCandidateEvaluation & {
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
} =>
|
||||
evaluation.profile !== undefined &&
|
||||
evaluation.compatibility !== undefined,
|
||||
);
|
||||
if (usable.length === 0)
|
||||
return ok({
|
||||
requestedProviderId: "auto",
|
||||
binding: null,
|
||||
candidates: statuses,
|
||||
});
|
||||
if (usable.length > 1)
|
||||
return err(selectionError("ambiguous", "auto", statuses));
|
||||
const selected = usable[0];
|
||||
if (selected === undefined)
|
||||
throw new TypeError("Usable provider selection disappeared");
|
||||
return ok(
|
||||
selectedResult("auto", "auto-single-candidate", selected, statuses),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const candidateStatus = (
|
||||
provider: AnalysisProviderCandidate,
|
||||
target: BinaryTarget | undefined,
|
||||
): AnalysisProviderCandidateStatus => ({
|
||||
provider: structuredClone(provider.identity()),
|
||||
availability: cloneAvailability(provider.inspectAvailability()),
|
||||
targetSupport:
|
||||
target === undefined
|
||||
? {
|
||||
status: "unknown",
|
||||
code: null,
|
||||
reason: "No target is open.",
|
||||
diagnostics: {},
|
||||
}
|
||||
: cloneTargetSupport(provider.inspectTargetSupport(target)),
|
||||
selected: false,
|
||||
capabilities: provider
|
||||
.capabilities()
|
||||
.map((capability) => structuredClone(capability)),
|
||||
});
|
||||
|
||||
const signalIsAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
|
||||
const selectedResult = (
|
||||
requestedProviderId: string,
|
||||
source: AnalysisProviderBinding["selectionSource"],
|
||||
evaluated: AnalysisProviderCandidateEvaluation & {
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
},
|
||||
statuses: readonly AnalysisProviderCandidateStatus[],
|
||||
): AnalysisProviderSelection => {
|
||||
const selectedStatus = {
|
||||
...evaluated.status,
|
||||
provider: structuredClone(evaluated.profile.provider),
|
||||
selected: true,
|
||||
} satisfies AnalysisProviderCandidateStatus;
|
||||
return {
|
||||
requestedProviderId,
|
||||
binding: {
|
||||
provider: evaluated.candidate,
|
||||
identity: structuredClone(evaluated.profile.provider),
|
||||
selectionSource: source,
|
||||
profile: structuredClone(evaluated.profile),
|
||||
compatibility: structuredClone(evaluated.compatibility),
|
||||
},
|
||||
candidates: replaceCandidate(statuses, selectedStatus),
|
||||
};
|
||||
};
|
||||
|
||||
const replaceCandidate = (
|
||||
statuses: readonly AnalysisProviderCandidateStatus[],
|
||||
replacement: AnalysisProviderCandidateStatus,
|
||||
): readonly AnalysisProviderCandidateStatus[] =>
|
||||
statuses.map((status) =>
|
||||
status.provider.id === replacement.provider.id ? replacement : status,
|
||||
);
|
||||
|
||||
const selectionError = (
|
||||
reason: ProviderSelectionFailureReason,
|
||||
requestedProviderId: string,
|
||||
candidates: readonly AnalysisProviderCandidateStatus[],
|
||||
operation = "open_binary",
|
||||
): ProviderSelectionError =>
|
||||
new ProviderSelectionError({
|
||||
operation,
|
||||
reason,
|
||||
requestedProviderId,
|
||||
candidateIds: candidates.map(({ provider }) => provider.id),
|
||||
rejections: candidateRejections(candidates),
|
||||
});
|
||||
|
||||
const candidateRejections = (
|
||||
candidates: readonly AnalysisProviderCandidateStatus[],
|
||||
): readonly ProviderSelectionRejection[] =>
|
||||
candidates.flatMap((candidate) =>
|
||||
[
|
||||
...(candidate.availability.status === "unavailable"
|
||||
? [candidate.availability]
|
||||
: []),
|
||||
...(candidate.targetSupport.status === "unsupported"
|
||||
? [candidate.targetSupport]
|
||||
: []),
|
||||
].map((rejection) => ({
|
||||
providerId: candidate.provider.id,
|
||||
code: rejection.code,
|
||||
reason: rejection.reason,
|
||||
diagnostics: structuredClone(rejection.diagnostics),
|
||||
})),
|
||||
);
|
||||
|
||||
const cloneAvailability = (
|
||||
availability: ProviderAvailability,
|
||||
): ProviderAvailability => ({
|
||||
...availability,
|
||||
diagnostics: jsonObjectSchema.parse(availability.diagnostics),
|
||||
});
|
||||
|
||||
const cloneTargetSupport = (
|
||||
support: ProviderTargetSupport,
|
||||
): ProviderTargetSupport => ({
|
||||
...support,
|
||||
diagnostics: jsonObjectSchema.parse(support.diagnostics),
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
||||
import {
|
||||
ANALYSIS_SNAPSHOT_PROFILE,
|
||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
ANALYSIS_SNAPSHOT_TARGET,
|
||||
} from "../../domain/analysisSnapshot.fixture.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../../domain/evidenceBundle.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import { createAnalysisExecution } from "../AnalysisProvider.js";
|
||||
import { AnalysisSnapshotCache } from "./AnalysisSnapshotCache.js";
|
||||
|
||||
describe("analysis snapshot cache partitioning", () => {
|
||||
it("returns detached data only from the exact provider/profile partition", () => {
|
||||
const cache = new AnalysisSnapshotCache();
|
||||
const execution = createAnalysisExecution(
|
||||
"main",
|
||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
{
|
||||
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||
rawResult: "main",
|
||||
subject: {
|
||||
path: ANALYSIS_SNAPSHOT_TARGET.path,
|
||||
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
|
||||
format: ANALYSIS_SNAPSHOT_TARGET.format,
|
||||
...(ANALYSIS_SNAPSHOT_TARGET.architecture === undefined
|
||||
? {}
|
||||
: { architecture: ANALYSIS_SNAPSHOT_TARGET.architecture }),
|
||||
},
|
||||
},
|
||||
);
|
||||
cache.record({
|
||||
target: ANALYSIS_SNAPSHOT_TARGET,
|
||||
profile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||
operation: "address_name",
|
||||
parameters: { address: "0x1000", document: "fixture" },
|
||||
execution,
|
||||
});
|
||||
const evidence = createEvidence(
|
||||
ANALYSIS_SNAPSHOT_TARGET,
|
||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
{
|
||||
operation: "address_name",
|
||||
parameters: { address: "0x1000", document: "fixture" },
|
||||
result: "main",
|
||||
rawResult: "main",
|
||||
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||
},
|
||||
);
|
||||
const exported = cache.export(
|
||||
ANALYSIS_SNAPSHOT_TARGET,
|
||||
ANALYSIS_SNAPSHOT_PROFILE,
|
||||
createEvidenceBundle([evidence]),
|
||||
);
|
||||
expect(exported.ok).toBe(true);
|
||||
if (!exported.ok) throw new Error("snapshot export failed");
|
||||
const tampered = structuredClone(exported.value);
|
||||
const tamperedEntry = tampered.entries[0];
|
||||
if (tamperedEntry === undefined)
|
||||
throw new Error("exported snapshot entry missing");
|
||||
tamperedEntry.execution.result = "changed without Evidence";
|
||||
let mergedEvidence = false;
|
||||
expect(
|
||||
new AnalysisSnapshotCache().import(tampered, undefined, () => {
|
||||
mergedEvidence = true;
|
||||
return ok(0);
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
expect(mergedEvidence).toBe(false);
|
||||
const entry = exported.value.entries[0];
|
||||
if (entry === undefined) throw new Error("exported snapshot entry missing");
|
||||
Reflect.set(entry.execution, "provider", {
|
||||
id: "forged",
|
||||
name: "Forged",
|
||||
version: "9",
|
||||
});
|
||||
entry.execution.limitations.push("forged");
|
||||
|
||||
expect(
|
||||
cache.lookup(
|
||||
ANALYSIS_SNAPSHOT_TARGET,
|
||||
ANALYSIS_SNAPSHOT_PROFILE,
|
||||
"address_name",
|
||||
{ address: "0x1000", document: "fixture" },
|
||||
),
|
||||
).toMatchObject({
|
||||
provider: ANALYSIS_SNAPSHOT_PROVIDER,
|
||||
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||
limitations: [expect.stringContaining("local REA analysis snapshot")],
|
||||
});
|
||||
const changedProfile = createAnalysisProfile(ANALYSIS_SNAPSHOT_PROVIDER, {
|
||||
loader: "configured-override",
|
||||
});
|
||||
expect(
|
||||
cache.lookup(ANALYSIS_SNAPSHOT_TARGET, changedProfile, "address_name", {
|
||||
address: "0x1000",
|
||||
document: "fixture",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
const changedProviderProfile = createAnalysisProfile(
|
||||
{ id: "other", name: "Other", version: "1" },
|
||||
{ loader: "mach-o-arm64" },
|
||||
);
|
||||
expect(
|
||||
cache.lookup(
|
||||
ANALYSIS_SNAPSHOT_TARGET,
|
||||
changedProviderProfile,
|
||||
"address_name",
|
||||
{ address: "0x1000", document: "fixture" },
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import type { EvidenceBundle } from "../../domain/evidenceBundle.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import { NoBinaryOpenError } from "../../domain/configurationErrors.js";
|
||||
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
analysisQueryId,
|
||||
createAnalysisSnapshotEntry,
|
||||
snapshotBinding,
|
||||
snapshotMatchesProfile,
|
||||
snapshotMatchesTarget,
|
||||
snapshotTarget,
|
||||
parseAnalysisSnapshot,
|
||||
type AnalysisSnapshot,
|
||||
type AnalysisSnapshotEntry,
|
||||
type AnalysisSnapshotBinding,
|
||||
type AnalysisSnapshotTarget,
|
||||
} from "../../domain/analysisSnapshot.js";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
AnalysisOperation,
|
||||
CapabilityDescriptor,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { GENERATED_MCP_TOOL_CATALOG } from "../../generatedMcpToolCatalog.js";
|
||||
|
||||
const STATEFUL_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
"health",
|
||||
"current_address",
|
||||
"current_procedure",
|
||||
"current_document",
|
||||
"goto_address",
|
||||
"list_documents",
|
||||
"set_current_document",
|
||||
]);
|
||||
|
||||
const CURSOR_DEFAULT_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
"address_name",
|
||||
"comment",
|
||||
"inline_comment",
|
||||
"next_address",
|
||||
"prev_address",
|
||||
"xrefs",
|
||||
]);
|
||||
|
||||
const DOCUMENT_SCOPED_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
GENERATED_MCP_TOOL_CATALOG.filter(({ kind }) => kind === "official-proxy")
|
||||
.map(({ name }) => name)
|
||||
.filter(
|
||||
(name) =>
|
||||
name !== "current_document" &&
|
||||
name !== "list_documents" &&
|
||||
name !== "set_current_document",
|
||||
),
|
||||
);
|
||||
|
||||
/** Whether an operation is immutable and independent of provider UI state. */
|
||||
export const isSnapshotCacheable = (
|
||||
operation: AnalysisOperation,
|
||||
descriptor: CapabilityDescriptor | undefined,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
): descriptor is CapabilityDescriptor =>
|
||||
!STATEFUL_OPERATIONS.has(operation) &&
|
||||
(!DOCUMENT_SCOPED_OPERATIONS.has(operation) ||
|
||||
typeof parameters.document === "string") &&
|
||||
(!CURSOR_DEFAULT_OPERATIONS.has(operation) ||
|
||||
typeof parameters.address === "string") &&
|
||||
descriptor?.effects.mutatesArtifact === false &&
|
||||
descriptor.cachePolicy !== "live" &&
|
||||
descriptor.effects.mayWriteFilesystem === false &&
|
||||
descriptor.effects.changesPermissions === false;
|
||||
|
||||
/** Bounded in-memory cache for one immutable binary identity. */
|
||||
export class AnalysisSnapshotCache {
|
||||
readonly #entries = new Map<string, AnalysisSnapshotEntry>();
|
||||
#target: AnalysisSnapshotTarget | undefined;
|
||||
#binding: AnalysisSnapshotBinding | undefined;
|
||||
|
||||
/** Whether staged entries belong to the supplied target and profile. */
|
||||
matches(
|
||||
target: BinaryTarget,
|
||||
profile: AnalysisProfileCommitment | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
(this.#target === undefined && this.#binding === undefined) ||
|
||||
(this.#target !== undefined &&
|
||||
this.#binding !== undefined &&
|
||||
profile !== undefined &&
|
||||
snapshotMatchesTarget(this.#target, target) &&
|
||||
snapshotMatchesProfile(this.#binding, profile))
|
||||
);
|
||||
}
|
||||
|
||||
/** Replace cache state when the target or selected profile changes. */
|
||||
select(target: BinaryTarget, profile: AnalysisProfileCommitment): void {
|
||||
if (!this.matches(target, profile)) this.#entries.clear();
|
||||
this.#target = snapshotTarget(target);
|
||||
this.#binding = snapshotBinding(profile);
|
||||
}
|
||||
|
||||
/** Merge already-validated entries and return the new-entry count. */
|
||||
stage(snapshot: AnalysisSnapshot): number {
|
||||
if (
|
||||
(this.#target !== undefined &&
|
||||
JSON.stringify(this.#target) !== JSON.stringify(snapshot.target)) ||
|
||||
(this.#binding !== undefined &&
|
||||
!snapshotMatchesProfile(
|
||||
this.#binding,
|
||||
snapshot.binding.analysis_profile,
|
||||
))
|
||||
)
|
||||
this.#entries.clear();
|
||||
this.#target = structuredClone(snapshot.target);
|
||||
this.#binding = structuredClone(snapshot.binding);
|
||||
let imported = 0;
|
||||
for (const entry of snapshot.entries) {
|
||||
if (!this.#entries.has(entry.query_id)) imported += 1;
|
||||
this.#entries.set(entry.query_id, structuredClone(entry));
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
/** Build a complete snapshot for an active target. */
|
||||
export(
|
||||
target: BinaryTarget | undefined,
|
||||
profile: AnalysisProfileCommitment | undefined,
|
||||
evidenceBundle: EvidenceBundle,
|
||||
): Result<AnalysisSnapshot, AnalysisError> {
|
||||
if (target === undefined || profile === undefined)
|
||||
return err(new NoBinaryOpenError());
|
||||
try {
|
||||
const snapshotTargetIdentity = snapshotTarget(target);
|
||||
const binding = snapshotBinding(profile);
|
||||
const retainedEvidence = structuredClone(evidenceBundle);
|
||||
return ok(
|
||||
parseAnalysisSnapshot({
|
||||
target: snapshotTargetIdentity,
|
||||
binding,
|
||||
entries: this.entries(),
|
||||
evidence_bundle: retainedEvidence,
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Analysis snapshot validation failed", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate target identity, merge evidence atomically, then stage entries. */
|
||||
import(
|
||||
snapshot: AnalysisSnapshot,
|
||||
active:
|
||||
| {
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
}
|
||||
| undefined,
|
||||
mergeEvidence: (
|
||||
bundle: EvidenceBundle,
|
||||
) => Result<number, EvidenceIntegrityError>,
|
||||
): Result<number, AnalysisError> {
|
||||
let validated: AnalysisSnapshot;
|
||||
try {
|
||||
validated = parseAnalysisSnapshot(snapshot);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError("Analysis snapshot validation failed", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (
|
||||
active !== undefined &&
|
||||
(!snapshotMatchesTarget(validated.target, active.target) ||
|
||||
!snapshotMatchesProfile(validated.binding, active.profile))
|
||||
)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshot profile_mismatch: target, provider, or analysis profile does not match the active binary",
|
||||
),
|
||||
);
|
||||
const importedEvidence = mergeEvidence(validated.evidence_bundle);
|
||||
return importedEvidence.ok ? ok(this.stage(validated)) : importedEvidence;
|
||||
}
|
||||
|
||||
/** Return canonical entries for persistence. */
|
||||
entries(): AnalysisSnapshotEntry[] {
|
||||
return [...this.#entries.values()]
|
||||
.sort((left, right) => left.query_id.localeCompare(right.query_id))
|
||||
.map((entry) => structuredClone(entry));
|
||||
}
|
||||
|
||||
/** Replay an exact provider-specific query, marking its cached provenance. */
|
||||
lookup(
|
||||
target: BinaryTarget,
|
||||
profile: AnalysisProfileCommitment,
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
): AnalysisExecution | undefined {
|
||||
const queryId = analysisQueryId(
|
||||
snapshotTarget(target),
|
||||
snapshotBinding(profile),
|
||||
operation,
|
||||
parameters,
|
||||
);
|
||||
const cached = this.#entries.get(queryId);
|
||||
if (cached === undefined) return undefined;
|
||||
const subject = cached.execution.subject;
|
||||
return structuredClone({
|
||||
result: cached.execution.result,
|
||||
rawResult: cached.execution.raw_result,
|
||||
provider: cached.execution.provider,
|
||||
analysisProfile: structuredClone(profile),
|
||||
limitations: [
|
||||
...cached.execution.limitations,
|
||||
"Loaded from a local REA analysis snapshot; this call did not re-run the provider.",
|
||||
],
|
||||
locations: cached.execution.locations,
|
||||
subject:
|
||||
subject === null
|
||||
? null
|
||||
: subject.architecture === null
|
||||
? {
|
||||
path: subject.path,
|
||||
sha256: subject.sha256,
|
||||
format: subject.format,
|
||||
}
|
||||
: {
|
||||
path: subject.path,
|
||||
sha256: subject.sha256,
|
||||
format: subject.format,
|
||||
architecture: subject.architecture,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Record one successful immutable call unless the cache is full. */
|
||||
record(input: {
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
readonly operation: AnalysisOperation;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly execution: AnalysisExecution;
|
||||
}): void {
|
||||
const { target, profile, operation, parameters, execution } = input;
|
||||
if (this.#entries.size >= 10_000) return;
|
||||
this.select(target, profile);
|
||||
const entry = createAnalysisSnapshotEntry({
|
||||
target: snapshotTarget(target),
|
||||
binding: snapshotBinding(profile),
|
||||
operation,
|
||||
parameters,
|
||||
execution,
|
||||
});
|
||||
this.#entries.set(entry.query_id, entry);
|
||||
}
|
||||
|
||||
/** Forget all target-bound entries. */
|
||||
clear(): void {
|
||||
this.#entries.clear();
|
||||
this.#target = undefined;
|
||||
this.#binding = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
parseAnalysisSnapshot,
|
||||
serializeAnalysisSnapshot,
|
||||
type AnalysisSnapshot,
|
||||
} from "../../domain/analysisSnapshot.js";
|
||||
import {
|
||||
EvidenceFileError,
|
||||
EvidenceIntegrityError,
|
||||
} from "../../domain/evidenceErrors.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { readJsonFile, writeTextFile } from "../JsonFiles.js";
|
||||
|
||||
type SnapshotFailure = EvidenceFileError | EvidenceIntegrityError;
|
||||
|
||||
/** Read and validate an analysis snapshot from the caller's path. */
|
||||
export const readAnalysisSnapshot = async (
|
||||
path: string,
|
||||
): Promise<Result<AnalysisSnapshot, SnapshotFailure>> => {
|
||||
const loaded = await readJsonFile(path);
|
||||
if (!loaded.ok) return loaded;
|
||||
try {
|
||||
return ok(parseAnalysisSnapshot(loaded.value));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError(snapshotValidationMessage(cause), { cause }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Atomically write a deterministic analysis snapshot to the caller's path. */
|
||||
export const writeAnalysisSnapshot = async (
|
||||
snapshot: AnalysisSnapshot,
|
||||
path: string,
|
||||
overwrite: boolean,
|
||||
): Promise<
|
||||
Result<{ readonly path: string; readonly bytes: number }, SnapshotFailure>
|
||||
> => {
|
||||
let encoded: string;
|
||||
try {
|
||||
encoded = serializeAnalysisSnapshot(snapshot);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new EvidenceIntegrityError(snapshotValidationMessage(cause), { cause }),
|
||||
);
|
||||
}
|
||||
return writeTextFile(encoded, path, overwrite);
|
||||
};
|
||||
|
||||
const snapshotValidationMessage = (cause: unknown): string =>
|
||||
cause instanceof TypeError
|
||||
? cause.message
|
||||
: "Analysis snapshot validation failed";
|
||||
@@ -0,0 +1,530 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import {
|
||||
analysisProfilesEqual,
|
||||
type AnalysisProfileCommitment,
|
||||
} from "../../domain/analysisProfile.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisExecution,
|
||||
ExecutionOptions,
|
||||
AnalysisOperationPort,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisOperation } from "../AnalysisProvider.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../../contracts/officialToolContracts.js";
|
||||
import { ENHANCED_TOOL_CONTRACTS } from "../../contracts/enhancedToolContracts.js";
|
||||
import type { BinarySessionPort } from "./BinarySessionPort.js";
|
||||
import {
|
||||
SessionProviderRouter,
|
||||
type SessionProviderRoute,
|
||||
} from "./SessionProviderRouter.js";
|
||||
import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
|
||||
import { BinarySessionRecords } from "./BinarySessionRecords.js";
|
||||
import { binarySessionStatus } from "./BinarySessionStatus.js";
|
||||
import {
|
||||
resolveSessionOpen,
|
||||
type BinarySessionOpenOptions,
|
||||
} from "./BinarySessionOpen.js";
|
||||
import {
|
||||
bindExecutionTarget,
|
||||
commitExecutionProfile,
|
||||
prepareSessionExecution,
|
||||
} from "./BinarySessionExecution.js";
|
||||
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||
export type { BinarySessionPort } from "./BinarySessionPort.js";
|
||||
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
);
|
||||
const ENHANCED_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
ENHANCED_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
);
|
||||
|
||||
/**
|
||||
* Owns the single active target shared by CLI and MCP adapters.
|
||||
*
|
||||
* Target transitions are serialized because switching targets tears down the
|
||||
* active provider client. A failed switch recreates the previous target instead
|
||||
* of retaining a client whose resources were already shut down.
|
||||
*/
|
||||
export class BinarySession
|
||||
extends BinarySessionRecords
|
||||
implements BinarySessionPort
|
||||
{
|
||||
#active:
|
||||
| {
|
||||
readonly target: BinaryTarget;
|
||||
readonly client: AnalysisClient;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
readonly route: SessionProviderRoute;
|
||||
readonly runId: string;
|
||||
}
|
||||
| undefined;
|
||||
#transition: Promise<void> = Promise.resolve();
|
||||
readonly #calls = new Set<Promise<unknown>>();
|
||||
readonly #providerRouter: SessionProviderRouter;
|
||||
readonly #runtimeUnavailability = new Map<
|
||||
string,
|
||||
{ readonly reason: string }
|
||||
>();
|
||||
readonly #availabilityListeners = new Set<() => void | Promise<void>>();
|
||||
|
||||
constructor(
|
||||
providerRouter: SessionProviderRouter,
|
||||
records: InvestigationRecords = new InvestigationRecords(),
|
||||
) {
|
||||
super(records);
|
||||
this.#providerRouter = providerRouter;
|
||||
}
|
||||
|
||||
/** Identify the provider producing evidence for this session. */
|
||||
providerIdentity(operation?: AnalysisOperation): ProviderIdentity {
|
||||
const route = this.#currentRoute();
|
||||
let selected = route.identity;
|
||||
if (operation !== undefined) {
|
||||
const exact = route.capabilities?.get(operation)?.provider;
|
||||
if (exact !== undefined) selected = exact;
|
||||
else if (ENHANCED_OPERATIONS.has(operation)) {
|
||||
const providers = new Map<string, ProviderIdentity>();
|
||||
for (const descriptor of route.capabilities?.values() ?? [])
|
||||
if (
|
||||
descriptor.available &&
|
||||
OFFICIAL_OPERATIONS.has(descriptor.operation)
|
||||
)
|
||||
providers.set(descriptor.provider.id, descriptor.provider);
|
||||
if (providers.size === 1) {
|
||||
const provider = providers.values().next().value;
|
||||
if (provider !== undefined) selected = provider;
|
||||
}
|
||||
}
|
||||
}
|
||||
const profile = this.#active?.profile;
|
||||
return structuredClone(
|
||||
profile !== null &&
|
||||
profile !== undefined &&
|
||||
profile.provider.id === selected.id
|
||||
? profile.provider
|
||||
: selected,
|
||||
);
|
||||
}
|
||||
|
||||
/** External live state cannot be replayed by a direct or composed CLI workflow. */
|
||||
allowsSnapshotReplay(operation: AnalysisOperation): boolean {
|
||||
const capabilities = this.#active?.route.capabilities;
|
||||
const descriptor = capabilities?.get(operation);
|
||||
if (descriptor !== undefined) return descriptor.cachePolicy !== "live";
|
||||
return ![...(capabilities?.values() ?? [])].some(
|
||||
({ cachePolicy }) => cachePolicy === "live",
|
||||
);
|
||||
}
|
||||
|
||||
/** Return the selected immutable profile, optionally scoped to an operation. */
|
||||
analysisProfile(
|
||||
operation?: AnalysisOperation,
|
||||
): AnalysisProfileCommitment | undefined {
|
||||
const profile = this.#active?.profile;
|
||||
if (profile === null || profile === undefined) return undefined;
|
||||
if (
|
||||
operation !== undefined &&
|
||||
this.providerIdentity(operation).id !== profile.provider.id
|
||||
)
|
||||
return undefined;
|
||||
return structuredClone(profile);
|
||||
}
|
||||
|
||||
/** Return opaque adapter metadata retained for legacy open_binary output. */
|
||||
openCompatibility(): Readonly<Record<string, JsonValue>> {
|
||||
return structuredClone(this.#active?.compatibility ?? {});
|
||||
}
|
||||
|
||||
/** Observe runtime provider-health changes that affect discovery metadata. */
|
||||
onAvailabilityChanged(listener: () => void | Promise<void>): () => void {
|
||||
this.#availabilityListeners.add(listener);
|
||||
return () => this.#availabilityListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open or switch targets after draining calls against the current target.
|
||||
* Returns the switch failure even if best-effort restoration also fails.
|
||||
*/
|
||||
open(
|
||||
path: string,
|
||||
options: BinarySessionOpenOptions = {},
|
||||
): Promise<Result<BinaryTarget, AnalysisError>> {
|
||||
return this.#serialize(async () => {
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const resolved = await resolveSessionOpen({
|
||||
router: this.#providerRouter,
|
||||
current: this.#active,
|
||||
path,
|
||||
options,
|
||||
stagedSnapshotMatches: (target, profile) =>
|
||||
this.matchesSnapshot(target, profile),
|
||||
});
|
||||
if (!resolved.ok) return resolved;
|
||||
const { target, route, sameTarget } = resolved.value;
|
||||
const { profile, compatibility } = route;
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const activeProfile = this.#active?.profile;
|
||||
const sameProfile =
|
||||
activeProfile === null || activeProfile === undefined
|
||||
? profile === null
|
||||
: profile !== null && analysisProfilesEqual(activeProfile, profile);
|
||||
if (sameTarget && sameProfile) {
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) return imported;
|
||||
}
|
||||
this.resetSnapshotInvalidation();
|
||||
return ok(target);
|
||||
}
|
||||
await this.#drainCalls();
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
if (previous !== undefined) {
|
||||
const closed = await closeAnalysisClient(
|
||||
previous.client,
|
||||
previous.route.identity.id,
|
||||
);
|
||||
if (!closed.ok) {
|
||||
this.clearSessionRecords();
|
||||
this.#clearRuntimeAvailability();
|
||||
return closed;
|
||||
}
|
||||
}
|
||||
const runId = randomUUID();
|
||||
const client = route.createClient(target, { runId });
|
||||
const started = await client.execute("health", {}, options);
|
||||
if (!started.ok) {
|
||||
await client.close();
|
||||
await this.#restore(previous);
|
||||
return started;
|
||||
}
|
||||
if (isAborted(options.signal)) {
|
||||
await client.close();
|
||||
await this.#restore(previous);
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
}
|
||||
this.#active = {
|
||||
target,
|
||||
client,
|
||||
profile,
|
||||
compatibility: structuredClone(compatibility),
|
||||
route,
|
||||
runId,
|
||||
};
|
||||
this.#clearRuntimeAvailability();
|
||||
if (options.snapshot === undefined) {
|
||||
if (profile === null) this.clearSnapshot();
|
||||
else this.selectSnapshot(target, profile);
|
||||
}
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) {
|
||||
this.#active = undefined;
|
||||
await client.close();
|
||||
await this.#restore(previous);
|
||||
return imported;
|
||||
}
|
||||
}
|
||||
this.resetSnapshotInvalidation();
|
||||
return ok(target);
|
||||
});
|
||||
}
|
||||
|
||||
/** Close the active target, if any. */
|
||||
close(
|
||||
options: Pick<ExecutionOptions, "progress"> & {
|
||||
readonly retainProviderDocuments?: boolean;
|
||||
} = {},
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
return this.#serialize(async () => {
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
await this.#drainCalls();
|
||||
const closed =
|
||||
previous === undefined
|
||||
? ok(null)
|
||||
: await closeAnalysisClient(
|
||||
previous.client,
|
||||
previous.route.identity.id,
|
||||
{
|
||||
...(options.progress === undefined
|
||||
? {}
|
||||
: { progress: options.progress }),
|
||||
...(options.retainProviderDocuments === true
|
||||
? { retainDocument: true }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
this.clearSessionRecords();
|
||||
this.#clearRuntimeAvailability();
|
||||
return closed;
|
||||
});
|
||||
}
|
||||
|
||||
/** Describe the active binary session. */
|
||||
status(): JsonValue {
|
||||
return binarySessionStatus({
|
||||
target: this.#active?.target,
|
||||
route: this.#currentRoute(),
|
||||
router: this.#providerRouter,
|
||||
runtimeUnavailability: this.#runtimeUnavailability,
|
||||
runId: this.#active?.runId,
|
||||
runtimeLineageSnapshots:
|
||||
this.#active?.client.runtimeLineageSnapshots?.() ?? [],
|
||||
requestActivitySnapshots:
|
||||
this.#active?.client.requestActivitySnapshots?.() ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
/** Return the immutable artifact identity captured before its provider started. */
|
||||
activeTarget(): BinaryTarget | undefined {
|
||||
return this.#active === undefined
|
||||
? undefined
|
||||
: structuredClone(this.#active.target);
|
||||
}
|
||||
|
||||
protected activeAnalysisBinding() {
|
||||
return this.#active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke a provider operation against the active target.
|
||||
* Calls may overlap, but a pending target transition prevents new calls from
|
||||
* entering until the transition has settled.
|
||||
*/
|
||||
async execute(
|
||||
name: Parameters<AnalysisOperationPort["execute"]>[0],
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
options?: { readonly signal?: AbortSignal },
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
const transitioned = await this.#waitForTransition(name, options?.signal);
|
||||
if (!transitioned.ok) return transitioned;
|
||||
const prepared = prepareSessionExecution({
|
||||
active: this.#active,
|
||||
operation: name,
|
||||
parameters: arguments_,
|
||||
unboundOperationError: (operation, route) =>
|
||||
this.#providerRouter.unboundOperationError(operation, route),
|
||||
lookupSnapshot: (target, profile, operation, parameters) =>
|
||||
operation === "decode_interface_builder" ||
|
||||
operation === "inspect_asset_catalog" ||
|
||||
operation === "inspect_keyed_archive"
|
||||
? undefined
|
||||
: this.lookupSnapshot(target, profile, operation, parameters),
|
||||
});
|
||||
if (!prepared.ok) return prepared;
|
||||
const { active, capability, profile, cacheable, cached } = prepared.value;
|
||||
if (cached !== undefined) return ok(cached);
|
||||
const call = active.client.execute(name, arguments_, options);
|
||||
this.#calls.add(call);
|
||||
try {
|
||||
const result = await call;
|
||||
const profiled = bindExecutionTarget(
|
||||
commitExecutionProfile(name, result, profile),
|
||||
name,
|
||||
active.target,
|
||||
);
|
||||
this.#observeRuntimeAvailability(name, profiled);
|
||||
if (
|
||||
profiled.ok &&
|
||||
cacheable &&
|
||||
profile !== undefined &&
|
||||
name !== "decode_interface_builder" &&
|
||||
name !== "inspect_asset_catalog" &&
|
||||
name !== "inspect_keyed_archive"
|
||||
) {
|
||||
const evidence = createEvidence(
|
||||
profiled.value.subject ?? active.target,
|
||||
profiled.value.provider,
|
||||
{
|
||||
operation: name,
|
||||
parameters: arguments_,
|
||||
result: profiled.value.result,
|
||||
analysisProfile: profile,
|
||||
rawResult: profiled.value.rawResult,
|
||||
limitations: profiled.value.limitations,
|
||||
locations: profiled.value.locations,
|
||||
},
|
||||
);
|
||||
const recorded = this.recordEvidence(evidence);
|
||||
if (!recorded.ok) return recorded;
|
||||
this.recordSnapshot({
|
||||
target: active.target,
|
||||
profile,
|
||||
operation: name,
|
||||
parameters: arguments_,
|
||||
execution: profiled.value,
|
||||
});
|
||||
} else if (profiled.ok && capability?.effects.mutatesArtifact === true) {
|
||||
this.invalidateSnapshot();
|
||||
}
|
||||
return profiled;
|
||||
} finally {
|
||||
this.#calls.delete(call);
|
||||
}
|
||||
}
|
||||
|
||||
#observeRuntimeAvailability(
|
||||
operation: AnalysisOperation,
|
||||
result: Result<AnalysisExecution, AnalysisError>,
|
||||
): void {
|
||||
if (result.ok) {
|
||||
if (this.#markRuntimeAvailable(operation))
|
||||
this.#emitAvailabilityChanged();
|
||||
return;
|
||||
}
|
||||
if (result.error._tag === "AnalysisCapabilityUnavailableError") {
|
||||
if (this.#markRuntimeUnavailable(operation, result.error.message))
|
||||
this.#emitAvailabilityChanged();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
[
|
||||
"ProviderAdapterError",
|
||||
"HopperProcessError",
|
||||
"HopperStartError",
|
||||
].includes(result.error._tag)
|
||||
) {
|
||||
const capabilities = this.#active?.route.capabilities;
|
||||
const providerId = capabilities?.get(operation)?.provider.id;
|
||||
let changed = false;
|
||||
for (const descriptor of capabilities?.values() ?? [])
|
||||
if (providerId !== undefined && descriptor.provider.id === providerId)
|
||||
changed =
|
||||
this.#markRuntimeUnavailable(
|
||||
descriptor.operation,
|
||||
`${result.error._tag}: ${result.error.message}`,
|
||||
) || changed;
|
||||
if (changed) this.#emitAvailabilityChanged();
|
||||
}
|
||||
}
|
||||
|
||||
#markRuntimeAvailable(operation: string): boolean {
|
||||
return this.#runtimeUnavailability.delete(operation);
|
||||
}
|
||||
|
||||
#markRuntimeUnavailable(operation: string, reason: string): boolean {
|
||||
const current = this.#runtimeUnavailability.get(operation);
|
||||
if (current?.reason === reason) return false;
|
||||
this.#runtimeUnavailability.set(operation, { reason });
|
||||
return true;
|
||||
}
|
||||
|
||||
#clearRuntimeAvailability(): void {
|
||||
if (this.#runtimeUnavailability.size === 0) return;
|
||||
this.#runtimeUnavailability.clear();
|
||||
this.#emitAvailabilityChanged();
|
||||
}
|
||||
|
||||
#emitAvailabilityChanged(): void {
|
||||
for (const listener of this.#availabilityListeners) {
|
||||
try {
|
||||
const notification = listener();
|
||||
if (notification !== undefined) {
|
||||
// best-effort cleanup: async observer notifications must not reject
|
||||
// unhandled; state transitions and other listeners continue.
|
||||
void notification.catch(() => undefined);
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
// External observers are best-effort notifications. Contain only the
|
||||
// callback failure so state transitions and other listeners continue.
|
||||
void cause;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#currentRoute(): SessionProviderRoute {
|
||||
return this.#active?.route ?? this.#providerRouter.initialRoute();
|
||||
}
|
||||
|
||||
#serialize<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.#transition.then(operation, operation);
|
||||
this.#transition = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async #drainCalls(): Promise<void> {
|
||||
await Promise.allSettled(this.#calls);
|
||||
}
|
||||
|
||||
async #restore(
|
||||
previous:
|
||||
| {
|
||||
readonly target: BinaryTarget;
|
||||
readonly client: AnalysisClient;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
readonly route: SessionProviderRoute;
|
||||
readonly runId: string;
|
||||
}
|
||||
| undefined,
|
||||
): Promise<void> {
|
||||
if (previous === undefined) return;
|
||||
const runId = randomUUID();
|
||||
const client = previous.route.createClient(previous.target, { runId });
|
||||
const started = await client.execute("health", {});
|
||||
if (started.ok)
|
||||
this.#active = {
|
||||
target: previous.target,
|
||||
client,
|
||||
profile: previous.profile,
|
||||
compatibility: previous.compatibility,
|
||||
route: previous.route,
|
||||
runId,
|
||||
};
|
||||
else await client.close();
|
||||
}
|
||||
|
||||
async #waitForTransition(
|
||||
operation: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<undefined, AnalysisCancelledError>> {
|
||||
if (signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
if (signal === undefined) {
|
||||
await this.#transition;
|
||||
return ok(undefined);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const onAbort = (): void => {
|
||||
resolve(err(new AnalysisCancelledError(operation)));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
this.#transition.then(
|
||||
() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(
|
||||
signal.aborted
|
||||
? err(new AnalysisCancelledError(operation))
|
||||
: ok(undefined),
|
||||
);
|
||||
},
|
||||
() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(
|
||||
signal.aborted
|
||||
? err(new AnalysisCancelledError(operation))
|
||||
: ok(undefined),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
|
||||
import type { AnalysisProvider } from "../AnalysisProvider.js";
|
||||
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
|
||||
import { BinarySession } from "./BinarySession.js";
|
||||
import { SessionProviderRouter } from "./SessionProviderRouter.js";
|
||||
|
||||
/**
|
||||
* Compose one provider-neutral session from deep-provider selection and
|
||||
* disjoint auxiliary operation families.
|
||||
*/
|
||||
export const composeBinarySession = (
|
||||
registry: AnalysisProviderRegistry | SessionProviderRouter,
|
||||
auxiliaryProviders: readonly AnalysisProvider[] = [],
|
||||
): BinarySession =>
|
||||
new BinarySession(
|
||||
registry instanceof SessionProviderRouter
|
||||
? registry
|
||||
: SessionProviderRouter.selectable(registry, auxiliaryProviders),
|
||||
new InvestigationRecords(),
|
||||
);
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||
import { NoBinaryOpenError } from "../../domain/configurationErrors.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import type { EvidenceSubjectTarget } from "../../domain/evidence.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisExecution,
|
||||
AnalysisOperation,
|
||||
CapabilityDescriptor,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { isSnapshotCacheable } from "./AnalysisSnapshotCache.js";
|
||||
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
|
||||
|
||||
interface ActiveExecutionBinding {
|
||||
readonly target: BinaryTarget;
|
||||
readonly client: AnalysisClient;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly route: SessionProviderRoute;
|
||||
}
|
||||
|
||||
export interface PreparedSessionExecution {
|
||||
readonly active: ActiveExecutionBinding;
|
||||
readonly capability: CapabilityDescriptor | undefined;
|
||||
readonly profile: AnalysisProfileCommitment | undefined;
|
||||
readonly cacheable: boolean;
|
||||
readonly cached: AnalysisExecution | undefined;
|
||||
}
|
||||
|
||||
interface PrepareSessionExecutionInput {
|
||||
readonly active: ActiveExecutionBinding | undefined;
|
||||
readonly operation: AnalysisOperation;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly unboundOperationError: (
|
||||
operation: AnalysisOperation,
|
||||
route: SessionProviderRoute,
|
||||
) => AnalysisError | undefined;
|
||||
readonly lookupSnapshot: (
|
||||
target: BinaryTarget,
|
||||
profile: AnalysisProfileCommitment,
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
) => AnalysisExecution | undefined;
|
||||
}
|
||||
|
||||
/** Resolve capability, profile, and snapshot state before invoking a provider. */
|
||||
export const prepareSessionExecution = (
|
||||
input: PrepareSessionExecutionInput,
|
||||
): Result<PreparedSessionExecution, AnalysisError> => {
|
||||
const {
|
||||
active,
|
||||
operation,
|
||||
parameters,
|
||||
unboundOperationError,
|
||||
lookupSnapshot,
|
||||
} = input;
|
||||
if (active === undefined) return err(new NoBinaryOpenError());
|
||||
const capability = active.route.capabilities?.get(operation);
|
||||
if (
|
||||
active.route.capabilities !== undefined &&
|
||||
capability?.available !== true
|
||||
) {
|
||||
const selectionError = unboundOperationError(operation, active.route);
|
||||
if (selectionError !== undefined) return err(selectionError);
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
active.route.binding?.identity.id ?? active.route.identity.id,
|
||||
operation,
|
||||
capability?.reason ?? "operation is not declared by this provider",
|
||||
),
|
||||
);
|
||||
}
|
||||
const profile =
|
||||
active.profile !== null &&
|
||||
(capability === undefined ||
|
||||
capability.provider.id === active.profile.provider.id)
|
||||
? active.profile
|
||||
: undefined;
|
||||
const cacheable =
|
||||
profile !== undefined &&
|
||||
isSnapshotCacheable(operation, capability, parameters);
|
||||
const cached = cacheable
|
||||
? lookupSnapshot(active.target, profile, operation, parameters)
|
||||
: undefined;
|
||||
return ok({ active, capability, profile, cacheable, cached });
|
||||
};
|
||||
|
||||
/** Attach the selected profile only when the provider identity agrees. */
|
||||
export const commitExecutionProfile = (
|
||||
operation: AnalysisOperation,
|
||||
result: Result<AnalysisExecution, AnalysisError>,
|
||||
profile: AnalysisProfileCommitment | undefined,
|
||||
): Result<AnalysisExecution, AnalysisError> => {
|
||||
if (!result.ok || profile === undefined) return result;
|
||||
const provider = result.value.provider;
|
||||
if (
|
||||
provider.id !== profile.provider.id ||
|
||||
provider.name !== profile.provider.name ||
|
||||
provider.version !== profile.provider.version
|
||||
)
|
||||
return err(
|
||||
new ProviderAdapterError(profile.provider.id, `${operation}:profile`),
|
||||
);
|
||||
return ok({
|
||||
...result.value,
|
||||
analysisProfile: structuredClone(profile),
|
||||
});
|
||||
};
|
||||
|
||||
/** Bind an execution to the selected target, preserving valid artifact subjects. */
|
||||
export const bindExecutionTarget = (
|
||||
result: Result<AnalysisExecution, AnalysisError>,
|
||||
operation: AnalysisOperation,
|
||||
target: BinaryTarget,
|
||||
): Result<AnalysisExecution, AnalysisError> => {
|
||||
if (!result.ok) return result;
|
||||
const subject: EvidenceSubjectTarget = {
|
||||
path: target.path,
|
||||
sha256: target.sha256,
|
||||
format:
|
||||
target.format === "analysis-database"
|
||||
? "analysis-database"
|
||||
: target.format,
|
||||
...(target.architecture === undefined
|
||||
? {}
|
||||
: { architecture: target.architecture }),
|
||||
};
|
||||
if (
|
||||
result.value.subject !== null &&
|
||||
result.value.subject.sha256 !== target.sha256 &&
|
||||
!isArtifactInventorySubject(operation, result.value.subject, target)
|
||||
)
|
||||
return err(
|
||||
new ProviderAdapterError(
|
||||
result.value.provider.id,
|
||||
`${operation}:subject`,
|
||||
),
|
||||
);
|
||||
return ok({ ...result.value, subject: result.value.subject ?? subject });
|
||||
};
|
||||
|
||||
const isArtifactInventorySubject = (
|
||||
operation: AnalysisOperation,
|
||||
subject: EvidenceSubjectTarget,
|
||||
target: BinaryTarget,
|
||||
): boolean =>
|
||||
(operation === "inventory_artifact" || operation === "inspect_artifact") &&
|
||||
subject.path === (target.sourcePath ?? target.path);
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ExecutableFormatHint } from "../../domain/dosCom.js";
|
||||
import type { AnalysisProviderSelector } from "../../contracts/providerSelection.js";
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import {
|
||||
snapshotMatchesBinding,
|
||||
snapshotMatchesTarget,
|
||||
snapshotTarget,
|
||||
} from "../../domain/analysisSnapshot.js";
|
||||
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
|
||||
import { parseBinaryTarget } from "../BinaryTargetResolver.js";
|
||||
import { type BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { err, type Result } from "../../domain/result.js";
|
||||
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
|
||||
import { SessionProviderRouter } from "./SessionProviderRouter.js";
|
||||
|
||||
export interface BinarySessionOpenOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly targetKind?: BinaryTarget["kind"];
|
||||
readonly formatHint?: ExecutableFormatHint;
|
||||
readonly snapshot?: AnalysisSnapshot;
|
||||
readonly providerId?: AnalysisProviderSelector;
|
||||
}
|
||||
|
||||
interface CurrentOpenBinding {
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly route: SessionProviderRoute;
|
||||
}
|
||||
|
||||
export interface ResolvedSessionOpen {
|
||||
readonly target: BinaryTarget;
|
||||
readonly route: SessionProviderRoute;
|
||||
readonly sameTarget: boolean;
|
||||
}
|
||||
|
||||
interface ResolveSessionOpenInput {
|
||||
readonly router: SessionProviderRouter;
|
||||
readonly current: CurrentOpenBinding | undefined;
|
||||
readonly path: string;
|
||||
readonly options: BinarySessionOpenOptions;
|
||||
readonly stagedSnapshotMatches: (
|
||||
target: BinaryTarget,
|
||||
profile: AnalysisProfileCommitment | null,
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
/** Parse a target, resolve its provider route, and validate snapshot binding. */
|
||||
export const resolveSessionOpen = async (
|
||||
input: ResolveSessionOpenInput,
|
||||
): Promise<Result<ResolvedSessionOpen, AnalysisError>> => {
|
||||
const { router, current, path, options, stagedSnapshotMatches } = input;
|
||||
const parsed = await parseBinaryTarget(
|
||||
path,
|
||||
process.cwd(),
|
||||
process.arch,
|
||||
options.targetKind,
|
||||
options.formatHint,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
const target = parsed.value;
|
||||
const sameTarget =
|
||||
current?.target.path === target.path &&
|
||||
snapshotMatchesTarget(snapshotTarget(current.target), target);
|
||||
const resolvedRoute =
|
||||
sameTarget && options.providerId === undefined && current !== undefined
|
||||
? ({ ok: true, value: current.route } as const)
|
||||
: await router.resolve(target, options.providerId, options.signal);
|
||||
if (!resolvedRoute.ok) return resolvedRoute;
|
||||
const route = resolvedRoute.value;
|
||||
const snapshotError = validateSnapshot({
|
||||
snapshot: options.snapshot,
|
||||
target,
|
||||
profile: route.profile,
|
||||
current,
|
||||
stagedSnapshotMatches,
|
||||
});
|
||||
if (snapshotError !== undefined) return err(snapshotError);
|
||||
return { ok: true, value: { target, route, sameTarget } };
|
||||
};
|
||||
|
||||
interface ValidateSnapshotInput {
|
||||
readonly snapshot: AnalysisSnapshot | undefined;
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly current: CurrentOpenBinding | undefined;
|
||||
readonly stagedSnapshotMatches: (
|
||||
target: BinaryTarget,
|
||||
profile: AnalysisProfileCommitment | null,
|
||||
) => boolean;
|
||||
}
|
||||
|
||||
const validateSnapshot = ({
|
||||
snapshot,
|
||||
target,
|
||||
profile,
|
||||
current,
|
||||
stagedSnapshotMatches,
|
||||
}: ValidateSnapshotInput): EvidenceIntegrityError | undefined => {
|
||||
if (
|
||||
snapshot !== undefined &&
|
||||
(profile === null || !snapshotMatchesBinding(snapshot, target, profile))
|
||||
)
|
||||
return new EvidenceIntegrityError(
|
||||
"Analysis snapshot profile_mismatch: target, provider, or analysis profile does not match the requested binary",
|
||||
);
|
||||
if (current === undefined && !stagedSnapshotMatches(target, profile))
|
||||
return new EvidenceIntegrityError(
|
||||
"Analysis snapshot profile_mismatch: staged target, provider, or analysis profile does not match",
|
||||
);
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { InvestigationRecordPort } from "../investigation/InvestigationRecordPort.js";
|
||||
import type { ExecutableFormatHint } from "../../domain/dosCom.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import type { AnalysisProviderSelector } from "../../contracts/providerSelection.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
|
||||
import type {
|
||||
AnalysisOperation,
|
||||
AnalysisOperationPort,
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
|
||||
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
|
||||
export interface BinarySessionPort
|
||||
extends AnalysisOperationPort, InvestigationRecordPort {
|
||||
open(
|
||||
path: string,
|
||||
options?: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly targetKind?: BinaryTarget["kind"];
|
||||
readonly formatHint?: ExecutableFormatHint;
|
||||
readonly snapshot?: AnalysisSnapshot;
|
||||
readonly providerId?: AnalysisProviderSelector;
|
||||
},
|
||||
): Promise<Result<BinaryTarget, AnalysisError>>;
|
||||
close(
|
||||
options?: Pick<ExecutionOptions, "progress"> & {
|
||||
readonly retainProviderDocuments?: boolean;
|
||||
},
|
||||
): Promise<Result<null, AnalysisError>>;
|
||||
status(): JsonValue;
|
||||
activeTarget(): BinaryTarget | undefined;
|
||||
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError>;
|
||||
importAnalysisSnapshot(
|
||||
snapshot: AnalysisSnapshot,
|
||||
): Result<number, AnalysisError>;
|
||||
providerIdentity(operation?: AnalysisOperation): ProviderIdentity;
|
||||
analysisProfile(
|
||||
operation?: AnalysisOperation,
|
||||
): AnalysisProfileCommitment | undefined;
|
||||
openCompatibility(): Readonly<Record<string, JsonValue>>;
|
||||
onAvailabilityChanged?(listener: () => void | Promise<void>): () => void;
|
||||
onAnalysisSnapshotChanged?(listener: () => void | Promise<void>): () => void;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import type { EvidenceBundle } from "../../domain/evidenceBundle.js";
|
||||
import { evidenceBundleForTarget } from "../../domain/evidenceBundle.js";
|
||||
import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
|
||||
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { type UnknownRegistryError } from "../../domain/unknownRegistryError.js";
|
||||
import type {
|
||||
RecordUnknownInput,
|
||||
ResidualUnknown,
|
||||
UnknownStatus,
|
||||
UpdateUnknownInput,
|
||||
} from "../../domain/residualUnknown.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
AnalysisOperation,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { AnalysisSnapshotCache } from "./AnalysisSnapshotCache.js";
|
||||
import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
|
||||
|
||||
export interface ActiveAnalysisBinding {
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
}
|
||||
|
||||
/** Binary snapshot owner and compatibility facade for composed investigation records. */
|
||||
export abstract class BinarySessionRecords {
|
||||
readonly #records: InvestigationRecords;
|
||||
readonly #snapshot = new AnalysisSnapshotCache();
|
||||
#snapshotInvalidated = false;
|
||||
readonly #snapshotListeners = new Set<() => void | Promise<void>>();
|
||||
|
||||
constructor(records: InvestigationRecords = new InvestigationRecords()) {
|
||||
this.#records = records;
|
||||
}
|
||||
|
||||
/** Observe changes to the mutable current analysis snapshot resource. */
|
||||
onAnalysisSnapshotChanged(listener: () => void | Promise<void>): () => void {
|
||||
this.#snapshotListeners.add(listener);
|
||||
return () => this.#snapshotListeners.delete(listener);
|
||||
}
|
||||
|
||||
recordEvidence(
|
||||
evidence: Evidence,
|
||||
): Result<"added" | "duplicate", EvidenceIntegrityError> {
|
||||
const recorded = this.#records.recordEvidence(evidence);
|
||||
if (recorded.ok && recorded.value === "added") this.#emitSnapshotChanged();
|
||||
return recorded;
|
||||
}
|
||||
|
||||
hasEvidence(evidenceId: string): boolean {
|
||||
return this.#records.hasEvidence(evidenceId);
|
||||
}
|
||||
|
||||
evidenceById(evidenceId: string): Evidence | undefined {
|
||||
return this.#records.evidenceById(evidenceId);
|
||||
}
|
||||
|
||||
exportEvidenceBundle(): EvidenceBundle {
|
||||
return this.#records.exportEvidenceBundle();
|
||||
}
|
||||
|
||||
importEvidenceBundle(
|
||||
bundle: unknown,
|
||||
): Result<number, EvidenceIntegrityError> {
|
||||
const imported = this.#records.mergeEvidenceBundle(bundle);
|
||||
if (!imported.ok) return imported;
|
||||
if (imported.value.changed) this.#emitSnapshotChanged();
|
||||
return ok(imported.value.recordsAdded);
|
||||
}
|
||||
|
||||
protected abstract activeAnalysisBinding(): ActiveAnalysisBinding | undefined;
|
||||
|
||||
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError> {
|
||||
const active = this.activeAnalysisBinding();
|
||||
if (this.#snapshotInvalidated)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshots are unavailable after analysis metadata mutations",
|
||||
),
|
||||
);
|
||||
const target = active?.target;
|
||||
const profile = active?.profile ?? undefined;
|
||||
if (target !== undefined && profile === undefined)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshots require a concrete provider analysis profile",
|
||||
),
|
||||
);
|
||||
return this.#snapshot.export(
|
||||
target,
|
||||
profile,
|
||||
target === undefined
|
||||
? this.#records.exportEvidenceBundle()
|
||||
: evidenceBundleForTarget(
|
||||
this.#records.exportEvidenceBundle(),
|
||||
target.sha256,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
importAnalysisSnapshot(
|
||||
snapshot: AnalysisSnapshot,
|
||||
): Result<number, AnalysisError> {
|
||||
const active = this.activeAnalysisBinding();
|
||||
if (active?.profile === null)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
"Analysis snapshot profile_mismatch: the active target has no concrete analysis profile",
|
||||
),
|
||||
);
|
||||
const imported = this.#snapshot.import(
|
||||
snapshot,
|
||||
active === undefined
|
||||
? undefined
|
||||
: { target: active.target, profile: active.profile },
|
||||
(bundle) => {
|
||||
const imported = this.#records.mergeEvidenceBundle(bundle);
|
||||
return imported.ok ? ok(imported.value.recordsAdded) : imported;
|
||||
},
|
||||
);
|
||||
if (imported.ok) this.#emitSnapshotChanged();
|
||||
return imported;
|
||||
}
|
||||
|
||||
protected matchesSnapshot(
|
||||
target: BinaryTarget,
|
||||
profile: AnalysisProfileCommitment | null,
|
||||
): boolean {
|
||||
return this.#snapshot.matches(target, profile ?? undefined);
|
||||
}
|
||||
|
||||
protected selectSnapshot(
|
||||
target: BinaryTarget,
|
||||
profile: AnalysisProfileCommitment,
|
||||
): void {
|
||||
this.#snapshot.select(target, profile);
|
||||
this.#emitSnapshotChanged();
|
||||
}
|
||||
|
||||
protected lookupSnapshot(
|
||||
target: BinaryTarget,
|
||||
profile: AnalysisProfileCommitment,
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<
|
||||
Record<string, import("../../domain/jsonValue.js").JsonValue>
|
||||
>,
|
||||
): AnalysisExecution | undefined {
|
||||
return this.#snapshot.lookup(target, profile, operation, parameters);
|
||||
}
|
||||
|
||||
protected recordSnapshot(
|
||||
input: Parameters<AnalysisSnapshotCache["record"]>[0],
|
||||
): void {
|
||||
this.#snapshot.record(input);
|
||||
this.#emitSnapshotChanged();
|
||||
}
|
||||
|
||||
protected invalidateSnapshot(): void {
|
||||
this.#snapshot.clear();
|
||||
this.#snapshotInvalidated = true;
|
||||
this.#emitSnapshotChanged();
|
||||
}
|
||||
|
||||
protected resetSnapshotInvalidation(): void {
|
||||
if (!this.#snapshotInvalidated) return;
|
||||
this.#snapshotInvalidated = false;
|
||||
this.#emitSnapshotChanged();
|
||||
}
|
||||
|
||||
protected clearSnapshot(): void {
|
||||
this.#snapshot.clear();
|
||||
this.#emitSnapshotChanged();
|
||||
}
|
||||
|
||||
protected clearSessionRecords(): void {
|
||||
this.#records.clear();
|
||||
this.#snapshot.clear();
|
||||
this.#snapshotInvalidated = false;
|
||||
this.#emitSnapshotChanged();
|
||||
}
|
||||
|
||||
#emitSnapshotChanged(): void {
|
||||
for (const listener of this.#snapshotListeners) {
|
||||
try {
|
||||
const notification = listener();
|
||||
if (notification !== undefined)
|
||||
void notification.catch((cause: unknown) => {
|
||||
// best-effort cleanup: async observer notifications must not reject unhandled.
|
||||
void cause;
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
// External resource observers are best-effort; one callback must not
|
||||
// make a committed evidence mutation appear to fail.
|
||||
void cause;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recordUnknown(
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError> {
|
||||
const target = this.activeAnalysisBinding()?.target;
|
||||
const recorded = this.#records.recordUnknown(input, target);
|
||||
if (recorded.ok) this.#emitSnapshotChanged();
|
||||
return recorded;
|
||||
}
|
||||
|
||||
recordEvidenceWithUnknown(
|
||||
evidence: Evidence,
|
||||
input: RecordUnknownInput,
|
||||
): Result<ResidualUnknown | null, AnalysisError> {
|
||||
const recorded = this.#records.recordEvidenceWithUnknown(evidence, input);
|
||||
if (recorded.ok) this.#emitSnapshotChanged();
|
||||
return recorded;
|
||||
}
|
||||
|
||||
updateUnknown(
|
||||
input: UpdateUnknownInput,
|
||||
): Result<ResidualUnknown, AnalysisError> {
|
||||
const target = this.activeAnalysisBinding()?.target;
|
||||
const updated = this.#records.updateUnknown(input, target);
|
||||
if (updated.ok) this.#emitSnapshotChanged();
|
||||
return updated;
|
||||
}
|
||||
|
||||
listUnknowns(
|
||||
filters: {
|
||||
readonly status?: UnknownStatus;
|
||||
readonly severity?: ResidualUnknown["severity"];
|
||||
readonly domain?: string;
|
||||
} = {},
|
||||
): ResidualUnknown[] {
|
||||
return this.#records.listUnknowns(filters);
|
||||
}
|
||||
|
||||
verifyUnknownResolution(unknownId: string): Result<
|
||||
{
|
||||
readonly valid: boolean;
|
||||
readonly truthVerified: boolean;
|
||||
readonly unknown: ResidualUnknown;
|
||||
},
|
||||
UnknownRegistryError
|
||||
> {
|
||||
return this.#records.verifyUnknownResolution(unknownId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import type {
|
||||
CapabilityDescriptor,
|
||||
ProviderRequestActivitySnapshot,
|
||||
ProviderRuntimeLineageSnapshot,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type {
|
||||
SessionProviderRoute,
|
||||
SessionProviderRouter,
|
||||
} from "./SessionProviderRouter.js";
|
||||
|
||||
interface BinarySessionStatusInput {
|
||||
readonly target: BinaryTarget | undefined;
|
||||
readonly route: SessionProviderRoute;
|
||||
readonly router: SessionProviderRouter;
|
||||
readonly runtimeUnavailability: ReadonlyMap<
|
||||
string,
|
||||
{ readonly reason: string }
|
||||
>;
|
||||
readonly runId: string | undefined;
|
||||
readonly runtimeLineageSnapshots: readonly ProviderRuntimeLineageSnapshot[];
|
||||
readonly requestActivitySnapshots: readonly ProviderRequestActivitySnapshot[];
|
||||
}
|
||||
|
||||
/** Project internal provider routing state into the caller-visible session status. */
|
||||
export const binarySessionStatus = ({
|
||||
target,
|
||||
route,
|
||||
router,
|
||||
runtimeUnavailability,
|
||||
runId,
|
||||
runtimeLineageSnapshots,
|
||||
requestActivitySnapshots,
|
||||
}: BinarySessionStatusInput): JsonValue => {
|
||||
const configuredProvider = router.configuredIdentity();
|
||||
const provider = providerSummary(configuredProvider);
|
||||
const providers = router.providerIdentities(route).map(providerSummary);
|
||||
const capabilities = [...(route.capabilities?.values() ?? [])]
|
||||
.filter(({ operation }) => operation !== "inventory_artifact")
|
||||
.sort((left, right) => left.operation.localeCompare(right.operation))
|
||||
.map((descriptor) => {
|
||||
const unavailable = runtimeUnavailability.get(descriptor.operation);
|
||||
return unavailable === undefined
|
||||
? descriptor
|
||||
: {
|
||||
...descriptor,
|
||||
available: false as const,
|
||||
reason: unavailable.reason,
|
||||
};
|
||||
})
|
||||
.map(capabilityStatus);
|
||||
const analysisProviderBinding =
|
||||
route.binding === null
|
||||
? null
|
||||
: {
|
||||
provider: providerSummary(route.binding.identity),
|
||||
selection_source: route.binding.selectionSource,
|
||||
analysis_profile: structuredClone(route.binding.profile),
|
||||
};
|
||||
const analysisProviderCandidates = router
|
||||
.candidateStatuses(route)
|
||||
.map((candidate) => ({
|
||||
provider: providerSummary(candidate.provider),
|
||||
availability: structuredClone(candidate.availability),
|
||||
target_support: structuredClone(candidate.targetSupport),
|
||||
selected: candidate.selected,
|
||||
capabilities: candidate.capabilities
|
||||
.slice()
|
||||
.filter(({ operation }) => operation !== "inventory_artifact")
|
||||
.sort((left, right) => left.operation.localeCompare(right.operation))
|
||||
.map(capabilityStatus),
|
||||
}));
|
||||
const common = {
|
||||
provider,
|
||||
providers,
|
||||
capabilities,
|
||||
analysis_provider_binding: analysisProviderBinding,
|
||||
analysis_provider_candidates: analysisProviderCandidates,
|
||||
analysis_run:
|
||||
runId === undefined
|
||||
? null
|
||||
: {
|
||||
run_id: runId,
|
||||
process_lineage: processLineageStatus(runtimeLineageSnapshots),
|
||||
},
|
||||
analysis_activity: requestActivityStatus(requestActivitySnapshots),
|
||||
};
|
||||
return target === undefined
|
||||
? { open: false, ...common }
|
||||
: {
|
||||
open: true,
|
||||
...common,
|
||||
path: target.path,
|
||||
sha256: target.sha256,
|
||||
format: target.format,
|
||||
kind: target.kind,
|
||||
architecture: target.architecture ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const requestActivityStatus = (
|
||||
snapshots: readonly ProviderRequestActivitySnapshot[],
|
||||
) => {
|
||||
const providers = snapshots
|
||||
.slice()
|
||||
.sort((left, right) => left.provider.id.localeCompare(right.provider.id))
|
||||
.map(({ provider, active, queuedRequests }) => ({
|
||||
provider: providerSummary(provider),
|
||||
active:
|
||||
active === null
|
||||
? null
|
||||
: {
|
||||
request_id: active.requestId,
|
||||
operation: active.operation,
|
||||
elapsed_ms: active.elapsedMs,
|
||||
caller_state: active.callerState,
|
||||
},
|
||||
queued_requests: queuedRequests,
|
||||
}));
|
||||
const active = snapshots.flatMap((snapshot) =>
|
||||
snapshot.active === null ? [] : [snapshot.active],
|
||||
);
|
||||
return {
|
||||
status:
|
||||
snapshots.length === 0
|
||||
? "not_observed"
|
||||
: active.length > 0
|
||||
? "busy"
|
||||
: "idle",
|
||||
providers,
|
||||
};
|
||||
};
|
||||
|
||||
const providerSummary = ({
|
||||
id,
|
||||
name,
|
||||
version,
|
||||
}: {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly version: string | null;
|
||||
}) => ({ id, name, version });
|
||||
|
||||
const processLineageStatus = (
|
||||
snapshots: readonly ProviderRuntimeLineageSnapshot[],
|
||||
) =>
|
||||
snapshots.length === 0
|
||||
? { status: "not_observed" }
|
||||
: {
|
||||
status: "snapshots",
|
||||
snapshots: snapshots
|
||||
.slice()
|
||||
.sort((left, right) =>
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
)
|
||||
.map(({ provider, observation }) => ({
|
||||
provider: providerSummary(provider),
|
||||
observation:
|
||||
observation.status === "unavailable"
|
||||
? {
|
||||
status: observation.status,
|
||||
observed_at: observation.observedAt,
|
||||
launcher_pid: observation.launcherPid,
|
||||
process_group_id: observation.processGroupId,
|
||||
reason: observation.reason,
|
||||
}
|
||||
: {
|
||||
status: observation.status,
|
||||
observed_at: observation.observedAt,
|
||||
launcher_pid: observation.lineage.launcherPid,
|
||||
launcher_parent_pid: observation.lineage.launcherParentPid,
|
||||
process_group_id: observation.lineage.processGroupId,
|
||||
descendants: observation.lineage.descendants.map(
|
||||
({ pid, parentPid, processGroupId }) => ({
|
||||
pid,
|
||||
parent_pid: parentPid,
|
||||
process_group_id: processGroupId,
|
||||
}),
|
||||
),
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
const capabilityStatus = (
|
||||
descriptor: Omit<CapabilityDescriptor, "available" | "reason"> & {
|
||||
readonly available: boolean;
|
||||
readonly reason: string | null;
|
||||
},
|
||||
) => ({
|
||||
operation: descriptor.operation,
|
||||
available: descriptor.available,
|
||||
reason: descriptor.reason,
|
||||
availability_code: descriptor.availabilityCode ?? null,
|
||||
effects: {
|
||||
mutates_artifact: descriptor.effects.mutatesArtifact,
|
||||
launches_process: descriptor.effects.launchesProcess,
|
||||
may_show_ui: descriptor.effects.mayShowUi,
|
||||
may_access_network: descriptor.effects.mayAccessNetwork,
|
||||
may_write_filesystem: descriptor.effects.mayWriteFilesystem,
|
||||
changes_permissions: descriptor.effects.changesPermissions,
|
||||
requires_root: descriptor.effects.requiresRoot,
|
||||
},
|
||||
limitations: [...descriptor.limitations],
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisClientContext,
|
||||
AnalysisProfileResolutionOptions,
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||
|
||||
/** Synthetic compatibility identity for a deterministic provider set. */
|
||||
export const compositeProviderIdentity = (
|
||||
identities: readonly ProviderIdentity[],
|
||||
): ProviderIdentity => ({
|
||||
id: `composite:${identities
|
||||
.map(({ id }) => id)
|
||||
.sort()
|
||||
.join("+")}`,
|
||||
name: "REA composite analysis provider",
|
||||
version: null,
|
||||
});
|
||||
|
||||
/** Deterministically route disjoint operations without eagerly starting children. */
|
||||
export class CompositeProvider implements AnalysisProvider {
|
||||
readonly #identity: ProviderIdentity;
|
||||
readonly #capabilities: readonly CapabilityDescriptor[];
|
||||
readonly #providerByOperation: ReadonlyMap<string, AnalysisProvider>;
|
||||
readonly #profileProvider: AnalysisProvider | undefined;
|
||||
|
||||
constructor(readonly providers: readonly AnalysisProvider[]) {
|
||||
if (providers.length === 0)
|
||||
throw new RangeError("CompositeProvider requires at least one provider");
|
||||
const profileProviders = providers.filter(
|
||||
({ resolveAnalysisProfile }) => resolveAnalysisProfile !== undefined,
|
||||
);
|
||||
if (profileProviders.length > 1)
|
||||
throw new TypeError(
|
||||
"CompositeProvider supports at most one target-bound analysis profile",
|
||||
);
|
||||
this.#profileProvider = profileProviders[0];
|
||||
this.#identity = Object.freeze(
|
||||
compositeProviderIdentity(
|
||||
providers.map((provider) => provider.identity()),
|
||||
),
|
||||
);
|
||||
const routes = new Map<string, AnalysisProvider>();
|
||||
const capabilities: CapabilityDescriptor[] = [];
|
||||
for (const provider of providers) {
|
||||
for (const descriptor of provider.capabilities()) {
|
||||
if (routes.has(descriptor.operation))
|
||||
throw new TypeError(
|
||||
`Multiple providers declare operation ${descriptor.operation}`,
|
||||
);
|
||||
routes.set(descriptor.operation, provider);
|
||||
capabilities.push(descriptor);
|
||||
}
|
||||
}
|
||||
this.#providerByOperation = routes;
|
||||
this.#capabilities = Object.freeze(
|
||||
capabilities.sort(
|
||||
(left, right) =>
|
||||
left.operation.localeCompare(right.operation) ||
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
identity(): ProviderIdentity {
|
||||
return this.#identity;
|
||||
}
|
||||
|
||||
capabilities(): readonly CapabilityDescriptor[] {
|
||||
return this.#capabilities;
|
||||
}
|
||||
|
||||
resolveAnalysisProfile(
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
) {
|
||||
const resolve = this.#profileProvider?.resolveAnalysisProfile;
|
||||
return resolve === undefined
|
||||
? Promise.resolve(ok({ profile: null, compatibility: {} }))
|
||||
: resolve.call(this.#profileProvider, target, options);
|
||||
}
|
||||
|
||||
createClient(
|
||||
target: BinaryTarget,
|
||||
profile?: AnalysisProfileCommitment,
|
||||
context?: AnalysisClientContext,
|
||||
): AnalysisClient {
|
||||
const clients = new Map<AnalysisProvider, AnalysisClient>();
|
||||
const clientFor = (provider: AnalysisProvider): AnalysisClient => {
|
||||
const existing = clients.get(provider);
|
||||
if (existing !== undefined) return existing;
|
||||
const created = provider.createClient(
|
||||
target,
|
||||
profile?.provider.id === provider.identity().id ? profile : undefined,
|
||||
context,
|
||||
);
|
||||
clients.set(provider, created);
|
||||
return created;
|
||||
};
|
||||
const closeWithOutcome: NonNullable<
|
||||
AnalysisClient["closeWithOutcome"]
|
||||
> = async (options) => {
|
||||
const outcomes = await Promise.all(
|
||||
[...clients.entries()].map(([provider, client]) =>
|
||||
closeAnalysisClient(client, provider.identity().id, options),
|
||||
),
|
||||
);
|
||||
return outcomes.find((outcome) => !outcome.ok) ?? ok(null);
|
||||
};
|
||||
return {
|
||||
execute: (operation, parameters, options) => {
|
||||
if (operation === "health")
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
result: null,
|
||||
rawResult: null,
|
||||
provider: this.#identity,
|
||||
limitations: [],
|
||||
locations: [],
|
||||
subject: null,
|
||||
}),
|
||||
);
|
||||
const provider = this.#providerByOperation.get(operation);
|
||||
return provider === undefined
|
||||
? Promise.resolve(
|
||||
err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
this.#identity.id,
|
||||
operation,
|
||||
"operation is not declared by this provider set",
|
||||
),
|
||||
),
|
||||
)
|
||||
: clientFor(provider).execute(operation, parameters, options);
|
||||
},
|
||||
runtimeLineageSnapshots: () =>
|
||||
[...clients.values()]
|
||||
.flatMap((client) => client.runtimeLineageSnapshots?.() ?? [])
|
||||
.sort((left, right) =>
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
),
|
||||
requestActivitySnapshots: () =>
|
||||
[...clients.values()]
|
||||
.flatMap((client) => client.requestActivitySnapshots?.() ?? [])
|
||||
.sort((left, right) =>
|
||||
left.provider.id.localeCompare(right.provider.id),
|
||||
),
|
||||
closeWithOutcome,
|
||||
close: async () => {
|
||||
await closeWithOutcome();
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { parseBinaryTarget } from "../BinaryTargetResolver.js";
|
||||
import type {
|
||||
AnalysisProvider,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { LazyAnalysisProvider } from "./LazyAnalysisProvider.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import { err } from "../../domain/result.js";
|
||||
|
||||
const identity: ProviderIdentity = {
|
||||
id: "test-provider",
|
||||
name: "Test provider",
|
||||
version: "1",
|
||||
};
|
||||
|
||||
const providerWithClient = (
|
||||
onCreate: () => void = () => undefined,
|
||||
): AnalysisProvider => ({
|
||||
identity: () => identity,
|
||||
capabilities: () => [],
|
||||
createClient: () => {
|
||||
onCreate();
|
||||
return {
|
||||
execute: async (operation) => err(new AnalysisCancelledError(operation)),
|
||||
close: async () => undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const target = async () => {
|
||||
const parsed = await parseBinaryTarget(process.execPath);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
return parsed.value;
|
||||
};
|
||||
|
||||
it("returns a tagged provider error when lazy loading fails", async () => {
|
||||
const provider = new LazyAnalysisProvider({
|
||||
identity,
|
||||
capabilities: [],
|
||||
load: async () => {
|
||||
throw new Error("provider module unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await provider
|
||||
.createClient(await target())
|
||||
.execute("health", {});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ProviderAdapterError",
|
||||
providerId: "test-provider",
|
||||
operation: "health",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns cancellation while an unabortable lazy load continues safely", async () => {
|
||||
let finishLoading: ((provider: AnalysisProvider) => void) | undefined;
|
||||
let markCreated: (() => void) | undefined;
|
||||
const created = new Promise<void>((resolve) => {
|
||||
markCreated = resolve;
|
||||
});
|
||||
const provider = new LazyAnalysisProvider({
|
||||
identity,
|
||||
capabilities: [],
|
||||
load: () =>
|
||||
new Promise<AnalysisProvider>((resolve) => {
|
||||
finishLoading = resolve;
|
||||
}),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const client = provider.createClient(await target());
|
||||
const pending = client.execute("health", {}, { signal: controller.signal });
|
||||
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError", operation: "health" },
|
||||
});
|
||||
|
||||
finishLoading?.(providerWithClient(() => markCreated?.()));
|
||||
await created;
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("reports that a lazy client was closed before its first operation", async () => {
|
||||
const provider = new LazyAnalysisProvider({
|
||||
identity,
|
||||
capabilities: [],
|
||||
load: async () => providerWithClient(),
|
||||
});
|
||||
const client = provider.createClient(await target());
|
||||
await client.close();
|
||||
|
||||
expect(await client.execute("health", {})).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ProviderAdapterError",
|
||||
operation: "health",
|
||||
diagnostics: { reason: "client_closed" },
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { err } from "../../domain/result.js";
|
||||
import { ABORTED, waitForAbortable } from "./AbortablePromise.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisClientContext,
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
ProviderRequestActivitySnapshot,
|
||||
ProviderRuntimeLineageSnapshot,
|
||||
} from "../AnalysisProvider.js";
|
||||
|
||||
type LoadAnalysisProvider = () => Promise<AnalysisProvider>;
|
||||
|
||||
const isAborted = (signal: AbortSignal | undefined): boolean =>
|
||||
signal?.aborted === true;
|
||||
|
||||
/**
|
||||
* Preserve synchronous provider discovery while loading implementation modules
|
||||
* only when a target-bound client first executes.
|
||||
*/
|
||||
export class LazyAnalysisProvider implements AnalysisProvider {
|
||||
readonly #providerIdentity: ProviderIdentity;
|
||||
readonly #capabilityDescriptors: readonly CapabilityDescriptor[];
|
||||
readonly #loadProvider: LoadAnalysisProvider;
|
||||
#provider: Promise<AnalysisProvider> | undefined;
|
||||
|
||||
constructor(options: {
|
||||
readonly identity: ProviderIdentity;
|
||||
readonly capabilities: readonly CapabilityDescriptor[];
|
||||
readonly load: LoadAnalysisProvider;
|
||||
}) {
|
||||
this.#providerIdentity = options.identity;
|
||||
this.#capabilityDescriptors = options.capabilities;
|
||||
this.#loadProvider = options.load;
|
||||
}
|
||||
|
||||
/** Return source-declared provider identity without loading its implementation. */
|
||||
identity(): ProviderIdentity {
|
||||
return this.#providerIdentity;
|
||||
}
|
||||
|
||||
/** Return source-declared capability metadata without loading its implementation. */
|
||||
capabilities(): readonly CapabilityDescriptor[] {
|
||||
return this.#capabilityDescriptors;
|
||||
}
|
||||
|
||||
/** Create a client whose implementation is loaded on first execution. */
|
||||
createClient(
|
||||
target: BinaryTarget,
|
||||
profile?: AnalysisProfileCommitment,
|
||||
context?: AnalysisClientContext,
|
||||
): AnalysisClient {
|
||||
return new LazyAnalysisClient(async () => {
|
||||
const provider = await this.#load();
|
||||
return provider.createClient(target, profile, context);
|
||||
}, this.#providerIdentity.id);
|
||||
}
|
||||
|
||||
async #load(): Promise<AnalysisProvider> {
|
||||
this.#provider ??= this.#loadProvider().catch((cause: unknown) => {
|
||||
this.#provider = undefined;
|
||||
throw cause;
|
||||
});
|
||||
return this.#provider;
|
||||
}
|
||||
}
|
||||
|
||||
class LazyAnalysisClient implements AnalysisClient {
|
||||
readonly #loadClient: () => Promise<AnalysisClient>;
|
||||
readonly #providerId: string;
|
||||
#client: AnalysisClient | undefined;
|
||||
#loading: Promise<AnalysisClient> | undefined;
|
||||
#closed = false;
|
||||
|
||||
constructor(loadClient: () => Promise<AnalysisClient>, providerId: string) {
|
||||
this.#loadClient = loadClient;
|
||||
this.#providerId = providerId;
|
||||
}
|
||||
|
||||
execute: AnalysisClient["execute"] = async (
|
||||
operation,
|
||||
parameters,
|
||||
options,
|
||||
) => {
|
||||
try {
|
||||
if (isAborted(options?.signal))
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const loaded = await waitForAbortable(
|
||||
this.#load(operation),
|
||||
options?.signal,
|
||||
);
|
||||
if (loaded === ABORTED) return err(new AnalysisCancelledError(operation));
|
||||
return await loaded.execute(operation, parameters, options);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
return err(
|
||||
new ProviderAdapterError(this.#providerId, operation, {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
runtimeLineageSnapshots(): readonly ProviderRuntimeLineageSnapshot[] {
|
||||
return this.#client?.runtimeLineageSnapshots?.() ?? [];
|
||||
}
|
||||
|
||||
requestActivitySnapshots(): readonly ProviderRequestActivitySnapshot[] {
|
||||
return this.#client?.requestActivitySnapshots?.() ?? [];
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.#closed = true;
|
||||
if (this.#loading === undefined) return;
|
||||
const client = await this.#loading;
|
||||
await client.close();
|
||||
}
|
||||
|
||||
async #load(operation: string): Promise<AnalysisClient> {
|
||||
if (this.#closed)
|
||||
throw new ProviderAdapterError(this.#providerId, operation, {
|
||||
diagnostics: { reason: "client_closed" },
|
||||
});
|
||||
this.#loading ??= this.#loadClient()
|
||||
.then((client) => {
|
||||
this.#client = client;
|
||||
return client;
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
this.#loading = undefined;
|
||||
throw cause;
|
||||
});
|
||||
return this.#loading;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import type { AnalysisProviderSelector } from "../../contracts/providerSelection.js";
|
||||
import {
|
||||
analysisProfileSchema,
|
||||
type AnalysisProfileCommitment,
|
||||
} from "../../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { ProviderSelectionError } from "../../domain/providerSelectionError.js";
|
||||
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisClientContext,
|
||||
AnalysisClientFactory,
|
||||
AnalysisProfileResolution,
|
||||
AnalysisProfileResolutionOptions,
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import {
|
||||
AnalysisProviderRegistry,
|
||||
type AnalysisProviderBinding,
|
||||
type AnalysisProviderCandidateStatus,
|
||||
type AnalysisProviderSelection,
|
||||
} from "./AnalysisProviderRegistry.js";
|
||||
import { ABORTED, waitForAbortable } from "./AbortablePromise.js";
|
||||
import {
|
||||
CompositeProvider,
|
||||
compositeProviderIdentity,
|
||||
} from "./CompositeProvider.js";
|
||||
|
||||
/** Immutable operation routes and binding metadata for one target transition. */
|
||||
export interface SessionProviderRoute {
|
||||
readonly identity: ProviderIdentity;
|
||||
readonly capabilities: ReadonlyMap<string, CapabilityDescriptor> | undefined;
|
||||
readonly profile: AnalysisProfileCommitment | null;
|
||||
readonly compatibility: Readonly<Record<string, JsonValue>>;
|
||||
readonly binding: AnalysisProviderBinding | null;
|
||||
readonly selection: AnalysisProviderSelection | undefined;
|
||||
createClient(
|
||||
target: BinaryTarget,
|
||||
context: AnalysisClientContext,
|
||||
): AnalysisClient;
|
||||
}
|
||||
|
||||
interface SingleProviderRuntime {
|
||||
readonly kind: "single";
|
||||
readonly provider: AnalysisProvider | AnalysisClientFactory;
|
||||
readonly identity: ProviderIdentity;
|
||||
readonly capabilities: ReadonlyMap<string, CapabilityDescriptor> | undefined;
|
||||
readonly resolveProfile: (
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
) => Promise<Result<AnalysisProfileResolution, AnalysisError>>;
|
||||
}
|
||||
|
||||
interface SelectableProviderRuntime {
|
||||
readonly kind: "selectable";
|
||||
readonly registry: AnalysisProviderRegistry;
|
||||
readonly auxiliaryProviders: readonly AnalysisProvider[];
|
||||
readonly identity: ProviderIdentity;
|
||||
readonly initialRoute: SessionProviderRoute;
|
||||
}
|
||||
|
||||
/** Resolve either the compatibility single-provider seam or a selectable set. */
|
||||
export class SessionProviderRouter {
|
||||
private constructor(
|
||||
private readonly runtime: SingleProviderRuntime | SelectableProviderRuntime,
|
||||
) {}
|
||||
|
||||
/** Create routing for an embedding backed by one fixed provider or factory. */
|
||||
static single(
|
||||
provider: AnalysisProvider | AnalysisClientFactory,
|
||||
options: {
|
||||
readonly resolveAnalysisProfile?: (
|
||||
target: BinaryTarget,
|
||||
resolutionOptions?: AnalysisProfileResolutionOptions,
|
||||
) => Promise<Result<AnalysisProfileResolution, AnalysisError>>;
|
||||
} = {},
|
||||
): SessionProviderRouter {
|
||||
const identity =
|
||||
typeof provider === "function"
|
||||
? { id: "unidentified", name: "Unidentified provider", version: null }
|
||||
: provider.identity();
|
||||
const capabilities =
|
||||
typeof provider === "function"
|
||||
? undefined
|
||||
: capabilityMap(provider.capabilities());
|
||||
const resolveProfile =
|
||||
options.resolveAnalysisProfile ??
|
||||
(typeof provider === "function" ||
|
||||
provider.resolveAnalysisProfile === undefined
|
||||
? () => Promise.resolve(ok({ profile: null, compatibility: {} }))
|
||||
: (
|
||||
target: BinaryTarget,
|
||||
resolutionOptions?: AnalysisProfileResolutionOptions,
|
||||
) =>
|
||||
provider.resolveAnalysisProfile?.(target, resolutionOptions) ??
|
||||
Promise.resolve(ok({ profile: null, compatibility: {} })));
|
||||
return new SessionProviderRouter({
|
||||
kind: "single",
|
||||
provider,
|
||||
identity,
|
||||
capabilities,
|
||||
resolveProfile,
|
||||
});
|
||||
}
|
||||
|
||||
/** Create selection-aware routing while keeping auxiliary families disjoint. */
|
||||
static selectable(
|
||||
registry: AnalysisProviderRegistry,
|
||||
auxiliaryProviders: readonly AnalysisProvider[],
|
||||
): SessionProviderRouter {
|
||||
const identities = [
|
||||
...registry.identities(),
|
||||
...auxiliaryProviders.map((provider) => provider.identity()),
|
||||
];
|
||||
assertUniqueProviderIds(identities);
|
||||
const auxiliaryOperations = assertDisjoint(auxiliaryProviders);
|
||||
for (const operation of registry.declaredOperations())
|
||||
if (auxiliaryOperations.has(operation))
|
||||
throw new TypeError(
|
||||
`Deep and auxiliary providers both declare operation ${operation}`,
|
||||
);
|
||||
const initialRoute = selectableRoute(auxiliaryProviders, undefined);
|
||||
return new SessionProviderRouter({
|
||||
kind: "selectable",
|
||||
registry,
|
||||
auxiliaryProviders: [...auxiliaryProviders],
|
||||
identity:
|
||||
identities.length === 0
|
||||
? emptyProviderIdentity()
|
||||
: compositeProviderIdentity(identities),
|
||||
initialRoute,
|
||||
});
|
||||
}
|
||||
|
||||
/** Compatibility identity covering every configured provider. */
|
||||
configuredIdentity(): ProviderIdentity {
|
||||
return structuredClone(this.runtime.identity);
|
||||
}
|
||||
|
||||
/** Provider identities exposed through the flat 1.x compatibility field. */
|
||||
providerIdentities(route: SessionProviderRoute): readonly ProviderIdentity[] {
|
||||
if (this.runtime.kind === "single") {
|
||||
if (route.capabilities === undefined) return [route.identity];
|
||||
return uniqueProviderIdentities(
|
||||
[...route.capabilities.values()].map(({ provider }) => provider),
|
||||
);
|
||||
}
|
||||
const selected = route.binding?.identity;
|
||||
return uniqueProviderIdentities([
|
||||
...this.runtime.auxiliaryProviders.map((provider) => provider.identity()),
|
||||
...this.runtime.registry
|
||||
.identities()
|
||||
.map((identity) =>
|
||||
identity.id === selected?.id ? selected : identity,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Target-free routes and discovery metadata. */
|
||||
initialRoute(): SessionProviderRoute {
|
||||
if (this.runtime.kind === "selectable") return this.runtime.initialRoute;
|
||||
const runtime = this.runtime;
|
||||
return {
|
||||
identity: structuredClone(runtime.identity),
|
||||
capabilities: runtime.capabilities,
|
||||
profile: null,
|
||||
compatibility: {},
|
||||
binding: null,
|
||||
selection: undefined,
|
||||
createClient: (target, context) =>
|
||||
createSingleProviderClient(runtime, target, undefined, context),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve target routes before any provider client is created. */
|
||||
async resolve(
|
||||
target: BinaryTarget,
|
||||
providerId?: AnalysisProviderSelector,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<SessionProviderRoute, AnalysisError>> {
|
||||
const resolutionOptions = signal === undefined ? undefined : { signal };
|
||||
if (this.runtime.kind === "selectable") {
|
||||
const selected = await this.runtime.registry.select(
|
||||
target,
|
||||
providerId,
|
||||
resolutionOptions,
|
||||
);
|
||||
return selected.ok
|
||||
? ok(selectableRoute(this.runtime.auxiliaryProviders, selected.value))
|
||||
: selected;
|
||||
}
|
||||
const runtime = this.runtime;
|
||||
if (
|
||||
providerId !== undefined &&
|
||||
providerId !== "auto" &&
|
||||
providerId !== runtime.identity.id
|
||||
)
|
||||
return err(
|
||||
new ProviderSelectionError({
|
||||
reason: "unknown_provider",
|
||||
requestedProviderId: providerId,
|
||||
candidateIds: [runtime.identity.id],
|
||||
}),
|
||||
);
|
||||
const resolved = await waitForAbortable(
|
||||
runtime.resolveProfile(target, resolutionOptions),
|
||||
signal,
|
||||
);
|
||||
if (resolved === ABORTED)
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
if (!resolved.ok) return resolved;
|
||||
const normalized = normalizeProfileResolution(
|
||||
resolved.value,
|
||||
runtime.identity.id,
|
||||
);
|
||||
if (!normalized.ok) return normalized;
|
||||
return ok({
|
||||
identity: structuredClone(runtime.identity),
|
||||
capabilities: runtime.capabilities,
|
||||
profile: normalized.value.profile,
|
||||
compatibility: normalized.value.compatibility,
|
||||
binding: null,
|
||||
selection: undefined,
|
||||
createClient: (openedTarget, context) =>
|
||||
createSingleProviderClient(
|
||||
runtime,
|
||||
openedTarget,
|
||||
normalized.value.profile ?? undefined,
|
||||
context,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** Candidate status authoritative for the active or target-free route. */
|
||||
candidateStatuses(
|
||||
route: SessionProviderRoute,
|
||||
): readonly AnalysisProviderCandidateStatus[] {
|
||||
if (this.runtime.kind === "single") return [];
|
||||
return route.selection?.candidates ?? this.runtime.registry.candidates();
|
||||
}
|
||||
|
||||
/** Produce exact candidate rejections for an intentionally unbound route. */
|
||||
unboundOperationError(
|
||||
operation: string,
|
||||
route: SessionProviderRoute,
|
||||
): ProviderSelectionError | undefined {
|
||||
if (
|
||||
this.runtime.kind !== "selectable" ||
|
||||
route.binding !== null ||
|
||||
route.selection === undefined
|
||||
)
|
||||
return undefined;
|
||||
return this.runtime.registry.unboundOperationError(
|
||||
operation,
|
||||
route.selection,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const selectableRoute = (
|
||||
auxiliaryProviders: readonly AnalysisProvider[],
|
||||
selection: AnalysisProviderSelection | undefined,
|
||||
): SessionProviderRoute => {
|
||||
const binding = selection?.binding ?? null;
|
||||
const providers = [
|
||||
...auxiliaryProviders,
|
||||
...(binding === null ? [] : [binding.provider]),
|
||||
];
|
||||
const provider =
|
||||
providers.length === 0 ? undefined : new CompositeProvider(providers);
|
||||
const identity = provider?.identity() ?? emptyProviderIdentity();
|
||||
const capabilities = capabilityMap(provider?.capabilities() ?? []);
|
||||
return {
|
||||
identity,
|
||||
capabilities,
|
||||
profile: binding?.profile ?? null,
|
||||
compatibility: structuredClone(binding?.compatibility ?? {}),
|
||||
binding,
|
||||
selection,
|
||||
createClient: (target, context) =>
|
||||
provider?.createClient(target, binding?.profile, context) ??
|
||||
emptyClient(identity),
|
||||
};
|
||||
};
|
||||
|
||||
const createSingleProviderClient = (
|
||||
runtime: SingleProviderRuntime,
|
||||
target: BinaryTarget,
|
||||
profile: AnalysisProfileCommitment | undefined,
|
||||
context: AnalysisClientContext,
|
||||
): AnalysisClient =>
|
||||
typeof runtime.provider === "function"
|
||||
? runtime.provider(target, profile, context)
|
||||
: runtime.provider.createClient(target, profile, context);
|
||||
|
||||
const emptyClient = (identity: ProviderIdentity): AnalysisClient => ({
|
||||
execute: (operation) =>
|
||||
operation === "health"
|
||||
? Promise.resolve(
|
||||
ok({
|
||||
result: null,
|
||||
rawResult: null,
|
||||
provider: identity,
|
||||
limitations: [],
|
||||
locations: [],
|
||||
subject: null,
|
||||
}),
|
||||
)
|
||||
: Promise.resolve(
|
||||
err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
identity.id,
|
||||
operation,
|
||||
"operation is not declared by this provider set",
|
||||
),
|
||||
),
|
||||
),
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
|
||||
const emptyProviderIdentity = (): ProviderIdentity => ({
|
||||
id: "composite:none",
|
||||
name: "REA composite analysis provider",
|
||||
version: null,
|
||||
});
|
||||
|
||||
const capabilityMap = (
|
||||
capabilities: readonly CapabilityDescriptor[],
|
||||
): ReadonlyMap<string, CapabilityDescriptor> =>
|
||||
new Map(capabilities.map((descriptor) => [descriptor.operation, descriptor]));
|
||||
|
||||
const uniqueProviderIdentities = (
|
||||
identities: readonly ProviderIdentity[],
|
||||
): readonly ProviderIdentity[] =>
|
||||
[...new Map(identities.map((identity) => [identity.id, identity])).values()]
|
||||
.sort((left, right) => left.id.localeCompare(right.id))
|
||||
.map((identity) => structuredClone(identity));
|
||||
|
||||
const assertDisjoint = (
|
||||
providers: readonly AnalysisProvider[],
|
||||
): ReadonlySet<string> => {
|
||||
const routes = new Set<string>();
|
||||
for (const provider of providers)
|
||||
for (const { operation } of provider.capabilities()) {
|
||||
if (routes.has(operation))
|
||||
throw new TypeError(
|
||||
`Multiple auxiliary providers declare operation ${operation}`,
|
||||
);
|
||||
routes.add(operation);
|
||||
}
|
||||
return routes;
|
||||
};
|
||||
|
||||
const assertUniqueProviderIds = (
|
||||
identities: readonly ProviderIdentity[],
|
||||
): void => {
|
||||
const ids = new Set<string>();
|
||||
for (const identity of identities) {
|
||||
if (ids.has(identity.id))
|
||||
throw new TypeError(`Duplicate configured provider ID: ${identity.id}`);
|
||||
ids.add(identity.id);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeProfileResolution = (
|
||||
resolution: AnalysisProfileResolution,
|
||||
providerId: string,
|
||||
): Result<AnalysisProfileResolution, ProviderAdapterError> => {
|
||||
try {
|
||||
return ok({
|
||||
profile:
|
||||
resolution.profile === null
|
||||
? null
|
||||
: analysisProfileSchema.parse(resolution.profile),
|
||||
compatibility: jsonObjectSchema.parse(resolution.compatibility),
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new ProviderAdapterError(providerId, "resolve_analysis_profile", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user