@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.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/index.d.ts +2679 -0
- package/index.js +63 -0
- package/layers/ConfigLive.js +46 -0
- package/layers/DataReaderLive.js +1923 -0
- package/layers/DataStoreLive.js +1283 -0
- package/layers/DetailResolverLive.js +9 -0
- package/layers/DiscoveryRegistryLive.js +70 -0
- package/layers/EnvironmentDetectorLive.js +36 -0
- package/layers/EnvironmentDetectorTest.js +13 -0
- package/layers/ExecutorResolverLive.js +9 -0
- package/layers/FormatSelectorLive.js +20 -0
- package/layers/HistoryTrackerLive.js +49 -0
- package/layers/HistoryTrackerTest.js +25 -0
- package/layers/LoggerLive.js +70 -0
- package/layers/OutputPipelineLive.js +20 -0
- package/layers/OutputRendererLive.js +23 -0
- package/layers/PathResolutionLive.js +46 -0
- package/layers/PerClientSessionMapLive.js +145 -0
- package/layers/ProjectDiscoveryLive.js +67 -0
- package/layers/ProjectDiscoveryTest.js +12 -0
- package/layers/ProjectIdentityLive.js +95 -0
- package/layers/RunContextLive.js +82 -0
- package/lib/format-triage.js +103 -0
- package/lib/format-wrapup.js +67 -0
- package/migrations/0001_initial.js +824 -0
- package/migrations/0002_test_artifacts.js +30 -0
- package/migrations/index.js +27 -0
- package/migrations/registry_0001_initial.js +39 -0
- package/migrations/session_map_0001_initial.js +38 -0
- package/package.json +59 -0
- package/platform.js +58 -0
- package/programs/end-agent.js +19 -0
- package/programs/hook-paths.js +115 -0
- package/programs/platform-sidecar.js +37 -0
- package/programs/record-session.js +49 -0
- package/programs/record-tdd-artifact.js +119 -0
- package/programs/record-turn.js +61 -0
- package/programs/record-workspace-changes.js +57 -0
- package/programs/register-agent.js +85 -0
- package/programs/resolve-session-for-recording.js +44 -0
- package/programs/session-env.js +85 -0
- package/project-dir.js +32 -0
- package/services/Config.js +15 -0
- package/services/DataReader.js +8 -0
- package/services/DataStore.js +15 -0
- package/services/DetailResolver.js +8 -0
- package/services/DiscoveryRegistry.js +8 -0
- package/services/EnvironmentDetector.js +8 -0
- package/services/ExecutorResolver.js +8 -0
- package/services/FormatSelector.js +8 -0
- package/services/HistoryTracker.js +20 -0
- package/services/OutputRenderer.js +8 -0
- package/services/PerClientSessionMap.js +22 -0
- package/services/ProjectDiscovery.js +8 -0
- package/services/ProjectIdentity.js +62 -0
- package/services/RunContext.js +53 -0
- package/services/idempotency.js +58 -0
- package/sql/assemblers.js +69 -0
- package/testing/layers.js +16 -0
- package/testing.d.ts +1101 -0
- package/testing.js +243 -0
- package/tsdoc-metadata.json +11 -0
- package/utils/ensure-migrated.js +47 -0
- package/utils/failure-signature.js +34 -0
- package/utils/resolve-data-path.js +64 -0
- package/utils/resolve-project-key-from-cwd.js +52 -0
- package/utils/resolve-workspace-key.js +41 -0
- 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 };
|