@vitest-agent/engine 0.1.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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/index.d.ts +2679 -0
  4. package/index.js +63 -0
  5. package/layers/ConfigLive.js +46 -0
  6. package/layers/DataReaderLive.js +1923 -0
  7. package/layers/DataStoreLive.js +1283 -0
  8. package/layers/DetailResolverLive.js +9 -0
  9. package/layers/DiscoveryRegistryLive.js +70 -0
  10. package/layers/EnvironmentDetectorLive.js +36 -0
  11. package/layers/EnvironmentDetectorTest.js +13 -0
  12. package/layers/ExecutorResolverLive.js +9 -0
  13. package/layers/FormatSelectorLive.js +20 -0
  14. package/layers/HistoryTrackerLive.js +49 -0
  15. package/layers/HistoryTrackerTest.js +25 -0
  16. package/layers/LoggerLive.js +70 -0
  17. package/layers/OutputPipelineLive.js +20 -0
  18. package/layers/OutputRendererLive.js +23 -0
  19. package/layers/PathResolutionLive.js +46 -0
  20. package/layers/PerClientSessionMapLive.js +145 -0
  21. package/layers/ProjectDiscoveryLive.js +67 -0
  22. package/layers/ProjectDiscoveryTest.js +12 -0
  23. package/layers/ProjectIdentityLive.js +95 -0
  24. package/layers/RunContextLive.js +82 -0
  25. package/lib/format-triage.js +103 -0
  26. package/lib/format-wrapup.js +67 -0
  27. package/migrations/0001_initial.js +824 -0
  28. package/migrations/0002_test_artifacts.js +30 -0
  29. package/migrations/index.js +27 -0
  30. package/migrations/registry_0001_initial.js +39 -0
  31. package/migrations/session_map_0001_initial.js +38 -0
  32. package/package.json +59 -0
  33. package/platform.js +58 -0
  34. package/programs/end-agent.js +19 -0
  35. package/programs/hook-paths.js +115 -0
  36. package/programs/platform-sidecar.js +37 -0
  37. package/programs/record-session.js +49 -0
  38. package/programs/record-tdd-artifact.js +119 -0
  39. package/programs/record-turn.js +61 -0
  40. package/programs/record-workspace-changes.js +57 -0
  41. package/programs/register-agent.js +85 -0
  42. package/programs/resolve-session-for-recording.js +44 -0
  43. package/programs/session-env.js +85 -0
  44. package/project-dir.js +32 -0
  45. package/services/Config.js +15 -0
  46. package/services/DataReader.js +8 -0
  47. package/services/DataStore.js +15 -0
  48. package/services/DetailResolver.js +8 -0
  49. package/services/DiscoveryRegistry.js +8 -0
  50. package/services/EnvironmentDetector.js +8 -0
  51. package/services/ExecutorResolver.js +8 -0
  52. package/services/FormatSelector.js +8 -0
  53. package/services/HistoryTracker.js +20 -0
  54. package/services/OutputRenderer.js +8 -0
  55. package/services/PerClientSessionMap.js +22 -0
  56. package/services/ProjectDiscovery.js +8 -0
  57. package/services/ProjectIdentity.js +62 -0
  58. package/services/RunContext.js +53 -0
  59. package/services/idempotency.js +58 -0
  60. package/sql/assemblers.js +69 -0
  61. package/testing/layers.js +16 -0
  62. package/testing.d.ts +1101 -0
  63. package/testing.js +243 -0
  64. package/tsdoc-metadata.json +11 -0
  65. package/utils/ensure-migrated.js +47 -0
  66. package/utils/failure-signature.js +34 -0
  67. package/utils/resolve-data-path.js +64 -0
  68. package/utils/resolve-project-key-from-cwd.js +52 -0
  69. package/utils/resolve-workspace-key.js +41 -0
  70. package/version.js +6 -0
@@ -0,0 +1,9 @@
1
+ import { DetailResolver } from "../services/DetailResolver.js";
2
+ import { Effect, Layer } from "effect";
3
+
4
+ //#region src/layers/DetailResolverLive.ts
5
+ /** @public */
6
+ const DetailResolverLive = Layer.succeed(DetailResolver, { resolve: (executor, health, explicit) => Effect.succeed(explicit ?? (executor === "agent" || executor === "ci" ? "verbose" : health.hasFailures ? "verbose" : health.belowTargets ? "standard" : health.hasTargets === false ? "neutral" : "minimal")) });
7
+
8
+ //#endregion
9
+ export { DetailResolverLive };
@@ -0,0 +1,70 @@
1
+ import { DiscoveryRegistry } from "../services/DiscoveryRegistry.js";
2
+ import { DataStoreError, extractSqlReason } from "@vitest-agent/sdk";
3
+ import { Effect, Layer } from "effect";
4
+ import { SqlClient } from "effect/unstable/sql/SqlClient";
5
+
6
+ //#region src/layers/DiscoveryRegistryLive.ts
7
+ const TABLE = "known_projects";
8
+ const rowToDomain = (row) => ({
9
+ projectKey: row.project_key,
10
+ canonicalForm: row.canonical_form,
11
+ dataDbPath: row.data_db_path,
12
+ gitRemoteOrigin: row.git_remote_origin,
13
+ workspaceRoot: row.workspace_root,
14
+ firstSeenAt: row.first_seen_at,
15
+ lastSeenAt: row.last_seen_at
16
+ });
17
+ const dayInMs = 864e5;
18
+ /** @public */
19
+ const DiscoveryRegistryLive = Layer.effect(DiscoveryRegistry, Effect.gen(function* () {
20
+ const sql = yield* SqlClient;
21
+ return {
22
+ recordProject: (input) => Effect.gen(function* () {
23
+ const now = Date.now();
24
+ yield* sql`
25
+ INSERT INTO ${sql(TABLE)} (
26
+ project_key, canonical_form, data_db_path, git_remote_origin, workspace_root,
27
+ first_seen_at, last_seen_at
28
+ ) VALUES (
29
+ ${input.projectKey}, ${input.canonicalForm}, ${input.dataDbPath},
30
+ ${input.gitRemoteOrigin}, ${input.workspaceRoot}, ${now}, ${now}
31
+ )
32
+ ON CONFLICT(project_key) DO UPDATE SET
33
+ canonical_form = excluded.canonical_form,
34
+ data_db_path = excluded.data_db_path,
35
+ git_remote_origin = excluded.git_remote_origin,
36
+ workspace_root = excluded.workspace_root,
37
+ last_seen_at = excluded.last_seen_at
38
+ `;
39
+ }).pipe(Effect.mapError((e) => new DataStoreError({
40
+ operation: "write",
41
+ table: TABLE,
42
+ reason: extractSqlReason(e)
43
+ }))),
44
+ listProjects: () => Effect.gen(function* () {
45
+ return (yield* sql`
46
+ SELECT project_key, canonical_form, data_db_path, git_remote_origin, workspace_root,
47
+ first_seen_at, last_seen_at
48
+ FROM ${sql(TABLE)}
49
+ ORDER BY last_seen_at DESC, rowid DESC
50
+ `).map(rowToDomain);
51
+ }).pipe(Effect.mapError((e) => new DataStoreError({
52
+ operation: "read",
53
+ table: TABLE,
54
+ reason: extractSqlReason(e)
55
+ }))),
56
+ prune: (maxAgeDays) => Effect.gen(function* () {
57
+ const cutoff = Date.now() - maxAgeDays * dayInMs;
58
+ const removable = (yield* sql`SELECT COUNT(*) AS c FROM ${sql(TABLE)} WHERE last_seen_at < ${cutoff}`)[0]?.c ?? 0;
59
+ if (removable > 0) yield* sql`DELETE FROM ${sql(TABLE)} WHERE last_seen_at < ${cutoff}`;
60
+ return removable;
61
+ }).pipe(Effect.mapError((e) => new DataStoreError({
62
+ operation: "write",
63
+ table: TABLE,
64
+ reason: extractSqlReason(e)
65
+ })))
66
+ };
67
+ }));
68
+
69
+ //#endregion
70
+ export { DiscoveryRegistryLive };
@@ -0,0 +1,36 @@
1
+ import { EnvironmentDetector } from "../services/EnvironmentDetector.js";
2
+ import { Effect, Layer } from "effect";
3
+ import { agent, isAgent } from "std-env";
4
+
5
+ //#region src/layers/EnvironmentDetectorLive.ts
6
+ const isGitHub = (env) => env.GITHUB_ACTIONS === "true" || env.GITHUB_ACTIONS === "1";
7
+ const isCI = (env) => isGitHub(env) || env.CI === "true";
8
+ /**
9
+ * Pure classification behind {@link EnvironmentDetectorLive}: an agent shell
10
+ * wins, then GitHub Actions, then generic CI, else a terminal.
11
+ *
12
+ * @param env - the environment map to consult
13
+ * @param agentShell - whether the process runs under an AI agent (`std-env`'s `isAgent`)
14
+ * @public
15
+ */
16
+ const classifyEnvironment = (env, agentShell) => {
17
+ if (agentShell) return "agent-shell";
18
+ if (isGitHub(env)) return "ci-github";
19
+ if (isCI(env)) return "ci-generic";
20
+ return "terminal";
21
+ };
22
+ /**
23
+ * Live environment detector. CI detection reads `GITHUB_ACTIONS` / `CI`
24
+ * from the injected `env` map; agent detection comes from `std-env`.
25
+ *
26
+ * @param env - the environment map to consult (the front end passes `process.env`)
27
+ * @public
28
+ */
29
+ const EnvironmentDetectorLive = (env) => Layer.succeed(EnvironmentDetector, {
30
+ detect: () => Effect.sync(() => classifyEnvironment(env, isAgent)),
31
+ isAgent: Effect.sync(() => isAgent),
32
+ agentName: Effect.sync(() => agent ?? void 0)
33
+ });
34
+
35
+ //#endregion
36
+ export { EnvironmentDetectorLive, classifyEnvironment };
@@ -0,0 +1,13 @@
1
+ import { EnvironmentDetector } from "../services/EnvironmentDetector.js";
2
+ import { Effect, Layer } from "effect";
3
+
4
+ //#region src/layers/EnvironmentDetectorTest.ts
5
+ /** @public */
6
+ const EnvironmentDetectorTest = { layer: (env = "terminal") => Layer.succeed(EnvironmentDetector, {
7
+ detect: () => Effect.succeed(env),
8
+ isAgent: Effect.succeed(env === "agent-shell"),
9
+ agentName: Effect.succeed(env === "agent-shell" ? "test-agent" : void 0)
10
+ }) };
11
+
12
+ //#endregion
13
+ export { EnvironmentDetectorTest };
@@ -0,0 +1,9 @@
1
+ import { ExecutorResolver } from "../services/ExecutorResolver.js";
2
+ import { Effect, Layer } from "effect";
3
+
4
+ //#region src/layers/ExecutorResolverLive.ts
5
+ /** @public */
6
+ const ExecutorResolverLive = Layer.succeed(ExecutorResolver, { resolve: (env) => Effect.succeed(env === "agent-shell" ? "agent" : env === "terminal" ? "human" : "ci") });
7
+
8
+ //#endregion
9
+ export { ExecutorResolverLive };
@@ -0,0 +1,20 @@
1
+ import { FormatSelector } from "../services/FormatSelector.js";
2
+ import { Effect, Layer } from "effect";
3
+
4
+ //#region src/layers/FormatSelectorLive.ts
5
+ /**
6
+ * Default format dispatch:
7
+ *
8
+ * - GitHub Actions CI executor: `ci-annotations` (renders as `::error::`).
9
+ * - Human executor: `silent` (Vitest's own reporters handle the user
10
+ * experience; we just write to the database).
11
+ * - Anyone else (agent-shell, terminal-bound CI): `terminal` — plain
12
+ * text + ANSI color, no markdown syntax in the place that doesn't
13
+ * render it. Markdown stays available via explicit `format: "markdown"`
14
+ * for surfaces that *do* render it (MCP tools, GitHub step summary).
15
+ * @public
16
+ */
17
+ const FormatSelectorLive = Layer.succeed(FormatSelector, { select: (executor, explicitFormat, environment) => Effect.succeed(explicitFormat ?? (environment === "ci-github" && executor === "ci" ? "ci-annotations" : executor === "human" ? "silent" : "terminal")) });
18
+
19
+ //#endregion
20
+ export { FormatSelectorLive };
@@ -0,0 +1,49 @@
1
+ import { DataReader } from "../services/DataReader.js";
2
+ import { HistoryTracker, historyKey } from "../services/HistoryTracker.js";
3
+ import { classifyTest } from "@vitest-agent/sdk";
4
+ import { Effect, Layer } from "effect";
5
+
6
+ //#region src/layers/HistoryTrackerLive.ts
7
+ const WINDOW_SIZE = 10;
8
+ /** @public */
9
+ const HistoryTrackerLive = Layer.effect(HistoryTracker, Effect.gen(function* () {
10
+ const reader = yield* DataReader;
11
+ return { classify: (project, testOutcomes, timestamp) => Effect.gen(function* () {
12
+ const existing = yield* reader.getHistory(project);
13
+ const testMap = /* @__PURE__ */ new Map();
14
+ for (const entry of existing.tests) testMap.set(historyKey(entry.modulePath, entry.fullName), {
15
+ ...entry,
16
+ runs: [...entry.runs]
17
+ });
18
+ const classifications = /* @__PURE__ */ new Map();
19
+ for (const outcome of testOutcomes) {
20
+ const key = historyKey(outcome.modulePath, outcome.fullName);
21
+ let entry = testMap.get(key);
22
+ if (!entry) {
23
+ entry = {
24
+ modulePath: outcome.modulePath,
25
+ fullName: outcome.fullName,
26
+ runs: []
27
+ };
28
+ testMap.set(key, entry);
29
+ }
30
+ const priorRuns = entry.runs;
31
+ entry.runs = [{
32
+ timestamp,
33
+ state: outcome.state
34
+ }, ...priorRuns].slice(0, WINDOW_SIZE);
35
+ classifications.set(key, classifyTest(outcome.state, priorRuns));
36
+ }
37
+ return {
38
+ history: {
39
+ project,
40
+ updatedAt: timestamp,
41
+ tests: Array.from(testMap.values())
42
+ },
43
+ classifications
44
+ };
45
+ }) };
46
+ }));
47
+
48
+ //#endregion
49
+ export { HistoryTrackerLive };
@@ -0,0 +1,25 @@
1
+ import { HistoryTracker } from "../services/HistoryTracker.js";
2
+ import { Effect, Layer } from "effect";
3
+
4
+ //#region src/layers/HistoryTrackerTest.ts
5
+ /** @public */
6
+ const HistoryTrackerTest = {
7
+ empty: () => ({ classifyCalls: [] }),
8
+ layer: (state, cannedResult) => Layer.succeed(HistoryTracker, { classify: (project, _outcomes, timestamp) => Effect.sync(() => {
9
+ state.classifyCalls.push({
10
+ project,
11
+ timestamp
12
+ });
13
+ return cannedResult ?? {
14
+ history: {
15
+ project,
16
+ updatedAt: timestamp,
17
+ tests: []
18
+ },
19
+ classifications: /* @__PURE__ */ new Map()
20
+ };
21
+ }) })
22
+ };
23
+
24
+ //#endregion
25
+ export { HistoryTrackerTest };
@@ -0,0 +1,70 @@
1
+ import { Layer, LogLevel, Logger, References } from "effect";
2
+ import { appendFileSync } from "node:fs";
3
+
4
+ //#region src/layers/LoggerLive.ts
5
+ /**
6
+ * Create a structured JSON (NDJSON) logger layer for stderr.
7
+ *
8
+ * When level is undefined or `"None"`, installs an empty logger set (silent).
9
+ * When logFile is set, composes the stderr logger with a file logger.
10
+ * Uses `Logger.formatJson` / `Logger.formatStructured` for machine-readable
11
+ * output.
12
+ * @public
13
+ */
14
+ const LoggerLive = (level, logFile) => {
15
+ if (!level || level === "None") return Logger.layer([]);
16
+ const stderrLogger = Logger.formatJson.pipe(Logger.withConsoleError);
17
+ const fileLogger = logFile ? Logger.formatStructured.pipe(Logger.map((entry) => {
18
+ const line = JSON.stringify({
19
+ timestamp: entry.timestamp,
20
+ level: entry.level,
21
+ message: entry.message,
22
+ ...entry.annotations
23
+ });
24
+ try {
25
+ appendFileSync(logFile, `${line}\n`);
26
+ } catch {}
27
+ return line;
28
+ })) : void 0;
29
+ const loggers = fileLogger ? [stderrLogger, fileLogger] : [stderrLogger];
30
+ return Layer.merge(Logger.layer(loggers), Layer.succeed(References.MinimumLogLevel, level));
31
+ };
32
+ const LEVEL_ALIASES = {
33
+ warn: "Warn",
34
+ warning: "Warn",
35
+ error: "Error",
36
+ info: "Info",
37
+ debug: "Debug",
38
+ trace: "Trace",
39
+ fatal: "Fatal",
40
+ all: "All",
41
+ none: "None"
42
+ };
43
+ /**
44
+ * Resolve log level from option or environment variable.
45
+ * Priority: explicit option \> `VITEST_REPORTER_LOG_LEVEL` in `env` \> undefined.
46
+ *
47
+ * @param env - the environment map to consult (the front end passes `process.env`)
48
+ * @param option - explicit override
49
+ * @public
50
+ */
51
+ function resolveLogLevel(env, option) {
52
+ const raw = option ?? env.VITEST_REPORTER_LOG_LEVEL;
53
+ if (!raw) return void 0;
54
+ const normalized = LEVEL_ALIASES[raw.toLowerCase()] ?? `${raw.charAt(0).toUpperCase()}${raw.slice(1).toLowerCase()}`;
55
+ return LogLevel.values.includes(normalized) ? normalized : void 0;
56
+ }
57
+ /**
58
+ * Resolve log file from option or environment variable
59
+ * (`VITEST_REPORTER_LOG_FILE` in `env`).
60
+ *
61
+ * @param env - the environment map to consult (the front end passes `process.env`)
62
+ * @param option - explicit override
63
+ * @public
64
+ */
65
+ function resolveLogFile(env, option) {
66
+ return option ?? env.VITEST_REPORTER_LOG_FILE ?? void 0;
67
+ }
68
+
69
+ //#endregion
70
+ export { LoggerLive, resolveLogFile, resolveLogLevel };
@@ -0,0 +1,20 @@
1
+ import { DetailResolverLive } from "./DetailResolverLive.js";
2
+ import { EnvironmentDetectorLive } from "./EnvironmentDetectorLive.js";
3
+ import { ExecutorResolverLive } from "./ExecutorResolverLive.js";
4
+ import { FormatSelectorLive } from "./FormatSelectorLive.js";
5
+ import { OutputRendererLive } from "./OutputRendererLive.js";
6
+ import { Layer } from "effect";
7
+
8
+ //#region src/layers/OutputPipelineLive.ts
9
+ /**
10
+ * The output pipeline: environment detection, executor resolution, format
11
+ * selection, detail resolution and rendering.
12
+ *
13
+ * @param env - the environment map `EnvironmentDetectorLive` consults
14
+ * (the front end passes `process.env`)
15
+ * @public
16
+ */
17
+ const OutputPipelineLive = (env) => Layer.mergeAll(EnvironmentDetectorLive(env), ExecutorResolverLive, FormatSelectorLive, DetailResolverLive, OutputRendererLive);
18
+
19
+ //#endregion
20
+ export { OutputPipelineLive };
@@ -0,0 +1,23 @@
1
+ import { OutputRenderer } from "../services/OutputRenderer.js";
2
+ import { GfmFormatter, JsonFormatter, MarkdownFormatter, SilentFormatter, TerminalFormatter, ciAnnotationsFormatter } from "@vitest-agent/sdk";
3
+ import { Effect, Layer } from "effect";
4
+
5
+ //#region src/layers/OutputRendererLive.ts
6
+ const formatters = /* @__PURE__ */ new Map([
7
+ ["terminal", TerminalFormatter],
8
+ ["markdown", MarkdownFormatter],
9
+ ["gfm", GfmFormatter],
10
+ ["json", JsonFormatter],
11
+ ["silent", SilentFormatter],
12
+ ["vitest-bypass", SilentFormatter],
13
+ ["ci-annotations", ciAnnotationsFormatter]
14
+ ]);
15
+ /** @public */
16
+ const OutputRendererLive = Layer.succeed(OutputRenderer, { render: (reports, format, context) => Effect.sync(() => {
17
+ const formatter = formatters.get(format);
18
+ if (!formatter) return [];
19
+ return formatter.render(reports, context);
20
+ }) });
21
+
22
+ //#endregion
23
+ export { OutputRendererLive };
@@ -0,0 +1,46 @@
1
+ import { ConfigLive } from "./ConfigLive.js";
2
+ import { Layer } from "effect";
3
+ import { WorkspaceDiscovery, WorkspaceRoot } from "@effected/workspaces";
4
+ import { AppDirs, Xdg } from "@effected/xdg";
5
+
6
+ //#region src/layers/PathResolutionLive.ts
7
+ /**
8
+ * The XDG namespace every vitest-agent data directory lives under —
9
+ * `<XDG data>/vitest-agent`. The one place the name exists.
10
+ * @public
11
+ */
12
+ const APP_NAMESPACE = "vitest-agent";
13
+ /**
14
+ * `AppDirs` over the ambient `Xdg` environment, bound to a `const` so the
15
+ * layer memoizes by reference (calling `AppDirs.layer(...)` inline at two
16
+ * provide sites would resolve two independent services). Requires
17
+ * `FileSystem` + `Path` at the edge.
18
+ */
19
+ const AppDirsLive = AppDirs.layer({ namespace: APP_NAMESPACE }).pipe(Layer.provide(Xdg.layer));
20
+ /**
21
+ * Composite layer providing every service `resolveDataPath` requires:
22
+ * `AppDirs` (XDG path resolution), `VitestAgentConfigFile` (TOML
23
+ * config loader), and `WorkspaceDiscovery` / `WorkspaceRoot` (workspace name
24
+ * lookup).
25
+ *
26
+ * Callers still need to provide `FileSystem` and `Path` from
27
+ * `@effect/platform-node`'s `NodeServices.layer` (or the equivalent on Bun).
28
+ *
29
+ * `WorkspaceDiscovery` is pinned to `projectDir` as its `cwd`, which sets the
30
+ * root for the layer-bound methods (`listPackages`, `info`, `refresh`). The
31
+ * per-root methods (`listPackagesIn`, `infoIn`, `refreshIn`) take a directory
32
+ * per call and ignore that pin — `resolveWorkspaceKey` and
33
+ * `ProjectIdentityLive` use them, so a long-lived host built against one root
34
+ * still answers correctly for a git worktree or an unrelated project.
35
+ *
36
+ * @param projectDir - Absolute path inside the user's workspace, used to
37
+ * anchor the config file resolvers and workspace discovery.
38
+ * @public
39
+ */
40
+ const PathResolutionLive = (projectDir) => {
41
+ const WorkspaceMinimalLive = WorkspaceDiscovery.layer({ cwd: projectDir }).pipe(Layer.provide(WorkspaceRoot.layer));
42
+ return Layer.mergeAll(AppDirsLive, ConfigLive(projectDir), WorkspaceMinimalLive);
43
+ };
44
+
45
+ //#endregion
46
+ export { APP_NAMESPACE, PathResolutionLive };
@@ -0,0 +1,145 @@
1
+ import { PerClientSessionMapReader, PerClientSessionMapWriter } from "../services/PerClientSessionMap.js";
2
+ import { DataStoreError, extractSqlReason } from "@vitest-agent/sdk";
3
+ import { Effect, Layer, Option } from "effect";
4
+ import { SqlClient } from "effect/unstable/sql/SqlClient";
5
+ import { randomUUID } from "node:crypto";
6
+ import { basename } from "node:path";
7
+
8
+ //#region src/layers/PerClientSessionMapLive.ts
9
+ const sessionRowToContext = (row) => ({
10
+ hostSessionId: row.host_session_id,
11
+ conversationId: row.conversation_id,
12
+ projectKey: row.project_key,
13
+ projectDir: row.project_dir,
14
+ mainAgentId: row.main_agent_id,
15
+ startedAt: row.started_at,
16
+ endedAt: row.ended_at
17
+ });
18
+ /**
19
+ * Extract the transcript UUID from a transcript file path. Claude
20
+ * Code's transcript filenames are the conversation UUID directly
21
+ * (with optional `.jsonl` suffix), so the basename is the right
22
+ * stable key — path normalization differences (symlinks, user
23
+ * renames) don't fragment the table.
24
+ */
25
+ const extractTranscriptUuid = (transcriptPath) => {
26
+ const base = basename(transcriptPath);
27
+ const dotIndex = base.lastIndexOf(".");
28
+ return dotIndex === -1 ? base : base.slice(0, dotIndex);
29
+ };
30
+ const lookupConversationImpl = (sql, transcriptPath) => Effect.gen(function* () {
31
+ const rows = yield* sql`
32
+ SELECT transcript_uuid, transcript_path, conversation_id, first_seen_at, last_seen_at
33
+ FROM conversation_map WHERE transcript_uuid = ${extractTranscriptUuid(transcriptPath)}
34
+ `;
35
+ return rows.length === 0 ? Option.none() : Option.some(rows[0].conversation_id);
36
+ }).pipe(Effect.mapError((e) => new DataStoreError({
37
+ operation: "read",
38
+ table: "conversation_map",
39
+ reason: extractSqlReason(e)
40
+ })));
41
+ const lookupByProjectDirImpl = (sql, projectDir) => Effect.gen(function* () {
42
+ const rows = yield* sql`
43
+ SELECT host_session_id, conversation_id, project_key, project_dir, main_agent_id, started_at, ended_at
44
+ FROM session_map
45
+ WHERE project_dir = ${projectDir} AND ended_at IS NULL
46
+ ORDER BY started_at DESC
47
+ LIMIT 1
48
+ `;
49
+ return rows.length === 0 ? Option.none() : Option.some(sessionRowToContext(rows[0]));
50
+ }).pipe(Effect.mapError((e) => new DataStoreError({
51
+ operation: "read",
52
+ table: "session_map",
53
+ reason: extractSqlReason(e)
54
+ })));
55
+ /**
56
+ * Read-only layer. The entry point provides an `SqlClient` configured
57
+ * with the `?mode=ro` URI parameter; this layer wraps the read
58
+ * methods and never declares write methods.
59
+ * @public
60
+ */
61
+ const PerClientSessionMapReaderLive = Layer.effect(PerClientSessionMapReader, Effect.gen(function* () {
62
+ const sql = yield* SqlClient;
63
+ return {
64
+ lookupConversation: (path) => lookupConversationImpl(sql, path),
65
+ lookupByProjectDir: (dir) => lookupByProjectDirImpl(sql, dir)
66
+ };
67
+ }));
68
+ /**
69
+ * Read-write layer. Provides both write methods and the Reader's read
70
+ * methods so the same layer instance satisfies callers of either
71
+ * tag.
72
+ * @public
73
+ */
74
+ const PerClientSessionMapWriterLive = Layer.effect(PerClientSessionMapWriter, Effect.gen(function* () {
75
+ const sql = yield* SqlClient;
76
+ const mapConversation = (transcriptPath) => Effect.gen(function* () {
77
+ const uuid = extractTranscriptUuid(transcriptPath);
78
+ const existing = yield* sql`
79
+ SELECT conversation_id FROM conversation_map WHERE transcript_uuid = ${uuid}
80
+ `;
81
+ const now = Date.now();
82
+ if (existing.length > 0) {
83
+ yield* sql`UPDATE conversation_map SET last_seen_at = ${now} WHERE transcript_uuid = ${uuid}`;
84
+ return existing[0].conversation_id;
85
+ }
86
+ const conversationId = randomUUID();
87
+ yield* sql`
88
+ INSERT INTO conversation_map (transcript_uuid, transcript_path, conversation_id, first_seen_at, last_seen_at)
89
+ VALUES (${uuid}, ${transcriptPath}, ${conversationId}, ${now}, ${now})
90
+ `;
91
+ return conversationId;
92
+ }).pipe(Effect.mapError((e) => new DataStoreError({
93
+ operation: "write",
94
+ table: "conversation_map",
95
+ reason: extractSqlReason(e)
96
+ })));
97
+ const mapSession = (input) => Effect.gen(function* () {
98
+ const existing = yield* sql`
99
+ SELECT main_agent_id, conversation_id FROM session_map WHERE host_session_id = ${input.hostSessionId}
100
+ `;
101
+ if (existing.length > 0) return {
102
+ mainAgentId: existing[0].main_agent_id,
103
+ conversationId: existing[0].conversation_id
104
+ };
105
+ const mainAgentId = randomUUID();
106
+ const now = Date.now();
107
+ yield* sql`
108
+ INSERT INTO session_map (
109
+ host_session_id, conversation_id, project_key, project_dir, main_agent_id, started_at, ended_at
110
+ ) VALUES (
111
+ ${input.hostSessionId}, ${input.conversationId}, ${input.projectKey}, ${input.projectDir},
112
+ ${mainAgentId}, ${now}, ${null}
113
+ )
114
+ `;
115
+ return {
116
+ mainAgentId,
117
+ conversationId: input.conversationId
118
+ };
119
+ }).pipe(Effect.mapError((e) => new DataStoreError({
120
+ operation: "write",
121
+ table: "session_map",
122
+ reason: extractSqlReason(e)
123
+ })));
124
+ const endSession = (hostSessionId, endedAt) => sql`UPDATE session_map SET ended_at = ${endedAt} WHERE host_session_id = ${hostSessionId}`.pipe(Effect.asVoid, Effect.mapError((e) => new DataStoreError({
125
+ operation: "write",
126
+ table: "session_map",
127
+ reason: extractSqlReason(e)
128
+ })));
129
+ return {
130
+ mapConversation,
131
+ mapSession,
132
+ endSession,
133
+ lookupConversation: (path) => lookupConversationImpl(sql, path),
134
+ lookupByProjectDir: (dir) => lookupByProjectDirImpl(sql, dir)
135
+ };
136
+ })).pipe(Layer.provideMerge(Layer.effect(PerClientSessionMapReader, Effect.gen(function* () {
137
+ const sql = yield* SqlClient;
138
+ return {
139
+ lookupConversation: (path) => lookupConversationImpl(sql, path),
140
+ lookupByProjectDir: (dir) => lookupByProjectDirImpl(sql, dir)
141
+ };
142
+ }))));
143
+
144
+ //#endregion
145
+ export { PerClientSessionMapReaderLive, PerClientSessionMapWriterLive };
@@ -0,0 +1,67 @@
1
+ import { ProjectDiscovery } from "../services/ProjectDiscovery.js";
2
+ import { DiscoveryError, NON_DISCOVERABLE_DIRS } from "@vitest-agent/sdk";
3
+ import { Effect, FileSystem, Layer } from "effect";
4
+
5
+ //#region src/layers/ProjectDiscoveryLive.ts
6
+ const TEST_FILE_PATTERN = /\.(test|spec)\.(ts|tsx)$/;
7
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
8
+ ...NON_DISCOVERABLE_DIRS,
9
+ "coverage",
10
+ ".turbo",
11
+ ".vite"
12
+ ]);
13
+ function isTestFile(filePath) {
14
+ return TEST_FILE_PATTERN.test(filePath);
15
+ }
16
+ function testFileToSource(testFile) {
17
+ return testFile.replace(/\.(test|spec)\.(ts|tsx)$/, ".$2");
18
+ }
19
+ function walkDir(fs, dir) {
20
+ return Effect.gen(function* () {
21
+ const entries = yield* fs.readDirectory(dir);
22
+ const results = [];
23
+ for (const entry of entries) {
24
+ if (SKIP_DIRS.has(entry)) continue;
25
+ const fullPath = `${dir}/${entry}`;
26
+ if ((yield* fs.stat(fullPath)).type === "Directory") {
27
+ const subFiles = yield* walkDir(fs, fullPath);
28
+ results.push(...subFiles);
29
+ } else results.push(fullPath);
30
+ }
31
+ return results;
32
+ });
33
+ }
34
+ /** @public */
35
+ const ProjectDiscoveryLive = Layer.effect(ProjectDiscovery, Effect.gen(function* () {
36
+ const fs = yield* FileSystem.FileSystem;
37
+ const mapTestToSource = (testFile) => Effect.gen(function* () {
38
+ const sourceFile = testFileToSource(testFile);
39
+ return (yield* fs.exists(sourceFile)) ? [sourceFile] : [];
40
+ }).pipe(Effect.mapError((error) => new DiscoveryError({
41
+ operation: "stat",
42
+ path: testFile,
43
+ reason: String(error)
44
+ })));
45
+ return {
46
+ discoverTestFiles: (rootDir) => Effect.gen(function* () {
47
+ const testFiles = (yield* walkDir(fs, rootDir).pipe(Effect.mapError((error) => new DiscoveryError({
48
+ operation: "glob",
49
+ path: rootDir,
50
+ reason: String(error)
51
+ })))).filter(isTestFile);
52
+ const entries = [];
53
+ for (const testFile of testFiles) {
54
+ const sourceFiles = yield* mapTestToSource(testFile);
55
+ entries.push({
56
+ testFile,
57
+ sourceFiles
58
+ });
59
+ }
60
+ return entries;
61
+ }),
62
+ mapTestToSource
63
+ };
64
+ }));
65
+
66
+ //#endregion
67
+ export { ProjectDiscoveryLive };
@@ -0,0 +1,12 @@
1
+ import { ProjectDiscovery } from "../services/ProjectDiscovery.js";
2
+ import { Effect, Layer } from "effect";
3
+
4
+ //#region src/layers/ProjectDiscoveryTest.ts
5
+ /** @public */
6
+ const ProjectDiscoveryTest = { layer: (entries) => Layer.succeed(ProjectDiscovery, {
7
+ discoverTestFiles: () => Effect.succeed(entries),
8
+ mapTestToSource: (testFile) => Effect.succeed(entries.find((e) => e.testFile === testFile)?.sourceFiles ?? [])
9
+ }) };
10
+
11
+ //#endregion
12
+ export { ProjectDiscoveryTest };