mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 22:36:08 +00:00
* fix(cli): describe one-shot timeouts for an invocation whose session has ended A one-shot CLI analysis that reached a provider deadline told its caller that the Ghidra session remained usable and reported a runtime-editable limit applying to the next operation, although the command had already released its provider session and runtime. The shared error projection now takes an explicit caller lifetime. Providers keep guidance that holds only for a continuing caller (session reuse, same-session runtime updates) apart from the request outcome, and the presentation adds it only for a continuing caller. One-shot owners (runDirectAnalysis, runProviderAnalysis, finishAnalysisCommand and the JavaScript application command) project for the invocation: details.limit reports scope rea-invocation, applies_at next_invocation and the launch environment seed, and the remediation leads with that setting for a new invocation. MCP output keeps its runtime-update and same-session guidance. Covers the Ghidra decompile timeout, decompiler failure, regex stack exhaustion and startup timeout, the shared regex deadline, the Kuna decompile timeout and the Hopper startup timeout. Fixes #8311 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): project composed child failures for the caller lifetime Cleanup composition embedded its primary and cleanup failures as continuing-caller projections, and baked the primary's message and the cleanup's remediation into the composed error. A one-shot command whose Ghidra startup timed out while shutdown also failed therefore still reported a rea-runtime limit and update_analysis_settings in details.diagnostics.primary_error. childFailureDiagnostics now snapshots each embedded child at composition for both lifetimes when its projection depends on the caller, so cleanup history keeps its composition-time state and the final projection selects the caller's form. composedCallerGuidance re-attributes message and remediation with their continuing-only parts kept separate. Error copies and redaction keep the invocation diagnostics. The Hopper startup timeout now carries its continuing guidance on the error like the other producers. Invocation limits keep agent_editable and rea_restart_required, with values that hold for a new invocation, so only scope and applies_at change and environment is added. In existing-project mode a decompile timeout or a cancelled session now says the next session copies and opens REA_GHIDRA_PROJECT_PATH again rather than repeating import and analysis. Tests compare delivered guidance with the owning formatters and constants instead of freezing prose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
556 lines
19 KiB
TypeScript
556 lines
19 KiB
TypeScript
import {
|
|
createAnalysisExecution,
|
|
type AnalysisExecution,
|
|
type ExecutionOptions,
|
|
} from "../application/AnalysisProvider.js";
|
|
import type { SqliteDatabasePort } from "../application/sqlite/SqliteDatabasePort.js";
|
|
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
|
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
|
|
import type {
|
|
AnalysisCapturedOutput,
|
|
AnalysisError,
|
|
} from "../domain/analysisErrorBase.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisOutputError,
|
|
AnalysisResourceConstraintError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import type { JsonValue } from "../domain/jsonValue.js";
|
|
import { createEvidence } from "../domain/evidence.js";
|
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import {
|
|
inspectSqliteDatabaseInputSchema,
|
|
sqliteDatabaseSchema,
|
|
type InspectSqliteDatabaseInput,
|
|
DEFAULT_SQLITE_WORKER_TIMEOUT_MS,
|
|
sqliteInspectionParameters,
|
|
} from "../domain/sqlite/sqliteDatabase.js";
|
|
import { SQLITE_PROVIDER_IDENTITY } from "../domain/sqlite/SqliteProviderIdentity.js";
|
|
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
|
|
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
|
import {
|
|
OwnedCommandFailure,
|
|
ownedCommandResources,
|
|
} from "../process/OwnedCommand.js";
|
|
import type {
|
|
ProviderProcessStopResult,
|
|
ProviderProcessSupervisor,
|
|
} from "../process/ProviderProcess.js";
|
|
import {
|
|
createSqliteCleanupError,
|
|
sqliteDatabaseFailure,
|
|
} from "./SqliteDatabaseFailures.js";
|
|
import { captureSqliteDatabaseSnapshot } from "./SqliteDatabaseSnapshot.js";
|
|
import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js";
|
|
|
|
import {
|
|
executeSqliteDatabaseWorker,
|
|
sqliteReplyFinalizationFailure,
|
|
type SqliteWorkerLauncher,
|
|
} from "./SqliteDatabaseWorkerExecution.js";
|
|
|
|
const operation = "inspect_sqlite_database";
|
|
type SqliteOutcome = Result<AnalysisExecution, AnalysisError>;
|
|
type SqliteRuntimeRoot = Pick<PrivateRuntimeRoot, "path" | "close">;
|
|
interface PendingSnapshotCleanup {
|
|
readonly input: InspectSqliteDatabaseInput;
|
|
readonly result: SqliteOutcome;
|
|
readonly output: AnalysisCapturedOutput | undefined;
|
|
readonly rootFailure: AnalysisError;
|
|
readonly processFailure: AnalysisError | undefined;
|
|
process: ProviderProcessSupervisor | undefined;
|
|
}
|
|
/** Inspect a provider-owned copy through a cancellable SQLite child, without source writes. */
|
|
export class SqliteDatabaseProvider implements SqliteDatabasePort {
|
|
readonly #active = new Set<Promise<SqliteOutcome>>();
|
|
readonly #artifactResources = new ArtifactResourceScope();
|
|
readonly #pendingCleanup = new Map<
|
|
SqliteRuntimeRoot,
|
|
PendingSnapshotCleanup
|
|
>();
|
|
#closed = false;
|
|
#closePromise: Promise<void> | undefined;
|
|
#retryPromise: Promise<Result<null, AnalysisError>> | undefined;
|
|
constructor(
|
|
readonly environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
|
readonly launcher?: SqliteWorkerLauncher,
|
|
readonly createRuntime: () => Promise<
|
|
Pick<PrivateRuntimeRoot, "path" | "close">
|
|
> = () => PrivateRuntimeRoot.create({ prefix: "rea-sqlite-database-" }),
|
|
) {}
|
|
|
|
/** Return exact original DB/WAL identities and current schema/selected committed rows. */
|
|
async inspect(
|
|
input: InspectSqliteDatabaseInput,
|
|
options?: ExecutionOptions,
|
|
): Promise<SqliteOutcome> {
|
|
if (options?.signal?.aborted)
|
|
return err(
|
|
new AnalysisCancelledError(operation, {
|
|
cause: options.signal.reason,
|
|
}),
|
|
);
|
|
if (this.#closed) return err(new AnalysisCancelledError(operation));
|
|
if (this.#active.size >= SQLITE_DATABASE_LIMITS.concurrentInspections)
|
|
return err(
|
|
new AnalysisResourceConstraintError(
|
|
operation,
|
|
"capacity",
|
|
"SQLite inspection capacity is occupied. Retry after an active inspection completes.",
|
|
{
|
|
active_inspections: this.#active.size,
|
|
max_concurrent_inspections:
|
|
SQLITE_DATABASE_LIMITS.concurrentInspections,
|
|
input_bytes_per_inspection: SQLITE_DATABASE_LIMITS.inputBytes,
|
|
},
|
|
{
|
|
remediationAction: `The concurrent SQLite inspection allowance is ${String(SQLITE_DATABASE_LIMITS.concurrentInspections)}. This release has no runtime override. Waiting for an active inspection to finish can free a slot. worker_timeout_ms does not raise it. Retry after an active SQLite inspection completes.`,
|
|
},
|
|
),
|
|
);
|
|
const inspection = this.#artifactResources
|
|
.run(() => this.#inspect(input, options))
|
|
.catch((cause: unknown) =>
|
|
err(
|
|
sqliteDatabaseFailure(
|
|
cause,
|
|
input.path,
|
|
"artifact-read",
|
|
options?.signal,
|
|
),
|
|
),
|
|
);
|
|
this.#active.add(inspection);
|
|
try {
|
|
return await inspection;
|
|
} finally {
|
|
this.#active.delete(inspection);
|
|
}
|
|
}
|
|
|
|
/** Await inspections and retry retained descriptors, workers, and snapshot roots. */
|
|
close(): Promise<void> {
|
|
this.#closed = true;
|
|
this.#closePromise ??= Promise.allSettled(this.#active)
|
|
.then(async () => {
|
|
let descriptorFailure: AnalysisError | undefined;
|
|
try {
|
|
await this.#artifactResources.close();
|
|
} catch (cause: unknown) {
|
|
const cleanup = ArtifactReaderFailure.cleanupObservation(
|
|
cause,
|
|
"SQLite artifact descriptors",
|
|
);
|
|
descriptorFailure = new ProviderCleanupError(
|
|
SQLITE_PROVIDER_IDENTITY.id,
|
|
cleanup.resources,
|
|
{ reason: cleanup.reason },
|
|
{ operation, cause },
|
|
);
|
|
bindArtifactFileCleanup(cause, descriptorFailure);
|
|
}
|
|
const cleaned = await this.#retryCleanup();
|
|
if (descriptorFailure !== undefined && !cleaned.ok) {
|
|
const failure = new ProviderCleanupError(
|
|
SQLITE_PROVIDER_IDENTITY.id,
|
|
[
|
|
...descriptorFailure.cleanupResources,
|
|
...cleaned.error.cleanupResources,
|
|
],
|
|
{
|
|
failures: [
|
|
projectAnalysisError(descriptorFailure),
|
|
projectAnalysisError(cleaned.error),
|
|
],
|
|
},
|
|
{ operation },
|
|
);
|
|
failure.bindCleanupDependencies([descriptorFailure, cleaned.error]);
|
|
throw failure;
|
|
}
|
|
if (descriptorFailure !== undefined) throw descriptorFailure;
|
|
if (!cleaned.ok) throw cleaned.error;
|
|
})
|
|
.catch((cause: unknown) => {
|
|
this.#closePromise = undefined;
|
|
throw cause;
|
|
});
|
|
return this.#closePromise;
|
|
}
|
|
|
|
#retryCleanup(): Promise<Result<null, AnalysisError>> {
|
|
this.#retryPromise ??= this.#cleanupPending().finally(() => {
|
|
this.#retryPromise = undefined;
|
|
});
|
|
return this.#retryPromise;
|
|
}
|
|
|
|
async #cleanupPending(): Promise<Result<null, AnalysisError>> {
|
|
const failures: AnalysisError[] = [];
|
|
for (const [root, pending] of [...this.#pendingCleanup]) {
|
|
if (pending.process !== undefined) {
|
|
const stopped = await pending.process.stop();
|
|
if (stopped.status === "incomplete" || stopped.status === "not-owned") {
|
|
const reason =
|
|
stopped.status === "incomplete"
|
|
? stopped.reason
|
|
: "SQLite worker ownership remains unresolved.";
|
|
const failure = sqliteCleanupFailure(
|
|
[
|
|
pending.process.launch.ownership?.runId ?? "owned-sqlite-worker",
|
|
root.path,
|
|
],
|
|
reason,
|
|
pending.result,
|
|
pending.input,
|
|
pending.output,
|
|
);
|
|
failure.bindCleanupDependencies(
|
|
[pending.processFailure, pending.rootFailure].filter(
|
|
(dependency): dependency is AnalysisError =>
|
|
dependency !== undefined,
|
|
),
|
|
);
|
|
failures.push(failure);
|
|
continue;
|
|
}
|
|
pending.process = undefined;
|
|
pending.processFailure?.resolveCleanup();
|
|
}
|
|
const cleaned = await cleanupSqliteRoot(
|
|
root,
|
|
pending.result,
|
|
pending.input,
|
|
pending.output,
|
|
);
|
|
if (cleaned.ok) {
|
|
pending.rootFailure.resolveCleanup();
|
|
this.#pendingCleanup.delete(root);
|
|
} else {
|
|
cleaned.error.bindCleanupDependencies([pending.rootFailure]);
|
|
failures.push(cleaned.error);
|
|
}
|
|
}
|
|
if (failures.length === 1 && failures[0] !== undefined)
|
|
return err(failures[0]);
|
|
return failures.length === 0
|
|
? ok(null)
|
|
: err(
|
|
new ProviderCleanupError(
|
|
SQLITE_PROVIDER_IDENTITY.id,
|
|
failures.flatMap((failure) => failure.cleanupResources),
|
|
{
|
|
failures: failures.map((failure) =>
|
|
projectAnalysisError(failure),
|
|
),
|
|
},
|
|
{ operation },
|
|
).bindCleanupDependencies(failures),
|
|
);
|
|
}
|
|
|
|
async #inspect(
|
|
input: InspectSqliteDatabaseInput,
|
|
options?: ExecutionOptions,
|
|
): Promise<SqliteOutcome> {
|
|
let root: Pick<PrivateRuntimeRoot, "path" | "close"> | undefined;
|
|
let processOwner: ProviderProcessSupervisor | undefined;
|
|
let processCleanup:
|
|
| Extract<ProviderProcessStopResult, { status: "incomplete" }>
|
|
| undefined;
|
|
let retainedOutput: AnalysisCapturedOutput | undefined;
|
|
let phase = "configuration";
|
|
let result: Result<AnalysisExecution, AnalysisError>;
|
|
const workerTimeoutMs =
|
|
input.worker_timeout_ms ?? DEFAULT_SQLITE_WORKER_TIMEOUT_MS;
|
|
try {
|
|
if (options?.signal?.aborted)
|
|
throw new AnalysisCancelledError(operation, {
|
|
cause: options.signal.reason,
|
|
});
|
|
validateSqliteInput(input);
|
|
const retired = await this.#retryCleanup();
|
|
if (!retired.ok) return retired;
|
|
if (options?.signal?.aborted)
|
|
throw new AnalysisCancelledError(operation, {
|
|
cause: options.signal.reason,
|
|
});
|
|
if (this.#closed) throw new AnalysisCancelledError(operation);
|
|
root = await this.createRuntime();
|
|
phase = "artifact-read";
|
|
const snapshot = await captureSqliteDatabaseSnapshot(
|
|
input.path,
|
|
root.path,
|
|
this.#artifactResources,
|
|
options?.signal,
|
|
);
|
|
phase = "worker";
|
|
const completed = await executeSqliteDatabaseWorker({
|
|
input,
|
|
root: root.path,
|
|
snapshotPath: snapshot.snapshotPath,
|
|
timeoutMs: workerTimeoutMs,
|
|
environment: this.environment,
|
|
resourceScope: this.#artifactResources,
|
|
...(this.launcher === undefined ? {} : { launcher: this.launcher }),
|
|
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
|
captured: (output) => {
|
|
retainedOutput = output;
|
|
},
|
|
retainCleanup: (owner, result) => {
|
|
processOwner = owner;
|
|
processCleanup = result;
|
|
},
|
|
});
|
|
const validated = sqliteDatabaseSchema.safeParse({
|
|
artifact: snapshot.artifact,
|
|
wal: snapshot.wal,
|
|
...completed.inspection,
|
|
worker_timeout_ms: workerTimeoutMs,
|
|
});
|
|
if (!validated.success)
|
|
throw sqliteReplyFinalizationFailure(
|
|
new AnalysisOutputError(
|
|
operation,
|
|
"SQLite worker report dimensions or identity fields are invalid",
|
|
retainedOutput === undefined
|
|
? undefined
|
|
: { capturedOutput: retainedOutput },
|
|
),
|
|
completed.finalization,
|
|
);
|
|
const execution = createSqliteObservation(validated.data, input.path);
|
|
result =
|
|
completed.finalization === undefined
|
|
? ok(execution)
|
|
: err(
|
|
completed.finalization.retainPartialObservation(
|
|
completedSqliteEvidence(input, execution),
|
|
),
|
|
);
|
|
} catch (cause: unknown) {
|
|
// A failure rethrown after retainCleanup carries no owner of its own;
|
|
// keep the retained worker instead of discarding it.
|
|
if (
|
|
cause instanceof OwnedCommandFailure &&
|
|
cause.cleanupOwner !== undefined
|
|
)
|
|
processOwner = cause.cleanupOwner;
|
|
const failure = sqliteDatabaseFailure(
|
|
cause,
|
|
input.path,
|
|
phase,
|
|
options?.signal,
|
|
workerTimeoutMs,
|
|
);
|
|
retainedOutput ??= failure.capturedOutput;
|
|
result = err(failure);
|
|
}
|
|
if (root !== undefined) {
|
|
if (processOwner !== undefined) {
|
|
const ownership = processOwner.launch.ownership;
|
|
const rootFailure = new ProviderCleanupError(
|
|
SQLITE_PROVIDER_IDENTITY.id,
|
|
[root.path],
|
|
{ reason: "SQLite runtime root cleanup remains unresolved." },
|
|
{ operation },
|
|
);
|
|
const processReason =
|
|
processCleanup?.reason ?? "SQLite worker cleanup remains unresolved.";
|
|
const processFailure = new ProviderCleanupError(
|
|
SQLITE_PROVIDER_IDENTITY.id,
|
|
ownership === undefined
|
|
? ["owned-sqlite-worker"]
|
|
: ownedCommandResources(ownership),
|
|
{ reason: processReason },
|
|
{ operation },
|
|
);
|
|
this.#pendingCleanup.set(root, {
|
|
input,
|
|
result,
|
|
output: retainedOutput,
|
|
rootFailure,
|
|
processFailure,
|
|
process: processOwner,
|
|
});
|
|
if (processCleanup !== undefined) {
|
|
const failure = sqliteCleanupFailure(
|
|
ownership === undefined
|
|
? ["owned-sqlite-worker", root.path]
|
|
: ownedCommandResources(ownership, root.path),
|
|
processCleanup.reason,
|
|
result,
|
|
input,
|
|
retainedOutput,
|
|
processCleanup.cleanupResult === undefined
|
|
? undefined
|
|
: {
|
|
...(processCleanup.cleanupResult.failures === undefined
|
|
? {}
|
|
: {
|
|
failures: processCleanup.cleanupResult.failures.map(
|
|
(failure) => ({ ...failure }),
|
|
),
|
|
}),
|
|
...(processCleanup.cleanupResult.unverified === undefined
|
|
? {}
|
|
: {
|
|
unverified: processCleanup.cleanupResult.unverified.map(
|
|
(process) => ({ ...process }),
|
|
),
|
|
}),
|
|
},
|
|
);
|
|
failure.bindCleanupDependencies([processFailure, rootFailure]);
|
|
return err(failure);
|
|
}
|
|
if (!result.ok) {
|
|
result.error.bindCleanupDependencies([processFailure, rootFailure]);
|
|
return result;
|
|
}
|
|
const failure = sqliteCleanupFailure(
|
|
[
|
|
...(ownership === undefined
|
|
? ["owned-sqlite-worker"]
|
|
: ownedCommandResources(ownership)),
|
|
root.path,
|
|
],
|
|
processReason,
|
|
result,
|
|
input,
|
|
retainedOutput,
|
|
);
|
|
failure.bindCleanupDependencies([processFailure, rootFailure]);
|
|
return err(failure);
|
|
}
|
|
const cleaned = await cleanupSqliteRoot(
|
|
root,
|
|
result,
|
|
input,
|
|
retainedOutput,
|
|
);
|
|
if (!cleaned.ok) {
|
|
const rootFailure = new ProviderCleanupError(
|
|
SQLITE_PROVIDER_IDENTITY.id,
|
|
[root.path],
|
|
{ reason: "SQLite runtime root cleanup remains unresolved." },
|
|
{ operation },
|
|
);
|
|
cleaned.error.bindCleanupDependencies([rootFailure]);
|
|
this.#pendingCleanup.set(root, {
|
|
input,
|
|
result,
|
|
output: retainedOutput,
|
|
rootFailure,
|
|
processFailure: undefined,
|
|
process: undefined,
|
|
});
|
|
return cleaned;
|
|
}
|
|
}
|
|
if (result.ok && options?.signal?.aborted)
|
|
return err(
|
|
new AnalysisCancelledError(operation, {
|
|
cause: options.signal.reason,
|
|
partialObservation: completedSqliteEvidence(input, result.value),
|
|
}),
|
|
);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
const createSqliteObservation = (
|
|
report: ReturnType<typeof sqliteDatabaseSchema.parse>,
|
|
path: string,
|
|
): AnalysisExecution => {
|
|
const locations: AnalysisExecution["locations"][number][] = [
|
|
{ kind: "artifact-path", path },
|
|
];
|
|
if (report.wal !== null)
|
|
locations.push({ kind: "artifact-path", path: report.wal.path });
|
|
return createAnalysisExecution(
|
|
report,
|
|
{ ...SQLITE_PROVIDER_IDENTITY, version: `SQLite ${report.engine.version}` },
|
|
{
|
|
rawResult: null,
|
|
subject: { path, format: "file", sha256: report.artifact.sha256 },
|
|
locations,
|
|
limitations: report.limitations,
|
|
},
|
|
);
|
|
};
|
|
const cleanupSqliteRoot = async (
|
|
root: Pick<PrivateRuntimeRoot, "path" | "close">,
|
|
result: Result<AnalysisExecution, AnalysisError>,
|
|
input: InspectSqliteDatabaseInput,
|
|
output?: AnalysisCapturedOutput,
|
|
): Promise<Result<null, AnalysisError>> => {
|
|
try {
|
|
await root.close();
|
|
return ok(null);
|
|
} catch (cause: unknown) {
|
|
return err(
|
|
sqliteCleanupFailure(
|
|
[root.path],
|
|
cause instanceof Error ? cause.message : String(cause),
|
|
result,
|
|
input,
|
|
output,
|
|
undefined,
|
|
cause,
|
|
),
|
|
);
|
|
}
|
|
};
|
|
|
|
const sqliteCleanupFailure = (
|
|
resources: readonly string[],
|
|
reason: string,
|
|
result: SqliteOutcome,
|
|
input: InspectSqliteDatabaseInput,
|
|
output?: AnalysisCapturedOutput,
|
|
details?: Readonly<Record<string, JsonValue>>,
|
|
cause?: unknown,
|
|
): ProviderCleanupError => {
|
|
const partialObservation = result.ok
|
|
? completedSqliteEvidence(input, result.value)
|
|
: result.error.partialObservation;
|
|
return createSqliteCleanupError(
|
|
resources,
|
|
{
|
|
...details,
|
|
reason,
|
|
},
|
|
{
|
|
...(output === undefined ? {} : { capturedOutput: output }),
|
|
...(result.ok ? {} : { cleanupPrimary: result.error }),
|
|
...(cause === undefined ? {} : { cause }),
|
|
...(partialObservation === undefined ? {} : { partialObservation }),
|
|
},
|
|
);
|
|
};
|
|
|
|
const completedSqliteEvidence = (
|
|
input: InspectSqliteDatabaseInput,
|
|
execution: AnalysisExecution,
|
|
) =>
|
|
createEvidence(execution.subject ?? undefined, execution.provider, {
|
|
operation,
|
|
parameters: sqliteInspectionParameters(input),
|
|
result: execution.result,
|
|
confidence: "observed",
|
|
locations: execution.locations,
|
|
limitations: execution.limitations,
|
|
});
|
|
|
|
const validateSqliteInput = (input: InspectSqliteDatabaseInput): void => {
|
|
const parsed = inspectSqliteDatabaseInputSchema.safeParse(input);
|
|
if (!parsed.success)
|
|
throw analysisInputErrorFromIssues(operation, parsed.error.issues, input, {
|
|
cause: parsed.error,
|
|
});
|
|
};
|