refactor(providers)!: await owned readiness observations

Carry asynchronous readiness through provider selection, session status, CLI and MCP. Join observations during cancellation and shutdown, keep status readable during finalization, and retain mixed provider errors and exact cleanup dependencies through the shared inspection failure owner.

Validation: 255 focused tests across 18 files; 21 registry tests rerun with primary restoration assertion; check:fast; docs:check and 5 README conformance tests. Registered MCP and SDK schema checks use controlled providers; no real Ghidra/JVM verification.

This is a prerequisite for #4561. The synchronous Ghidra Java producer and its doctor/client ownership migration remain unfinished.

BREAKING CHANGE: binary_session candidate availability can be unknown during owner finalization. Consumers must handle this state; the internal session status API is now asynchronous.

Refs: #4561
This commit is contained in:
morluto committed 2026-10-17 09:57:43 +08:00
1 parent 4087048270
commit 150e996a86
25 files changed
+643 -132

No files matched your search

+3
View File
@@ -83,6 +83,9 @@ providers need not expose identical state machines.
recovery handshakes. Bound abandoned unauthenticated listeners where the
protocol permits it; that deadline must not expire authenticated idle sessions
or long-running native work. Listener expiry does not prove target closure.
- Readiness observations may await owned prerequisite commands. Status must
remain readable during owner finalization without starting replacement work;
report readiness as unknown when it cannot be re-observed.
- Shared startup has one producer and independently cancellable caller waits.
Keep it running while any caller still needs it. The final departing caller
waits for owned cleanup and retains its failure. Provider closure is distinct
+9 -2
View File
@@ -300,8 +300,15 @@ export interface AnalysisProviderCandidate extends AnalysisProvider {
target: BinaryTarget,
options?: AnalysisProfileResolutionOptions,
): Promise<Result<AnalysisProfileResolution, AnalysisError>>;
inspectAvailability(): ProviderAvailability;
inspectReadinessSnapshot?(): AnalysisProviderReadinessSnapshot;
/** Await prerequisite observations and finalization under the supplied owner signal. */
inspectAvailability(
signal?: AbortSignal,
): ProviderAvailability | Promise<ProviderAvailability>;
inspectReadinessSnapshot?(
signal?: AbortSignal,
):
| AnalysisProviderReadinessSnapshot
| Promise<AnalysisProviderReadinessSnapshot>;
inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport;
/** Observe retained candidate cleanup without starting or retrying work. */
hasPendingOwnedResources?(): boolean;
+1 -1
View File
@@ -21,7 +21,7 @@ export const runSessionStatus = async (
if (!config.ok) return { error: projectAnalysisError(config.error) };
const session = await dependencies.createBinarySession(config.value, logger);
try {
const status = jsonObjectSchema.parse(session.status());
const status = jsonObjectSchema.parse(await session.status());
return {
...status,
capabilities: [
@@ -65,7 +65,7 @@ it("rejects malformed provider capability declarations", () => {
).toThrow(/Invalid analysis provider ID: auto/u);
});
it("uses one owner readiness snapshot for candidate availability and capabilities", () => {
it("uses one owner readiness snapshot for candidate availability and capabilities", async () => {
const base = candidate("alpha").provider;
let observed = 0;
let capabilityReads = 0;
@@ -92,7 +92,7 @@ it("uses one owner readiness snapshot for candidate availability and capabilitie
},
};
const [status] = new AnalysisProviderRegistry([provider]).candidates();
const [status] = await new AnalysisProviderRegistry([provider]).candidates();
expect(observed).toBe(1);
expect(capabilityReads).toBe(1);
expect(status).toMatchObject({
@@ -763,3 +763,197 @@ it("registers before production and gates fresh selections until shutdown has jo
await registry.closeOwnedResources();
}
});
it("joins cancelled readiness finalization and shutdown before admitting another observation", async () => {
const base = candidate("alpha").provider;
let enter!: () => void;
let finish!: () => void;
const entered = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
let calls = 0;
let observed: AbortSignal | undefined;
const registry = new AnalysisProviderRegistry([
{
...base,
inspectAvailability: async (signal) => {
calls++;
if (calls === 1) {
observed = signal;
enter();
await gate;
}
return base.inspectAvailability();
},
},
]);
const controller = new AbortController();
const reason = { request: "opaque readiness cancellation" };
let settled = false;
const observing = registry
.candidates(undefined, { signal: controller.signal })
.catch((cause) => {
settled = true;
return cause;
});
await entered;
controller.abort(reason);
let closed = false;
const closing = registry.closeOwnedResources().then((value) => {
closed = true;
return value;
});
const next = registry.candidates();
try {
await new Promise<void>((resolve) => setImmediate(resolve));
expect(settled).toBe(false);
expect(closed).toBe(false);
expect(calls).toBe(1);
expect(observed?.reason).toBe(reason);
expect(registry.hasPendingOwnedResources()).toBe(true);
finish();
const failure = await observing;
expect(failure).toBeInstanceOf(AnalysisCancelledError);
expect(failure.cause).toBe(reason);
expect(await closing).toEqual(ok(null));
expect(await next).toMatchObject([
{
availability: {
status: "unknown",
diagnostics: { finalization: "in_progress" },
},
},
]);
expect(await registry.candidates()).toHaveLength(1);
expect(calls).toBe(2);
expect(registry.hasPendingOwnedResources()).toBe(false);
} finally {
finish();
await Promise.allSettled([observing, closing, next]);
await registry.closeOwnedResources();
}
});
it("joins every candidate and retains physical failure with exact mixed cleanup owners", async () => {
const alpha = candidate("alpha").provider;
const beta = candidate("beta").provider;
const gamma = candidate("gamma").provider;
let finish!: () => void;
let enter!: () => void;
const entered = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
const physical = new ProviderAdapterError("alpha", "binary_session", {
diagnostics: { reason: "physical observation failed" },
});
const cleanup = new ProviderCleanupError(
"beta",
["beta retained prerequisite"],
{ reason: "controlled cleanup refusal" },
);
let releases = 0;
const registry = new AnalysisProviderRegistry([
{
...alpha,
inspectAvailability: async () => {
throw physical;
},
},
{
...beta,
inspectAvailability: async () => {
enter();
await gate;
throw cleanup;
},
closeOwnedResources: async () => {
releases++;
cleanup.resolveCleanup();
return ok(null);
},
},
gamma,
]);
let settled = false;
const observing = registry.candidates().catch((cause) => {
settled = true;
return cause;
});
await entered;
try {
await new Promise<void>((resolve) => setImmediate(resolve));
expect(settled).toBe(false);
finish();
const failure = await observing;
expect(failure.cleanupIncomplete).toBe(true);
expect(failure.cleanupResources).toContain("beta retained prerequisite");
expect(failure.diagnostics).toMatchObject({
readiness_candidates: [
{ provider: { id: "gamma" }, availability: { status: "available" } },
],
});
expect(failure.cleanupPrimary).toBe(physical);
expect(projectAnalysisError(failure).details).toMatchObject({
diagnostics: {
readiness_errors: expect.arrayContaining([
{
provider_id: "alpha",
error: expect.objectContaining({ code: "execution_failure" }),
},
{
provider_id: "beta",
error: expect.objectContaining({ code: "cleanup_incomplete" }),
},
]),
},
});
expect(registry.hasPendingOwnedResources()).toBe(true);
expect(await registry.closeOwnedResources()).toEqual(ok(null));
expect(releases).toBe(1);
expect(failure.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(failure)).toMatchObject({
code: "execution_failure",
details: { diagnostics: { reason: "physical observation failed" } },
});
expect(registry.hasPendingOwnedResources()).toBe(false);
} finally {
finish();
await observing;
await registry.closeOwnedResources();
}
});
it("registers selection before async readiness can race shutdown", async () => {
let calls = 0;
let observed: AbortSignal | undefined;
const base = candidate("alpha").provider;
const registry = new AnalysisProviderRegistry([
{
...base,
inspectAvailability: async (signal) => {
calls++;
observed = signal;
return base.inspectAvailability();
},
},
]);
const selecting = registry.select(DATABASE_TARGET, "alpha");
const closing = registry.closeOwnedResources();
const [selected, closed] = await Promise.all([selecting, closing]);
expect(selected).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(closed).toEqual(ok(null));
expect(calls).toBe(1);
expect(observed?.aborted).toBe(true);
expect((await registry.select(DATABASE_TARGET, "alpha")).ok).toBe(true);
expect(calls).toBe(2);
expect(registry.hasPendingOwnedResources()).toBe(false);
});
@@ -14,13 +14,17 @@ import {
import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js";
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
ProviderSelectionError,
type ProviderSelectionFailureReason,
type ProviderSelectionRejection,
} from "../../domain/providerSelectionError.js";
import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js";
import {
jsonObjectSchema,
jsonValueSchema,
type JsonValue,
} from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import type {
AnalysisProviderCandidate,
@@ -39,7 +43,14 @@ import type { FileCloseResourceOwner } from "../AnalysisProvider.js";
/** Deterministic discovery truth for one configured deep provider. */
export interface AnalysisProviderCandidateStatus {
readonly provider: ProviderIdentity;
readonly availability: ProviderAvailability;
readonly availability:
| ProviderAvailability
| {
readonly status: "unknown";
readonly code: null;
readonly reason: string;
readonly diagnostics: Readonly<Record<string, JsonValue>>;
};
readonly targetSupport:
| ProviderTargetSupport
| {
@@ -89,6 +100,11 @@ export class AnalysisProviderRegistry {
readonly #defaultSelector: AnalysisProviderSelector;
readonly #selections = new Set<SharedStartupAttempt<SelectionResult>>();
readonly #discoveries = new Set<
SharedStartupAttempt<
Result<readonly AnalysisProviderCandidateStatus[], AnalysisError>
>
>();
readonly #selectionCleanup = new Set<AnalysisError>();
readonly #profileFiles = new ArtifactResourceScope();
readonly #profileFileOwner: FileCloseResourceOwner = {
@@ -167,6 +183,7 @@ export class AnalysisProviderRegistry {
hasPendingOwnedResources(): boolean {
return (
this.#selections.size > 0 ||
this.#discoveries.size > 0 ||
this.#profileFiles.hasPending ||
[...this.#selectionCleanup].some((error) => error.cleanupIncomplete) ||
this.#providers.some(
@@ -187,8 +204,13 @@ export class AnalysisProviderRegistry {
async #closeOwnedResources(): Promise<Result<null, AnalysisError>> {
const selections = [...this.#selections];
const discoveries = [...this.#discoveries];
for (const discovery of discoveries) discovery.close();
for (const selection of selections) selection.close();
await Promise.allSettled(selections.map((selection) => selection.promise));
await Promise.allSettled([
...discoveries.map((discovery) => discovery.promise),
...selections.map((selection) => selection.promise),
]);
const failures: AnalysisError[] = [];
try {
await this.#profileFiles.retryCleanup();
@@ -236,10 +258,98 @@ export class AnalysisProviderRegistry {
}
/** Discover candidates for status without resolving profiles or starting clients. */
candidates(
async candidates(
target?: BinaryTarget,
): readonly AnalysisProviderCandidateStatus[] {
return this.#providers.map((provider) => candidateStatus(provider, target));
options: { readonly signal?: AbortSignal } = {},
): Promise<readonly AnalysisProviderCandidateStatus[]> {
if (options.signal?.aborted)
throw new AnalysisCancelledError("binary_session", {
cause: options.signal.reason,
});
if (this.#closing !== undefined)
return this.#providers.map((provider) =>
projectCandidateStatus(provider, target, {
availability: {
status: "unknown",
code: null,
reason:
"Provider ownership finalization is in progress; readiness has not been re-observed.",
diagnostics: { finalization: "in_progress" },
},
capabilities: provider.capabilities(),
}),
);
const discovery = new SharedStartupAttempt<
Result<readonly AnalysisProviderCandidateStatus[], AnalysisError>
>(
(signal) =>
Promise.resolve().then(() => this.#discoverCandidates(target, signal)),
options.signal,
);
this.#discoveries.add(discovery);
try {
const result = await discovery.promise;
if (!result.ok) throw result.error;
if (discovery.signal.aborted)
throw new AnalysisCancelledError("binary_session", {
cause: discovery.signal.reason,
diagnostics: {
readiness_candidates: jsonValueSchema.parse(result.value),
},
});
return result.value;
} finally {
this.#discoveries.delete(discovery);
}
}
async #discoverCandidates(
target: BinaryTarget | undefined,
signal: AbortSignal,
operation: "binary_session" | "open_binary" = "binary_session",
): Promise<
Result<readonly AnalysisProviderCandidateStatus[], AnalysisError>
> {
if (signal.aborted)
return err(
new AnalysisCancelledError(operation, { cause: signal.reason }),
);
const results = await Promise.allSettled(
this.#providers.map((provider) =>
candidateStatus(provider, target, signal, operation),
),
);
const statuses: AnalysisProviderCandidateStatus[] = [];
const failures: {
readonly providerId: string;
readonly error: AnalysisError;
}[] = [];
for (const [index, result] of results.entries()) {
if (result.status === "fulfilled") statuses.push(result.value);
else
failures.push({
providerId: this.#providers[index]!.identity().id,
error:
result.reason instanceof AnalysisError
? result.reason
: new ProviderAdapterError(
this.#providers[index]!.identity().id,
operation,
{ cause: result.reason },
),
});
}
for (const { error } of failures)
if (error.cleanupIncomplete) this.#selectionCleanup.add(error);
if (failures.length > 0)
return err(
providerInspectionFailure(
failures,
{ operation, candidates: statuses },
signal,
),
);
return ok(statuses);
}
/** Select one whole deep-operation family for a parsed target. */
@@ -265,7 +375,7 @@ export class AnalysisProviderRegistry {
) {
// A retry may release retained owners; never stop another live selection
// merely because a new caller wants admission.
if (this.#selections.size > 0)
if (this.#selections.size > 0 || this.#discoveries.size > 0)
return err(
[...this.#selectionCleanup].find(
(failure) => failure.cleanupIncomplete,
@@ -324,6 +434,13 @@ export class AnalysisProviderRegistry {
return err(
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
);
const discovery = await this.#discoverCandidates(
target,
signal,
"open_binary",
);
if (!discovery.ok) return discovery;
const baseline = discovery.value;
const parsedRequest =
requestedSelector === undefined
? undefined
@@ -335,12 +452,11 @@ export class AnalysisProviderRegistry {
typeof requestedSelector === "string"
? requestedSelector
: "<invalid>",
this.candidates(target),
baseline,
),
);
const request = parsedRequest?.data;
const selector = request ?? this.#defaultSelector;
const baseline = this.candidates(target);
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
@@ -496,7 +612,13 @@ export class AnalysisProviderRegistry {
failures.some(({ error }) => error.cleanupIncomplete) ||
signalIsAborted(signal)
)
return err(profileSelectionFailure(failures, signal));
return err(
providerInspectionFailure(
failures,
{ operation: "resolve_analysis_profile" },
signal,
),
);
const statuses = evaluations.map(({ status }) => status);
const usable = evaluations.filter(
(
@@ -522,14 +644,32 @@ export class AnalysisProviderRegistry {
}
}
const candidateStatus = (
const candidateStatus = async (
provider: AnalysisProviderCandidate,
target: BinaryTarget | undefined,
signal: AbortSignal,
operation: "binary_session" | "open_binary",
): Promise<AnalysisProviderCandidateStatus> => {
if (signal.aborted)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
const readiness =
provider.inspectReadinessSnapshot === undefined
? {
availability: await provider.inspectAvailability(signal),
capabilities: provider.capabilities(),
}
: await provider.inspectReadinessSnapshot(signal);
return projectCandidateStatus(provider, target, readiness);
};
const projectCandidateStatus = (
provider: AnalysisProviderCandidate,
target: BinaryTarget | undefined,
readiness: {
readonly availability: AnalysisProviderCandidateStatus["availability"];
readonly capabilities: readonly CapabilityDescriptor[];
},
): AnalysisProviderCandidateStatus => {
const readiness = provider.inspectReadinessSnapshot?.() ?? {
availability: provider.inspectAvailability(),
capabilities: provider.capabilities(),
};
return {
provider: structuredClone(provider.identity()),
availability: cloneAvailability(readiness.availability),
@@ -626,8 +766,8 @@ const candidateRejections = (
);
const cloneAvailability = (
availability: ProviderAvailability,
): ProviderAvailability => ({
availability: AnalysisProviderCandidateStatus["availability"],
): AnalysisProviderCandidateStatus["availability"] => ({
...availability,
diagnostics: jsonObjectSchema.parse(availability.diagnostics),
});
@@ -640,28 +780,49 @@ const cloneTargetSupport = (
});
/** Every terminal child remains attributed; cleanup is conjunctive across owners. */
const profileSelectionFailure = (
const providerInspectionFailure = (
failures: readonly {
readonly providerId: string;
readonly error: AnalysisError;
}[],
context:
| { readonly operation: "resolve_analysis_profile" }
| {
readonly operation: "binary_session" | "open_binary";
readonly candidates: readonly AnalysisProviderCandidateStatus[];
},
signal?: AbortSignal,
): AnalysisError => {
const primary =
failures.find(({ error }) => !(error instanceof AnalysisCancelledError)) ??
failures[0];
if (primary === undefined)
return new AnalysisCancelledError("open_binary", { cause: signal?.reason });
if (failures.length === 1) return primary.error;
return new AnalysisCancelledError(
context.operation === "resolve_analysis_profile"
? "open_binary"
: context.operation,
{ cause: signal?.reason },
);
if (
failures.length === 1 &&
(context.operation === "resolve_analysis_profile" ||
context.candidates.length === 0)
)
return primary.error;
const cleanupFailures = failures
.filter(({ error }) => error.cleanupIncomplete)
.map(({ error }) => error);
const options = {
diagnostics: {
profile_errors: failures.map(({ providerId, error }) => ({
[context.operation === "resolve_analysis_profile"
? "profile_errors"
: "readiness_errors"]: failures.map(({ providerId, error }) => ({
provider_id: providerId,
error: projectAnalysisError(error),
})),
...(context.operation === "resolve_analysis_profile"
? {}
: { readiness_candidates: jsonValueSchema.parse(context.candidates) }),
},
...(primary.error.capturedOutput === undefined
? {}
@@ -672,6 +833,7 @@ const profileSelectionFailure = (
...(cleanupFailures.length === 0
? {}
: {
cleanupPrimary: primary.error,
cleanup: {
reason: cleanupFailures.map((error) => error.message).join("; "),
resources: [
@@ -684,16 +846,21 @@ const profileSelectionFailure = (
};
const failure =
primary.error instanceof AnalysisCancelledError
? new AnalysisCancelledError("open_binary", {
...options,
cause: primary.error.cause,
})
: new ProviderAdapterError("rea", "resolve_analysis_profile", {
? new AnalysisCancelledError(
context.operation === "resolve_analysis_profile"
? "open_binary"
: context.operation,
{
...options,
cause: primary.error.cause,
},
)
: new ProviderAdapterError("rea", context.operation, {
...options,
userMessage: projectAnalysisError(primary.error).message,
cause: new AggregateError(
failures.map(({ error }) => error),
"Provider profile resolutions failed",
"Provider inspections failed",
{ cause: primary.error },
),
});
+2 -1
View File
@@ -591,10 +591,11 @@ export class BinarySession
}
/** Describe the active binary session. */
status(): JsonValue {
status(options: { readonly signal?: AbortSignal } = {}): Promise<JsonValue> {
const run = this.#active ?? this.#pendingCleanup?.run;
const client = run?.client;
return binarySessionStatus({
...options,
target: this.#active?.target,
route: this.#currentRoute(),
router: this.#providerRouter,
@@ -205,17 +205,17 @@ it("clears retired runtime availability after cancelled-switch restoration", asy
});
expect((await session.open(process.execPath)).ok).toBe(true);
expect((await session.execute("address_name", {})).ok).toBe(false);
const availability = () =>
buildCapabilityInventory(session.status(), {
const availability = async () =>
buildCapabilityInventory(await session.status(), {
processCaptureEnabled: false,
}).find(({ name }) => name === "address_name");
const initialAvailability = availability();
const initialAvailability = await availability();
expect(initialAvailability).toMatchObject({
available: false,
reason: "provider_unavailable",
});
const originalTarget = session.activeTarget();
const initialRunId = sessionRunId(session.status());
const initialRunId = sessionRunId(await session.status());
const controller = new AbortController();
const switching = session.open("/bin/ls", { signal: controller.signal });
@@ -231,9 +231,9 @@ it("clears retired runtime availability after cancelled-switch restoration", asy
expect(session.activeTarget()).toEqual(originalTarget);
expect(deep.created).toHaveLength(1);
expect(deep.created[0]?.closeCalled).toBe(true);
const restoredStatus = session.status();
const restoredStatus = await session.status();
expect(sessionRunId(restoredStatus)).not.toBe(initialRunId);
expect(availability()).toMatchObject({
expect(await availability()).toMatchObject({
available: true,
reason: "available",
remediation: null,
@@ -242,20 +242,20 @@ it("clears retired runtime availability after cancelled-switch restoration", asy
// restoration, while the new restored client is healthy and usable.
expect((await session.execute("address_name", {})).ok).toBe(true);
expect(deep.created).toHaveLength(2);
expect(availability()).toMatchObject({
expect(await availability()).toMatchObject({
available: true,
reason: "available",
remediation: null,
});
deep.created[1]?.setOperationFailure(true);
expect((await session.execute("address_name", {})).ok).toBe(false);
expect(availability()).toMatchObject({
expect(await availability()).toMatchObject({
available: false,
reason: "provider_unavailable",
});
deep.created[1]?.setOperationFailure(false);
expect((await session.execute("address_name", {})).ok).toBe(true);
expect(availability()).toMatchObject({
expect(await availability()).toMatchObject({
available: true,
reason: "available",
remediation: null,
@@ -263,7 +263,9 @@ it("clears retired runtime availability after cancelled-switch restoration", asy
await session.close();
});
const sessionRunId = (status: ReturnType<BinarySession["status"]>): string => {
const sessionRunId = (
status: Awaited<ReturnType<BinarySession["status"]>>,
): string => {
if (typeof status !== "object" || status === null || Array.isArray(status))
throw new Error("Expected a binary session status object");
const analysisRun = status.analysis_run;
+1 -1
View File
@@ -87,7 +87,7 @@ export interface BinarySessionPort
overwrite: boolean,
options?: Pick<ExecutionOptions, "progress">,
): Promise<Result<SavedAnalysisSnapshot, AnalysisError>>;
status(): JsonValue;
status(options?: { readonly signal?: AbortSignal }): Promise<JsonValue>;
activeTarget(): BinaryTarget | undefined;
exportAnalysisSnapshot(): Result<AnalysisSnapshot, AnalysisError>;
importAnalysisSnapshot(
+20 -18
View File
@@ -31,10 +31,11 @@ interface BinarySessionStatusInput {
readonly requestActivitySnapshots: readonly ProviderRequestActivitySnapshot[];
readonly providerOperationHealth?: ProviderOperationHealth | null;
readonly cleanupPending?: boolean;
readonly signal?: AbortSignal;
}
/** Project internal provider routing state into the caller-visible session status. */
export const binarySessionStatus = ({
export const binarySessionStatus = async ({
target,
route,
router,
@@ -44,7 +45,8 @@ export const binarySessionStatus = ({
requestActivitySnapshots,
providerOperationHealth,
cleanupPending = false,
}: BinarySessionStatusInput): JsonValue => {
signal,
}: BinarySessionStatusInput): Promise<JsonValue> => {
const routeCapabilities = [...route.capabilities.values()]
.filter(({ operation }) => operation !== "inventory_artifact")
.sort((left, right) =>
@@ -69,22 +71,22 @@ export const binarySessionStatus = ({
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: providerCapabilitySets(
candidate.capabilities
.slice()
.filter(({ operation }) => operation !== "inventory_artifact")
.sort((left, right) =>
compareUnicodeCodePoints(left.operation, right.operation),
),
),
}));
const analysisProviderCandidates = (
await router.candidateStatuses(route, signal)
).map((candidate) => ({
provider: providerSummary(candidate.provider),
availability: structuredClone(candidate.availability),
target_support: structuredClone(candidate.targetSupport),
selected: candidate.selected,
capabilities: providerCapabilitySets(
candidate.capabilities
.slice()
.filter(({ operation }) => operation !== "inventory_artifact")
.sort((left, right) =>
compareUnicodeCodePoints(left.operation, right.operation),
),
),
}));
const common = {
cleanup_pending: cleanupPending,
capabilities,
@@ -98,10 +98,17 @@ export class SessionProviderRouter {
}
/** Candidate status authoritative for the active or target-free route. */
candidateStatuses(
async candidateStatuses(
route: SessionProviderRoute,
): readonly AnalysisProviderCandidateStatus[] {
return route.selection?.candidates ?? this.registry.candidates();
signal?: AbortSignal,
): Promise<readonly AnalysisProviderCandidateStatus[]> {
return (
route.selection?.candidates ??
this.registry.candidates(
undefined,
signal === undefined ? {} : { signal },
)
);
}
/** Produce exact candidate rejections for an intentionally unbound route. */
@@ -259,6 +259,12 @@ const providerRejectionCode: z.ZodType<ProviderRejectionCode> = z.enum(
const providerDiagnostics = z.record(z.string(), jsonValueSchema);
const providerAvailability = z.discriminatedUnion("status", [
z.object({
status: z.literal("unknown"),
code: z.null(),
reason: z.string().min(1),
diagnostics: providerDiagnostics,
}),
z.object({
status: z.literal("available"),
code: z.null(),
+6 -3
View File
@@ -146,11 +146,14 @@ const completionInventoryLookup = async (
return signal?.aborted === true ? undefined : result;
};
const providerCandidates = (
const providerCandidates = async (
session: BinarySessionPort | undefined,
): readonly string[] => {
): Promise<readonly string[]> => {
if (session === undefined) return [];
const parsed = providerStatusSchema.safeParse(session.status());
const signal = currentMcpRequestSignal();
const parsed = providerStatusSchema.safeParse(
await session.status(signal === undefined ? {} : { signal }),
);
if (!parsed.success) return [];
const candidates = parsed.data.analysis_provider_candidates.map(
({ provider }) => provider.id,
+6 -8
View File
@@ -3,7 +3,7 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js";
import type { ProviderAvailability } from "../application/AnalysisProvider.js";
import type { JebAnalysisAvailability } from "../application/jeb/JebAnalysisPort.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
buildCapabilityInventory,
compactToolAvailability,
@@ -13,7 +13,7 @@ import type {
ClientFeatureAvailability,
ToolAvailability,
} from "../contracts/toolOutputSchemaPrimitives.js";
import { jsonObjectSchema } from "../domain/jsonValue.js";
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
import { createServerIdentity } from "../serverIdentity.js";
import { mcpClientMetadata } from "./mcpClientMetadata.js";
import type { AvailabilityPolicy } from "../application/CapabilityInventory.js";
@@ -73,6 +73,7 @@ export const registerSessionStatusTool = (
let adbAvailability: ProviderAvailability | undefined;
let apktoolAvailability: ProviderAvailability | undefined;
let jebAvailability: JebAnalysisAvailability | undefined;
let status: JsonValue;
try {
const [android, adb, apktool, jeb] = await Promise.all([
androidAnalysisAvailability(context.mcpReq.signal),
@@ -84,14 +85,11 @@ export const registerSessionStatusTool = (
adbAvailability = adb;
apktoolAvailability = apktool;
jebAvailability = jeb;
status = await session.status({ signal: context.mcpReq.signal });
} catch (cause) {
if (!context.mcpReq.signal.aborted) throw cause;
return server.delivery.toCallToolResult(
err(new AnalysisCancelledError("binary_session")),
contract,
);
if (!(cause instanceof AnalysisError)) throw cause;
return server.delivery.toCallToolResult(err(cause), contract);
}
const status = session.status();
const statusObject = jsonObjectSchema.parse(status);
const toolAvailability = buildCapabilityInventory(
status,
@@ -107,7 +107,7 @@ describe("analysis snapshots: persistence", () => {
expect(
parseAnalysisSnapshot(JSON.parse(await readFile(path, "utf8"))),
).toEqual(snapshotFixture());
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
open: false,
cleanup_pending: true,
});
@@ -115,11 +115,11 @@ describe("analysis snapshots: persistence", () => {
ok: false,
error: { cleanupIncomplete: true },
});
expect(session.status()).toMatchObject({ cleanup_pending: true });
expect(await session.status()).toMatchObject({ cleanup_pending: true });
close.mockRestore();
expect(await session.close()).toEqual({ ok: true, value: null });
expect(session.status()).toMatchObject({ cleanup_pending: false });
expect(await session.status()).toMatchObject({ cleanup_pending: false });
});
it("writes canonical private v2 JSON and rejects changed query identity", async () => {
@@ -1027,7 +1027,9 @@ describe.skipIf(process.platform === "win32")(
expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError");
expect(result.error.message).toContain(readFailure.message);
expect(result.error.cleanupIncomplete).toBe(true);
expect(binary.status()).toMatchObject({ cleanup_pending: true });
expect(await binary.status()).toMatchObject({
cleanup_pending: true,
});
expect(createClient).not.toHaveBeenCalled();
const failedClose = await binary.close();
expect(failedClose.ok).toBe(false);
@@ -1039,7 +1041,9 @@ describe.skipIf(process.platform === "win32")(
expect(result.error.cleanupIncomplete).toBe(false);
if (failedClose.ok) throw new Error("Close unexpectedly succeeded");
expect(failedClose.error.cleanupIncomplete).toBe(false);
expect(binary.status()).toMatchObject({ cleanup_pending: false });
expect(await binary.status()).toMatchObject({
cleanup_pending: false,
});
} finally {
allowClose = true;
await binary.close();
+116 -2
View File
@@ -20,6 +20,7 @@ import { composeBinarySession } from "../../../src/application/binary/BinarySess
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { err, ok } from "../../../src/domain/result.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { silentLogger } from "../../../src/logger.js";
import { createServer } from "../../../src/server/createServer.js";
@@ -301,12 +302,12 @@ it("retains attributed mixed profile cleanup through registered MCP calls and pa
code: "cleanup_incomplete",
details: { resources: ["beta-file"] },
});
expect(session.status()).toMatchObject({ cleanup_pending: true });
expect(await session.status()).toMatchObject({ cleanup_pending: true });
released.add("beta");
expect(
(await mcp.callTool({ name: "close_binary", arguments: {} })).isError,
).not.toBe(true);
expect(session.status()).toMatchObject({ cleanup_pending: false });
expect(await session.status()).toMatchObject({ cleanup_pending: false });
expect(failures.alpha.cleanupIncomplete).toBe(false);
expect(failures.beta.cleanupIncomplete).toBe(false);
} finally {
@@ -315,3 +316,116 @@ it("retains attributed mixed profile cleanup through registered MCP calls and pa
await session.close();
}
});
it.each(["ready", "cancelled", "physical"] as const)(
"awaits asynchronous candidate finalization through registered MCP status (%s)",
async (mode) => {
const starts: string[] = [];
const base = candidate("alpha", starts);
let enter!: () => void;
let finish!: () => void;
const entered = new Promise<void>((resolve) => {
enter = resolve;
});
const gate = new Promise<void>((resolve) => {
finish = resolve;
});
const session = composeBinarySession(
new AnalysisProviderRegistry([
{
...base,
inspectAvailability: async (signal) => {
enter();
await gate;
if (mode === "physical")
throw new ProviderAdapterError("alpha", "binary_session", {
diagnostics: {
reason: "controlled physical readiness failure",
},
});
if (signal?.aborted)
throw new AnalysisCancelledError("binary_session", {
cause: signal.reason,
});
return base.inspectAvailability();
},
},
]),
);
const server = createServer(
{ kind: "session", session },
{ logger: silentLogger },
);
const mcp = new Client({ name: "async-readiness", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
resources.push(mcp, server, { close: () => session.close() });
await server.connect(serverTransport);
await mcp.connect(clientTransport);
let settled = false;
const observing = mcp
.callTool({ name: "binary_session", arguments: {} })
.then((result) => {
settled = true;
return result;
});
let closing: ReturnType<typeof session.close> | undefined;
try {
await entered;
if (mode !== "ready") {
closing = session.close();
const during = structured(
await mcp.callTool({ name: "binary_session", arguments: {} }),
);
expect(during.result).toMatchObject({
cleanup_pending: true,
analysis_provider_candidates: [
{
provider: { id: "alpha" },
availability: {
status: "unknown",
diagnostics: { finalization: "in_progress" },
},
},
],
});
}
await new Promise<void>((resolve) => setImmediate(resolve));
expect(settled).toBe(false);
finish();
const result = await observing;
if (mode === "ready") {
expect(result.isError).not.toBe(true);
expect(structured(result).result).toMatchObject({
analysis_provider_candidates: [
{
provider: { id: "alpha" },
availability: { status: "available" },
},
],
});
} else {
expect(parseMcpToolError(result).error).toMatchObject(
mode === "cancelled"
? { code: "cancelled", details: { operation: "binary_session" } }
: {
code: "execution_failure",
details: {
operation: "binary_session",
diagnostics: {
reason: "controlled physical readiness failure",
},
},
},
);
expect(await closing).toEqual(ok(null));
}
expect(starts).toEqual([]);
} finally {
finish();
await observing;
await closing;
await session.close();
}
},
);
@@ -125,7 +125,7 @@ it("admits Rizin startup across a bound analysis session", async () => {
expect((await analysisSession.open(target)).ok).toBe(true);
const initialRunId = z
.object({ analysis_run: z.object({ run_id: z.string() }) })
.parse(analysisSession.status()).analysis_run.run_id;
.parse(await analysisSession.status()).analysis_run.run_id;
const manager = {
start: async () => ({
ok: true,
@@ -170,7 +170,7 @@ it("admits Rizin startup across a bound analysis session", async () => {
expect((await analysisSession.open(target)).ok).toBe(true);
const currentRunId = z
.object({ analysis_run: z.object({ run_id: z.string() }) })
.parse(analysisSession.status()).analysis_run.run_id;
.parse(await analysisSession.status()).analysis_run.run_id;
expect(currentRunId).not.toBe(initialRunId);
expect(analysisSession.activeTarget()?.path).toBe(target);
} finally {
@@ -196,7 +196,7 @@ it.skipIf(process.platform === "win32")(
});
expect(switched.ok).toBe(true);
expect(session.providerIdentity().id).toBe("ghidra");
expect(session.status()).toMatchObject({ open: true });
expect(await session.status()).toMatchObject({ open: true });
expect(external.fixture.requests.map(({ method }) => method)).toEqual([
"health",
"address_name",
@@ -468,12 +468,12 @@ it
cleanupIncomplete: true,
cleanupResources: [fixture.kuna],
});
expect(binary.status()).toMatchObject({ cleanup_pending: true });
expect(await binary.status()).toMatchObject({ cleanup_pending: true });
expect(await binary.close()).toEqual({ ok: true, value: null });
expect(originatingError.cleanupIncomplete).toBe(false);
expect(injected.closeCalls).toBe(stage === "fingerprint" ? 2 : 3);
expect(binary.status()).toMatchObject({ cleanup_pending: false });
expect(await binary.status()).toMatchObject({ cleanup_pending: false });
} finally {
injected.failCloseReadStage = undefined;
const closed = await binary.close();
@@ -501,7 +501,7 @@ it.skipIf(process.platform === "win32")(
cleanupResources: [fixture.kuna],
});
expect(injected.closeCalls).toBe(1);
expect(binary.status()).toMatchObject({
expect(await binary.status()).toMatchObject({
open: false,
cleanup_pending: true,
});
@@ -510,12 +510,12 @@ it.skipIf(process.platform === "win32")(
error: { cleanupIncomplete: true, cleanupResources: [fixture.kuna] },
});
expect(injected.closeCalls).toBe(2);
expect(binary.status()).toMatchObject({ cleanup_pending: true });
expect(await binary.status()).toMatchObject({ cleanup_pending: true });
injected.releaseCloseFailures = true;
expect(await binary.close()).toEqual({ ok: true, value: null });
expect(injected.closeCalls).toBe(3);
expect(binary.status()).toMatchObject({ cleanup_pending: false });
expect(await binary.status()).toMatchObject({ cleanup_pending: false });
expect((await fakeKunaCalls(fixture.kuna)).map(({ argv }) => argv)).toEqual(
[["--version"], ["--version"]],
);
@@ -583,7 +583,7 @@ it.skipIf(process.platform === "win32")(
ok: false,
error: { cleanupIncomplete: true, cleanupResources: [retired] },
});
expect(binary.status()).toMatchObject({
expect(await binary.status()).toMatchObject({
open: false,
cleanup_pending: true,
});
@@ -592,7 +592,7 @@ it.skipIf(process.platform === "win32")(
);
await chmod(retired, 0o700);
expect(await binary.close()).toEqual({ ok: true, value: null });
expect(binary.status()).toMatchObject({ cleanup_pending: false });
expect(await binary.status()).toMatchObject({ cleanup_pending: false });
await expect(readFile(join(retired, "evidence"))).rejects.toMatchObject({
code: "ENOENT",
});
@@ -148,24 +148,27 @@ it("waits for an aborted pre-session hash before retrying its late close owner",
await new Promise<void>((resolve) => setImmediate(resolve));
expect(previewSettled).toBe(false);
expect(closeSettled).toBe(false);
expect(binary.status()).toMatchObject({ cleanup_pending: true });
expect(await binary.status()).toMatchObject({ cleanup_pending: true });
releaseClose();
const preview = await previewWork;
expect(preview).toMatchObject({
ok: false,
error: { _tag: "ProviderAdapterError", cleanupIncomplete: true },
error: { _tag: "AnalysisCancelledError", cleanupIncomplete: true },
});
if (preview.ok) throw new Error("Cancellation unexpectedly succeeded");
expect(preview.error.cause).toBe(controller.signal.reason);
expect(preview.error.cleanupResources).toContain(fixture.kuna);
const firstClose = await closing;
expect(firstClose).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
expect(binary.status()).toMatchObject({ cleanup_pending: true });
expect(await binary.status()).toMatchObject({ cleanup_pending: true });
close.mockRestore();
expect(await binary.close()).toEqual({ ok: true, value: null });
expect(binary.status()).toMatchObject({ cleanup_pending: false });
expect(await binary.status()).toMatchObject({ cleanup_pending: false });
if (preview.ok || firstClose.ok)
throw new Error("Cleanup unexpectedly succeeded");
expect(preview.error.cleanupIncomplete).toBe(false);
@@ -32,7 +32,7 @@ it("runs static .app decoding without registering native analysis engines", asyn
const staticSession = await createArtifactBinarySession({
REA_ANALYSIS_PROVIDER: "auto",
});
expect(staticSession.status()).toMatchObject({
expect(await staticSession.status()).toMatchObject({
analysis_provider_candidates: [],
});
await staticSession.close();
@@ -83,7 +83,7 @@ describe("detached provider and target metadata", () => {
const [first] = await createBinarySessionTargets();
const session = createTestBinarySession(createCacheProvider([]));
expect((await session.open(first)).ok).toBe(true);
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
analysis_run: {
run_id: expect.any(String),
process_lineage: { status: "not_observed" },
@@ -127,12 +127,12 @@ describe("detached provider and target metadata", () => {
}),
};
const session = createTestBinarySession(provider);
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
open: false,
provider_operation_health: null,
});
expect((await session.open(first)).ok).toBe(true);
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
open: true,
provider_operation_health: {
state: "busy",
@@ -149,7 +149,7 @@ describe("detached provider and target metadata", () => {
},
});
healthState = "exited";
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
open: true,
provider_operation_health: {
state: "exited",
@@ -166,7 +166,7 @@ describe("detached provider and target metadata", () => {
},
});
await session.close();
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
open: false,
provider_operation_health: null,
});
@@ -942,7 +942,7 @@ describe("provider health and availability", () => {
});
expect((await session.open(first)).ok).toBe(true);
expect((await session.execute("address_name", {})).ok).toBe(false);
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
capabilities: expect.arrayContaining([
expect.objectContaining({
provider: {
@@ -963,7 +963,7 @@ describe("provider health and availability", () => {
});
expect(changes).toBe(1);
expect((await session.open(second)).ok).toBe(true);
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
capabilities: expect.arrayContaining([
expect.objectContaining({
provider: {
@@ -1020,7 +1020,7 @@ describe("provider health and availability", () => {
{ ok: true },
);
expect(delivered).toBe(1);
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
capabilities: expect.arrayContaining([
expect.objectContaining({
provider: {
@@ -1123,13 +1123,13 @@ describe("typed unavailability without dispatching", () => {
const session = createTestBinarySession(() => client(created++ === 1));
expect((await session.open(first)).ok).toBe(true);
expect((await session.open(second)).ok).toBe(false);
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
open: true,
sha256:
"7692c3ad3540bb803c020b3aee66cd8887123234ea0c6e7143c0add73ff431ed",
architecture: null,
});
expect(JSON.stringify(session.status())).toContain("first.hop");
expect(JSON.stringify(await session.status())).toContain("first.hop");
expect(created).toBe(3);
});
@@ -1164,7 +1164,7 @@ describe("typed unavailability without dispatching", () => {
expect((await secondCall).ok).toBe(true);
expect((await closing).ok).toBe(true);
expect((await afterClose).ok).toBe(false);
expect(session.status()).toMatchObject({ open: false });
expect(await session.status()).toMatchObject({ open: false });
expect(clients[0]?.closed).toBe(1);
expect(clients[1]?.closed).toBe(1);
});
@@ -1394,7 +1394,7 @@ describe("cancellation during profile resolution", () => {
expect(observedSignal?.reason).toBe(reason);
expect(openSettled).toBe(false);
expect(closeSettled).toBe(false);
expect(session.status()).toMatchObject({ cleanup_pending: true });
expect(await session.status()).toMatchObject({ cleanup_pending: true });
release.resolve();
await expect(opening).resolves.toMatchObject({
ok: false,
@@ -1402,7 +1402,7 @@ describe("cancellation during profile resolution", () => {
});
expect(await closing).toEqual(resultOk(null));
expect(created).toBe(0);
expect(session.status()).toMatchObject({ cleanup_pending: false });
expect(await session.status()).toMatchObject({ cleanup_pending: false });
} finally {
release.resolve();
await Promise.all([opening, closing]);
@@ -1452,7 +1452,7 @@ describe("cancellation during client transitions", () => {
expect(clients[1]?.closed).toBe(1);
expect(clients[0]?.closed).toBe(1);
expect(clients[2]?.closed).toBe(0);
expect(JSON.stringify(session.status())).toContain("first.hop");
expect(JSON.stringify(await session.status())).toContain("first.hop");
await session.close();
await session.close();
expect(clients[2]?.closed).toBe(1);
@@ -1495,7 +1495,7 @@ describe("cancellation during client transitions", () => {
expect((await session.open(first)).ok).toBe(true);
expect(await session.close()).toEqual(err(cleanupError));
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
open: false,
analysis_activity: { status: "not_observed", providers: [] },
});
@@ -1522,7 +1522,7 @@ describe("no replacement start", () => {
expect(await session.open(second)).toEqual(err(cleanupError));
expect(created).toBe(1);
expect(session.status()).toMatchObject({ open: false });
expect(await session.status()).toMatchObject({ open: false });
});
});
@@ -113,7 +113,7 @@ it.each(["close", "switch", "open after close"] as const)(
transition === "switch" ? await attempt() : await session.close();
expect(initial).toEqual(err(cleanupError));
expect(session.activeTarget()).toBeUndefined();
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
open: false,
cleanup_pending: true,
analysis_run: {
@@ -133,7 +133,7 @@ it.each(["close", "switch", "open after close"] as const)(
await access(runtime);
expect(await attempt()).toEqual(err(cleanupError));
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
analysis_run: { run_id: fixture.runIds[0] },
});
expect(fixture.created()).toBe(1);
@@ -141,10 +141,10 @@ it.each(["close", "switch", "open after close"] as const)(
fixture.allowCleanup();
expect((await attempt()).ok).toBe(true);
expect(session.status()).toMatchObject({ cleanup_pending: false });
expect(await session.status()).toMatchObject({ cleanup_pending: false });
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
expect(fixture.created()).toBe(transition === "close" ? 1 : 2);
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
analysis_run:
transition === "close"
? null
@@ -173,7 +173,7 @@ it("keeps a failed candidate for cleanup and preserves its startup failure", asy
},
});
expect(fixture.session.activeTarget()).toBeUndefined();
expect(fixture.session.status()).toMatchObject({
expect(await fixture.session.status()).toMatchObject({
open: false,
cleanup_pending: true,
analysis_run: {
@@ -206,7 +206,7 @@ it("retains cancelled startup cleanup and the cancellation reason", async () =>
diagnostics: { primary_error: { code: "cancelled" } },
},
});
expect(fixture.session.status()).toMatchObject({
expect(await fixture.session.status()).toMatchObject({
open: false,
cleanup_pending: true,
analysis_run: {
@@ -290,7 +290,7 @@ it("keeps failed restoration resources available for a close retry", async () =>
error: { cleanupIncomplete: true, cleanupResources: [runtime] },
});
expect(session.activeTarget()).toBeUndefined();
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
open: false,
cleanup_pending: true,
analysis_run: { run_id: runIds[2] },
@@ -86,7 +86,7 @@ describe("composite analysis provider", () => {
ok: true,
});
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
analysis_run: {
process_lineage: {
status: "snapshots",
@@ -183,7 +183,7 @@ describe("composite provider health status", () => {
expect((await session.execute("procedure_info", {})).ok).toBe(true);
failed = true;
expect(session.status()).toMatchObject({
expect(await session.status()).toMatchObject({
provider_operation_health: {
state: "exited",
stage: "connection",
@@ -61,7 +61,7 @@ describe("target-bound provider routing", () => {
expect(session.activeTarget()).toBeUndefined();
expect((await session.open(target, { providerId: "alpha" })).ok).toBe(true);
expect(bindingStatus(session)).toMatchObject({
expect(await bindingStatus(session)).toMatchObject({
analysis_provider_binding: {
provider: { id: "alpha", version: "1" },
selection_source: "request",
@@ -100,7 +100,7 @@ describe("target-bound provider routing", () => {
alpha.available = false;
expect((await session.open(target)).ok).toBe(true);
expect(bindingStatus(session)).toMatchObject({
expect(await bindingStatus(session)).toMatchObject({
analysis_provider_binding: { provider: { id: "alpha" } },
});
@@ -108,7 +108,7 @@ describe("target-bound provider routing", () => {
expect(unknown.ok).toBe(false);
if (!unknown.ok)
expect(unknown.error).toMatchObject({ reason: "unknown_provider" });
expect(bindingStatus(session)).toMatchObject({
expect(await bindingStatus(session)).toMatchObject({
analysis_provider_binding: { provider: { id: "alpha" } },
});
@@ -119,7 +119,7 @@ describe("target-bound provider routing", () => {
expect(failed.ok).toBe(false);
if (!failed.ok) expect(failed.error._tag).toBe("ProviderAdapterError");
expect(alpha.clients[0]?.calls).toEqual(["address_name"]);
expect(bindingStatus(session)).toMatchObject({
expect(await bindingStatus(session)).toMatchObject({
analysis_provider_binding: { provider: { id: "beta" } },
});
@@ -178,7 +178,7 @@ describe("target-bound provider routing", () => {
});
expect(alpha.clients[0]?.closed).toBe(false);
expect(beta.clients).toHaveLength(0);
expect(bindingStatus(session)).toMatchObject({
expect(await bindingStatus(session)).toMatchObject({
analysis_provider_binding: { provider: { id: "alpha" } },
});
@@ -199,7 +199,7 @@ describe("selected provider capability provenance", () => {
expect((await session.open(target, { providerId: "alpha" })).ok).toBe(true);
const resolved = { id: "alpha", name: "alpha provider", version: "1" };
const status = bindingStatus(session);
const status = await bindingStatus(session);
expect(status).toMatchObject({
analysis_provider_binding: { provider: resolved },
analysis_provider_candidates: [
@@ -391,14 +391,14 @@ const databaseTarget = async (): Promise<string> => {
return path;
};
const bindingStatus = (session: BinarySession) =>
const bindingStatus = async (session: BinarySession) =>
z
.object({
analysis_provider_binding: z.unknown(),
analysis_provider_candidates: z.array(z.unknown()),
})
.passthrough()
.parse(session.status());
.parse(await session.status());
interface Deferred<Value> {
readonly promise: Promise<Value>;