@vitest-agent/engine 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Context, Effect, FileSystem, Layer, LogLevel, Option, Path, Schema } from "effect";
2
2
  import { ConfigFileShape } from "@effected/config-file";
3
- import { Agent, AgentNotFoundError, AgentReport, ArtifactKind, ArtifactKind as ArtifactKind$1, ArtifactSuite, ArtifactSuite as ArtifactSuite$1, BehaviorDetail, BehaviorNotFoundError, BehaviorRow, BehaviorStatus, CacheManifest, CitedArtifact, CoverageBaselines, CoverageReport, DataStoreError, DetailLevel, DiscoveryError, Environment, Executor, FileCoverageReport, GoalDetail, GoalNotFoundError, GoalRow, GoalStatus, HistoryRecord, IdempotencyHit, IllegalStatusTransitionError, Phase, ProjectIdentityNotResolvableError, RegistrationConflictError, ResolvedThresholds, TddTaskAlreadyEndedError, TddTaskNotFoundError, TestClassification, TestRun, TrendEntry, TrendRecord, TurnPayload, VitestAgentConfig } from "@vitest-agent/sdk";
3
+ import { Agent, AgentNotFoundError, AgentReport, ArtifactKind, ArtifactKind as ArtifactKind$1, ArtifactSuite, ArtifactSuite as ArtifactSuite$1, BehaviorDetail, BehaviorNotFoundError, BehaviorRow, BehaviorStatus, CacheManifest, CitedArtifact, CoverageBaselines, CoverageReport, DataStoreError, DetailLevel, DiscoveryError, Environment, Executor, FileCoverageReport, GoalDetail, GoalNotFoundError, GoalRow, GoalStatus, HistoryRecord, IdempotencyHit, IllegalStatusTransitionError, Phase, ProjectIdentityNotResolvableError, RegistrationConflictError, ResolvedThresholds, RunRetentionPolicy, TddTaskAlreadyEndedError, TddTaskNotFoundError, TestClassification, TestRun, TrendEntry, TrendRecord, TurnPayload, VitestAgentConfig } from "@vitest-agent/sdk";
4
4
  import { SqlClient } from "effect/sql/SqlClient";
5
5
  import { RuntimeEnv } from "@effected/env";
6
6
  import { WorkspaceDiscovery, WorkspaceDiscoveryFailure, WorkspaceRootNotFoundError } from "@effected/workspaces";
@@ -457,6 +457,15 @@ export interface RegisterAgentInput {
457
457
  */
458
458
  readonly agentId?: string;
459
459
  }
460
+ /**
461
+ * What `DataStore.pruneRuns` removed: `test_runs` rows deleted (their
462
+ * cascaded rows are not counted) and orphaned `files` rows garbage-collected.
463
+ * @public
464
+ */
465
+ export interface PruneRunsResult {
466
+ readonly prunedRuns: number;
467
+ readonly prunedFiles: number;
468
+ }
460
469
  /** @public */
461
470
  export interface AssociateRunSessionInput {
462
471
  readonly chatId: string;
@@ -554,6 +563,25 @@ declare const DataStore_base: Context.ServiceClass<DataStore, "vitest-agent/Data
554
563
  readonly affectedSessions: number;
555
564
  readonly prunedTurns: number;
556
565
  }, DataStoreError>;
566
+ /**
567
+ * Apply a run-retention policy: delete every `test_runs` row beyond the
568
+ * newest `policy.keepRuns` of its project, and any run older than
569
+ * `policy.maxAgeDays` days when set, letting the schema's cascades drop
570
+ * the dependent rows (modules, suites, cases, tags, errors, frames,
571
+ * coverage, trends, triggers), then garbage-collect `files` rows no
572
+ * table references any more. `test_history` rows survive with a NULL
573
+ * `run_id`; they keep their own newest-10-per-series window. Before
574
+ * the delete, `file_coverage` rows held by a doomed run are moved onto
575
+ * the lowest surviving run of the same invocation (coverage is written
576
+ * once per invocation, issue 622) unless a survivor already carries
577
+ * coverage rows, so every sibling project's coverage read stays whole.
578
+ *
579
+ * A handful of synchronous statements on the open connection, no
580
+ * yielding, so it may run inside `withPersistTransaction` after the
581
+ * run's own rows are written: the run just written is the newest of
582
+ * its project and is never pruned.
583
+ */
584
+ readonly pruneRuns: (policy: RunRetentionPolicy) => Effect.Effect<PruneRunsResult, DataStoreError>;
557
585
  readonly associateLatestRunWithSession: (input: AssociateRunSessionInput) => Effect.Effect<void, DataStoreError>;
558
586
  readonly backfillTestCaseTurns: (chatId: string) => Effect.Effect<number, DataStoreError>;
559
587
  /**
@@ -1688,6 +1716,7 @@ declare const migration$1: Effect.Effect<void, import("effect/sql/SqlError").Sql
1688
1716
  export declare const PROJECT_MIGRATIONS: {
1689
1717
  readonly "0001_initial": import("effect/Effect").Effect<void, import("effect/sql/SqlError").SqlError, import("effect/sql/SqlClient").SqlClient>;
1690
1718
  readonly "0002_test_artifacts": import("effect/Effect").Effect<void, import("effect/sql/SqlError").SqlError, import("effect/sql/SqlClient").SqlClient>;
1719
+ readonly "0003_run_retention": import("effect/Effect").Effect<void, import("effect/sql/SqlError").SqlError, import("effect/sql/SqlClient").SqlClient>;
1691
1720
  };
1692
1721
  /**
1693
1722
  * Migration for the global discovery registry SQLite at
@@ -2540,6 +2569,30 @@ export interface FailureSignatureInput {
2540
2569
  export declare const normalizeAssertionShape: (message: string) => string | null;
2541
2570
  /** @public */
2542
2571
  export declare const computeFailureSignature: (input: FailureSignatureInput) => string;
2572
+ /**
2573
+ * Run `effect` as a nested transaction that keeps the
2574
+ * {@link withPersistTransaction} caches honest.
2575
+ *
2576
+ * Outside a persist batch this is exactly `sql.withTransaction(effect)`.
2577
+ * Inside one, the nested `withTransaction` becomes a SAVEPOINT, and when it
2578
+ * rolls back (a failure, a defect, an interrupt) every `files` / `tags` row
2579
+ * the savepoint inserted is gone while the ids `DataStoreLive` cached for
2580
+ * them would survive in `PersistBatchState`. This helper snapshots the
2581
+ * cache sizes before the savepoint opens and discards the entries added
2582
+ * since whenever it does not commit, so a later write in the same batch
2583
+ * re-inserts the row instead of referencing one that no longer exists
2584
+ * (issue #628: a FK failure or a dangling id). A savepoint that commits
2585
+ * keeps its entries.
2586
+ *
2587
+ * Use this, never a bare `sql.withTransaction`, for any nested transaction
2588
+ * whose failure a caller may catch and continue from inside the batch; a
2589
+ * bare one that rolls back leaves the caches stale. The bookkeeping is
2590
+ * synchronous, so the batch's no-yield guarantee is unchanged.
2591
+ *
2592
+ * @param effect - the writes to run as one savepoint (or transaction)
2593
+ * @public
2594
+ */
2595
+ export declare const withPersistSavepoint: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E | SqlError, R | SqlClient>;
2543
2596
  /**
2544
2597
  * Run `effect` as one SQLite write transaction on the `data.db` connection:
2545
2598
  * every `DataStore` write inside it commits together or not at all, and a
@@ -2560,8 +2613,13 @@ export declare const computeFailureSignature: (input: FailureSignatureInput) =>
2560
2613
  * genuinely asynchronous work for that reason; CPU-heavy work that needs no
2561
2614
  * database belongs before the block.
2562
2615
  *
2563
- * Nested `withTransaction` calls inside `effect` become savepoints. A
2564
- * `DataReader` query inside it runs on the transaction's connection.
2616
+ * Nested `withTransaction` calls inside `effect` become savepoints. Open
2617
+ * them through {@link withPersistSavepoint}: a savepoint that rolls back
2618
+ * also discards the tag / file ids cached while it was open, so a write the
2619
+ * caller recovers from cannot leave a later write pointing at a row the
2620
+ * rollback removed. A bare nested `sql.withTransaction` whose failure is
2621
+ * caught inside the block leaves those caches stale. A `DataReader` query
2622
+ * inside the block runs on the transaction's connection.
2565
2623
  *
2566
2624
  * @param effect - the writes (and the reads they depend on) to run atomically
2567
2625
  * @public
package/index.js CHANGED
@@ -4,7 +4,7 @@ import { DataReader } from "./services/DataReader.js";
4
4
  import { HistoryTracker, historyKey } from "./services/HistoryTracker.js";
5
5
  import { DataReaderLive } from "./layers/DataReaderLive.js";
6
6
  import { DataStore, INLINE_ATTACHMENT_BODY_CAP_BYTES } from "./services/DataStore.js";
7
- import { withPersistTransaction } from "./utils/persist-transaction.js";
7
+ import { withPersistSavepoint, withPersistTransaction } from "./utils/persist-transaction.js";
8
8
  import { DataStoreLive } from "./layers/DataStoreLive.js";
9
9
  import { DetailResolver } from "./services/DetailResolver.js";
10
10
  import { DetailResolverLive } from "./layers/DetailResolverLive.js";
@@ -57,4 +57,4 @@ import { DATABASE_FILENAME, resolveDataPath } from "./utils/resolve-data-path.js
57
57
  import { resolveWorkspaceKey } from "./utils/resolve-workspace-key.js";
58
58
  import { CURRENT_ENGINE_VERSION } from "./version.js";
59
59
 
60
- export { APP_NAMESPACE, AgentContext, CURRENT_ENGINE_VERSION, ConfigLive, DATABASE_FILENAME, DATA_DB_FILENAME, DATA_FALLBACK_DIR, DataReader, DataReaderLive, DataStore, DataStoreLive, DetailResolver, DetailResolverLive, DiscoveryRegistry, DiscoveryRegistryLive, EnvironmentDetector, EnvironmentDetectorLive, EnvironmentDetectorTest, ExecutorResolver, ExecutorResolverLive, HistoryTracker, HistoryTrackerLive, HistoryTrackerTest, IDEMPOTENCY_ROOT_SENTINEL, INLINE_ATTACHMENT_BODY_CAP_BYTES, LoggerLive, NodePlatformLayer, OutputPipelineLive, PROJECT_MIGRATIONS, PathResolutionLive, PerClientSessionMapReader, PerClientSessionMapReaderLive, PerClientSessionMapWriter, PerClientSessionMapWriterLive, PlatformLive, ProjectDiscovery, ProjectDiscoveryLive, ProjectDiscoveryTest, ProjectIdentity, ProjectIdentityLive, ProjectIdentityTest, REGISTRY_DB_FILENAME, REGISTRY_STORE_OPTIONS, RunContext, RunContext$, RunContextLive, RunContextService, RunContextTest, SESSIONS_DB_FILENAME, SidecarPlatformLive, VitestAgentConfigFile, _resetMigrationCacheForTesting, assembleHistoryRecord, assembleManifest, classifyEnvironment, computeFailureSignature, deriveIdempotencyKey, dispatchRecordTddArtifactEffect, endAgentEffect, ensureMigrated, formatTriageEffect, formatWrapupEffect, historyKey, makeSqliteStack, migration as migration0001, migration$1 as migration0002, normalizeAssertionShape, parseAndValidateTurnPayload, parseSessionEnvExports, recordRunWorkspaceChangesEffect, recordSessionEnd, recordSessionStart, recordTddArtifactByTaskIdEffect, recordTddArtifactEffect, recordTurnEffect, recoverSessionContextFromSessionEnv, registerAgentEffect, migration$2 as registryMigration0001, resolveDataPath, resolveHookPaths, resolveLogFile, resolveLogLevel, resolveProjectDir, resolveProjectIdentityFromCandidates, resolveProjectKeyFromCwd, resolveProjectKeyFromCwdEffect, resolveSessionForRecording, resolveSessionMapPath, resolveWorkspaceKey, migration$3 as sessionMapMigration0001, withPersistTransaction };
60
+ export { APP_NAMESPACE, AgentContext, CURRENT_ENGINE_VERSION, ConfigLive, DATABASE_FILENAME, DATA_DB_FILENAME, DATA_FALLBACK_DIR, DataReader, DataReaderLive, DataStore, DataStoreLive, DetailResolver, DetailResolverLive, DiscoveryRegistry, DiscoveryRegistryLive, EnvironmentDetector, EnvironmentDetectorLive, EnvironmentDetectorTest, ExecutorResolver, ExecutorResolverLive, HistoryTracker, HistoryTrackerLive, HistoryTrackerTest, IDEMPOTENCY_ROOT_SENTINEL, INLINE_ATTACHMENT_BODY_CAP_BYTES, LoggerLive, NodePlatformLayer, OutputPipelineLive, PROJECT_MIGRATIONS, PathResolutionLive, PerClientSessionMapReader, PerClientSessionMapReaderLive, PerClientSessionMapWriter, PerClientSessionMapWriterLive, PlatformLive, ProjectDiscovery, ProjectDiscoveryLive, ProjectDiscoveryTest, ProjectIdentity, ProjectIdentityLive, ProjectIdentityTest, REGISTRY_DB_FILENAME, REGISTRY_STORE_OPTIONS, RunContext, RunContext$, RunContextLive, RunContextService, RunContextTest, SESSIONS_DB_FILENAME, SidecarPlatformLive, VitestAgentConfigFile, _resetMigrationCacheForTesting, assembleHistoryRecord, assembleManifest, classifyEnvironment, computeFailureSignature, deriveIdempotencyKey, dispatchRecordTddArtifactEffect, endAgentEffect, ensureMigrated, formatTriageEffect, formatWrapupEffect, historyKey, makeSqliteStack, migration as migration0001, migration$1 as migration0002, normalizeAssertionShape, parseAndValidateTurnPayload, parseSessionEnvExports, recordRunWorkspaceChangesEffect, recordSessionEnd, recordSessionStart, recordTddArtifactByTaskIdEffect, recordTddArtifactEffect, recordTurnEffect, recoverSessionContextFromSessionEnv, registerAgentEffect, migration$2 as registryMigration0001, resolveDataPath, resolveHookPaths, resolveLogFile, resolveLogLevel, resolveProjectDir, resolveProjectIdentityFromCandidates, resolveProjectKeyFromCwd, resolveProjectKeyFromCwdEffect, resolveSessionForRecording, resolveSessionMapPath, resolveWorkspaceKey, migration$3 as sessionMapMigration0001, withPersistSavepoint, withPersistTransaction };
@@ -8,6 +8,28 @@ import { SqlClient } from "effect/sql/SqlClient";
8
8
  /** @public */
9
9
  const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
10
10
  const sql = yield* SqlClient;
11
+ /**
12
+ * The run whose `file_coverage` rows describe `runId`'s coverage.
13
+ *
14
+ * The reporter writes per-file coverage ONCE per invocation, under
15
+ * one run of that invocation (issue #622), while every project gets
16
+ * its own `test_runs` row. A project run's rows therefore live under
17
+ * the sibling run sharing its `invocation_id`. Preference order: the
18
+ * run itself when it carries rows (a database an older reporter wrote
19
+ * holds an identical copy under every run of the invocation — reading
20
+ * exactly one run keeps that layout deduplicated), else the lowest
21
+ * sibling run id that carries rows. A run whose invocation wrote no
22
+ * rows resolves to itself, so the caller's `WHERE run_id = ?` is
23
+ * simply empty. Never crosses an invocation boundary.
24
+ */
25
+ const resolveCoverageRunId = (runId) => Effect.gen(function* () {
26
+ return (yield* sql`SELECT fc.run_id
27
+ FROM file_coverage fc
28
+ JOIN test_runs tr ON tr.id = fc.run_id
29
+ WHERE tr.invocation_id = (SELECT invocation_id FROM test_runs WHERE id = ${runId})
30
+ ORDER BY CASE WHEN fc.run_id = ${runId} THEN 0 ELSE 1 END, fc.run_id ASC
31
+ LIMIT 1`)[0]?.run_id ?? runId;
32
+ });
11
33
  const getLatestRun = (project) => Effect.gen(function* () {
12
34
  yield* Effect.logDebug("getLatestRun").pipe(Effect.annotateLogs({ project }));
13
35
  const runs = yield* sql`SELECT id, timestamp, reason, duration, total, passed, failed, skipped, project
@@ -80,11 +102,12 @@ const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
80
102
  WHERE run_id = ${run.id} AND scope = 'unhandled'`;
81
103
  const failedFiles = failedModules.map((m) => m.file_path);
82
104
  const projectName = run.project;
105
+ const coverageRunId = yield* resolveCoverageRunId(run.id);
83
106
  const fileCovRows = yield* sql`SELECT f.path AS file_path, fc.statements, fc.branches, fc.functions, fc.lines,
84
107
  fc.uncovered_lines, fc.tier
85
108
  FROM file_coverage fc
86
109
  JOIN files f ON f.id = fc.file_id
87
- WHERE fc.run_id = ${run.id}`;
110
+ WHERE fc.run_id = ${coverageRunId}`;
88
111
  const lowCoverage = fileCovRows.filter((r) => r.tier === "below_threshold").map((r) => ({
89
112
  file: r.file_path,
90
113
  summary: {
@@ -440,10 +463,11 @@ const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
440
463
  })));
441
464
  const getFileCoverage = (runId) => Effect.gen(function* () {
442
465
  yield* Effect.logDebug("getFileCoverage").pipe(Effect.annotateLogs({ runId }));
466
+ const coverageRunId = yield* resolveCoverageRunId(runId);
443
467
  return (yield* sql`SELECT f.path as file_path, fc.statements, fc.branches, fc.functions, fc.lines, fc.uncovered_lines
444
468
  FROM file_coverage fc
445
469
  JOIN files f ON f.id = fc.file_id
446
- WHERE fc.run_id = ${runId}`).map((r) => ({
470
+ WHERE fc.run_id = ${coverageRunId}`).map((r) => ({
447
471
  file: r.file_path,
448
472
  summary: {
449
473
  statements: r.statements,
@@ -464,7 +488,7 @@ const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
464
488
  WHERE project = ${project}
465
489
  ORDER BY timestamp DESC LIMIT 1`;
466
490
  if (runs.length === 0) return Option.none();
467
- const runId = runs[0].id;
491
+ const runId = yield* resolveCoverageRunId(runs[0].id);
468
492
  const fileCoverageRows = yield* sql`SELECT f.path as file_path, fc.statements, fc.branches, fc.functions, fc.lines, fc.uncovered_lines, fc.tier
469
493
  FROM file_coverage fc JOIN files f ON f.id = fc.file_id
470
494
  WHERE fc.run_id = ${runId}`;
@@ -1,7 +1,7 @@
1
1
  import { DataStore, INLINE_ATTACHMENT_BODY_CAP_BYTES } from "../services/DataStore.js";
2
- import { PersistBatch } from "../utils/persist-transaction.js";
2
+ import { PersistBatch, withPersistSavepoint } from "../utils/persist-transaction.js";
3
3
  import { Agent, AgentNotFoundError, BehaviorNotFoundError, DataStoreError, GoalNotFoundError, IdempotencyHit, IllegalStatusTransitionError, RegistrationConflictError, TddTaskAlreadyEndedError, TddTaskNotFoundError, coerceErrorText, extractSqlReason } from "@vitest-agent/sdk";
4
- import { Effect, Layer, Option } from "effect";
4
+ import { Clock, Effect, Layer, Option } from "effect";
5
5
  import { SqlClient } from "effect/sql/SqlClient";
6
6
  import { Buffer } from "node:buffer";
7
7
  import { randomUUID } from "node:crypto";
@@ -36,6 +36,7 @@ const boolToInt = (v) => v === void 0 ? null : v ? 1 : 0;
36
36
  const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
37
37
  const sql = yield* SqlClient;
38
38
  yield* sql`PRAGMA foreign_keys=ON`.pipe(Effect.catch(() => Effect.void));
39
+ const savepoint = (effect) => withPersistSavepoint(effect).pipe(Effect.provideService(SqlClient, sql));
39
40
  const ensureFile = (filePath) => Effect.gen(function* () {
40
41
  yield* Effect.logDebug("ensureFile").pipe(Effect.annotateLogs({ filePath }));
41
42
  const batch = yield* PersistBatch;
@@ -275,7 +276,7 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
275
276
  * enforced Vitest thresholds or the aspirational coverageTargets
276
277
  * (issue #237).
277
278
  */
278
- const writeCoveragePolicy = (kind, policy) => sql.withTransaction(Effect.gen(function* () {
279
+ const writeCoveragePolicy = (kind, policy) => savepoint(Effect.gen(function* () {
279
280
  yield* Effect.logDebug("writeCoveragePolicy").pipe(Effect.annotateLogs({ kind }));
280
281
  const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
281
282
  const { global: g, patterns = [] } = policy;
@@ -427,7 +428,7 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
427
428
  table: "sessions",
428
429
  reason: extractSqlReason(e)
429
430
  })));
430
- const writeTurn = (input) => sql.withTransaction(Effect.gen(function* () {
431
+ const writeTurn = (input) => savepoint(Effect.gen(function* () {
431
432
  yield* Effect.logDebug("writeTurn").pipe(Effect.annotateLogs({
432
433
  sessionId: input.sessionId,
433
434
  type: input.type
@@ -533,7 +534,7 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
533
534
  table: "hypotheses",
534
535
  reason: extractSqlReason(e)
535
536
  })));
536
- const writeTddTask = (input) => sql.withTransaction(Effect.gen(function* () {
537
+ const writeTddTask = (input) => savepoint(Effect.gen(function* () {
537
538
  yield* Effect.logDebug("writeTddTask").pipe(Effect.annotateLogs({
538
539
  sessionId: input.sessionId,
539
540
  goal: input.goal
@@ -797,7 +798,7 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
797
798
  reason: extractSqlReason(e)
798
799
  })));
799
800
  });
800
- const createBehavior = (input) => sql.withTransaction(Effect.gen(function* () {
801
+ const createBehavior = (input) => savepoint(Effect.gen(function* () {
801
802
  yield* Effect.logDebug("createBehavior").pipe(Effect.annotateLogs({
802
803
  goalId: input.goalId,
803
804
  behavior: input.behavior
@@ -836,7 +837,7 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
836
837
  table: "tdd_session_behaviors",
837
838
  reason: extractSqlReason(e)
838
839
  })));
839
- const updateBehavior = (input) => sql.withTransaction(Effect.gen(function* () {
840
+ const updateBehavior = (input) => savepoint(Effect.gen(function* () {
840
841
  yield* Effect.logDebug("updateBehavior").pipe(Effect.annotateLogs({ id: input.id }));
841
842
  const existing = yield* sql`
842
843
  SELECT id, goal_id, ordinal, behavior, suggested_test_name, status, created_at
@@ -1113,6 +1114,82 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
1113
1114
  table: "turns",
1114
1115
  reason: extractSqlReason(e)
1115
1116
  })));
1117
+ const pruneRuns = (policy) => Effect.gen(function* () {
1118
+ yield* Effect.logDebug("pruneRuns").pipe(Effect.annotateLogs({
1119
+ keepRuns: policy.keepRuns,
1120
+ maxAgeDays: policy.maxAgeDays ?? null
1121
+ }));
1122
+ const now = yield* Clock.currentTimeMillis;
1123
+ const cutoff = policy.maxAgeDays === void 0 ? null : (/* @__PURE__ */ new Date(now - policy.maxAgeDays * 24 * 60 * 60 * 1e3)).toISOString();
1124
+ yield* sql`CREATE TEMP TABLE IF NOT EXISTS prune_doomed_runs (id INTEGER PRIMARY KEY)`;
1125
+ yield* sql`DELETE FROM prune_doomed_runs`;
1126
+ yield* sql`
1127
+ INSERT INTO prune_doomed_runs (id)
1128
+ SELECT id FROM (
1129
+ SELECT id, timestamp,
1130
+ ROW_NUMBER() OVER (PARTITION BY project ORDER BY timestamp DESC, id DESC) AS rn
1131
+ FROM test_runs
1132
+ )
1133
+ WHERE rn > ${policy.keepRuns} OR (${cutoff} IS NOT NULL AND timestamp < ${cutoff})`;
1134
+ yield* sql`
1135
+ WITH sources AS (
1136
+ SELECT tr.invocation_id, MIN(tr.id) AS from_run_id
1137
+ FROM test_runs tr
1138
+ JOIN prune_doomed_runs d ON d.id = tr.id
1139
+ WHERE EXISTS (SELECT 1 FROM file_coverage fc WHERE fc.run_id = tr.id)
1140
+ GROUP BY tr.invocation_id
1141
+ ),
1142
+ moves AS (
1143
+ SELECT s.from_run_id,
1144
+ (SELECT MIN(tr2.id) FROM test_runs tr2
1145
+ WHERE tr2.invocation_id = s.invocation_id
1146
+ AND tr2.id NOT IN (SELECT id FROM prune_doomed_runs)) AS to_run_id
1147
+ FROM sources s
1148
+ WHERE NOT EXISTS (
1149
+ SELECT 1 FROM file_coverage fc2
1150
+ JOIN test_runs tr3 ON tr3.id = fc2.run_id
1151
+ WHERE tr3.invocation_id = s.invocation_id
1152
+ AND tr3.id NOT IN (SELECT id FROM prune_doomed_runs)
1153
+ )
1154
+ )
1155
+ UPDATE file_coverage
1156
+ SET run_id = (SELECT to_run_id FROM moves WHERE moves.from_run_id = file_coverage.run_id)
1157
+ WHERE run_id IN (SELECT from_run_id FROM moves WHERE to_run_id IS NOT NULL)`;
1158
+ const deletedRuns = yield* sql`
1159
+ DELETE FROM test_runs WHERE id IN (SELECT id FROM prune_doomed_runs) RETURNING id`;
1160
+ yield* sql`DELETE FROM prune_doomed_runs`;
1161
+ if (deletedRuns.length === 0) return {
1162
+ prunedRuns: 0,
1163
+ prunedFiles: 0
1164
+ };
1165
+ const deletedFiles = yield* sql`
1166
+ DELETE FROM files WHERE
1167
+ NOT EXISTS (SELECT 1 FROM test_modules WHERE file_id = files.id)
1168
+ AND NOT EXISTS (SELECT 1 FROM scoped_files WHERE file_id = files.id)
1169
+ AND NOT EXISTS (SELECT 1 FROM file_coverage WHERE file_id = files.id)
1170
+ AND NOT EXISTS (SELECT 1 FROM stack_frames WHERE file_id = files.id)
1171
+ AND NOT EXISTS (SELECT 1 FROM test_annotations WHERE location_file_id = files.id)
1172
+ AND NOT EXISTS (SELECT 1 FROM test_artifacts WHERE location_file_id = files.id)
1173
+ AND NOT EXISTS (SELECT 1 FROM import_durations WHERE file_id = files.id OR importer_file_id = files.id)
1174
+ AND NOT EXISTS (SELECT 1 FROM source_test_map WHERE source_file_id = files.id)
1175
+ AND NOT EXISTS (SELECT 1 FROM file_edits WHERE file_id = files.id)
1176
+ AND NOT EXISTS (SELECT 1 FROM run_changed_files WHERE file_id = files.id)
1177
+ AND NOT EXISTS (SELECT 1 FROM tdd_artifacts WHERE file_id = files.id)
1178
+ RETURNING id`;
1179
+ const batch = yield* PersistBatch;
1180
+ if (batch !== void 0 && deletedFiles.length > 0) {
1181
+ const gone = new Set(deletedFiles.map((row) => row.id));
1182
+ for (const [path, id] of batch.fileIds) if (gone.has(id)) batch.fileIds.delete(path);
1183
+ }
1184
+ return {
1185
+ prunedRuns: deletedRuns.length,
1186
+ prunedFiles: deletedFiles.length
1187
+ };
1188
+ }).pipe(Effect.annotateLogs("service", "DataStore"), Effect.mapError((e) => new DataStoreError({
1189
+ operation: "write",
1190
+ table: "test_runs",
1191
+ reason: extractSqlReason(e)
1192
+ })));
1116
1193
  const associateLatestRunWithSession = (input) => Effect.gen(function* () {
1117
1194
  yield* Effect.logDebug("associateLatestRunWithSession").pipe(Effect.annotateLogs({
1118
1195
  chatId: input.chatId,
@@ -1288,6 +1365,7 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
1288
1365
  writeRunChangedFiles,
1289
1366
  recordIdempotentResponse,
1290
1367
  pruneSessions,
1368
+ pruneRuns,
1291
1369
  associateLatestRunWithSession,
1292
1370
  backfillTestCaseTurns,
1293
1371
  registerAgent,
@@ -0,0 +1,66 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/sql/SqlClient";
3
+
4
+ // src/migrations/0003_run_retention.ts
5
+ /** @internal */
6
+ const migration = Effect.gen(function* () {
7
+ const sql = yield* SqlClient;
8
+ yield* sql`
9
+ CREATE TABLE test_history_new (
10
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
11
+ run_id INTEGER REFERENCES test_runs(id) ON DELETE SET NULL,
12
+ project TEXT NOT NULL,
13
+ module_path TEXT NOT NULL,
14
+ full_name TEXT NOT NULL,
15
+ timestamp TEXT NOT NULL,
16
+ state TEXT NOT NULL CHECK (state IN ('passed', 'failed', 'skipped', 'pending')),
17
+ duration INTEGER,
18
+ flaky INTEGER CHECK (flaky IN (0, 1)),
19
+ retry_count INTEGER DEFAULT 0,
20
+ error_message TEXT,
21
+ UNIQUE(project, module_path, full_name, timestamp)
22
+ )
23
+ `;
24
+ yield* sql`
25
+ INSERT INTO test_history_new (id, run_id, project, module_path, full_name, timestamp, state, duration, flaky, retry_count, error_message)
26
+ SELECT id, run_id, project, module_path, full_name, timestamp, state, duration, flaky, retry_count, error_message
27
+ FROM test_history
28
+ `;
29
+ yield* sql`DROP INDEX IF EXISTS idx_test_history_lookup`;
30
+ yield* sql`DROP INDEX IF EXISTS idx_test_history_full_name`;
31
+ yield* sql`DROP INDEX IF EXISTS idx_test_history_run`;
32
+ yield* sql`DROP TABLE test_history`;
33
+ yield* sql`ALTER TABLE test_history_new RENAME TO test_history`;
34
+ yield* sql`CREATE INDEX idx_test_history_lookup ON test_history(project, module_path, full_name)`;
35
+ yield* sql`CREATE INDEX idx_test_history_full_name ON test_history(full_name, timestamp)`;
36
+ yield* sql`CREATE INDEX idx_test_history_run ON test_history(run_id)`;
37
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_test_runs_project_timestamp ON test_runs(project, timestamp)`;
38
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_test_modules_file ON test_modules(file_id)`;
39
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_scoped_files_file ON scoped_files(file_id)`;
40
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_stack_frames_file ON stack_frames(file_id)`;
41
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_test_annotations_file ON test_annotations(location_file_id)`;
42
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_test_artifacts_file ON test_artifacts(location_file_id)`;
43
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_import_durations_file ON import_durations(file_id)`;
44
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_tdd_artifacts_file ON tdd_artifacts(file_id)`;
45
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_tdd_artifacts_test_run ON tdd_artifacts(test_run_id)`;
46
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_tdd_artifacts_first_failure_run ON tdd_artifacts(test_first_failure_run_id)`;
47
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_failure_signatures_first_seen_run ON failure_signatures(first_seen_run_id)`;
48
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_test_suites_parent ON test_suites(parent_suite_id)`;
49
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_test_errors_suite ON test_errors(test_suite_id)`;
50
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_test_errors_module ON test_errors(module_id)`;
51
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_test_errors_cause ON test_errors(cause_error_id)`;
52
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_tdd_artifacts_test_case ON tdd_artifacts(test_case_id)`;
53
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_task_metadata_case ON task_metadata(test_case_id)`;
54
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_task_metadata_suite ON task_metadata(test_suite_id)`;
55
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_task_metadata_module ON task_metadata(module_id)`;
56
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_hook_executions_module ON hook_executions(test_module_id)`;
57
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_hook_executions_suite ON hook_executions(test_suite_id)`;
58
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_hook_executions_case ON hook_executions(test_case_id)`;
59
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_hook_executions_error ON hook_executions(error_id)`;
60
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_attachments_artifact ON attachments(artifact_id)`;
61
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_attachments_annotation ON attachments(annotation_id)`;
62
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_hypotheses_cited_error ON hypotheses(cited_test_error_id)`;
63
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_hypotheses_cited_frame ON hypotheses(cited_stack_frame_id)`;
64
+ });
65
+
66
+ export { migration as default };
@@ -1,5 +1,6 @@
1
1
  import migration from "./0001_initial.js";
2
2
  import migration$1 from "./0002_test_artifacts.js";
3
+ import migration$2 from "./0003_run_retention.js";
3
4
 
4
5
  // src/migrations/index.ts
5
6
  /**
@@ -21,7 +22,8 @@ import migration$1 from "./0002_test_artifacts.js";
21
22
  */
22
23
  const PROJECT_MIGRATIONS = {
23
24
  "0001_initial": migration,
24
- "0002_test_artifacts": migration$1
25
+ "0002_test_artifacts": migration$1,
26
+ "0003_run_retention": migration$2
25
27
  };
26
28
 
27
29
  export { PROJECT_MIGRATIONS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/engine",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "description": "Shared services, layers, SQLite data layer, migrations and platform resolution for the vitest-agent CLI and MCP server.",
6
6
  "keywords": [
@@ -55,7 +55,7 @@
55
55
  "@effected/workspaces": "^0.38.0",
56
56
  "@effected/xdg": "^0.14.1",
57
57
  "@effected/yaml": "^0.25.0",
58
- "@vitest-agent/sdk": "7.0.5",
58
+ "@vitest-agent/sdk": "7.1.0",
59
59
  "effect": "^4.0.6"
60
60
  },
61
61
  "engines": {
package/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Agent, Agent as Agent$1, AgentId, AgentNotFoundError, AgentNotFoundError as AgentNotFoundError$1, AgentReport, AgentReport as AgentReport$1, ArtifactKind, ArtifactKind as ArtifactKind$1, ArtifactKind as ArtifactKind$2, ArtifactSuite, ArtifactSuite as ArtifactSuite$1, ArtifactSuite as ArtifactSuite$2, BehaviorDetail, BehaviorDetail as BehaviorDetail$1, BehaviorNotFoundError, BehaviorNotFoundError as BehaviorNotFoundError$1, BehaviorRow, BehaviorRow as BehaviorRow$1, BehaviorStatus, BehaviorStatus as BehaviorStatus$1, CacheManifest, CacheManifest as CacheManifest$1, ChatId, CitedArtifact, CitedArtifact as CitedArtifact$1, CoverageBaselines, CoverageBaselines as CoverageBaselines$1, CoverageReport, CoverageReport as CoverageReport$1, DataStoreError, DataStoreError as DataStoreError$1, FileCoverageReport, FileCoverageReport as FileCoverageReport$1, GoalDetail, GoalDetail as GoalDetail$1, GoalNotFoundError, GoalNotFoundError as GoalNotFoundError$1, GoalRow, GoalRow as GoalRow$1, GoalStatus, GoalStatus as GoalStatus$1, HistoryRecord, HistoryRecord as HistoryRecord$1, IdempotencyHit, IdempotencyHit as IdempotencyHit$1, IllegalStatusTransitionEntity, IllegalStatusTransitionError, IllegalStatusTransitionError as IllegalStatusTransitionError$1, Phase, Phase as Phase$1, RegistrationConflictError, RegistrationConflictError as RegistrationConflictError$1, ResolvedThresholds, ResolvedThresholds as ResolvedThresholds$1, TddTaskAlreadyEndedError, TddTaskAlreadyEndedError as TddTaskAlreadyEndedError$1, TddTaskEndOutcome, TddTaskNotFoundError, TddTaskNotFoundError as TddTaskNotFoundError$1, TrendEntry, TrendEntry as TrendEntry$1, TrendRecord, TrendRecord as TrendRecord$1 } from "@vitest-agent/sdk";
1
+ import { Agent, Agent as Agent$1, AgentId, AgentNotFoundError, AgentNotFoundError as AgentNotFoundError$1, AgentReport, AgentReport as AgentReport$1, ArtifactKind, ArtifactKind as ArtifactKind$1, ArtifactKind as ArtifactKind$2, ArtifactSuite, ArtifactSuite as ArtifactSuite$1, ArtifactSuite as ArtifactSuite$2, BehaviorDetail, BehaviorDetail as BehaviorDetail$1, BehaviorNotFoundError, BehaviorNotFoundError as BehaviorNotFoundError$1, BehaviorRow, BehaviorRow as BehaviorRow$1, BehaviorStatus, BehaviorStatus as BehaviorStatus$1, CacheManifest, CacheManifest as CacheManifest$1, ChatId, CitedArtifact, CitedArtifact as CitedArtifact$1, CoverageBaselines, CoverageBaselines as CoverageBaselines$1, CoverageReport, CoverageReport as CoverageReport$1, DataStoreError, DataStoreError as DataStoreError$1, FileCoverageReport, FileCoverageReport as FileCoverageReport$1, GoalDetail, GoalDetail as GoalDetail$1, GoalNotFoundError, GoalNotFoundError as GoalNotFoundError$1, GoalRow, GoalRow as GoalRow$1, GoalStatus, GoalStatus as GoalStatus$1, HistoryRecord, HistoryRecord as HistoryRecord$1, IdempotencyHit, IdempotencyHit as IdempotencyHit$1, IllegalStatusTransitionEntity, IllegalStatusTransitionError, IllegalStatusTransitionError as IllegalStatusTransitionError$1, Phase, Phase as Phase$1, RegistrationConflictError, RegistrationConflictError as RegistrationConflictError$1, ResolvedThresholds, ResolvedThresholds as ResolvedThresholds$1, RunRetentionPolicy, RunRetentionPolicy as RunRetentionPolicy$1, TddTaskAlreadyEndedError, TddTaskAlreadyEndedError as TddTaskAlreadyEndedError$1, TddTaskEndOutcome, TddTaskNotFoundError, TddTaskNotFoundError as TddTaskNotFoundError$1, TrendEntry, TrendEntry as TrendEntry$1, TrendRecord, TrendRecord as TrendRecord$1 } from "@vitest-agent/sdk";
2
2
  import { Context, Effect, Layer, Option } from "effect";
3
3
  import "@effect/platform-node/NodeServices";
4
4
  import "@effect/sql-sqlite-node/SqliteClient";
@@ -408,6 +408,15 @@ interface RegisterAgentInput {
408
408
  */
409
409
  readonly agentId?: string;
410
410
  }
411
+ /**
412
+ * What `DataStore.pruneRuns` removed: `test_runs` rows deleted (their
413
+ * cascaded rows are not counted) and orphaned `files` rows garbage-collected.
414
+ * @public
415
+ */
416
+ interface PruneRunsResult {
417
+ readonly prunedRuns: number;
418
+ readonly prunedFiles: number;
419
+ }
411
420
  /** @public */
412
421
  interface AssociateRunSessionInput {
413
422
  readonly chatId: string;
@@ -505,6 +514,25 @@ declare const DataStore_base: Context.ServiceClass<DataStore, "vitest-agent/Data
505
514
  readonly affectedSessions: number;
506
515
  readonly prunedTurns: number;
507
516
  }, DataStoreError$1>;
517
+ /**
518
+ * Apply a run-retention policy: delete every `test_runs` row beyond the
519
+ * newest `policy.keepRuns` of its project, and any run older than
520
+ * `policy.maxAgeDays` days when set, letting the schema's cascades drop
521
+ * the dependent rows (modules, suites, cases, tags, errors, frames,
522
+ * coverage, trends, triggers), then garbage-collect `files` rows no
523
+ * table references any more. `test_history` rows survive with a NULL
524
+ * `run_id`; they keep their own newest-10-per-series window. Before
525
+ * the delete, `file_coverage` rows held by a doomed run are moved onto
526
+ * the lowest surviving run of the same invocation (coverage is written
527
+ * once per invocation, issue 622) unless a survivor already carries
528
+ * coverage rows, so every sibling project's coverage read stays whole.
529
+ *
530
+ * A handful of synchronous statements on the open connection, no
531
+ * yielding, so it may run inside `withPersistTransaction` after the
532
+ * run's own rows are written: the run just written is the newest of
533
+ * its project and is never pruned.
534
+ */
535
+ readonly pruneRuns: (policy: RunRetentionPolicy$1) => Effect.Effect<PruneRunsResult, DataStoreError$1>;
508
536
  readonly associateLatestRunWithSession: (input: AssociateRunSessionInput) => Effect.Effect<void, DataStoreError$1>;
509
537
  readonly backfillTestCaseTurns: (chatId: string) => Effect.Effect<number, DataStoreError$1>;
510
538
  /**
@@ -1085,6 +1113,7 @@ type PlatformLiveError = SqlError | StoreError | StoreMigrationError;
1085
1113
  export declare const PROJECT_MIGRATIONS: {
1086
1114
  readonly "0001_initial": import("effect/Effect").Effect<void, import("effect/sql/SqlError").SqlError, import("effect/sql/SqlClient").SqlClient>;
1087
1115
  readonly "0002_test_artifacts": import("effect/Effect").Effect<void, import("effect/sql/SqlError").SqlError, import("effect/sql/SqlClient").SqlClient>;
1116
+ readonly "0003_run_retention": import("effect/Effect").Effect<void, import("effect/sql/SqlError").SqlError, import("effect/sql/SqlClient").SqlClient>;
1088
1117
  };
1089
1118
  /** @public */
1090
1119
  export declare function makeTestLayer(filename: string): Layer.Layer<DataReader | DataStore | import("effect/sql/SqlClient").SqlClient | import("@effect/sql-sqlite-node/SqliteClient").SqliteClient | import("@effect/platform-node/NodeServices").NodeServices, PlatformLiveError, never>;
@@ -1100,5 +1129,5 @@ export declare function withFailures(filename: string): Layer.Layer<DataReader |
1100
1129
  export declare function flaky(filename: string): Layer.Layer<DataReader | DataStore | import("effect/sql/SqlClient").SqlClient | import("@effect/sql-sqlite-node/SqliteClient").SqliteClient | import("@effect/platform-node/NodeServices").NodeServices, import("@vitest-agent/sdk").DataStoreError | PlatformLiveError, never>;
1101
1130
  /** @public */
1102
1131
  export declare function withTddTask(filename: string): Layer.Layer<DataReader | DataStore | import("effect/sql/SqlClient").SqlClient | import("@effect/sql-sqlite-node/SqliteClient").SqliteClient | import("@effect/platform-node/NodeServices").NodeServices, import("@vitest-agent/sdk").BehaviorNotFoundError | import("@vitest-agent/sdk").DataStoreError | import("@vitest-agent/sdk").GoalNotFoundError | import("@vitest-agent/sdk").IllegalStatusTransitionError | import("@vitest-agent/sdk").TddTaskAlreadyEndedError | import("@vitest-agent/sdk").TddTaskNotFoundError | PlatformLiveError, never>;
1103
- export { type AcceptanceMetrics, Agent, AgentId, AgentNotFoundError, AgentReport, type ArtifactKind, type ArtifactSuite, type AssociateRunSessionInput, BehaviorDetail, BehaviorNotFoundError, BehaviorRow, BehaviorStatus, CacheManifest, type ChangeKind, ChatId, type CitedArtifact, type CitedArtifactRow, type ClassificationQueryOptions, type CommitChangesEntry, CoverageBaselines, CoverageReport, type CreateBehaviorInput, type CreateGoalInput, type CurrentTddPhase, DataStoreError, type EndTddTaskInput, type FailureSignatureDetail, type FailureSignatureWriteInput, type FileCoverageInput, FileCoverageReport, type FlakyTest, GoalDetail, GoalNotFoundError, GoalRow, GoalStatus, type HistoryQueryOptions, HistoryRecord, type HypothesisDetail, type HypothesisInput, IdempotencyHit, type IdempotentResponseInput, type IllegalStatusTransitionEntity, IllegalStatusTransitionError, type ModuleInput, type ModuleListEntry, type NoteInput, type NoteRow, type PersistedAttachment, type PersistentFailure, type Phase, type PlatformLiveError, type ProjectRunSummary, type RegisterAgentInput, RegistrationConflictError, type ResolvedThresholds, type RunChangedFile, type RunInvocationMethod, type SessionDetail, type SessionInput, type SettingsInput, type SettingsListEntry, type SettingsRow, type StackFrameInput, type SuiteInput, type SuiteListEntry, type TagInventoryRow, type TddArtifactDetail, type TddArtifactRow, type TddPhaseDetail, TddTaskAlreadyEndedError, type TddTaskDetail, type TddTaskEndOutcome, type TddTaskInput, TddTaskNotFoundError, type TddTaskSummary, type TestAnnotationInput, type TestAnnotationRow, type TestArtifactInput, type TestArtifactQueryOptions, type TestArtifactRow, type TestAttachmentInput, type TestCaseInput, type TestError, type TestErrorInput, type TestListEntry, type TestLookupOptions, type TestRunInput, TrendEntry, TrendRecord, type TurnInput, type TurnSearchOptions, type TurnSummary, type UpdateBehaviorInput, type UpdateGoalInput, type ValidateHypothesisInput, type WriteCommitInput, type WriteRunChangedFilesInput, type WriteTddArtifactInput, type WriteTddPhaseInput, type WriteTddPhaseOutput };
1132
+ export { type AcceptanceMetrics, Agent, AgentId, AgentNotFoundError, AgentReport, type ArtifactKind, type ArtifactSuite, type AssociateRunSessionInput, BehaviorDetail, BehaviorNotFoundError, BehaviorRow, BehaviorStatus, CacheManifest, type ChangeKind, ChatId, type CitedArtifact, type CitedArtifactRow, type ClassificationQueryOptions, type CommitChangesEntry, CoverageBaselines, CoverageReport, type CreateBehaviorInput, type CreateGoalInput, type CurrentTddPhase, DataStoreError, type EndTddTaskInput, type FailureSignatureDetail, type FailureSignatureWriteInput, type FileCoverageInput, FileCoverageReport, type FlakyTest, GoalDetail, GoalNotFoundError, GoalRow, GoalStatus, type HistoryQueryOptions, HistoryRecord, type HypothesisDetail, type HypothesisInput, IdempotencyHit, type IdempotentResponseInput, type IllegalStatusTransitionEntity, IllegalStatusTransitionError, type ModuleInput, type ModuleListEntry, type NoteInput, type NoteRow, type PersistedAttachment, type PersistentFailure, type Phase, type PlatformLiveError, type ProjectRunSummary, type PruneRunsResult, type RegisterAgentInput, RegistrationConflictError, type ResolvedThresholds, type RunChangedFile, type RunInvocationMethod, RunRetentionPolicy, type SessionDetail, type SessionInput, type SettingsInput, type SettingsListEntry, type SettingsRow, type StackFrameInput, type SuiteInput, type SuiteListEntry, type TagInventoryRow, type TddArtifactDetail, type TddArtifactRow, type TddPhaseDetail, TddTaskAlreadyEndedError, type TddTaskDetail, type TddTaskEndOutcome, type TddTaskInput, TddTaskNotFoundError, type TddTaskSummary, type TestAnnotationInput, type TestAnnotationRow, type TestArtifactInput, type TestArtifactQueryOptions, type TestArtifactRow, type TestAttachmentInput, type TestCaseInput, type TestError, type TestErrorInput, type TestListEntry, type TestLookupOptions, type TestRunInput, TrendEntry, TrendRecord, type TurnInput, type TurnSearchOptions, type TurnSummary, type UpdateBehaviorInput, type UpdateGoalInput, type ValidateHypothesisInput, type WriteCommitInput, type WriteRunChangedFilesInput, type WriteTddArtifactInput, type WriteTddPhaseInput, type WriteTddPhaseOutput };
1104
1133
  //# sourceMappingURL=testing.d.ts.map
package/testing.js CHANGED
@@ -2,7 +2,7 @@ import { DataReader } from "./services/DataReader.js";
2
2
  import { DataStore } from "./services/DataStore.js";
3
3
  import { PROJECT_MIGRATIONS } from "./migrations/index.js";
4
4
  import { DataStoreTestLayer, makeTestLayer } from "./testing/layers.js";
5
- import { Agent, AgentId, AgentNotFoundError, AgentReport, BehaviorDetail, BehaviorNotFoundError, BehaviorRow, BehaviorStatus, CacheManifest, ChatId, CoverageBaselines, CoverageReport, DataStoreError, FileCoverageReport, GoalDetail, GoalNotFoundError, GoalRow, GoalStatus, HistoryRecord, IdempotencyHit, IllegalStatusTransitionError, RegistrationConflictError, TddTaskAlreadyEndedError, TddTaskNotFoundError, TrendEntry, TrendRecord } from "@vitest-agent/sdk";
5
+ import { Agent, AgentId, AgentNotFoundError, AgentReport, BehaviorDetail, BehaviorNotFoundError, BehaviorRow, BehaviorStatus, CacheManifest, ChatId, CoverageBaselines, CoverageReport, DataStoreError, FileCoverageReport, GoalDetail, GoalNotFoundError, GoalRow, GoalStatus, HistoryRecord, IdempotencyHit, IllegalStatusTransitionError, RegistrationConflictError, RunRetentionPolicy, TddTaskAlreadyEndedError, TddTaskNotFoundError, TrendEntry, TrendRecord } from "@vitest-agent/sdk";
6
6
  import { Effect, Layer } from "effect";
7
7
 
8
8
  // src/testing/index.ts
@@ -239,4 +239,4 @@ function withTddTask(filename) {
239
239
  return Layer.effectDiscard(seed).pipe(Layer.provideMerge(base));
240
240
  }
241
241
 
242
- export { Agent, AgentId, AgentNotFoundError, AgentReport, BehaviorDetail, BehaviorNotFoundError, BehaviorRow, BehaviorStatus, CacheManifest, ChatId, CoverageBaselines, CoverageReport, DataReader, DataStore, DataStoreError, DataStoreTestLayer, FileCoverageReport, GoalDetail, GoalNotFoundError, GoalRow, GoalStatus, HistoryRecord, IdempotencyHit, IllegalStatusTransitionError, PROJECT_MIGRATIONS, RegistrationConflictError, TddTaskAlreadyEndedError, TddTaskNotFoundError, TrendEntry, TrendRecord, empty, flaky, makeTestLayer, singlePassingRun, withFailures, withTddTask };
242
+ export { Agent, AgentId, AgentNotFoundError, AgentReport, BehaviorDetail, BehaviorNotFoundError, BehaviorRow, BehaviorStatus, CacheManifest, ChatId, CoverageBaselines, CoverageReport, DataReader, DataStore, DataStoreError, DataStoreTestLayer, FileCoverageReport, GoalDetail, GoalNotFoundError, GoalRow, GoalStatus, HistoryRecord, IdempotencyHit, IllegalStatusTransitionError, PROJECT_MIGRATIONS, RegistrationConflictError, RunRetentionPolicy, TddTaskAlreadyEndedError, TddTaskNotFoundError, TrendEntry, TrendRecord, empty, flaky, makeTestLayer, singlePassingRun, withFailures, withTddTask };
@@ -1,5 +1,5 @@
1
1
  import { DataStoreError, extractSqlReason } from "@vitest-agent/sdk";
2
- import { Context, Effect, References } from "effect";
2
+ import { Context, Effect, Exit, References } from "effect";
3
3
  import { SqlClient } from "effect/sql/SqlClient";
4
4
  import { isSqlError } from "effect/sql/SqlError";
5
5
 
@@ -11,6 +11,55 @@ import { isSqlError } from "effect/sql/SqlError";
11
11
  * @internal
12
12
  */
13
13
  const PersistBatch = Context.Reference("@vitest-agent/engine/PersistBatch", { defaultValue: () => void 0 });
14
+ /**
15
+ * Drop every entry added after `size`. The caches only ever grow (an id for
16
+ * a name never changes while the write lock is held), and a `Map` / `Set`
17
+ * iterates in insertion order, so the entries past the snapshot size are
18
+ * exactly the ones a rolled-back savepoint added.
19
+ */
20
+ const truncate = (collection, size) => {
21
+ if (collection.size <= size) return;
22
+ let index = 0;
23
+ for (const key of collection.keys()) if (index++ >= size) collection.delete(key);
24
+ };
25
+ /**
26
+ * Run `effect` as a nested transaction that keeps the
27
+ * {@link withPersistTransaction} caches honest.
28
+ *
29
+ * Outside a persist batch this is exactly `sql.withTransaction(effect)`.
30
+ * Inside one, the nested `withTransaction` becomes a SAVEPOINT, and when it
31
+ * rolls back (a failure, a defect, an interrupt) every `files` / `tags` row
32
+ * the savepoint inserted is gone while the ids `DataStoreLive` cached for
33
+ * them would survive in `PersistBatchState`. This helper snapshots the
34
+ * cache sizes before the savepoint opens and discards the entries added
35
+ * since whenever it does not commit, so a later write in the same batch
36
+ * re-inserts the row instead of referencing one that no longer exists
37
+ * (issue #628: a FK failure or a dangling id). A savepoint that commits
38
+ * keeps its entries.
39
+ *
40
+ * Use this, never a bare `sql.withTransaction`, for any nested transaction
41
+ * whose failure a caller may catch and continue from inside the batch; a
42
+ * bare one that rolls back leaves the caches stale. The bookkeeping is
43
+ * synchronous, so the batch's no-yield guarantee is unchanged.
44
+ *
45
+ * @param effect - the writes to run as one savepoint (or transaction)
46
+ * @public
47
+ */
48
+ const withPersistSavepoint = (effect) => Effect.gen(function* () {
49
+ const sql = yield* SqlClient;
50
+ const batch = yield* PersistBatch;
51
+ if (batch === void 0) return yield* sql.withTransaction(effect);
52
+ const snapshot = {
53
+ historyRunIds: batch.historyRunIds.size,
54
+ tagIds: batch.tagIds.size,
55
+ fileIds: batch.fileIds.size
56
+ };
57
+ return yield* sql.withTransaction(effect).pipe(Effect.onExit((exit) => Exit.isSuccess(exit) ? Effect.void : Effect.sync(() => {
58
+ truncate(batch.historyRunIds, snapshot.historyRunIds);
59
+ truncate(batch.tagIds, snapshot.tagIds);
60
+ truncate(batch.fileIds, snapshot.fileIds);
61
+ })));
62
+ });
14
63
  /** Matches the per-write window `DataStoreLive.writeHistory` keeps outside a batch. */
15
64
  const HISTORY_WINDOW = 10;
16
65
  /**
@@ -53,8 +102,13 @@ const pruneHistoryForRun = (sql, runId) => sql`DELETE FROM test_history WHERE id
53
102
  * genuinely asynchronous work for that reason; CPU-heavy work that needs no
54
103
  * database belongs before the block.
55
104
  *
56
- * Nested `withTransaction` calls inside `effect` become savepoints. A
57
- * `DataReader` query inside it runs on the transaction's connection.
105
+ * Nested `withTransaction` calls inside `effect` become savepoints. Open
106
+ * them through {@link withPersistSavepoint}: a savepoint that rolls back
107
+ * also discards the tag / file ids cached while it was open, so a write the
108
+ * caller recovers from cannot leave a later write pointing at a row the
109
+ * rollback removed. A bare nested `sql.withTransaction` whose failure is
110
+ * caught inside the block leaves those caches stale. A `DataReader` query
111
+ * inside the block runs on the transaction's connection.
58
112
  *
59
113
  * @param effect - the writes (and the reads they depend on) to run atomically
60
114
  * @public
@@ -81,4 +135,4 @@ const withPersistTransaction = (effect) => Effect.gen(function* () {
81
135
  reason: extractSqlReason(e)
82
136
  }))));
83
137
 
84
- export { PersistBatch, withPersistTransaction };
138
+ export { PersistBatch, withPersistSavepoint, withPersistTransaction };
package/version.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
2
  /** Build-time injected package version (see architecture.md D36). @public */
3
- const CURRENT_ENGINE_VERSION = "1.1.2";
3
+ const CURRENT_ENGINE_VERSION = "1.2.0";
4
4
 
5
5
  export { CURRENT_ENGINE_VERSION };