Expose a per-call SQLite worker deadline with exact CLI/MCP recovery controls, parent-owned execution metadata and historical unknown preservation. Keep fixed resource limits and cleanup admission intact. Share typed cleanup composition with one captured-output owner and preserve the timeout after cleanup resolves. Fixes #5812. Follow-up tracking: #5808. Existing CLI test-oracle failure: #5845. Verified 155 SQLite tests (3 skipped), static/docs/discovery checks, six real CLI/MCP pairs and 13 actual Codex sessions including four corrected deadline recoveries. Three pre-dispatch discovery failures remain explicit, not passes. Review findings include real retained-cleanup and fixture ownership regressions. Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
130 lines
4.6 KiB
TypeScript
130 lines
4.6 KiB
TypeScript
import { isAbsolute } from "node:path";
|
|
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
|
import type { SqliteDatabasePort } from "./SqliteDatabasePort.js";
|
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisInputError,
|
|
AnalysisOutputError,
|
|
} from "../../domain/analysisErrorCore.js";
|
|
import {
|
|
createEvidence,
|
|
type Evidence,
|
|
type EvidenceSubjectTarget,
|
|
} from "../../domain/evidence.js";
|
|
import {
|
|
inspectSqliteDatabaseInputSchema,
|
|
sqliteDatabaseSchema,
|
|
type InspectSqliteDatabaseInput,
|
|
type SqliteDatabase,
|
|
SQLITE_ROW_LIMIT_DEFAULT,
|
|
DEFAULT_SQLITE_WORKER_TIMEOUT_MS,
|
|
sqliteSchemaScope,
|
|
sqliteInspectionParameters,
|
|
} from "../../domain/sqlite/sqliteDatabase.js";
|
|
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
|
import { err, ok, type Result } from "../../domain/result.js";
|
|
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
|
|
|
const OPERATION = "inspect_sqlite_database";
|
|
|
|
const matchesSelectedDatabase = (
|
|
report: SqliteDatabase,
|
|
input: InspectSqliteDatabaseInput,
|
|
subject: EvidenceSubjectTarget,
|
|
): boolean =>
|
|
report.artifact.path === input.path &&
|
|
subject.path === input.path &&
|
|
subject.sha256 === report.artifact.sha256 &&
|
|
subject.format === "file" &&
|
|
// Fresh executions must report known settings; only historical parsing permits omission.
|
|
report.worker_timeout_ms ===
|
|
(input.worker_timeout_ms ?? DEFAULT_SQLITE_WORKER_TIMEOUT_MS) &&
|
|
(report.wal === null || report.wal.path === `${input.path}-wal`) &&
|
|
report.schema.scope === sqliteSchemaScope(input) &&
|
|
(report.schema.scope !== "selected_table" ||
|
|
(report.schema.tables.length === 1 &&
|
|
report.schema.tables[0]?.name === input.table &&
|
|
report.schema.indexes.every((index) => index.table === input.table) &&
|
|
report.schema.triggers.every(
|
|
(trigger) => trigger.table === input.table,
|
|
) &&
|
|
report.schema.views.length === 0)) &&
|
|
(input.table === undefined
|
|
? report.rows === null
|
|
: report.rows?.table === input.table &&
|
|
report.rows.row_limit === (input.row_limit ?? SQLITE_ROW_LIMIT_DEFAULT) &&
|
|
(input.columns === undefined ||
|
|
(report.rows.columns.length === input.columns.length &&
|
|
report.rows.columns.every(
|
|
(name, index) => name === input.columns?.[index],
|
|
))));
|
|
|
|
/** Shared snapshot inspection and caller-selection validation for CLI and MCP. */
|
|
export class SqliteDatabaseService {
|
|
constructor(readonly provider: SqliteDatabasePort) {}
|
|
|
|
/** Await provider shutdown, retaining failed cleanup owners for retry. */
|
|
close(): Promise<void> {
|
|
return this.provider.close?.() ?? Promise.resolve();
|
|
}
|
|
|
|
/** Inspect an explicit database snapshot and preserve its original database/WAL identity. */
|
|
async inspect(
|
|
rawInput: unknown,
|
|
options?: ExecutionOptions,
|
|
): Promise<Result<Evidence, AnalysisError>> {
|
|
if (options?.signal?.aborted)
|
|
return err(
|
|
new AnalysisCancelledError(OPERATION, {
|
|
cause: options.signal.reason,
|
|
}),
|
|
);
|
|
const input = inspectSqliteDatabaseInputSchema.safeParse(rawInput);
|
|
if (!input.success)
|
|
return err(
|
|
analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, {
|
|
cause: input.error,
|
|
}),
|
|
);
|
|
if (!isAbsolute(input.data.path))
|
|
return err(
|
|
new AnalysisInputError(OPERATION, undefined, [
|
|
{
|
|
path: ["path"],
|
|
reason: "invalid_format",
|
|
message: "Expected an absolute filesystem path on this host.",
|
|
},
|
|
]),
|
|
);
|
|
const inspected = await this.provider.inspect(input.data, options);
|
|
if (!inspected.ok) return inspected;
|
|
const execution = inspected.value;
|
|
const report = sqliteDatabaseSchema.safeParse(execution.result);
|
|
if (
|
|
!report.success ||
|
|
execution.subject == null ||
|
|
!matchesSelectedDatabase(report.data, input.data, execution.subject)
|
|
)
|
|
return err(
|
|
new AnalysisOutputError(
|
|
OPERATION,
|
|
"SQLite adapter returned malformed data or changed the selected database identity/table/row limit/worker budget.",
|
|
),
|
|
);
|
|
return ok(
|
|
createEvidence(execution.subject, execution.provider, {
|
|
operation: OPERATION,
|
|
parameters: sqliteInspectionParameters(input.data),
|
|
result: jsonValueSchema.parse(report.data),
|
|
...(execution.rawResult === null
|
|
? {}
|
|
: { rawResult: execution.rawResult }),
|
|
confidence: "observed",
|
|
locations: execution.locations,
|
|
limitations: execution.limitations,
|
|
}),
|
|
);
|
|
}
|
|
}
|