@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,30 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/unstable/sql/SqlClient";
3
+
4
+ //#region src/migrations/0002_test_artifacts.ts
5
+ /** @internal */
6
+ const migration = Effect.gen(function* () {
7
+ const sql = yield* SqlClient;
8
+ yield* sql`DROP INDEX IF EXISTS idx_test_annotations_case`;
9
+ yield* sql`DROP INDEX IF EXISTS idx_test_annotations_type`;
10
+ yield* sql`DROP TABLE IF EXISTS test_annotations`;
11
+ yield* sql`
12
+ CREATE TABLE test_annotations (
13
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14
+ test_case_id INTEGER NOT NULL REFERENCES test_cases(id) ON DELETE CASCADE,
15
+ type TEXT NOT NULL,
16
+ message TEXT NOT NULL,
17
+ location_file_id INTEGER REFERENCES files(id),
18
+ location_line INTEGER,
19
+ location_column INTEGER
20
+ )
21
+ `;
22
+ yield* sql`CREATE INDEX idx_test_annotations_case ON test_annotations(test_case_id)`;
23
+ yield* sql`CREATE INDEX idx_test_annotations_type ON test_annotations(type)`;
24
+ yield* sql`ALTER TABLE test_artifacts ADD COLUMN data TEXT`;
25
+ yield* sql`ALTER TABLE attachments ADD COLUMN byte_size INTEGER`;
26
+ yield* sql`ALTER TABLE attachments ADD COLUMN body_encoding TEXT`;
27
+ });
28
+
29
+ //#endregion
30
+ export { migration as default };
@@ -0,0 +1,27 @@
1
+ import migration from "./0001_initial.js";
2
+ import migration$1 from "./0002_test_artifacts.js";
3
+
4
+ //#region src/migrations/index.ts
5
+ /**
6
+ * Every migration that must run against a per-project `data.db`, keyed by
7
+ * migration id in application order.
8
+ *
9
+ * This is the single source of truth for the project database's migration
10
+ * set: `makeSqliteStack` (and through it `PlatformLive`, `ensureMigrated`,
11
+ * the testing layers, `ReporterLive` and `SidecarLive`) defaults to it when
12
+ * calling `SqliteMigrator.fromRecord`, so adding a migration file and
13
+ * registering it here is enough to reach every process that opens a project
14
+ * database.
15
+ *
16
+ * The session-map and discovery-registry databases are separate schemas with
17
+ * their own migrations and are deliberately not listed here.
18
+ *
19
+ * @public
20
+ */
21
+ const PROJECT_MIGRATIONS = {
22
+ "0001_initial": migration,
23
+ "0002_test_artifacts": migration$1
24
+ };
25
+
26
+ //#endregion
27
+ export { PROJECT_MIGRATIONS };
@@ -0,0 +1,39 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/unstable/sql/SqlClient";
3
+
4
+ //#region src/migrations/registry_0001_initial.ts
5
+ /**
6
+ * Migration for the global discovery registry SQLite at
7
+ * `$XDG_DATA_HOME/vitest-agent/registry.db`.
8
+ *
9
+ * Single table `known_projects` indexed by `project_key` (the
10
+ * filesystem-safe form from {@link ProjectIdentity}). Tooling like
11
+ * `mcp-app` queries this table to enumerate every vitest-agent
12
+ * project the user has ever run on this machine.
13
+ *
14
+ * The registry never blocks anything — its writes are best-effort.
15
+ * Concurrent writers from multiple Claude Code windows converge via
16
+ * the `ON CONFLICT(project_key) DO UPDATE` upsert.
17
+ * @public
18
+ */
19
+ const migration = Effect.gen(function* () {
20
+ const sql = yield* SqlClient;
21
+ yield* sql`PRAGMA journal_mode=WAL`;
22
+ yield* sql`PRAGMA foreign_keys=ON`;
23
+ yield* sql`PRAGMA busy_timeout=5000`;
24
+ yield* sql`
25
+ CREATE TABLE known_projects (
26
+ project_key TEXT PRIMARY KEY,
27
+ canonical_form TEXT NOT NULL,
28
+ data_db_path TEXT NOT NULL,
29
+ git_remote_origin TEXT,
30
+ workspace_root TEXT NOT NULL,
31
+ first_seen_at INTEGER NOT NULL,
32
+ last_seen_at INTEGER NOT NULL
33
+ ) STRICT
34
+ `;
35
+ yield* sql`CREATE INDEX idx_known_projects_workspace_root ON known_projects(workspace_root)`;
36
+ });
37
+
38
+ //#endregion
39
+ export { migration as default };
@@ -0,0 +1,38 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/unstable/sql/SqlClient";
3
+
4
+ //#region src/migrations/session_map_0001_initial.ts
5
+ /** @internal */
6
+ const migration = Effect.gen(function* () {
7
+ const sql = yield* SqlClient;
8
+ yield* sql`PRAGMA journal_mode=WAL`;
9
+ yield* sql`PRAGMA foreign_keys=ON`;
10
+ yield* sql`PRAGMA busy_timeout=5000`;
11
+ yield* sql`
12
+ CREATE TABLE conversation_map (
13
+ transcript_uuid TEXT PRIMARY KEY,
14
+ transcript_path TEXT NOT NULL,
15
+ conversation_id TEXT NOT NULL UNIQUE,
16
+ first_seen_at INTEGER NOT NULL,
17
+ last_seen_at INTEGER NOT NULL
18
+ ) STRICT
19
+ `;
20
+ yield* sql`
21
+ CREATE TABLE session_map (
22
+ host_session_id TEXT PRIMARY KEY,
23
+ conversation_id TEXT NOT NULL REFERENCES conversation_map(conversation_id) ON DELETE RESTRICT,
24
+ project_key TEXT NOT NULL,
25
+ project_dir TEXT NOT NULL,
26
+ main_agent_id TEXT NOT NULL,
27
+ started_at INTEGER NOT NULL,
28
+ ended_at INTEGER
29
+ ) STRICT
30
+ `;
31
+ yield* sql`CREATE INDEX idx_session_map_project_key ON session_map(project_key)`;
32
+ yield* sql`CREATE INDEX idx_session_map_project_dir ON session_map(project_dir)`;
33
+ yield* sql`CREATE INDEX idx_session_map_active ON session_map(project_dir) WHERE ended_at IS NULL`;
34
+ yield* sql`CREATE INDEX idx_session_map_conversation_id ON session_map(conversation_id)`;
35
+ });
36
+
37
+ //#endregion
38
+ export { migration as default };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@vitest-agent/engine",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Shared services, layers, SQLite data layer, migrations and platform resolution for the vitest-agent CLI and MCP server.",
6
+ "keywords": [
7
+ "vitest",
8
+ "agent",
9
+ "shared",
10
+ "effect",
11
+ "sqlite"
12
+ ],
13
+ "homepage": "https://github.com/spencerbeggs/vitest-agent#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/spencerbeggs/vitest-agent/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/spencerbeggs/vitest-agent.git",
20
+ "directory": "packages/engine"
21
+ },
22
+ "license": "MIT",
23
+ "author": {
24
+ "name": "C. Spencer Beggs",
25
+ "email": "spencer@beggs.codes",
26
+ "url": "https://spencerbeg.gs"
27
+ },
28
+ "type": "module",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./index.d.ts",
32
+ "import": "./index.js",
33
+ "default": "./index.js"
34
+ },
35
+ "./testing": {
36
+ "types": "./testing.d.ts",
37
+ "import": "./testing.js",
38
+ "default": "./testing.js"
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "dependencies": {
43
+ "@effect/platform-node": "4.0.0-rc.115",
44
+ "@effect/sql-sqlite-node": "4.0.0-rc.115",
45
+ "@effected/config-file": "^0.9.0",
46
+ "@effected/jsonc": "^0.11.0",
47
+ "@effected/toml": "^0.7.0",
48
+ "@effected/walker": "^0.9.0",
49
+ "@effected/workspaces": "^0.21.1",
50
+ "@effected/xdg": "^0.5.1",
51
+ "@effected/yaml": "^0.15.1",
52
+ "@vitest-agent/sdk": "4.0.0",
53
+ "effect": "4.0.0-rc.115",
54
+ "std-env": "^4.2.0"
55
+ },
56
+ "engines": {
57
+ "node": ">=24.11.0"
58
+ }
59
+ }
package/platform.js ADDED
@@ -0,0 +1,58 @@
1
+ import { DataReaderLive } from "./layers/DataReaderLive.js";
2
+ import { DataStoreLive } from "./layers/DataStoreLive.js";
3
+ import { HistoryTrackerLive } from "./layers/HistoryTrackerLive.js";
4
+ import { LoggerLive } from "./layers/LoggerLive.js";
5
+ import { OutputPipelineLive } from "./layers/OutputPipelineLive.js";
6
+ import { ProjectDiscoveryLive } from "./layers/ProjectDiscoveryLive.js";
7
+ import { PROJECT_MIGRATIONS } from "./migrations/index.js";
8
+ import { Layer } from "effect";
9
+ import * as NodeServices from "@effect/platform-node/NodeServices";
10
+ import { layer } from "@effect/sql-sqlite-node/SqliteClient";
11
+ import * as SqliteMigrator from "@effect/sql-sqlite-node/SqliteMigrator";
12
+
13
+ //#region src/platform.ts
14
+ /**
15
+ * The Node platform services every SQLite stack and every Live layer share.
16
+ * `NodeServices.layer` aggregates FileSystem | Path | ChildProcessSpawner |
17
+ * Crypto | Stdio | Terminal.
18
+ * @public
19
+ */
20
+ const NodePlatformLayer = NodeServices.layer;
21
+ /**
22
+ * Build one SQLite database stack: the client layer for `filename` and a
23
+ * migrator over `migrations` that is already provided with that client and
24
+ * the Node platform services. The single assembly the CLI, MCP server,
25
+ * plugin, `ensureMigrated` and the testing layers all share.
26
+ *
27
+ * @param filename - SQLite file path, or `":memory:"`
28
+ * @param migrations - the migration record to run; defaults to the project
29
+ * `data.db` set (`PROJECT_MIGRATIONS`)
30
+ * @public
31
+ */
32
+ const makeSqliteStack = (filename, migrations = PROJECT_MIGRATIONS) => {
33
+ const SqliteLayer = layer({ filename });
34
+ return {
35
+ SqliteLayer,
36
+ MigratorLayer: SqliteMigrator.layer({ loader: SqliteMigrator.fromRecord(migrations) }).pipe(Layer.provide(Layer.merge(SqliteLayer, NodePlatformLayer)))
37
+ };
38
+ };
39
+ /**
40
+ * The one platform assembly shared by the CLI, the MCP server and the Vitest
41
+ * plugin: SQLite + migrator + Node platform services + Logger, with
42
+ * `DataReader`, `DataStore`, `ProjectDiscovery`, `HistoryTracker` and the
43
+ * output pipeline (`EnvironmentDetector`, `ExecutorResolver`,
44
+ * `FormatSelector`, `DetailResolver`, `OutputRenderer`) built over them.
45
+ *
46
+ * Every env read goes through `options.env`; the engine never touches
47
+ * `process` itself.
48
+ *
49
+ * @param options - database path, env map and optional logging overrides
50
+ * @public
51
+ */
52
+ const PlatformLive = (options) => {
53
+ const { SqliteLayer, MigratorLayer } = makeSqliteStack(options.dbPath);
54
+ return Layer.mergeAll(ProjectDiscoveryLive, HistoryTrackerLive, OutputPipelineLive(options.env)).pipe(Layer.provideMerge(DataReaderLive), Layer.provideMerge(DataStoreLive), Layer.provideMerge(MigratorLayer), Layer.provideMerge(SqliteLayer), Layer.provideMerge(NodePlatformLayer), Layer.provideMerge(LoggerLive(options.logLevel, options.logFile)));
55
+ };
56
+
57
+ //#endregion
58
+ export { NodePlatformLayer, PlatformLive, makeSqliteStack };
@@ -0,0 +1,19 @@
1
+ import { DataStore } from "../services/DataStore.js";
2
+ import { PerClientSessionMapWriter } from "../services/PerClientSessionMap.js";
3
+ import { Effect } from "effect";
4
+
5
+ //#region src/programs/end-agent.ts
6
+ /**
7
+ * End an agent. Closes the `agents` row, optionally also closes the
8
+ * `session_map` row.
9
+ *
10
+ * @param input - the agent id, end timestamp, and optional host session id
11
+ * @public
12
+ */
13
+ const endAgentEffect = (input) => Effect.gen(function* () {
14
+ yield* (yield* DataStore).endAgent(input.agentId, input.endedAt);
15
+ if (input.hostSessionId !== void 0) yield* (yield* PerClientSessionMapWriter).endSession(input.hostSessionId, input.endedAt);
16
+ });
17
+
18
+ //#endregion
19
+ export { endAgentEffect };
@@ -0,0 +1,115 @@
1
+ import { APP_NAMESPACE } from "../layers/PathResolutionLive.js";
2
+ import { ProjectIdentityNotResolvableError } from "@vitest-agent/sdk";
3
+ import { ConfigProvider, Effect, FileSystem, Layer, Path } from "effect";
4
+ import { AppDirs, Xdg } from "@effected/xdg";
5
+
6
+ //#region src/programs/hook-paths.ts
7
+ /**
8
+ * Filename of the per-project test-data SQLite database.
9
+ *
10
+ * @public
11
+ */
12
+ const DATA_DB_FILENAME = "data.db";
13
+ /**
14
+ * Filename of the per-client session-map SQLite database.
15
+ *
16
+ * @public
17
+ */
18
+ const SESSIONS_DB_FILENAME = "sessions.db";
19
+ /**
20
+ * Filename of the global discovery-registry SQLite database.
21
+ *
22
+ * @public
23
+ */
24
+ const REGISTRY_DB_FILENAME = "registry.db";
25
+ /**
26
+ * Where the XDG data root lands when `XDG_DATA_HOME` is unset:
27
+ * `$HOME/.local/share/vitest-agent` — the XDG spec default the sidecar has
28
+ * always used. (`AppDirs` alone would fall back to `$HOME/.vitest-agent`.)
29
+ */
30
+ const HOOK_DATA_FALLBACK_DIR = `.local/share/${APP_NAMESPACE}`;
31
+ /** Directory under the home dir that holds the per-client `sessions.db` fallback. */
32
+ const SESSION_MAP_HOME_DIR = `.${APP_NAMESPACE}`;
33
+ const nonEmpty = (value) => value !== void 0 && value.length > 0 ? value : void 0;
34
+ /**
35
+ * `AppDirs` for the hook family, resolved from the caller's `env` map.
36
+ *
37
+ * `Xdg.layer` reads `HOME` and `XDG_DATA_HOME` through the ambient
38
+ * `ConfigProvider`, so the env map is installed as that provider — no
39
+ * `process.env` read. `USERPROFILE` stands in for `HOME` when the latter is
40
+ * unset (Windows), matching the session-map fallback order below.
41
+ */
42
+ const hookAppDirs = (env) => {
43
+ const home = nonEmpty(env.HOME) ?? nonEmpty(env.USERPROFILE);
44
+ const provider = ConfigProvider.fromEnvRecord({
45
+ ...env,
46
+ ...home !== void 0 && { HOME: home }
47
+ });
48
+ const XdgLive = Xdg.layer.pipe(Layer.provide(ConfigProvider.layer(provider)));
49
+ return AppDirs.layer({
50
+ namespace: APP_NAMESPACE,
51
+ fallbackDir: HOOK_DATA_FALLBACK_DIR
52
+ }).pipe(Layer.provide(XdgLive));
53
+ };
54
+ /**
55
+ * Resolve the per-client `sessions.db` path.
56
+ *
57
+ * Precedence: the `CLAUDE_PLUGIN_DATA` env var, then
58
+ * `VITEST_AGENT_SESSION_MAP_DIR`, then `~/.vitest-agent/` (`HOME`, then
59
+ * `USERPROFILE`). The chosen directory is created. Fails with
60
+ * `ProjectIdentityNotResolvableError` when no home directory is resolvable.
61
+ *
62
+ * @param env - the environment map to consult (the front end passes `process.env`)
63
+ * @returns an Effect resolving to the absolute `sessions.db` path
64
+ * @public
65
+ */
66
+ const resolveSessionMapPath = (env) => Effect.gen(function* () {
67
+ const fs = yield* FileSystem.FileSystem;
68
+ const path = yield* Path.Path;
69
+ const home = nonEmpty(env.HOME) ?? nonEmpty(env.USERPROFILE);
70
+ const dir = nonEmpty(env.CLAUDE_PLUGIN_DATA) ?? nonEmpty(env.VITEST_AGENT_SESSION_MAP_DIR) ?? (home !== void 0 ? path.join(home, SESSION_MAP_HOME_DIR) : void 0);
71
+ if (dir === void 0) return yield* new ProjectIdentityNotResolvableError({ tried: [
72
+ {
73
+ source: "CLAUDE_PLUGIN_DATA",
74
+ reason: "env var not set"
75
+ },
76
+ {
77
+ source: "VITEST_AGENT_SESSION_MAP_DIR",
78
+ reason: "env var not set"
79
+ },
80
+ {
81
+ source: "HOME",
82
+ reason: "env var not set (USERPROFILE also unset)"
83
+ }
84
+ ] });
85
+ yield* fs.makeDirectory(dir, { recursive: true });
86
+ return path.join(dir, SESSIONS_DB_FILENAME);
87
+ });
88
+ /**
89
+ * Resolve (and create) every path the sidecar-family subcommands open for
90
+ * `projectKey`: the per-project `data.db`, the global `registry.db` and the
91
+ * per-client `sessions.db`. See the module header for the precedence rules.
92
+ *
93
+ * @param input - the environment map (the front end passes `process.env`)
94
+ * and the normalized project key (e.g. `@org__pkg`)
95
+ * @returns an Effect resolving to the created {@link HookPaths}
96
+ * @public
97
+ */
98
+ const resolveHookPaths = (input) => Effect.gen(function* () {
99
+ const fs = yield* FileSystem.FileSystem;
100
+ const path = yield* Path.Path;
101
+ const dataRoot = yield* (yield* AppDirs).ensureData;
102
+ const projectDataDir = path.join(dataRoot, input.projectKey);
103
+ yield* fs.makeDirectory(projectDataDir, { recursive: true });
104
+ const sessionMapDbPath = yield* resolveSessionMapPath(input.env);
105
+ return {
106
+ dataRoot,
107
+ projectDataDir,
108
+ perProjectDbPath: path.join(projectDataDir, DATA_DB_FILENAME),
109
+ registryDbPath: path.join(dataRoot, REGISTRY_DB_FILENAME),
110
+ sessionMapDbPath
111
+ };
112
+ }).pipe(Effect.provide(hookAppDirs(input.env)));
113
+
114
+ //#endregion
115
+ export { DATA_DB_FILENAME, REGISTRY_DB_FILENAME, SESSIONS_DB_FILENAME, resolveHookPaths, resolveSessionMapPath };
@@ -0,0 +1,37 @@
1
+ import { DataReaderLive } from "../layers/DataReaderLive.js";
2
+ import { DataStoreLive } from "../layers/DataStoreLive.js";
3
+ import { DiscoveryRegistryLive } from "../layers/DiscoveryRegistryLive.js";
4
+ import { LoggerLive } from "../layers/LoggerLive.js";
5
+ import { PerClientSessionMapWriterLive } from "../layers/PerClientSessionMapLive.js";
6
+ import { RunContextLive } from "../layers/RunContextLive.js";
7
+ import migration from "../migrations/registry_0001_initial.js";
8
+ import migration$1 from "../migrations/session_map_0001_initial.js";
9
+ import { NodePlatformLayer, makeSqliteStack } from "../platform.js";
10
+ import { Layer } from "effect";
11
+
12
+ //#region src/programs/platform-sidecar.ts
13
+ /**
14
+ * Build the sidecar Live layer for the supplied SQLite paths.
15
+ *
16
+ * Each store gets its own `SqlClient` connection (separate scopes,
17
+ * independent migrators, all built through the engine's shared
18
+ * `makeSqliteStack`) so concurrent operations on the three stores
19
+ * don't share lock state.
20
+ *
21
+ * @param paths - the three SQLite database paths to open
22
+ * @param env - the environment map `RunContextLive` probes for host
23
+ * metadata (the bin passes `process.env`)
24
+ * @public
25
+ */
26
+ const SidecarPlatformLive = (paths, env) => {
27
+ const project = makeSqliteStack(paths.perProjectDbPath);
28
+ const ProjectStoreLayer = Layer.mergeAll(DataStoreLive.pipe(Layer.provide(project.SqliteLayer)), DataReaderLive.pipe(Layer.provide(project.SqliteLayer)), project.MigratorLayer);
29
+ const sessionMap = makeSqliteStack(paths.sessionMapDbPath, { "0001_initial": migration$1 });
30
+ const SessionMapLayer = Layer.mergeAll(PerClientSessionMapWriterLive.pipe(Layer.provide(sessionMap.SqliteLayer)), sessionMap.MigratorLayer);
31
+ const registry = makeSqliteStack(paths.registryDbPath, { "0001_initial": migration });
32
+ const RegistryLayer = Layer.mergeAll(DiscoveryRegistryLive.pipe(Layer.provide(registry.SqliteLayer)), registry.MigratorLayer);
33
+ return Layer.mergeAll(ProjectStoreLayer, SessionMapLayer, RegistryLayer, RunContextLive(env)).pipe(Layer.provideMerge(NodePlatformLayer), Layer.provideMerge(LoggerLive()));
34
+ };
35
+
36
+ //#endregion
37
+ export { SidecarPlatformLive };
@@ -0,0 +1,49 @@
1
+ import { DataReader } from "../services/DataReader.js";
2
+ import { DataStore } from "../services/DataStore.js";
3
+ import { Effect } from "effect";
4
+
5
+ //#region src/programs/record-session.ts
6
+ /**
7
+ * Upsert a `sessions` row for a SessionStart / SubagentStart hook, resolving
8
+ * `parentChatId` to the parent's numeric session id when present.
9
+ *
10
+ * @param input - the session identity to record
11
+ * @returns the numeric `sessions.id`
12
+ * @public
13
+ */
14
+ const recordSessionStart = (input) => Effect.gen(function* () {
15
+ const reader = yield* DataReader;
16
+ const store = yield* DataStore;
17
+ let parentSessionId;
18
+ if (input.parentChatId !== void 0) {
19
+ const parent = yield* reader.getSessionByChatId(input.parentChatId);
20
+ if (parent._tag === "Some") parentSessionId = parent.value.id;
21
+ }
22
+ return { sessionId: yield* store.upsertSession({
23
+ chatId: input.chatId,
24
+ project: input.project,
25
+ cwd: input.cwd,
26
+ agentKind: input.agentKind,
27
+ ...input.agentType !== void 0 && { agentType: input.agentType },
28
+ ...parentSessionId !== void 0 && { parentSessionId },
29
+ triageWasNonEmpty: input.triageWasNonEmpty,
30
+ startedAt: input.startedAt
31
+ }) };
32
+ });
33
+ /**
34
+ * Close a `sessions` row for a SessionEnd hook. Fails with an `Error`
35
+ * when no session exists for `chatId`.
36
+ *
37
+ * @param input - the session to close
38
+ * @public
39
+ */
40
+ const recordSessionEnd = (input) => Effect.gen(function* () {
41
+ const reader = yield* DataReader;
42
+ const store = yield* DataStore;
43
+ if ((yield* reader.getSessionByChatId(input.chatId))._tag === "None") return yield* Effect.fail(/* @__PURE__ */ new Error(`Unknown chatId: ${input.chatId}`));
44
+ yield* store.endSession(input.chatId, input.endedAt, input.endReason);
45
+ return { ok: true };
46
+ });
47
+
48
+ //#endregion
49
+ export { recordSessionEnd, recordSessionStart };
@@ -0,0 +1,119 @@
1
+ import { DataReader } from "../services/DataReader.js";
2
+ import { DataStore } from "../services/DataStore.js";
3
+ import { resolveSessionForRecording } from "./resolve-session-for-recording.js";
4
+ import { Effect, Option } from "effect";
5
+
6
+ //#region src/programs/record-tdd-artifact.ts
7
+ /**
8
+ * Resolve (auto-opening a `spike` phase if needed) the current open
9
+ * phase for `tddTaskId`, then write the artifact under it. Shared by
10
+ * both `recordTddArtifactEffect` (chat-id → session → task resolution)
11
+ * and `recordTddArtifactByTaskIdEffect` (explicit task-id escape hatch).
12
+ */
13
+ const writeArtifactUnderOpenPhase = (tddTaskId, input) => Effect.gen(function* () {
14
+ const reader = yield* DataReader;
15
+ const store = yield* DataStore;
16
+ const phaseOpt = yield* reader.getCurrentTddPhase(tddTaskId);
17
+ const phaseId = Option.isSome(phaseOpt) ? phaseOpt.value.id : (yield* store.writeTddPhase({
18
+ tddTaskId,
19
+ phase: "spike",
20
+ startedAt: input.recordedAt,
21
+ transitionReason: "auto-opened by record tdd-artifact (no prior phase)"
22
+ })).id;
23
+ return {
24
+ id: yield* store.writeTddArtifact({
25
+ phaseId,
26
+ artifactKind: input.artifactKind,
27
+ ...input.fileId !== void 0 && { fileId: input.fileId },
28
+ ...input.testCaseId !== void 0 && { testCaseId: input.testCaseId },
29
+ ...input.testRunId !== void 0 && { testRunId: input.testRunId },
30
+ ...input.testFirstFailureRunId !== void 0 && { testFirstFailureRunId: input.testFirstFailureRunId },
31
+ ...input.diffExcerpt !== void 0 && { diffExcerpt: input.diffExcerpt },
32
+ ...input.suite !== void 0 && { suite: input.suite },
33
+ recordedAt: input.recordedAt
34
+ }),
35
+ phaseId
36
+ };
37
+ });
38
+ /**
39
+ * Record a TDD artifact under the open TDD task for `chatId`, walking
40
+ * parent and conversation links to find the task and auto-opening a
41
+ * `spike` phase when the task has none.
42
+ *
43
+ * @param input - the artifact to record, keyed by host chat id
44
+ * @public
45
+ */
46
+ const recordTddArtifactEffect = (input) => Effect.gen(function* () {
47
+ const reader = yield* DataReader;
48
+ const session = yield* resolveSessionForRecording({
49
+ chatId: input.chatId,
50
+ recordedAt: input.recordedAt,
51
+ ...input.project !== void 0 && { project: input.project },
52
+ cwd: input.cwd
53
+ });
54
+ const openTdd = (yield* reader.listTddTasksForSession(session.id, {
55
+ walkParents: true,
56
+ walkConversation: true
57
+ })).find((t) => t.endedAt === null);
58
+ if (openTdd === void 0) return yield* Effect.fail(/* @__PURE__ */ new Error(`No open TDD task under chat_id ${input.chatId}. Call tdd_task start first.`));
59
+ return yield* writeArtifactUnderOpenPhase(openTdd.id, input);
60
+ });
61
+ /**
62
+ * Explicit task-id escape hatch (issue #144). Bypasses `chat_id` →
63
+ * session → task resolution entirely — the caller already knows which
64
+ * TDD task the artifact belongs to (e.g. a hook seeded with
65
+ * `VITEST_AGENT_TDD_TASK_ID` in a detached-session environment where
66
+ * neither the parent walk nor the conversation-id fallback resolves
67
+ * the right task). Fails loudly when the task does not exist or has
68
+ * already ended — writing to a closed task's phase would silently
69
+ * corrupt the evidence trail.
70
+ *
71
+ * @param input - the artifact to record, keyed by TDD task id
72
+ * @public
73
+ */
74
+ const recordTddArtifactByTaskIdEffect = (input) => Effect.gen(function* () {
75
+ const taskOpt = yield* (yield* DataReader).getTddTaskById(input.tddTaskId);
76
+ if (Option.isNone(taskOpt)) return yield* Effect.fail(/* @__PURE__ */ new Error(`No TDD task found for id ${input.tddTaskId}.`));
77
+ if (taskOpt.value.endedAt !== null) return yield* Effect.fail(/* @__PURE__ */ new Error(`TDD task ${input.tddTaskId} has already ended (endedAt=${taskOpt.value.endedAt}).`));
78
+ return yield* writeArtifactUnderOpenPhase(input.tddTaskId, input);
79
+ });
80
+ /**
81
+ * CLI-command-facing dispatcher (issue #144). Keeps `commands/record.ts`
82
+ * a thin flag-parsing wrapper per this package's convention. `tddTaskId`
83
+ * takes priority when both are supplied — it is the explicit escape
84
+ * hatch and should never silently fall back to (weaker) session
85
+ * resolution.
86
+ *
87
+ * @param input - the artifact plus either a chat id or a TDD task id
88
+ * @public
89
+ */
90
+ const dispatchRecordTddArtifactEffect = (input) => {
91
+ if (input.tddTaskId !== void 0) return recordTddArtifactByTaskIdEffect({
92
+ tddTaskId: input.tddTaskId,
93
+ artifactKind: input.artifactKind,
94
+ ...input.fileId !== void 0 && { fileId: input.fileId },
95
+ ...input.testCaseId !== void 0 && { testCaseId: input.testCaseId },
96
+ ...input.testRunId !== void 0 && { testRunId: input.testRunId },
97
+ ...input.testFirstFailureRunId !== void 0 && { testFirstFailureRunId: input.testFirstFailureRunId },
98
+ ...input.diffExcerpt !== void 0 && { diffExcerpt: input.diffExcerpt },
99
+ ...input.suite !== void 0 && { suite: input.suite },
100
+ recordedAt: input.recordedAt
101
+ });
102
+ if (input.chatId !== void 0) return recordTddArtifactEffect({
103
+ chatId: input.chatId,
104
+ artifactKind: input.artifactKind,
105
+ ...input.fileId !== void 0 && { fileId: input.fileId },
106
+ ...input.testCaseId !== void 0 && { testCaseId: input.testCaseId },
107
+ ...input.testRunId !== void 0 && { testRunId: input.testRunId },
108
+ ...input.testFirstFailureRunId !== void 0 && { testFirstFailureRunId: input.testFirstFailureRunId },
109
+ ...input.diffExcerpt !== void 0 && { diffExcerpt: input.diffExcerpt },
110
+ ...input.suite !== void 0 && { suite: input.suite },
111
+ recordedAt: input.recordedAt,
112
+ cwd: input.cwd,
113
+ ...input.project !== void 0 && { project: input.project }
114
+ });
115
+ return Effect.fail(/* @__PURE__ */ new Error("record tdd-artifact requires either --chat-id or --tdd-task-id."));
116
+ };
117
+
118
+ //#endregion
119
+ export { dispatchRecordTddArtifactEffect, recordTddArtifactByTaskIdEffect, recordTddArtifactEffect };
@@ -0,0 +1,61 @@
1
+ import { DataStore } from "../services/DataStore.js";
2
+ import { resolveSessionForRecording } from "./resolve-session-for-recording.js";
3
+ import { TurnPayload } from "@vitest-agent/sdk";
4
+ import { Effect, Schema } from "effect";
5
+
6
+ //#region src/programs/record-turn.ts
7
+ /**
8
+ * Parse a raw JSON string and validate it against the `TurnPayload` schema
9
+ * without throwing.
10
+ *
11
+ * @param raw - the JSON text delivered by the hook
12
+ * @returns the decoded payload, or an error describing why it was rejected
13
+ * @public
14
+ */
15
+ const parseAndValidateTurnPayload = (raw) => {
16
+ let parsed;
17
+ try {
18
+ parsed = JSON.parse(raw);
19
+ } catch (e) {
20
+ return {
21
+ ok: false,
22
+ error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}`
23
+ };
24
+ }
25
+ const decoded = Schema.decodeUnknownResult(TurnPayload)(parsed);
26
+ if (decoded._tag === "Failure") return {
27
+ ok: false,
28
+ error: `Invalid TurnPayload: ${decoded.failure.message}`
29
+ };
30
+ return {
31
+ ok: true,
32
+ payload: decoded.success
33
+ };
34
+ };
35
+ /**
36
+ * Validate a turn payload and write it as a `turns` row under the session
37
+ * resolved for `chatId` (bootstrapping the session row when needed).
38
+ *
39
+ * @param input - the turn to record
40
+ * @returns the new `turns.id`
41
+ * @public
42
+ */
43
+ const recordTurnEffect = (input) => Effect.gen(function* () {
44
+ const parse = parseAndValidateTurnPayload(input.payloadJson);
45
+ if (!parse.ok) return yield* Effect.fail(new Error(parse.error));
46
+ const session = yield* resolveSessionForRecording({
47
+ chatId: input.chatId,
48
+ recordedAt: input.occurredAt,
49
+ ...input.project !== void 0 && { project: input.project },
50
+ cwd: input.cwd
51
+ });
52
+ return { turnId: yield* (yield* DataStore).writeTurn({
53
+ sessionId: session.id,
54
+ type: parse.payload.type,
55
+ payload: input.payloadJson,
56
+ occurredAt: input.occurredAt
57
+ }) };
58
+ });
59
+
60
+ //#endregion
61
+ export { parseAndValidateTurnPayload, recordTurnEffect };