@vitest-agent/engine 1.1.2 → 2.0.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,8 +1,8 @@
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, ProjectIdentityError, ProjectIdentityNotResolvableError, RegistrationConflictError, ResolvedThresholds, RunRetentionPolicy, StrayOutput, TddTaskAlreadyEndedError, TddTaskNotFoundError, TestClassification, TestRun, TrendEntry, TrendRecord, TurnPayload, VitestAgentConfig } from "@vitest-agent/sdk";
4
4
  import { SqlClient } from "effect/sql/SqlClient";
5
- import { RuntimeEnv } from "@effected/env";
5
+ import { CiName, RuntimeEnv } from "@effected/env";
6
6
  import { WorkspaceDiscovery, WorkspaceDiscoveryFailure, WorkspaceRootNotFoundError } from "@effected/workspaces";
7
7
  import { AppDirs, AppDirsError, XdgEnvError } from "@effected/xdg";
8
8
  import { ChildProcessSpawner } from "effect/process";
@@ -141,6 +141,13 @@ export interface TestRunInput {
141
141
  readonly hostSource?: string | null;
142
142
  readonly hostValue?: string | null;
143
143
  readonly hostMetadata?: Record<string, unknown> | null;
144
+ /**
145
+ * Output the run wrote straight to the terminal past Vitest's console
146
+ * capture (issue 578). A run-level signal: every project row of one
147
+ * invocation carries the same value. Omitted or `null` when the run
148
+ * recorded nothing, which the row stores as NULL in every `stray_*` column.
149
+ */
150
+ readonly strayOutput?: StrayOutput | null;
144
151
  }
145
152
  /** @public */
146
153
  export interface ModuleInput {
@@ -457,6 +464,15 @@ export interface RegisterAgentInput {
457
464
  */
458
465
  readonly agentId?: string;
459
466
  }
467
+ /**
468
+ * What `DataStore.pruneRuns` removed: `test_runs` rows deleted (their
469
+ * cascaded rows are not counted) and orphaned `files` rows garbage-collected.
470
+ * @public
471
+ */
472
+ export interface PruneRunsResult {
473
+ readonly prunedRuns: number;
474
+ readonly prunedFiles: number;
475
+ }
460
476
  /** @public */
461
477
  export interface AssociateRunSessionInput {
462
478
  readonly chatId: string;
@@ -554,6 +570,25 @@ declare const DataStore_base: Context.ServiceClass<DataStore, "vitest-agent/Data
554
570
  readonly affectedSessions: number;
555
571
  readonly prunedTurns: number;
556
572
  }, DataStoreError>;
573
+ /**
574
+ * Apply a run-retention policy: delete every `test_runs` row beyond the
575
+ * newest `policy.keepRuns` of its project, and any run older than
576
+ * `policy.maxAgeDays` days when set, letting the schema's cascades drop
577
+ * the dependent rows (modules, suites, cases, tags, errors, frames,
578
+ * coverage, trends, triggers), then garbage-collect `files` rows no
579
+ * table references any more. `test_history` rows survive with a NULL
580
+ * `run_id`; they keep their own newest-10-per-series window. Before
581
+ * the delete, `file_coverage` rows held by a doomed run are moved onto
582
+ * the lowest surviving run of the same invocation (coverage is written
583
+ * once per invocation, issue 622) unless a survivor already carries
584
+ * coverage rows, so every sibling project's coverage read stays whole.
585
+ *
586
+ * A handful of synchronous statements on the open connection, no
587
+ * yielding, so it may run inside `withPersistTransaction` after the
588
+ * run's own rows are written: the run just written is the newest of
589
+ * its project and is never pruned.
590
+ */
591
+ readonly pruneRuns: (policy: RunRetentionPolicy) => Effect.Effect<PruneRunsResult, DataStoreError>;
557
592
  readonly associateLatestRunWithSession: (input: AssociateRunSessionInput) => Effect.Effect<void, DataStoreError>;
558
593
  readonly backfillTestCaseTurns: (chatId: string) => Effect.Effect<number, DataStoreError>;
559
594
  /**
@@ -587,6 +622,32 @@ export interface ProjectRunSummary {
587
622
  readonly passed: number;
588
623
  readonly failed: number;
589
624
  readonly skipped: number;
625
+ /** The latest run's stray output (issue 578); `null` when it recorded none. */
626
+ readonly strayOutput: StrayOutput | null;
627
+ }
628
+ /**
629
+ * Options for `DataReader.getStrayOutputHistory`.
630
+ * @public
631
+ */
632
+ export interface StrayOutputHistoryOptions {
633
+ /** Only runs of this project; every project otherwise. */
634
+ readonly project?: string;
635
+ /** How many of the most recent runs to consider. Default 10. */
636
+ readonly limit?: number;
637
+ }
638
+ /**
639
+ * How often recent runs wrote stray output (issue 578). A "run" is one
640
+ * reporter invocation: stray output is a run-level signal that every project
641
+ * row of an invocation repeats, so rows are counted once per `invocation_id`.
642
+ * @public
643
+ */
644
+ export interface StrayOutputHistory {
645
+ /** Runs considered: the most recent ones, up to the requested limit. */
646
+ readonly runs: number;
647
+ /** How many of them recorded any stray output. */
648
+ readonly runsWithStrayOutput: number;
649
+ /** The most recent run's stray output, when that run recorded any. */
650
+ readonly latest: StrayOutput | null;
590
651
  }
591
652
  /** @public */
592
653
  export interface FlakyTest {
@@ -945,6 +1006,8 @@ export interface TestArtifactRow {
945
1006
  declare const DataReader_base: Context.ServiceClass<DataReader, "vitest-agent/DataReader", {
946
1007
  readonly getLatestRun: (project: string) => Effect.Effect<Option.Option<AgentReport>, DataStoreError>;
947
1008
  readonly getRunsByProject: () => Effect.Effect<ReadonlyArray<ProjectRunSummary>, DataStoreError>;
1009
+ /** How often the most recent runs wrote stray output; see {@link StrayOutputHistory}. */
1010
+ readonly getStrayOutputHistory: (options?: StrayOutputHistoryOptions) => Effect.Effect<StrayOutputHistory, DataStoreError>;
948
1011
  readonly getHistory: (project: string, options?: HistoryQueryOptions) => Effect.Effect<HistoryRecord, DataStoreError>;
949
1012
  readonly getBaselines: (project: string) => Effect.Effect<Option.Option<CoverageBaselines>, DataStoreError>;
950
1013
  readonly getTrends: (project: string, limit?: number) => Effect.Effect<Option.Option<TrendRecord>, DataStoreError>;
@@ -1177,8 +1240,21 @@ declare const EnvironmentDetector_base: Context.ServiceClass<EnvironmentDetector
1177
1240
  readonly detect: () => Effect.Effect<Environment>;
1178
1241
  readonly isAgent: Effect.Effect<boolean>;
1179
1242
  readonly agentName: Effect.Effect<string | undefined>;
1243
+ readonly ci: Effect.Effect<Option.Option<CiName>>;
1180
1244
  }>;
1181
- /** @public */
1245
+ /**
1246
+ * Who is running the program and where.
1247
+ *
1248
+ * @remarks
1249
+ * `detect` is the console audience: an agent marker wins over CI, so an
1250
+ * agent inside GitHub Actions reads `agent-shell`. `ci` is the CI vendor
1251
+ * independently of that precedence, so a caller can keep GitHub-only
1252
+ * extras (the step summary, annotations, the `github-actions` reporter)
1253
+ * on for the humans reviewing the job even when the console audience is
1254
+ * the agent (issue #624).
1255
+ *
1256
+ * @public
1257
+ */
1182
1258
  export declare class EnvironmentDetector extends EnvironmentDetector_base {}
1183
1259
  /**
1184
1260
  * Pure classification behind {@link EnvironmentDetectorLive}: an agent shell
@@ -1207,9 +1283,21 @@ export declare const classifyEnvironment: (runtime: RuntimeEnv) => Environment;
1207
1283
  * @public
1208
1284
  */
1209
1285
  export declare const EnvironmentDetectorLive: (env: Record<string, string | undefined>) => Layer.Layer<EnvironmentDetector>;
1210
- /** @public */
1286
+ /**
1287
+ * A fixed `EnvironmentDetector` for tests.
1288
+ *
1289
+ * @remarks
1290
+ * `ci` defaults to the vendor the environment implies (`ci-github` is
1291
+ * GitHub Actions, `ci-generic` another CI, the rest none). Pass
1292
+ * `options.ci` to decouple the two, as the live detector does for an agent
1293
+ * running inside GitHub Actions: `layer("agent-shell", { ci: "github-actions" })`.
1294
+ *
1295
+ * @public
1296
+ */
1211
1297
  export declare const EnvironmentDetectorTest: {
1212
- layer: (env?: Environment) => Layer.Layer<EnvironmentDetector, never, never>;
1298
+ layer: (env?: Environment, options?: {
1299
+ readonly ci?: CiName;
1300
+ }) => Layer.Layer<EnvironmentDetector, never, never>;
1213
1301
  };
1214
1302
  declare const ExecutorResolver_base: Context.ServiceClass<ExecutorResolver, "vitest-agent/ExecutorResolver", {
1215
1303
  readonly resolve: (env: Environment) => Effect.Effect<Executor>;
@@ -1528,12 +1616,18 @@ export interface ResolveProjectIdentityOptions {
1528
1616
  */
1529
1617
  export declare const resolveProjectIdentityFromCandidates: (candidates: ProjectIdentityCandidates) => ResolvedIdentity | null;
1530
1618
  declare const ProjectIdentity_base: Context.ServiceClass<ProjectIdentity, "vitest-agent/ProjectIdentity", {
1531
- readonly resolve: (workspaceRoot: string, options?: ResolveProjectIdentityOptions) => Effect.Effect<ResolvedIdentity, ProjectIdentityNotResolvableError>;
1619
+ readonly resolve: (workspaceRoot: string, options?: ResolveProjectIdentityOptions) => Effect.Effect<ResolvedIdentity, ProjectIdentityError>;
1532
1620
  }>;
1533
1621
  /**
1534
1622
  * Service tag. The Live layer (in `layers/ProjectIdentityLive.ts`)
1535
1623
  * wires up the I/O sources; tests provide a stub that returns a
1536
1624
  * pre-built `ResolvedIdentity`.
1625
+ *
1626
+ * `resolve` fails with `ProjectIdentityNotResolvableError` when every
1627
+ * source is absent or empty, and with `ProjectIdentitySourceError` when a
1628
+ * source is present but unusable (a malformed `vitest-agent.config.toml`,
1629
+ * an unreadable root `package.json`): a broken config is reported, never
1630
+ * silently replaced by the default identity (issue 321).
1537
1631
  * @public
1538
1632
  */
1539
1633
  export declare class ProjectIdentity extends ProjectIdentity_base {}
@@ -1553,17 +1647,21 @@ export type CandidateContext = FileSystem.FileSystem | WorkspaceDiscovery | Vite
1553
1647
  * `FileSystem`, `CommandExecutor`, `WorkspaceDiscovery`, and
1554
1648
  * `VitestAgentConfigFile`. The platform context comes from
1555
1649
  * `NodeServices.layer` at the entry point.
1650
+ *
1651
+ * Absent sources fall through (no config file, no workspace root, no
1652
+ * `repository` field, no git remote); a present source that cannot be read
1653
+ * or decoded fails with `ProjectIdentitySourceError`.
1556
1654
  * @public
1557
1655
  */
1558
1656
  export declare const ProjectIdentityLive: Layer.Layer<ProjectIdentity, never, CandidateContext>;
1559
1657
  /**
1560
1658
  * Build a Test layer that returns a fixed `ResolvedIdentity` (or
1561
- * fails with `ProjectIdentityNotResolvableError`). Use in unit tests
1659
+ * fails with the given `ProjectIdentityError`). Use in unit tests
1562
1660
  * that depend on `ProjectIdentity` but don't want to wire upstream
1563
1661
  * services.
1564
1662
  * @public
1565
1663
  */
1566
- export declare const ProjectIdentityTest: (result: ResolvedIdentity | ProjectIdentityNotResolvableError) => Layer.Layer<ProjectIdentity, never, never>;
1664
+ export declare const ProjectIdentityTest: (result: ResolvedIdentity | ProjectIdentityError) => Layer.Layer<ProjectIdentity, never, never>;
1567
1665
  declare const RunContext_base: Schema.Class<RunContext, Schema.Struct<{
1568
1666
  readonly gitBranch: Schema.NullOr<Schema.String>;
1569
1667
  readonly gitCommitSha: Schema.NullOr<Schema.String>;
@@ -1643,6 +1741,13 @@ interface FormatTriageOptions {
1643
1741
  readonly project?: string;
1644
1742
  readonly maxLines?: number;
1645
1743
  }
1744
+ /**
1745
+ * The "stray output" line of the Recent Test Runs section, or `undefined`
1746
+ * when none of the recent runs wrote past Vitest's console capture (issue
1747
+ * 578). Pure, so the wording is testable without a database.
1748
+ * @public
1749
+ */
1750
+ export declare const formatStrayOutputLine: (history: StrayOutputHistory) => string | undefined;
1646
1751
  /**
1647
1752
  * Generates an orientation triage markdown string for LLM agents.
1648
1753
  * Summarises recent test runs, active sessions, acceptance metrics,
@@ -1688,6 +1793,8 @@ declare const migration$1: Effect.Effect<void, import("effect/sql/SqlError").Sql
1688
1793
  export declare const PROJECT_MIGRATIONS: {
1689
1794
  readonly "0001_initial": import("effect/Effect").Effect<void, import("effect/sql/SqlError").SqlError, import("effect/sql/SqlClient").SqlClient>;
1690
1795
  readonly "0002_test_artifacts": import("effect/Effect").Effect<void, import("effect/sql/SqlError").SqlError, import("effect/sql/SqlClient").SqlClient>;
1796
+ readonly "0003_run_retention": import("effect/Effect").Effect<void, import("effect/sql/SqlError").SqlError, import("effect/sql/SqlClient").SqlClient>;
1797
+ readonly "0004_stray_output": import("effect/Effect").Effect<void, import("effect/sql/SqlError").SqlError, import("effect/sql/SqlClient").SqlClient>;
1691
1798
  };
1692
1799
  /**
1693
1800
  * Migration for the global discovery registry SQLite at
@@ -2540,6 +2647,30 @@ export interface FailureSignatureInput {
2540
2647
  export declare const normalizeAssertionShape: (message: string) => string | null;
2541
2648
  /** @public */
2542
2649
  export declare const computeFailureSignature: (input: FailureSignatureInput) => string;
2650
+ /**
2651
+ * Run `effect` as a nested transaction that keeps the
2652
+ * {@link withPersistTransaction} caches honest.
2653
+ *
2654
+ * Outside a persist batch this is exactly `sql.withTransaction(effect)`.
2655
+ * Inside one, the nested `withTransaction` becomes a SAVEPOINT, and when it
2656
+ * rolls back (a failure, a defect, an interrupt) every `files` / `tags` row
2657
+ * the savepoint inserted is gone while the ids `DataStoreLive` cached for
2658
+ * them would survive in `PersistBatchState`. This helper snapshots the
2659
+ * cache sizes before the savepoint opens and discards the entries added
2660
+ * since whenever it does not commit, so a later write in the same batch
2661
+ * re-inserts the row instead of referencing one that no longer exists
2662
+ * (issue #628: a FK failure or a dangling id). A savepoint that commits
2663
+ * keeps its entries.
2664
+ *
2665
+ * Use this, never a bare `sql.withTransaction`, for any nested transaction
2666
+ * whose failure a caller may catch and continue from inside the batch; a
2667
+ * bare one that rolls back leaves the caches stale. The bookkeeping is
2668
+ * synchronous, so the batch's no-yield guarantee is unchanged.
2669
+ *
2670
+ * @param effect - the writes to run as one savepoint (or transaction)
2671
+ * @public
2672
+ */
2673
+ export declare const withPersistSavepoint: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E | SqlError, R | SqlClient>;
2543
2674
  /**
2544
2675
  * Run `effect` as one SQLite write transaction on the `data.db` connection:
2545
2676
  * every `DataStore` write inside it commits together or not at all, and a
@@ -2560,8 +2691,13 @@ export declare const computeFailureSignature: (input: FailureSignatureInput) =>
2560
2691
  * genuinely asynchronous work for that reason; CPU-heavy work that needs no
2561
2692
  * database belongs before the block.
2562
2693
  *
2563
- * Nested `withTransaction` calls inside `effect` become savepoints. A
2564
- * `DataReader` query inside it runs on the transaction's connection.
2694
+ * Nested `withTransaction` calls inside `effect` become savepoints. Open
2695
+ * them through {@link withPersistSavepoint}: a savepoint that rolls back
2696
+ * also discards the tag / file ids cached while it was open, so a write the
2697
+ * caller recovers from cannot leave a later write pointing at a row the
2698
+ * rollback removed. A bare nested `sql.withTransaction` whose failure is
2699
+ * caught inside the block leaves those caches stale. A `DataReader` query
2700
+ * inside the block runs on the transaction's connection.
2565
2701
  *
2566
2702
  * @param effect - the writes (and the reads they depend on) to run atomically
2567
2703
  * @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";
@@ -29,7 +29,7 @@ import { ProjectIdentity, resolveProjectIdentityFromCandidates } from "./service
29
29
  import { ProjectIdentityLive, ProjectIdentityTest } from "./layers/ProjectIdentityLive.js";
30
30
  import { AgentContext, RunContext, RunContext$, RunContextService } from "./services/RunContext.js";
31
31
  import { RunContextLive, RunContextTest } from "./layers/RunContextLive.js";
32
- import { formatTriageEffect } from "./lib/format-triage.js";
32
+ import { formatStrayOutputLine, formatTriageEffect } from "./lib/format-triage.js";
33
33
  import { formatWrapupEffect } from "./lib/format-wrapup.js";
34
34
  import migration from "./migrations/0001_initial.js";
35
35
  import migration$1 from "./migrations/0002_test_artifacts.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, formatStrayOutputLine, 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 };
@@ -1,16 +1,53 @@
1
1
  import { DataReader } from "../services/DataReader.js";
2
2
  import { historyKey } from "../services/HistoryTracker.js";
3
- import { DataStoreError, extractSqlReason } from "@vitest-agent/sdk";
4
- import { Effect, Layer, Option } from "effect";
3
+ import { DataStoreError, StrayOutputSample, extractSqlReason } from "@vitest-agent/sdk";
4
+ import { Effect, Layer, Option, Schema } from "effect";
5
5
  import { SqlClient } from "effect/sql/SqlClient";
6
6
 
7
7
  // src/layers/DataReaderLive.ts
8
+ const decodeStraySamples = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Array(StrayOutputSample)));
9
+ /**
10
+ * The run's stray output from its `test_runs` row, or `null` when the row
11
+ * recorded none (every `stray_*` column NULL: a run that wrote nothing, a
12
+ * passthrough-mode run, or a row older than migration 0004). Pure; the
13
+ * inverse is `DataStoreLive`'s `strayOutputColumns`. A samples column that
14
+ * does not decode reads as no samples rather than failing the row.
15
+ */
16
+ const strayOutputFromRow = (row) => row.stray_total === null ? null : {
17
+ total: row.stray_total,
18
+ stdout: row.stray_stdout ?? 0,
19
+ stderr: row.stray_stderr ?? 0,
20
+ bytes: row.stray_bytes ?? 0,
21
+ samples: Option.getOrElse(decodeStraySamples(row.stray_samples ?? "[]"), () => [])
22
+ };
8
23
  /** @public */
9
24
  const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
10
25
  const sql = yield* SqlClient;
26
+ /**
27
+ * The run whose `file_coverage` rows describe `runId`'s coverage.
28
+ *
29
+ * The reporter writes per-file coverage ONCE per invocation, under
30
+ * one run of that invocation (issue #622), while every project gets
31
+ * its own `test_runs` row. A project run's rows therefore live under
32
+ * the sibling run sharing its `invocation_id`. Preference order: the
33
+ * run itself when it carries rows (a database an older reporter wrote
34
+ * holds an identical copy under every run of the invocation — reading
35
+ * exactly one run keeps that layout deduplicated), else the lowest
36
+ * sibling run id that carries rows. A run whose invocation wrote no
37
+ * rows resolves to itself, so the caller's `WHERE run_id = ?` is
38
+ * simply empty. Never crosses an invocation boundary.
39
+ */
40
+ const resolveCoverageRunId = (runId) => Effect.gen(function* () {
41
+ return (yield* sql`SELECT fc.run_id
42
+ FROM file_coverage fc
43
+ JOIN test_runs tr ON tr.id = fc.run_id
44
+ WHERE tr.invocation_id = (SELECT invocation_id FROM test_runs WHERE id = ${runId})
45
+ ORDER BY CASE WHEN fc.run_id = ${runId} THEN 0 ELSE 1 END, fc.run_id ASC
46
+ LIMIT 1`)[0]?.run_id ?? runId;
47
+ });
11
48
  const getLatestRun = (project) => Effect.gen(function* () {
12
49
  yield* Effect.logDebug("getLatestRun").pipe(Effect.annotateLogs({ project }));
13
- const runs = yield* sql`SELECT id, timestamp, reason, duration, total, passed, failed, skipped, project
50
+ const runs = yield* sql`SELECT id, timestamp, reason, duration, total, passed, failed, skipped, project, stray_total, stray_stdout, stray_stderr, stray_bytes, stray_samples
14
51
  FROM test_runs
15
52
  WHERE project = ${project}
16
53
  ORDER BY timestamp DESC LIMIT 1`;
@@ -80,11 +117,13 @@ const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
80
117
  WHERE run_id = ${run.id} AND scope = 'unhandled'`;
81
118
  const failedFiles = failedModules.map((m) => m.file_path);
82
119
  const projectName = run.project;
120
+ const strayOutput = strayOutputFromRow(run);
121
+ const coverageRunId = yield* resolveCoverageRunId(run.id);
83
122
  const fileCovRows = yield* sql`SELECT f.path AS file_path, fc.statements, fc.branches, fc.functions, fc.lines,
84
123
  fc.uncovered_lines, fc.tier
85
124
  FROM file_coverage fc
86
125
  JOIN files f ON f.id = fc.file_id
87
- WHERE fc.run_id = ${run.id}`;
126
+ WHERE fc.run_id = ${coverageRunId}`;
88
127
  const lowCoverage = fileCovRows.filter((r) => r.tier === "below_threshold").map((r) => ({
89
128
  file: r.file_path,
90
129
  summary: {
@@ -156,7 +195,8 @@ const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
156
195
  ...e.diff != null ? { diff: e.diff } : {}
157
196
  })),
158
197
  failedFiles,
159
- ...coverage ? { coverage } : {}
198
+ ...coverage ? { coverage } : {},
199
+ ...strayOutput !== null ? { strayOutput } : {}
160
200
  };
161
201
  return Option.some(report);
162
202
  }).pipe(Effect.annotateLogs("service", "DataReader"), Effect.mapError((e) => new DataStoreError({
@@ -173,7 +213,12 @@ const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
173
213
  t1.total,
174
214
  t1.passed,
175
215
  t1.failed,
176
- t1.skipped
216
+ t1.skipped,
217
+ t1.stray_total,
218
+ t1.stray_stdout,
219
+ t1.stray_stderr,
220
+ t1.stray_bytes,
221
+ t1.stray_samples
177
222
  FROM test_runs t1
178
223
  INNER JOIN (
179
224
  SELECT project, MAX(timestamp) as max_ts
@@ -187,8 +232,38 @@ const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
187
232
  total: r.total,
188
233
  passed: r.passed,
189
234
  failed: r.failed,
190
- skipped: r.skipped
235
+ skipped: r.skipped,
236
+ strayOutput: strayOutputFromRow(r)
237
+ }));
238
+ }).pipe(Effect.annotateLogs("service", "DataReader"), Effect.mapError((e) => new DataStoreError({
239
+ operation: "read",
240
+ table: "test_runs",
241
+ reason: extractSqlReason(e)
242
+ })));
243
+ const getStrayOutputHistory = (options = {}) => Effect.gen(function* () {
244
+ const limit = options.limit ?? 10;
245
+ yield* Effect.logDebug("getStrayOutputHistory").pipe(Effect.annotateLogs({
246
+ project: options.project ?? "*",
247
+ limit
191
248
  }));
249
+ const rows = yield* sql`SELECT
250
+ MAX(timestamp) AS timestamp,
251
+ MAX(stray_total) AS stray_total,
252
+ MAX(stray_stdout) AS stray_stdout,
253
+ MAX(stray_stderr) AS stray_stderr,
254
+ MAX(stray_bytes) AS stray_bytes,
255
+ MAX(stray_samples) AS stray_samples
256
+ FROM test_runs
257
+ ${options.project !== void 0 ? sql`WHERE project = ${options.project}` : sql``}
258
+ GROUP BY invocation_id
259
+ ORDER BY timestamp DESC
260
+ LIMIT ${limit}`;
261
+ const recorded = rows.map(strayOutputFromRow);
262
+ return {
263
+ runs: rows.length,
264
+ runsWithStrayOutput: recorded.filter((s) => s !== null).length,
265
+ latest: recorded[0] ?? null
266
+ };
192
267
  }).pipe(Effect.annotateLogs("service", "DataReader"), Effect.mapError((e) => new DataStoreError({
193
268
  operation: "read",
194
269
  table: "test_runs",
@@ -440,10 +515,11 @@ const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
440
515
  })));
441
516
  const getFileCoverage = (runId) => Effect.gen(function* () {
442
517
  yield* Effect.logDebug("getFileCoverage").pipe(Effect.annotateLogs({ runId }));
518
+ const coverageRunId = yield* resolveCoverageRunId(runId);
443
519
  return (yield* sql`SELECT f.path as file_path, fc.statements, fc.branches, fc.functions, fc.lines, fc.uncovered_lines
444
520
  FROM file_coverage fc
445
521
  JOIN files f ON f.id = fc.file_id
446
- WHERE fc.run_id = ${runId}`).map((r) => ({
522
+ WHERE fc.run_id = ${coverageRunId}`).map((r) => ({
447
523
  file: r.file_path,
448
524
  summary: {
449
525
  statements: r.statements,
@@ -464,7 +540,7 @@ const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
464
540
  WHERE project = ${project}
465
541
  ORDER BY timestamp DESC LIMIT 1`;
466
542
  if (runs.length === 0) return Option.none();
467
- const runId = runs[0].id;
543
+ const runId = yield* resolveCoverageRunId(runs[0].id);
468
544
  const fileCoverageRows = yield* sql`SELECT f.path as file_path, fc.statements, fc.branches, fc.functions, fc.lines, fc.uncovered_lines, fc.tier
469
545
  FROM file_coverage fc JOIN files f ON f.id = fc.file_id
470
546
  WHERE fc.run_id = ${runId}`;
@@ -1854,6 +1930,7 @@ const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
1854
1930
  return {
1855
1931
  getLatestRun,
1856
1932
  getRunsByProject,
1933
+ getStrayOutputHistory,
1857
1934
  getHistory,
1858
1935
  getBaselines,
1859
1936
  getTrends,
@@ -1,12 +1,30 @@
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";
8
8
 
9
9
  // src/layers/DataStoreLive.ts
10
+ /**
11
+ * The five `test_runs.stray_*` column values for a run's stray output: all
12
+ * NULL when the run recorded nothing, so a reader cannot mistake "nothing
13
+ * recorded" for "zero lines" (pure; the inverse lives in `DataReaderLive`).
14
+ */
15
+ const strayOutputColumns = (stray) => stray == null ? {
16
+ total: null,
17
+ stdout: null,
18
+ stderr: null,
19
+ bytes: null,
20
+ samples: null
21
+ } : {
22
+ total: stray.total,
23
+ stdout: stray.stdout,
24
+ stderr: stray.stderr,
25
+ bytes: stray.bytes,
26
+ samples: JSON.stringify(stray.samples)
27
+ };
10
28
  const isLegalLifecycleTransition = (from, to) => {
11
29
  if (from === to) return true;
12
30
  if (from === "done" || from === "abandoned") return false;
@@ -36,6 +54,7 @@ const boolToInt = (v) => v === void 0 ? null : v ? 1 : 0;
36
54
  const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
37
55
  const sql = yield* SqlClient;
38
56
  yield* sql`PRAGMA foreign_keys=ON`.pipe(Effect.catch(() => Effect.void));
57
+ const savepoint = (effect) => withPersistSavepoint(effect).pipe(Effect.provideService(SqlClient, sql));
39
58
  const ensureFile = (filePath) => Effect.gen(function* () {
40
59
  yield* Effect.logDebug("ensureFile").pipe(Effect.annotateLogs({ filePath }));
41
60
  const batch = yield* PersistBatch;
@@ -69,7 +88,8 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
69
88
  const conversationId = input.conversationId ?? null;
70
89
  const gitDirtyInt = input.gitDirty === void 0 || input.gitDirty === null ? null : input.gitDirty ? 1 : 0;
71
90
  const hostMetadataJson = input.hostMetadata == null ? null : JSON.stringify(input.hostMetadata);
72
- yield* sql`INSERT INTO test_runs (invocation_id, project, settings_hash, timestamp, commit_sha, branch, reason, duration, total, passed, failed, skipped, scoped, snapshot_added, snapshot_matched, snapshot_unmatched, snapshot_updated, snapshot_unchecked, snapshot_total, snapshot_failure, snapshot_did_update, snapshot_files_added, snapshot_files_removed, snapshot_files_unmatched, snapshot_files_updated, actor_type, agent_id, conversation_id, git_branch, git_commit_sha, git_dirty, git_upstream, git_worktree_dir, host_source, host_value, host_metadata) VALUES (${input.invocationId}, ${input.project}, ${input.settingsHash}, ${input.timestamp}, ${input.commitSha ?? null}, ${input.branch ?? null}, ${input.reason}, ${input.duration}, ${input.total}, ${input.passed}, ${input.failed}, ${input.skipped}, ${input.scoped ? 1 : 0}, ${input.snapshotAdded ?? 0}, ${input.snapshotMatched ?? 0}, ${input.snapshotUnmatched ?? 0}, ${input.snapshotUpdated ?? 0}, ${input.snapshotUnchecked ?? 0}, ${input.snapshotTotal ?? 0}, ${boolToInt(input.snapshotFailure) ?? 0}, ${boolToInt(input.snapshotDidUpdate) ?? 0}, ${input.snapshotFilesAdded ?? 0}, ${input.snapshotFilesRemoved ?? 0}, ${input.snapshotFilesUnmatched ?? 0}, ${input.snapshotFilesUpdated ?? 0}, ${actorType}, ${agentId}, ${conversationId}, ${input.gitBranch ?? null}, ${input.gitCommitSha ?? null}, ${gitDirtyInt}, ${input.gitUpstream ?? null}, ${input.gitWorktreeDir ?? null}, ${input.hostSource ?? null}, ${input.hostValue ?? null}, ${hostMetadataJson})`;
91
+ const stray = strayOutputColumns(input.strayOutput);
92
+ yield* sql`INSERT INTO test_runs (invocation_id, project, settings_hash, timestamp, commit_sha, branch, reason, duration, total, passed, failed, skipped, scoped, snapshot_added, snapshot_matched, snapshot_unmatched, snapshot_updated, snapshot_unchecked, snapshot_total, snapshot_failure, snapshot_did_update, snapshot_files_added, snapshot_files_removed, snapshot_files_unmatched, snapshot_files_updated, actor_type, agent_id, conversation_id, git_branch, git_commit_sha, git_dirty, git_upstream, git_worktree_dir, host_source, host_value, host_metadata, stray_total, stray_stdout, stray_stderr, stray_bytes, stray_samples) VALUES (${input.invocationId}, ${input.project}, ${input.settingsHash}, ${input.timestamp}, ${input.commitSha ?? null}, ${input.branch ?? null}, ${input.reason}, ${input.duration}, ${input.total}, ${input.passed}, ${input.failed}, ${input.skipped}, ${input.scoped ? 1 : 0}, ${input.snapshotAdded ?? 0}, ${input.snapshotMatched ?? 0}, ${input.snapshotUnmatched ?? 0}, ${input.snapshotUpdated ?? 0}, ${input.snapshotUnchecked ?? 0}, ${input.snapshotTotal ?? 0}, ${boolToInt(input.snapshotFailure) ?? 0}, ${boolToInt(input.snapshotDidUpdate) ?? 0}, ${input.snapshotFilesAdded ?? 0}, ${input.snapshotFilesRemoved ?? 0}, ${input.snapshotFilesUnmatched ?? 0}, ${input.snapshotFilesUpdated ?? 0}, ${actorType}, ${agentId}, ${conversationId}, ${input.gitBranch ?? null}, ${input.gitCommitSha ?? null}, ${gitDirtyInt}, ${input.gitUpstream ?? null}, ${input.gitWorktreeDir ?? null}, ${input.hostSource ?? null}, ${input.hostValue ?? null}, ${hostMetadataJson}, ${stray.total}, ${stray.stdout}, ${stray.stderr}, ${stray.bytes}, ${stray.samples})`;
73
93
  return (yield* sql`SELECT last_insert_rowid() as id`)[0].id;
74
94
  }).pipe(Effect.annotateLogs("service", "DataStore"), Effect.mapError((e) => new DataStoreError({
75
95
  operation: "write",
@@ -275,7 +295,7 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
275
295
  * enforced Vitest thresholds or the aspirational coverageTargets
276
296
  * (issue #237).
277
297
  */
278
- const writeCoveragePolicy = (kind, policy) => sql.withTransaction(Effect.gen(function* () {
298
+ const writeCoveragePolicy = (kind, policy) => savepoint(Effect.gen(function* () {
279
299
  yield* Effect.logDebug("writeCoveragePolicy").pipe(Effect.annotateLogs({ kind }));
280
300
  const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
281
301
  const { global: g, patterns = [] } = policy;
@@ -427,7 +447,7 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
427
447
  table: "sessions",
428
448
  reason: extractSqlReason(e)
429
449
  })));
430
- const writeTurn = (input) => sql.withTransaction(Effect.gen(function* () {
450
+ const writeTurn = (input) => savepoint(Effect.gen(function* () {
431
451
  yield* Effect.logDebug("writeTurn").pipe(Effect.annotateLogs({
432
452
  sessionId: input.sessionId,
433
453
  type: input.type
@@ -533,7 +553,7 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
533
553
  table: "hypotheses",
534
554
  reason: extractSqlReason(e)
535
555
  })));
536
- const writeTddTask = (input) => sql.withTransaction(Effect.gen(function* () {
556
+ const writeTddTask = (input) => savepoint(Effect.gen(function* () {
537
557
  yield* Effect.logDebug("writeTddTask").pipe(Effect.annotateLogs({
538
558
  sessionId: input.sessionId,
539
559
  goal: input.goal
@@ -797,7 +817,7 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
797
817
  reason: extractSqlReason(e)
798
818
  })));
799
819
  });
800
- const createBehavior = (input) => sql.withTransaction(Effect.gen(function* () {
820
+ const createBehavior = (input) => savepoint(Effect.gen(function* () {
801
821
  yield* Effect.logDebug("createBehavior").pipe(Effect.annotateLogs({
802
822
  goalId: input.goalId,
803
823
  behavior: input.behavior
@@ -836,7 +856,7 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
836
856
  table: "tdd_session_behaviors",
837
857
  reason: extractSqlReason(e)
838
858
  })));
839
- const updateBehavior = (input) => sql.withTransaction(Effect.gen(function* () {
859
+ const updateBehavior = (input) => savepoint(Effect.gen(function* () {
840
860
  yield* Effect.logDebug("updateBehavior").pipe(Effect.annotateLogs({ id: input.id }));
841
861
  const existing = yield* sql`
842
862
  SELECT id, goal_id, ordinal, behavior, suggested_test_name, status, created_at
@@ -1113,6 +1133,82 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
1113
1133
  table: "turns",
1114
1134
  reason: extractSqlReason(e)
1115
1135
  })));
1136
+ const pruneRuns = (policy) => Effect.gen(function* () {
1137
+ yield* Effect.logDebug("pruneRuns").pipe(Effect.annotateLogs({
1138
+ keepRuns: policy.keepRuns,
1139
+ maxAgeDays: policy.maxAgeDays ?? null
1140
+ }));
1141
+ const now = yield* Clock.currentTimeMillis;
1142
+ const cutoff = policy.maxAgeDays === void 0 ? null : (/* @__PURE__ */ new Date(now - policy.maxAgeDays * 24 * 60 * 60 * 1e3)).toISOString();
1143
+ yield* sql`CREATE TEMP TABLE IF NOT EXISTS prune_doomed_runs (id INTEGER PRIMARY KEY)`;
1144
+ yield* sql`DELETE FROM prune_doomed_runs`;
1145
+ yield* sql`
1146
+ INSERT INTO prune_doomed_runs (id)
1147
+ SELECT id FROM (
1148
+ SELECT id, timestamp,
1149
+ ROW_NUMBER() OVER (PARTITION BY project ORDER BY timestamp DESC, id DESC) AS rn
1150
+ FROM test_runs
1151
+ )
1152
+ WHERE rn > ${policy.keepRuns} OR (${cutoff} IS NOT NULL AND timestamp < ${cutoff})`;
1153
+ yield* sql`
1154
+ WITH sources AS (
1155
+ SELECT tr.invocation_id, MIN(tr.id) AS from_run_id
1156
+ FROM test_runs tr
1157
+ JOIN prune_doomed_runs d ON d.id = tr.id
1158
+ WHERE EXISTS (SELECT 1 FROM file_coverage fc WHERE fc.run_id = tr.id)
1159
+ GROUP BY tr.invocation_id
1160
+ ),
1161
+ moves AS (
1162
+ SELECT s.from_run_id,
1163
+ (SELECT MIN(tr2.id) FROM test_runs tr2
1164
+ WHERE tr2.invocation_id = s.invocation_id
1165
+ AND tr2.id NOT IN (SELECT id FROM prune_doomed_runs)) AS to_run_id
1166
+ FROM sources s
1167
+ WHERE NOT EXISTS (
1168
+ SELECT 1 FROM file_coverage fc2
1169
+ JOIN test_runs tr3 ON tr3.id = fc2.run_id
1170
+ WHERE tr3.invocation_id = s.invocation_id
1171
+ AND tr3.id NOT IN (SELECT id FROM prune_doomed_runs)
1172
+ )
1173
+ )
1174
+ UPDATE file_coverage
1175
+ SET run_id = (SELECT to_run_id FROM moves WHERE moves.from_run_id = file_coverage.run_id)
1176
+ WHERE run_id IN (SELECT from_run_id FROM moves WHERE to_run_id IS NOT NULL)`;
1177
+ const deletedRuns = yield* sql`
1178
+ DELETE FROM test_runs WHERE id IN (SELECT id FROM prune_doomed_runs) RETURNING id`;
1179
+ yield* sql`DELETE FROM prune_doomed_runs`;
1180
+ if (deletedRuns.length === 0) return {
1181
+ prunedRuns: 0,
1182
+ prunedFiles: 0
1183
+ };
1184
+ const deletedFiles = yield* sql`
1185
+ DELETE FROM files WHERE
1186
+ NOT EXISTS (SELECT 1 FROM test_modules WHERE file_id = files.id)
1187
+ AND NOT EXISTS (SELECT 1 FROM scoped_files WHERE file_id = files.id)
1188
+ AND NOT EXISTS (SELECT 1 FROM file_coverage WHERE file_id = files.id)
1189
+ AND NOT EXISTS (SELECT 1 FROM stack_frames WHERE file_id = files.id)
1190
+ AND NOT EXISTS (SELECT 1 FROM test_annotations WHERE location_file_id = files.id)
1191
+ AND NOT EXISTS (SELECT 1 FROM test_artifacts WHERE location_file_id = files.id)
1192
+ AND NOT EXISTS (SELECT 1 FROM import_durations WHERE file_id = files.id OR importer_file_id = files.id)
1193
+ AND NOT EXISTS (SELECT 1 FROM source_test_map WHERE source_file_id = files.id)
1194
+ AND NOT EXISTS (SELECT 1 FROM file_edits WHERE file_id = files.id)
1195
+ AND NOT EXISTS (SELECT 1 FROM run_changed_files WHERE file_id = files.id)
1196
+ AND NOT EXISTS (SELECT 1 FROM tdd_artifacts WHERE file_id = files.id)
1197
+ RETURNING id`;
1198
+ const batch = yield* PersistBatch;
1199
+ if (batch !== void 0 && deletedFiles.length > 0) {
1200
+ const gone = new Set(deletedFiles.map((row) => row.id));
1201
+ for (const [path, id] of batch.fileIds) if (gone.has(id)) batch.fileIds.delete(path);
1202
+ }
1203
+ return {
1204
+ prunedRuns: deletedRuns.length,
1205
+ prunedFiles: deletedFiles.length
1206
+ };
1207
+ }).pipe(Effect.annotateLogs("service", "DataStore"), Effect.mapError((e) => new DataStoreError({
1208
+ operation: "write",
1209
+ table: "test_runs",
1210
+ reason: extractSqlReason(e)
1211
+ })));
1116
1212
  const associateLatestRunWithSession = (input) => Effect.gen(function* () {
1117
1213
  yield* Effect.logDebug("associateLatestRunWithSession").pipe(Effect.annotateLogs({
1118
1214
  chatId: input.chatId,
@@ -1288,6 +1384,7 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
1288
1384
  writeRunChangedFiles,
1289
1385
  recordIdempotentResponse,
1290
1386
  pruneSessions,
1387
+ pruneRuns,
1291
1388
  associateLatestRunWithSession,
1292
1389
  backfillTestCaseTurns,
1293
1390
  registerAgent,
@@ -48,7 +48,8 @@ const EnvironmentDetectorLive = (env) => Layer.effect(EnvironmentDetector, Effec
48
48
  return {
49
49
  detect: () => Effect.succeed(environment),
50
50
  isAgent: Effect.succeed(Option.isSome(runtime.agent)),
51
- agentName: Effect.succeed(Option.getOrUndefined(runtime.agent))
51
+ agentName: Effect.succeed(Option.getOrUndefined(runtime.agent)),
52
+ ci: Effect.succeed(runtime.ci)
52
53
  };
53
54
  })).pipe(Layer.provide(CurrentRuntimeEnv.layerFrom(env)));
54
55
 
@@ -1,12 +1,30 @@
1
1
  import { EnvironmentDetector } from "../services/EnvironmentDetector.js";
2
- import { Effect, Layer } from "effect";
2
+ import { Effect, Layer, Option } from "effect";
3
3
 
4
4
  // src/layers/EnvironmentDetectorTest.ts
5
- /** @public */
6
- const EnvironmentDetectorTest = { layer: (env = "terminal") => Layer.succeed(EnvironmentDetector, {
5
+ /** The CI vendor each environment implies when a test does not say otherwise. */
6
+ const CI_FOR_ENVIRONMENT = {
7
+ "ci-github": Option.some("github-actions"),
8
+ "ci-generic": Option.some("generic"),
9
+ "agent-shell": Option.none(),
10
+ terminal: Option.none()
11
+ };
12
+ /**
13
+ * A fixed `EnvironmentDetector` for tests.
14
+ *
15
+ * @remarks
16
+ * `ci` defaults to the vendor the environment implies (`ci-github` is
17
+ * GitHub Actions, `ci-generic` another CI, the rest none). Pass
18
+ * `options.ci` to decouple the two, as the live detector does for an agent
19
+ * running inside GitHub Actions: `layer("agent-shell", { ci: "github-actions" })`.
20
+ *
21
+ * @public
22
+ */
23
+ const EnvironmentDetectorTest = { layer: (env = "terminal", options = {}) => Layer.succeed(EnvironmentDetector, {
7
24
  detect: () => Effect.succeed(env),
8
25
  isAgent: Effect.succeed(env === "agent-shell"),
9
- agentName: Effect.succeed(env === "agent-shell" ? "test-agent" : void 0)
26
+ agentName: Effect.succeed(env === "agent-shell" ? "test-agent" : void 0),
27
+ ci: Effect.succeed(options.ci !== void 0 ? Option.some(options.ci) : CI_FOR_ENVIRONMENT[env])
10
28
  }) };
11
29
 
12
30
  export { EnvironmentDetectorTest };
@@ -1,11 +1,21 @@
1
1
  import { VitestAgentConfigFile } from "../services/Config.js";
2
2
  import { ProjectIdentity, resolveProjectIdentityFromCandidates } from "../services/ProjectIdentity.js";
3
- import { ProjectIdentityNotResolvableError, VitestAgentConfig } from "@vitest-agent/sdk";
3
+ import { ProjectIdentityNotResolvableError, ProjectIdentitySourceError, VitestAgentConfig } from "@vitest-agent/sdk";
4
4
  import { Effect, FileSystem, Layer, Option } from "effect";
5
5
  import { WorkspaceDiscovery } from "@effected/workspaces";
6
6
  import { ChildProcess, ChildProcessSpawner } from "effect/process";
7
7
 
8
8
  // src/layers/ProjectIdentityLive.ts
9
+ /** The one-line text of a kit failure, for `ProjectIdentitySourceError.detail`. */
10
+ const detailOf = (cause) => cause instanceof Error && cause.message.length > 0 ? cause.message : String(cause);
11
+ /**
12
+ * The git remote is an OPTIONAL source: a repository with no
13
+ * `remote.origin.url`, a directory that is not a git checkout, or a machine
14
+ * with no `git` on `PATH` are all "no remote" rather than failures. `git
15
+ * config --get` exits non-zero for an unset key, so every spawner failure
16
+ * folds to `Option.none()` on purpose — this is the one catch in this module
17
+ * that stays broad, and it is a policy, not an accident.
18
+ */
9
19
  const readGitRemote = (cwd) => Effect.gen(function* () {
10
20
  const trimmed = (yield* (yield* ChildProcessSpawner.ChildProcessSpawner).string(ChildProcess.make("git", [
11
21
  "config",
@@ -14,23 +24,55 @@ const readGitRemote = (cwd) => Effect.gen(function* () {
14
24
  ]).pipe(ChildProcess.setCwd(cwd)))).trim();
15
25
  return trimmed.length > 0 ? Option.some(trimmed) : Option.none();
16
26
  }).pipe(Effect.catch(() => Effect.succeed(Option.none())));
27
+ /**
28
+ * The `repository` URL of the root `package.json` discovery named.
29
+ *
30
+ * A file that is not there (`NotFound`) is an absent source. Any other
31
+ * filesystem failure (permission denied, an I/O error) and a body that is not
32
+ * JSON are `ProjectIdentitySourceError`s: the file exists and says
33
+ * something, and silently reading it as "no repository" would hand the
34
+ * operator the wrong identity.
35
+ */
17
36
  const readPackageRepoUrl = (packageJsonPath) => Effect.gen(function* () {
18
- const raw = yield* (yield* FileSystem.FileSystem).readFileString(packageJsonPath).pipe(Effect.catch(() => Effect.succeed("")));
19
- if (raw.length === 0) return Option.none();
20
- try {
21
- const repo = JSON.parse(raw).repository;
22
- if (typeof repo === "string" && repo.trim().length > 0) return Option.some(repo.trim());
23
- if (repo !== null && typeof repo === "object" && "url" in repo && typeof repo.url === "string") {
24
- const url = repo.url.trim();
25
- return url.length > 0 ? Option.some(url) : Option.none();
26
- }
27
- } catch {}
37
+ const raw = yield* (yield* FileSystem.FileSystem).readFileString(packageJsonPath).pipe(Effect.map(Option.some), Effect.catchTag("PlatformError", (error) => error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(new ProjectIdentitySourceError({
38
+ source: "package-json",
39
+ path: packageJsonPath,
40
+ detail: detailOf(error),
41
+ cause: error
42
+ }))));
43
+ if (Option.isNone(raw)) return Option.none();
44
+ const parsed = yield* Effect.try({
45
+ try: () => JSON.parse(raw.value),
46
+ catch: (cause) => new ProjectIdentitySourceError({
47
+ source: "package-json",
48
+ path: packageJsonPath,
49
+ detail: detailOf(cause),
50
+ cause
51
+ })
52
+ });
53
+ if (parsed === null || typeof parsed !== "object") return Option.none();
54
+ const repo = parsed.repository;
55
+ if (typeof repo === "string" && repo.trim().length > 0) return Option.some(repo.trim());
56
+ if (repo !== null && typeof repo === "object" && "url" in repo && typeof repo.url === "string") {
57
+ const url = repo.url.trim();
58
+ return url.length > 0 ? Option.some(url) : Option.none();
59
+ }
28
60
  return Option.none();
29
61
  });
30
62
  const collectCandidates = (workspaceRoot, options) => Effect.gen(function* () {
31
- const tomlConfig = yield* (yield* VitestAgentConfigFile).loadOrDefault(new VitestAgentConfig({})).pipe(Effect.catch(() => Effect.succeed(new VitestAgentConfig({}))));
63
+ const tomlConfig = yield* (yield* VitestAgentConfigFile).loadOrDefault(new VitestAgentConfig({})).pipe(Effect.mapError((cause) => new ProjectIdentitySourceError({
64
+ source: "toml",
65
+ path: cause._tag === "ConfigValidationError" ? Option.getOrElse(cause.path, () => workspaceRoot) : cause.path ?? workspaceRoot,
66
+ detail: detailOf(cause),
67
+ cause
68
+ })));
32
69
  const gitRemote = yield* readGitRemote(workspaceRoot);
33
- const root = (yield* (yield* WorkspaceDiscovery).listPackagesIn(workspaceRoot).pipe(Effect.catch(() => Effect.succeed([])))).find((pkg) => pkg.isRootWorkspace);
70
+ const root = (yield* (yield* WorkspaceDiscovery).listPackagesIn(workspaceRoot).pipe(Effect.catchTag("WorkspaceRootNotFoundError", () => Effect.succeed([])), Effect.mapError((cause) => new ProjectIdentitySourceError({
71
+ source: "workspace",
72
+ path: cause._tag === "WorkspacePatternError" ? cause.root : cause.path,
73
+ detail: detailOf(cause),
74
+ cause
75
+ })))).find((pkg) => pkg.isRootWorkspace);
34
76
  const packageJsonRepoUrl = root ? yield* readPackageRepoUrl(root.packageJsonPath) : Option.none();
35
77
  const candidates = {};
36
78
  if (options.projectId !== void 0) candidates.explicit = options.projectId;
@@ -69,6 +111,10 @@ const candidatesToTriedList = (candidates) => [
69
111
  * `FileSystem`, `CommandExecutor`, `WorkspaceDiscovery`, and
70
112
  * `VitestAgentConfigFile`. The platform context comes from
71
113
  * `NodeServices.layer` at the entry point.
114
+ *
115
+ * Absent sources fall through (no config file, no workspace root, no
116
+ * `repository` field, no git remote); a present source that cannot be read
117
+ * or decoded fails with `ProjectIdentitySourceError`.
72
118
  * @public
73
119
  */
74
120
  const ProjectIdentityLive = Layer.effect(ProjectIdentity, Effect.gen(function* () {
@@ -84,11 +130,11 @@ const ProjectIdentityLive = Layer.effect(ProjectIdentity, Effect.gen(function* (
84
130
  }));
85
131
  /**
86
132
  * Build a Test layer that returns a fixed `ResolvedIdentity` (or
87
- * fails with `ProjectIdentityNotResolvableError`). Use in unit tests
133
+ * fails with the given `ProjectIdentityError`). Use in unit tests
88
134
  * that depend on `ProjectIdentity` but don't want to wire upstream
89
135
  * services.
90
136
  * @public
91
137
  */
92
- const ProjectIdentityTest = (result) => Layer.succeed(ProjectIdentity, { resolve: () => result instanceof ProjectIdentityNotResolvableError ? Effect.fail(result) : Effect.succeed(result) });
138
+ const ProjectIdentityTest = (result) => Layer.succeed(ProjectIdentity, { resolve: () => result instanceof ProjectIdentityNotResolvableError || result instanceof ProjectIdentitySourceError ? Effect.fail(result) : Effect.succeed(result) });
93
139
 
94
140
  export { ProjectIdentityLive, ProjectIdentityTest };
@@ -2,6 +2,19 @@ import { DataReader } from "../services/DataReader.js";
2
2
  import { Effect, Option } from "effect";
3
3
 
4
4
  // src/lib/format-triage.ts
5
+ const STRAY_OUTPUT_WINDOW = 10;
6
+ const describeStrayOutput = (stray) => {
7
+ const lines = `${stray.total} line${stray.total === 1 ? "" : "s"}`;
8
+ const sample = stray.samples[0];
9
+ return sample === void 0 ? `${lines}, ${stray.bytes} bytes` : `${lines}, ${stray.bytes} bytes; first: \`${sample.text}\``;
10
+ };
11
+ /**
12
+ * The "stray output" line of the Recent Test Runs section, or `undefined`
13
+ * when none of the recent runs wrote past Vitest's console capture (issue
14
+ * 578). Pure, so the wording is testable without a database.
15
+ * @public
16
+ */
17
+ const formatStrayOutputLine = (history) => history.runsWithStrayOutput === 0 ? void 0 : `- Stray output in ${history.runsWithStrayOutput} of the last ${history.runs} run${history.runs === 1 ? "" : "s"}${history.latest === null ? "" : ` (latest: ${describeStrayOutput(history.latest)})`} — tests or child processes writing straight to the terminal`;
5
18
  /**
6
19
  * Generates an orientation triage markdown string for LLM agents.
7
20
  * Summarises recent test runs, active sessions, acceptance metrics,
@@ -16,6 +29,14 @@ const formatTriageEffect = (options = {}) => Effect.gen(function* () {
16
29
  const allProjects = yield* reader.getRunsByProject().pipe(Effect.orElseSucceed(() => []));
17
30
  const projects = options.project ? allProjects.filter((p) => p.project === options.project) : allProjects;
18
31
  const sessions = yield* reader.listSessions({}).pipe(Effect.orElseSucceed(() => []));
32
+ const strayHistory = yield* reader.getStrayOutputHistory({
33
+ ...options.project !== void 0 && { project: options.project },
34
+ limit: STRAY_OUTPUT_WINDOW
35
+ }).pipe(Effect.orElseSucceed(() => ({
36
+ runs: 0,
37
+ runsWithStrayOutput: 0,
38
+ latest: null
39
+ })));
19
40
  const fallbackMetrics = {
20
41
  phaseEvidenceIntegrity: {
21
42
  total: 0,
@@ -59,6 +80,8 @@ const formatTriageEffect = (options = {}) => Effect.gen(function* () {
59
80
  const counts = `${p.passed} passed, ${p.failed} failed`;
60
81
  lines.push(`- **${p.project}** — ${status} (${counts})`);
61
82
  }
83
+ const strayLine = formatStrayOutputLine(strayHistory);
84
+ if (strayLine !== void 0) lines.push(strayLine);
62
85
  lines.push("");
63
86
  } else {
64
87
  lines.push("### Recent Test Runs");
@@ -99,4 +122,4 @@ const formatTriageEffect = (options = {}) => Effect.gen(function* () {
99
122
  return out;
100
123
  });
101
124
 
102
- export { formatTriageEffect };
125
+ export { formatStrayOutputLine, formatTriageEffect };
@@ -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 };
@@ -0,0 +1,15 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/sql/SqlClient";
3
+
4
+ // src/migrations/0004_stray_output.ts
5
+ /** @internal */
6
+ const migration = Effect.gen(function* () {
7
+ const sql = yield* SqlClient;
8
+ yield* sql`ALTER TABLE test_runs ADD COLUMN stray_total INTEGER`;
9
+ yield* sql`ALTER TABLE test_runs ADD COLUMN stray_stdout INTEGER`;
10
+ yield* sql`ALTER TABLE test_runs ADD COLUMN stray_stderr INTEGER`;
11
+ yield* sql`ALTER TABLE test_runs ADD COLUMN stray_bytes INTEGER`;
12
+ yield* sql`ALTER TABLE test_runs ADD COLUMN stray_samples TEXT`;
13
+ });
14
+
15
+ export { migration as default };
@@ -1,5 +1,7 @@
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";
4
+ import migration$3 from "./0004_stray_output.js";
3
5
 
4
6
  // src/migrations/index.ts
5
7
  /**
@@ -21,7 +23,9 @@ import migration$1 from "./0002_test_artifacts.js";
21
23
  */
22
24
  const PROJECT_MIGRATIONS = {
23
25
  "0001_initial": migration,
24
- "0002_test_artifacts": migration$1
26
+ "0002_test_artifacts": migration$1,
27
+ "0003_run_retention": migration$2,
28
+ "0004_stray_output": migration$3
25
29
  };
26
30
 
27
31
  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": "2.0.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": "8.0.0",
59
59
  "effect": "^4.0.6"
60
60
  },
61
61
  "engines": {
@@ -1,7 +1,19 @@
1
1
  import { Context } from "effect";
2
2
 
3
3
  // src/services/EnvironmentDetector.ts
4
- /** @public */
4
+ /**
5
+ * Who is running the program and where.
6
+ *
7
+ * @remarks
8
+ * `detect` is the console audience: an agent marker wins over CI, so an
9
+ * agent inside GitHub Actions reads `agent-shell`. `ci` is the CI vendor
10
+ * independently of that precedence, so a caller can keep GitHub-only
11
+ * extras (the step summary, annotations, the `github-actions` reporter)
12
+ * on for the humans reviewing the job even when the console audience is
13
+ * the agent (issue #624).
14
+ *
15
+ * @public
16
+ */
5
17
  var EnvironmentDetector = class extends Context.Service()("vitest-agent/EnvironmentDetector") {};
6
18
 
7
19
  export { EnvironmentDetector };
@@ -54,6 +54,12 @@ const resolveProjectIdentityFromCandidates = (candidates) => {
54
54
  * Service tag. The Live layer (in `layers/ProjectIdentityLive.ts`)
55
55
  * wires up the I/O sources; tests provide a stub that returns a
56
56
  * pre-built `ResolvedIdentity`.
57
+ *
58
+ * `resolve` fails with `ProjectIdentityNotResolvableError` when every
59
+ * source is absent or empty, and with `ProjectIdentitySourceError` when a
60
+ * source is present but unusable (a malformed `vitest-agent.config.toml`,
61
+ * an unreadable root `package.json`): a broken config is reported, never
62
+ * silently replaced by the default identity (issue 321).
57
63
  * @public
58
64
  */
59
65
  var ProjectIdentity = class extends Context.Service()("vitest-agent/ProjectIdentity") {};
package/testing.d.ts CHANGED
@@ -1,10 +1,11 @@
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, StrayOutput, 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";
5
5
  import { StoreError, StoreMigrationError } from "@effected/store";
6
6
  import "effect/sql/SqlClient";
7
7
  import { SqlError } from "effect/sql/SqlError";
8
+ import "@effected/env";
8
9
  /** @public */
9
10
  interface CreateGoalInput {
10
11
  readonly tddTaskId: number;
@@ -99,6 +100,13 @@ interface TestRunInput {
99
100
  readonly hostSource?: string | null;
100
101
  readonly hostValue?: string | null;
101
102
  readonly hostMetadata?: Record<string, unknown> | null;
103
+ /**
104
+ * Output the run wrote straight to the terminal past Vitest's console
105
+ * capture (issue 578). A run-level signal: every project row of one
106
+ * invocation carries the same value. Omitted or `null` when the run
107
+ * recorded nothing, which the row stores as NULL in every `stray_*` column.
108
+ */
109
+ readonly strayOutput?: StrayOutput | null;
102
110
  }
103
111
  /** @public */
104
112
  interface ModuleInput {
@@ -408,6 +416,15 @@ interface RegisterAgentInput {
408
416
  */
409
417
  readonly agentId?: string;
410
418
  }
419
+ /**
420
+ * What `DataStore.pruneRuns` removed: `test_runs` rows deleted (their
421
+ * cascaded rows are not counted) and orphaned `files` rows garbage-collected.
422
+ * @public
423
+ */
424
+ interface PruneRunsResult {
425
+ readonly prunedRuns: number;
426
+ readonly prunedFiles: number;
427
+ }
411
428
  /** @public */
412
429
  interface AssociateRunSessionInput {
413
430
  readonly chatId: string;
@@ -505,6 +522,25 @@ declare const DataStore_base: Context.ServiceClass<DataStore, "vitest-agent/Data
505
522
  readonly affectedSessions: number;
506
523
  readonly prunedTurns: number;
507
524
  }, DataStoreError$1>;
525
+ /**
526
+ * Apply a run-retention policy: delete every `test_runs` row beyond the
527
+ * newest `policy.keepRuns` of its project, and any run older than
528
+ * `policy.maxAgeDays` days when set, letting the schema's cascades drop
529
+ * the dependent rows (modules, suites, cases, tags, errors, frames,
530
+ * coverage, trends, triggers), then garbage-collect `files` rows no
531
+ * table references any more. `test_history` rows survive with a NULL
532
+ * `run_id`; they keep their own newest-10-per-series window. Before
533
+ * the delete, `file_coverage` rows held by a doomed run are moved onto
534
+ * the lowest surviving run of the same invocation (coverage is written
535
+ * once per invocation, issue 622) unless a survivor already carries
536
+ * coverage rows, so every sibling project's coverage read stays whole.
537
+ *
538
+ * A handful of synchronous statements on the open connection, no
539
+ * yielding, so it may run inside `withPersistTransaction` after the
540
+ * run's own rows are written: the run just written is the newest of
541
+ * its project and is never pruned.
542
+ */
543
+ readonly pruneRuns: (policy: RunRetentionPolicy$1) => Effect.Effect<PruneRunsResult, DataStoreError$1>;
508
544
  readonly associateLatestRunWithSession: (input: AssociateRunSessionInput) => Effect.Effect<void, DataStoreError$1>;
509
545
  readonly backfillTestCaseTurns: (chatId: string) => Effect.Effect<number, DataStoreError$1>;
510
546
  /**
@@ -538,6 +574,32 @@ interface ProjectRunSummary {
538
574
  readonly passed: number;
539
575
  readonly failed: number;
540
576
  readonly skipped: number;
577
+ /** The latest run's stray output (issue 578); `null` when it recorded none. */
578
+ readonly strayOutput: StrayOutput | null;
579
+ }
580
+ /**
581
+ * Options for `DataReader.getStrayOutputHistory`.
582
+ * @public
583
+ */
584
+ interface StrayOutputHistoryOptions {
585
+ /** Only runs of this project; every project otherwise. */
586
+ readonly project?: string;
587
+ /** How many of the most recent runs to consider. Default 10. */
588
+ readonly limit?: number;
589
+ }
590
+ /**
591
+ * How often recent runs wrote stray output (issue 578). A "run" is one
592
+ * reporter invocation: stray output is a run-level signal that every project
593
+ * row of an invocation repeats, so rows are counted once per `invocation_id`.
594
+ * @public
595
+ */
596
+ interface StrayOutputHistory {
597
+ /** Runs considered: the most recent ones, up to the requested limit. */
598
+ readonly runs: number;
599
+ /** How many of them recorded any stray output. */
600
+ readonly runsWithStrayOutput: number;
601
+ /** The most recent run's stray output, when that run recorded any. */
602
+ readonly latest: StrayOutput | null;
541
603
  }
542
604
  /** @public */
543
605
  interface FlakyTest {
@@ -896,6 +958,8 @@ interface TestArtifactRow {
896
958
  declare const DataReader_base: Context.ServiceClass<DataReader, "vitest-agent/DataReader", {
897
959
  readonly getLatestRun: (project: string) => Effect.Effect<Option.Option<AgentReport$1>, DataStoreError$1>;
898
960
  readonly getRunsByProject: () => Effect.Effect<ReadonlyArray<ProjectRunSummary>, DataStoreError$1>;
961
+ /** How often the most recent runs wrote stray output; see {@link StrayOutputHistory}. */
962
+ readonly getStrayOutputHistory: (options?: StrayOutputHistoryOptions) => Effect.Effect<StrayOutputHistory, DataStoreError$1>;
899
963
  readonly getHistory: (project: string, options?: HistoryQueryOptions) => Effect.Effect<HistoryRecord$1, DataStoreError$1>;
900
964
  readonly getBaselines: (project: string) => Effect.Effect<Option.Option<CoverageBaselines$1>, DataStoreError$1>;
901
965
  readonly getTrends: (project: string, limit?: number) => Effect.Effect<Option.Option<TrendRecord$1>, DataStoreError$1>;
@@ -1085,6 +1149,8 @@ type PlatformLiveError = SqlError | StoreError | StoreMigrationError;
1085
1149
  export declare const PROJECT_MIGRATIONS: {
1086
1150
  readonly "0001_initial": import("effect/Effect").Effect<void, import("effect/sql/SqlError").SqlError, import("effect/sql/SqlClient").SqlClient>;
1087
1151
  readonly "0002_test_artifacts": import("effect/Effect").Effect<void, import("effect/sql/SqlError").SqlError, import("effect/sql/SqlClient").SqlClient>;
1152
+ readonly "0003_run_retention": import("effect/Effect").Effect<void, import("effect/sql/SqlError").SqlError, import("effect/sql/SqlClient").SqlClient>;
1153
+ readonly "0004_stray_output": import("effect/Effect").Effect<void, import("effect/sql/SqlError").SqlError, import("effect/sql/SqlClient").SqlClient>;
1088
1154
  };
1089
1155
  /** @public */
1090
1156
  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 +1166,5 @@ export declare function withFailures(filename: string): Layer.Layer<DataReader |
1100
1166
  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
1167
  /** @public */
1102
1168
  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 };
1169
+ 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 StrayOutputHistory, type StrayOutputHistoryOptions, 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
1170
  //# 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 = "2.0.0";
4
4
 
5
5
  export { CURRENT_ENGINE_VERSION };