mirror of
https://github.com/morluto/rea.git
synced 2026-11-08 07:46:28 +00:00
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:
1 parent
4087048270
commit
150e996a86
25 files changed
+643
-132
No files matched your search
@@ -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 },
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user