Files
rea/src/application/AnalysisSettings.ts
T
morluto f3ed1657f8 feat(mcp): support runtime analysis limit recovery (#7049)
* feat(mcp): support runtime analysis limit recovery

* refactor(config): remove unused limit re-exports

* fix(android): keep fixture release metadata dependency-free

* fix(settings): preserve provider budgets and recovery facts

* fix(tests): resolve lint plugins from their repository owner

* refactor(mcp): verify guidance delivery without frozen wording

* fix(android): align availability with runtime timeout

* test(recovery): assert limit facts instead of frozen wording

* fix(mcp): share runtime settings with the default JADX provider

* test(mcp): bound acknowledgements and release held work on failure

* fix(catalog): declare runtime settings tools as MCP-only
2026-10-25 09:45:26 +08:00

171 lines
6.1 KiB
TypeScript

import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { launchAnalysisSetting } from "../config/analysisSettings.js";
import { AsyncLocalStorage } from "node:async_hooks";
import type { z } from "zod";
import type {
AnalysisSettingId,
AnalysisSettingsSnapshot,
AnalysisSettingValues,
} from "../domain/analysisSettings.js";
import {
analysisSettingDefinition,
analysisSettingIds,
} from "../domain/analysisSettingDefinitions.js";
import {
inspectAnalysisSettingsInputSchema,
updateAnalysisSettingsInputSchema,
} from "../contracts/analysisSettingsContracts.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import { err, ok } from "../domain/result.js";
/** One runtime's admitted overrides and operation snapshots; never writes configuration. */
export class AnalysisSettings {
readonly #accepted = new AsyncLocalStorage<AnalysisSettingsSnapshot>();
#current: AnalysisSettingsSnapshot = Object.freeze({
revision: 0,
overrides: Object.freeze({}),
});
readonly #launch: ReadonlyMap<
AnalysisSettingId,
ReturnType<typeof launchAnalysisSetting>
>;
constructor(environment: Readonly<Record<string, string | undefined>>) {
this.#launch = new Map(
analysisSettingIds.map((id) => [
id,
launchAnalysisSetting(id, environment),
]),
);
}
/** Provider readers inherit exactly the snapshot accepted by their enclosing operation. */
readonly read = (): AnalysisSettingsSnapshot =>
this.#accepted.getStore() ?? this.#current;
run<Value>(operation: () => Value): Value {
return this.#accepted.run(this.#current, operation);
}
inspect(input: z.infer<typeof inspectAnalysisSettingsInputSchema> = {}) {
return {
revision: this.#current.revision,
scope: "rea-runtime" as const,
active_values: { status: "not_observed" as const },
settings: analysisSettingIds
.filter((id) => {
const definition = analysisSettingDefinition(id);
return (
(input.settings === undefined || input.settings.includes(id)) &&
(input.provider === undefined ||
input.provider === definition.provider ||
definition.relatedProviders?.includes(input.provider) === true) &&
(input.operation === undefined ||
definition.operations.includes(input.operation))
);
})
.map((id) => this.#describe(id)),
};
}
update(raw: unknown) {
const input = updateAnalysisSettingsInputSchema.safeParse(raw);
if (!input.success)
return err(
analysisInputErrorFromIssues(
"update_analysis_settings",
input.error.issues,
raw,
),
);
if (
input.data.expected_revision !== undefined &&
input.data.expected_revision !== this.#current.revision
)
return err(
new AnalysisInputError(
"update_analysis_settings",
{
userMessage: `Settings revision conflict: expected ${input.data.expected_revision}, current ${this.#current.revision}. Inspect settings before retrying.`,
},
[
{
path: ["expected_revision"],
reason: "invalid_value",
expected: this.#current.revision,
},
],
),
);
const ids = input.data.changes.map((change) => change.setting);
if (new Set(ids).size !== ids.length)
return err(
new AnalysisInputError(
"update_analysis_settings",
{
userMessage:
"Each setting may occur only once in an atomic update.",
},
[{ path: ["changes"], reason: "invalid_value" }],
),
);
const next: Partial<Record<AnalysisSettingId, number | null>> = {
...this.#current.overrides,
};
const previous = new Map(ids.map((id) => [id, this.#describe(id).value]));
for (const change of input.data.changes) {
if (change.action === "reset") delete next[change.setting];
else next[change.setting] = change.value;
}
const changed = ids.some((id) => next[id] !== this.#current.overrides[id]);
if (changed)
this.#current = Object.freeze({
revision: this.#current.revision + 1,
overrides: Object.freeze(next) as Readonly<
Partial<AnalysisSettingValues>
>,
});
return ok({
...this.inspect({ settings: ids }),
changed,
changes: ids.map((setting) => {
const oldValue = previous.get(setting);
const newValue = this.#describe(setting).value;
return {
setting,
...(oldValue === undefined ? {} : { old_value: oldValue }),
...(newValue === undefined ? {} : { new_value: newValue }),
};
}),
active_work_changed: false as const,
rea_restart_required: false as const,
});
}
#describe(setting: AnalysisSettingId) {
const definition = analysisSettingDefinition(setting);
const launch = this.#launch.get(setting)!;
const override = this.#current.overrides[setting];
const launchValue = launch.ok ? launch.value.value : undefined;
const value = override === undefined ? launchValue : override;
const launchSource = launch.ok ? launch.value.source : ("invalid" as const);
return {
setting,
...(value === undefined ? {} : { value }),
...(launch.ok
? { launch_value: launch.value.value }
: { configuration_error: projectAnalysisError(launch.error) }),
default_value: definition.defaultValue,
source: override === undefined ? launchSource : ("runtime" as const),
unit: definition.unit,
minimum: definition.minimum,
maximum: definition.maximum,
applies_at: definition.appliesAt,
provider: definition.provider,
operations: [...definition.operations],
...(definition.environment === undefined
? {}
: { environment: definition.environment }),
...(definition.parameter === undefined
? {}
: { parameter: definition.parameter }),
agent_editable: true as const,
};
}
}