@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 +146 -10
- package/index.js +3 -3
- package/layers/DataReaderLive.js +86 -9
- package/layers/DataStoreLive.js +105 -8
- package/layers/EnvironmentDetectorLive.js +2 -1
- package/layers/EnvironmentDetectorTest.js +22 -4
- package/layers/ProjectIdentityLive.js +61 -15
- package/lib/format-triage.js +24 -1
- package/migrations/0003_run_retention.js +66 -0
- package/migrations/0004_stray_output.js +15 -0
- package/migrations/index.js +5 -1
- package/package.json +2 -2
- package/services/EnvironmentDetector.js +13 -1
- package/services/ProjectIdentity.js +6 -0
- package/testing.d.ts +68 -2
- package/testing.js +2 -2
- package/utils/persist-transaction.js +58 -4
- package/version.js +1 -1
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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,
|
|
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 `
|
|
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 |
|
|
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.
|
|
2564
|
-
*
|
|
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 };
|
package/layers/DataReaderLive.js
CHANGED
|
@@ -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 = ${
|
|
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 = ${
|
|
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,
|
package/layers/DataStoreLive.js
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
-
/**
|
|
6
|
-
const
|
|
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.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
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.
|
|
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 `
|
|
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 };
|
package/lib/format-triage.js
CHANGED
|
@@ -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 };
|
package/migrations/index.js
CHANGED
|
@@ -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": "
|
|
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": "
|
|
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
|
-
/**
|
|
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.
|
|
57
|
-
*
|
|
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