@vitest-agent/sdk 1.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.
Files changed (121) hide show
  1. package/DataReader-2kJ9I9Hh.d.ts +1809 -0
  2. package/LICENSE +21 -0
  3. package/README.md +52 -0
  4. package/dispatch.d.ts +48 -0
  5. package/dispatch.js +5 -0
  6. package/errors/AgentErrors.js +65 -0
  7. package/errors/DataStoreError.js +44 -0
  8. package/errors/DiscoveryError.js +21 -0
  9. package/errors/PathResolutionError.js +27 -0
  10. package/errors/ProjectIdentityError.js +29 -0
  11. package/errors/RunContextError.js +35 -0
  12. package/errors/TddErrors.js +66 -0
  13. package/exit-code-for-tag.js +18 -0
  14. package/formatters/ci-annotations.js +61 -0
  15. package/formatters/gfm.js +20 -0
  16. package/formatters/json.js +18 -0
  17. package/formatters/markdown.js +39 -0
  18. package/formatters/silent.js +9 -0
  19. package/formatters/terminal.js +56 -0
  20. package/index.d.ts +3334 -0
  21. package/index.js +120 -0
  22. package/internal-inject-env.js +42 -0
  23. package/layers/ConfigLive.js +47 -0
  24. package/layers/DataReaderLive.js +1666 -0
  25. package/layers/DataStoreLive.js +1185 -0
  26. package/layers/DetailResolverLive.js +9 -0
  27. package/layers/DiscoveryRegistryLive.js +70 -0
  28. package/layers/EnvironmentDetectorLive.js +21 -0
  29. package/layers/EnvironmentDetectorTest.js +13 -0
  30. package/layers/ExecutorResolverLive.js +9 -0
  31. package/layers/FormatSelectorLive.js +20 -0
  32. package/layers/HistoryTrackerLive.js +47 -0
  33. package/layers/HistoryTrackerTest.js +25 -0
  34. package/layers/LoggerLive.js +64 -0
  35. package/layers/OutputPipelineLive.js +13 -0
  36. package/layers/OutputRendererLive.js +28 -0
  37. package/layers/PathResolutionLive.js +36 -0
  38. package/layers/PerClientSessionMapLive.js +145 -0
  39. package/layers/ProjectDiscoveryLive.js +70 -0
  40. package/layers/ProjectDiscoveryTest.js +12 -0
  41. package/layers/ProjectIdentityLive.js +92 -0
  42. package/layers/RunContextLive.js +81 -0
  43. package/lib/format-triage.js +103 -0
  44. package/lib/format-wrapup.js +67 -0
  45. package/migrations/0001_initial.js +849 -0
  46. package/migrations/registry_0001_initial.js +39 -0
  47. package/migrations/session_map_0001_initial.js +61 -0
  48. package/package.json +64 -0
  49. package/schemas/Agent.js +42 -0
  50. package/schemas/AgentReport.js +71 -0
  51. package/schemas/Baselines.js +16 -0
  52. package/schemas/CacheManifest.js +27 -0
  53. package/schemas/ChannelEvent.js +138 -0
  54. package/schemas/Common.js +105 -0
  55. package/schemas/Config.js +28 -0
  56. package/schemas/Coverage.js +51 -0
  57. package/schemas/CoverageLevel.js +117 -0
  58. package/schemas/CoverageTargets.js +39 -0
  59. package/schemas/History.js +31 -0
  60. package/schemas/Identity.js +64 -0
  61. package/schemas/Options.js +57 -0
  62. package/schemas/RenderState.js +161 -0
  63. package/schemas/RunEvent.js +163 -0
  64. package/schemas/Tdd.js +44 -0
  65. package/schemas/Thresholds.js +30 -0
  66. package/schemas/Transport.js +16 -0
  67. package/schemas/Trends.js +23 -0
  68. package/schemas/turns/FileEditPayload.js +15 -0
  69. package/schemas/turns/HookFirePayload.js +13 -0
  70. package/schemas/turns/HypothesisPayload.js +13 -0
  71. package/schemas/turns/NotePayload.js +13 -0
  72. package/schemas/turns/ToolCallPayload.js +13 -0
  73. package/schemas/turns/ToolResultPayload.js +15 -0
  74. package/schemas/turns/UserPromptPayload.js +12 -0
  75. package/schemas/turns/index.js +15 -0
  76. package/services/Config.js +15 -0
  77. package/services/DataReader.js +8 -0
  78. package/services/DataStore.js +8 -0
  79. package/services/DetailResolver.js +8 -0
  80. package/services/DiscoveryRegistry.js +8 -0
  81. package/services/EnvironmentDetector.js +8 -0
  82. package/services/ExecutorResolver.js +8 -0
  83. package/services/FormatSelector.js +8 -0
  84. package/services/HistoryTracker.js +8 -0
  85. package/services/OutputRenderer.js +8 -0
  86. package/services/PerClientSessionMap.js +22 -0
  87. package/services/ProjectDiscovery.js +8 -0
  88. package/services/ProjectIdentity.js +63 -0
  89. package/services/RunContext.js +53 -0
  90. package/services/idempotency.js +57 -0
  91. package/sidecar-dispatch.js +84 -0
  92. package/sql/assemblers.js +59 -0
  93. package/testing/layers.js +21 -0
  94. package/testing.d.ts +25 -0
  95. package/testing.js +243 -0
  96. package/tsdoc-metadata.json +11 -0
  97. package/utils/ansi.js +49 -0
  98. package/utils/build-report.js +130 -0
  99. package/utils/canonicalize-git-url.js +66 -0
  100. package/utils/classify-test.js +22 -0
  101. package/utils/compress-lines.js +45 -0
  102. package/utils/compute-trend.js +88 -0
  103. package/utils/detect-pm.js +58 -0
  104. package/utils/detect-timeout.js +17 -0
  105. package/utils/ensure-migrated.js +52 -0
  106. package/utils/failure-signature.js +33 -0
  107. package/utils/format-console.js +219 -0
  108. package/utils/format-fatal-error.js +35 -0
  109. package/utils/format-gfm.js +161 -0
  110. package/utils/format-terminal.js +480 -0
  111. package/utils/function-boundary.js +75 -0
  112. package/utils/hyperlink.js +11 -0
  113. package/utils/match-vitest-command.js +88 -0
  114. package/utils/normalize-workspace-key.js +34 -0
  115. package/utils/probe-host-metadata.js +99 -0
  116. package/utils/resolve-data-path.js +66 -0
  117. package/utils/resolve-project-key-from-cwd.js +52 -0
  118. package/utils/resolve-workspace-key.js +33 -0
  119. package/utils/safe-filename.js +34 -0
  120. package/utils/validate-coverage-targets-shape.js +84 -0
  121. package/utils/validate-phase-transition.js +113 -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 { DataStoreError, extractSqlReason } from "../errors/DataStoreError.js";
2
+ import { DiscoveryRegistry } from "../services/DiscoveryRegistry.js";
3
+ import { Effect, Layer } from "effect";
4
+ import { SqlClient } from "@effect/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 = 3600 * 24 * 1e3;
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,21 @@
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 = () => process.env.GITHUB_ACTIONS === "true" || process.env.GITHUB_ACTIONS === "1";
7
+ const isCI = () => isGitHub() || process.env.CI === "true";
8
+ /** @public */
9
+ const EnvironmentDetectorLive = Layer.succeed(EnvironmentDetector, {
10
+ detect: () => Effect.sync(() => {
11
+ if (isAgent) return "agent-shell";
12
+ if (isGitHub()) return "ci-github";
13
+ if (isCI()) return "ci-generic";
14
+ return "terminal";
15
+ }),
16
+ isAgent: Effect.sync(() => isAgent),
17
+ agentName: Effect.sync(() => agent ?? void 0)
18
+ });
19
+
20
+ //#endregion
21
+ export { EnvironmentDetectorLive };
@@ -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,47 @@
1
+ import { DataReader } from "../services/DataReader.js";
2
+ import { HistoryTracker } from "../services/HistoryTracker.js";
3
+ import { classifyTest } from "../utils/classify-test.js";
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(entry.fullName, {
15
+ ...entry,
16
+ runs: [...entry.runs]
17
+ });
18
+ const classifications = /* @__PURE__ */ new Map();
19
+ for (const outcome of testOutcomes) {
20
+ let entry = testMap.get(outcome.fullName);
21
+ if (!entry) {
22
+ entry = {
23
+ fullName: outcome.fullName,
24
+ runs: []
25
+ };
26
+ testMap.set(outcome.fullName, entry);
27
+ }
28
+ const priorRuns = entry.runs;
29
+ entry.runs = [{
30
+ timestamp,
31
+ state: outcome.state
32
+ }, ...priorRuns].slice(0, WINDOW_SIZE);
33
+ classifications.set(outcome.fullName, classifyTest(outcome.state, priorRuns));
34
+ }
35
+ return {
36
+ history: {
37
+ project,
38
+ updatedAt: timestamp,
39
+ tests: Array.from(testMap.values())
40
+ },
41
+ classifications
42
+ };
43
+ }) };
44
+ }));
45
+
46
+ //#endregion
47
+ 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,64 @@
1
+ import { Layer, LogLevel, Logger } 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 LogLevel.None, replaces the default logger with Logger.none (silent).
9
+ * When logFile is set, composes with a file logger via Logger.zip.
10
+ * Uses Logger.structuredLogger serialized to JSON for machine-readable output.
11
+ * @public
12
+ */
13
+ const LoggerLive = (level, logFile) => {
14
+ if (!level || level._tag === "None") return Logger.replace(Logger.defaultLogger, Logger.none);
15
+ const stderrLogger = Logger.structuredLogger.pipe(Logger.map((entry) => JSON.stringify(entry)), Logger.withConsoleError);
16
+ const baseLogger = logFile ? (() => {
17
+ const fileLogger = Logger.make(({ message, date, logLevel, annotations }) => {
18
+ const entry = JSON.stringify({
19
+ timestamp: date.toISOString(),
20
+ level: logLevel._tag,
21
+ message,
22
+ ...Object.fromEntries(annotations)
23
+ });
24
+ try {
25
+ appendFileSync(logFile, `${entry}\n`);
26
+ } catch {}
27
+ });
28
+ return Logger.zip(stderrLogger, fileLogger).pipe(Logger.map(() => void 0));
29
+ })() : stderrLogger;
30
+ return Layer.merge(Logger.replace(Logger.defaultLogger, baseLogger), Logger.minimumLogLevel(level));
31
+ };
32
+ /**
33
+ * Resolve log level from option or environment variable.
34
+ * Priority: explicit option \> VITEST_REPORTER_LOG_LEVEL env var \> undefined
35
+ * @public
36
+ */
37
+ const LEVEL_ALIASES = {
38
+ warn: "Warning",
39
+ error: "Error",
40
+ info: "Info",
41
+ debug: "Debug",
42
+ trace: "Trace",
43
+ fatal: "Fatal",
44
+ all: "All",
45
+ none: "None",
46
+ warning: "Warning"
47
+ };
48
+ /** @public */
49
+ function resolveLogLevel(option) {
50
+ const raw = option ?? process.env.VITEST_REPORTER_LOG_LEVEL;
51
+ if (!raw) return void 0;
52
+ const normalized = LEVEL_ALIASES[raw.toLowerCase()] ?? `${raw.charAt(0).toUpperCase()}${raw.slice(1).toLowerCase()}`;
53
+ return LogLevel.fromLiteral(normalized);
54
+ }
55
+ /**
56
+ * Resolve log file from option or environment variable.
57
+ * @public
58
+ */
59
+ function resolveLogFile(option) {
60
+ return option ?? process.env.VITEST_REPORTER_LOG_FILE ?? void 0;
61
+ }
62
+
63
+ //#endregion
64
+ export { LoggerLive, resolveLogFile, resolveLogLevel };
@@ -0,0 +1,13 @@
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
+ /** @public */
10
+ const OutputPipelineLive = Layer.mergeAll(EnvironmentDetectorLive, ExecutorResolverLive, FormatSelectorLive, DetailResolverLive, OutputRendererLive);
11
+
12
+ //#endregion
13
+ export { OutputPipelineLive };
@@ -0,0 +1,28 @@
1
+ import { ciAnnotationsFormatter } from "../formatters/ci-annotations.js";
2
+ import { GfmFormatter } from "../formatters/gfm.js";
3
+ import { JsonFormatter } from "../formatters/json.js";
4
+ import { MarkdownFormatter } from "../formatters/markdown.js";
5
+ import { SilentFormatter } from "../formatters/silent.js";
6
+ import { TerminalFormatter } from "../formatters/terminal.js";
7
+ import { OutputRenderer } from "../services/OutputRenderer.js";
8
+ import { Effect, Layer } from "effect";
9
+
10
+ //#region src/layers/OutputRendererLive.ts
11
+ const formatters = /* @__PURE__ */ new Map([
12
+ ["terminal", TerminalFormatter],
13
+ ["markdown", MarkdownFormatter],
14
+ ["gfm", GfmFormatter],
15
+ ["json", JsonFormatter],
16
+ ["silent", SilentFormatter],
17
+ ["vitest-bypass", SilentFormatter],
18
+ ["ci-annotations", ciAnnotationsFormatter]
19
+ ]);
20
+ /** @public */
21
+ const OutputRendererLive = Layer.succeed(OutputRenderer, { render: (reports, format, context) => Effect.sync(() => {
22
+ const formatter = formatters.get(format);
23
+ if (!formatter) return [];
24
+ return formatter.render(reports, context);
25
+ }) });
26
+
27
+ //#endregion
28
+ export { OutputRendererLive };
@@ -0,0 +1,36 @@
1
+ import { ConfigLive } from "./ConfigLive.js";
2
+ import { Layer } from "effect";
3
+ import { WorkspaceDiscoveryLive, WorkspaceRootLive } from "workspaces-effect";
4
+ import { AppDirsConfig, XdgLive } from "xdg-effect";
5
+
6
+ //#region src/layers/PathResolutionLive.ts
7
+ const APP_NAMESPACE = "vitest-agent";
8
+ /**
9
+ * Minimal slice of `workspaces-effect` for `resolveWorkspaceKey`.
10
+ *
11
+ * `WorkspacesLive` from `workspaces-effect` composes seven services and
12
+ * runs eager I/O at layer construction in `LockfileReaderLive` (reads and
13
+ * parses the entire lockfile). We only call `WorkspaceDiscovery.listPackages`
14
+ * to read the root `package.json`'s `name` — `WorkspaceRoot` is the only
15
+ * upstream dependency `WorkspaceDiscoveryLive` actually needs. Skipping
16
+ * the rest avoids the lockfile-read cost on every reporter init, which
17
+ * otherwise fires once per Vitest project (5+ reads on monorepos).
18
+ */
19
+ const WorkspaceMinimalLive = WorkspaceDiscoveryLive.pipe(Layer.provide(WorkspaceRootLive));
20
+ /**
21
+ * Composite layer providing every service `resolveDataPath` requires:
22
+ * `AppDirs` (XDG path resolution), `VitestAgentConfigFile` (TOML
23
+ * config loader), `WorkspaceDiscovery` and `WorkspaceRoot` (workspace name
24
+ * lookup).
25
+ *
26
+ * Callers still need to provide `FileSystem` and `Path` from
27
+ * `@effect/platform-node`'s `NodeContext.layer` (or the equivalent on Bun).
28
+ *
29
+ * @param projectDir - Absolute path inside the user's workspace, used to
30
+ * anchor the config file resolvers.
31
+ * @public
32
+ */
33
+ const PathResolutionLive = (projectDir) => Layer.mergeAll(XdgLive(new AppDirsConfig({ namespace: APP_NAMESPACE })), ConfigLive(projectDir), WorkspaceMinimalLive);
34
+
35
+ //#endregion
36
+ export { PathResolutionLive };
@@ -0,0 +1,145 @@
1
+ import { DataStoreError, extractSqlReason } from "../errors/DataStoreError.js";
2
+ import { PerClientSessionMapReader, PerClientSessionMapWriter } from "../services/PerClientSessionMap.js";
3
+ import { Effect, Layer, Option } from "effect";
4
+ import { basename } from "node:path";
5
+ import { SqlClient } from "@effect/sql/SqlClient";
6
+ import { randomUUID } from "node:crypto";
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,70 @@
1
+ import { DiscoveryError } from "../errors/DiscoveryError.js";
2
+ import { ProjectDiscovery } from "../services/ProjectDiscovery.js";
3
+ import { Effect, Layer } from "effect";
4
+ import { FileSystem } from "@effect/platform";
5
+
6
+ //#region src/layers/ProjectDiscoveryLive.ts
7
+ const TEST_FILE_PATTERN = /\.(test|spec)\.(ts|tsx)$/;
8
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
9
+ "node_modules",
10
+ ".git",
11
+ "dist",
12
+ "coverage",
13
+ ".turbo",
14
+ ".vite"
15
+ ]);
16
+ function isTestFile(filePath) {
17
+ return TEST_FILE_PATTERN.test(filePath);
18
+ }
19
+ function testFileToSource(testFile) {
20
+ return testFile.replace(/\.(test|spec)\.(ts|tsx)$/, ".$2");
21
+ }
22
+ function walkDir(fs, dir) {
23
+ return Effect.gen(function* () {
24
+ const entries = yield* fs.readDirectory(dir);
25
+ const results = [];
26
+ for (const entry of entries) {
27
+ if (SKIP_DIRS.has(entry)) continue;
28
+ const fullPath = `${dir}/${entry}`;
29
+ if ((yield* fs.stat(fullPath)).type === "Directory") {
30
+ const subFiles = yield* walkDir(fs, fullPath);
31
+ results.push(...subFiles);
32
+ } else results.push(fullPath);
33
+ }
34
+ return results;
35
+ });
36
+ }
37
+ /** @public */
38
+ const ProjectDiscoveryLive = Layer.effect(ProjectDiscovery, Effect.gen(function* () {
39
+ const fs = yield* FileSystem.FileSystem;
40
+ const mapTestToSource = (testFile) => Effect.gen(function* () {
41
+ const sourceFile = testFileToSource(testFile);
42
+ return (yield* fs.exists(sourceFile)) ? [sourceFile] : [];
43
+ }).pipe(Effect.mapError((error) => new DiscoveryError({
44
+ operation: "stat",
45
+ path: testFile,
46
+ reason: String(error)
47
+ })));
48
+ return {
49
+ discoverTestFiles: (rootDir) => Effect.gen(function* () {
50
+ const testFiles = (yield* walkDir(fs, rootDir).pipe(Effect.mapError((error) => new DiscoveryError({
51
+ operation: "glob",
52
+ path: rootDir,
53
+ reason: String(error)
54
+ })))).filter(isTestFile);
55
+ const entries = [];
56
+ for (const testFile of testFiles) {
57
+ const sourceFiles = yield* mapTestToSource(testFile);
58
+ entries.push({
59
+ testFile,
60
+ sourceFiles
61
+ });
62
+ }
63
+ return entries;
64
+ }),
65
+ mapTestToSource
66
+ };
67
+ }));
68
+
69
+ //#endregion
70
+ 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 };