@vitest-agent/engine 2.0.1 → 3.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.
@@ -1,9 +1,12 @@
1
1
  import { VitestAgentConfigFile } from "../services/Config.js";
2
2
  import { ProjectIdentity, resolveProjectIdentityFromCandidates } from "../services/ProjectIdentity.js";
3
+ import { repositoryUrlOf } from "../utils/package-manifest.js";
4
+ import { Git } from "@effected/git";
3
5
  import { ProjectIdentityNotResolvableError, ProjectIdentitySourceError, VitestAgentConfig } from "@vitest-agent/sdk";
4
- import { Effect, FileSystem, Layer, Option } from "effect";
6
+ import { Effect, FileSystem, Layer, Option, Result } from "effect";
5
7
  import { WorkspaceDiscovery } from "@effected/workspaces";
6
- import { ChildProcess, ChildProcessSpawner } from "effect/process";
8
+ import { AppDirs } from "@effected/xdg";
9
+ import { LenientManifest } from "@effected/package-json";
7
10
 
8
11
  // src/layers/ProjectIdentityLive.ts
9
12
  /** The one-line text of a kit failure, for `ProjectIdentitySourceError.detail`. */
@@ -11,25 +14,23 @@ const detailOf = (cause) => cause instanceof Error && cause.message.length > 0 ?
11
14
  /**
12
15
  * The git remote is an OPTIONAL source: a repository with no
13
16
  * `remote.origin.url`, a directory that is not a git checkout, or a machine
14
- * with no `git` on `PATH` are all "no remote" rather than failures. `git
15
- * config --get` exits non-zero for an unset key, so every spawner failure
16
- * folds to `Option.none()` on purpose — this is the one catch in this module
17
- * that stays broad, and it is a policy, not an accident.
17
+ * with no `git` on `PATH` are all "no remote" rather than failures, so every
18
+ * `Git` failure folds to `Option.none()` on purpose — this is the one catch
19
+ * in this module that stays broad, and it is a policy, not an accident.
20
+ *
21
+ * Read as the RAW config value (`Git.configGet`, i.e. `git config --get
22
+ * remote.origin.url`), not `Git.remoteUrl`: `git remote get-url` applies
23
+ * `url.<base>.insteadOf` rewrites, and the project key derived from this URL
24
+ * names the persisted data directory, so a rewrite must not re-key it.
18
25
  */
19
- const readGitRemote = (cwd) => Effect.gen(function* () {
20
- const trimmed = (yield* (yield* ChildProcessSpawner.ChildProcessSpawner).string(ChildProcess.make("git", [
21
- "config",
22
- "--get",
23
- "remote.origin.url"
24
- ]).pipe(ChildProcess.setCwd(cwd)))).trim();
25
- return trimmed.length > 0 ? Option.some(trimmed) : Option.none();
26
- }).pipe(Effect.catch(() => Effect.succeed(Option.none())));
26
+ const readGitRemote = (cwd) => Effect.flatMap(Git, (git) => git.configGet(cwd, "remote.origin.url")).pipe(Effect.map(Option.filter((url) => url.length > 0)), Effect.orElseSucceed(() => Option.none()));
27
27
  /**
28
28
  * The `repository` URL of the root `package.json` discovery named.
29
29
  *
30
30
  * A file that is not there (`NotFound`) is an absent source. Any other
31
31
  * filesystem failure (permission denied, an I/O error) and a body that is not
32
- * JSON are `ProjectIdentitySourceError`s: the file exists and says
32
+ * JSON (`@effected/package-json`'s `PackageJsonSyntaxError`, `invalid-json`)
33
+ * are `ProjectIdentitySourceError`s: the file exists and says
33
34
  * something, and silently reading it as "no repository" would hand the
34
35
  * operator the wrong identity.
35
36
  */
@@ -41,23 +42,13 @@ const readPackageRepoUrl = (packageJsonPath) => Effect.gen(function* () {
41
42
  cause: error
42
43
  }))));
43
44
  if (Option.isNone(raw)) return Option.none();
44
- const parsed = yield* Effect.try({
45
- try: () => JSON.parse(raw.value),
46
- catch: (cause) => new ProjectIdentitySourceError({
47
- source: "package-json",
48
- path: packageJsonPath,
49
- detail: detailOf(cause),
50
- cause
51
- })
52
- });
53
- if (parsed === null || typeof parsed !== "object") return Option.none();
54
- const repo = parsed.repository;
55
- if (typeof repo === "string" && repo.trim().length > 0) return Option.some(repo.trim());
56
- if (repo !== null && typeof repo === "object" && "url" in repo && typeof repo.url === "string") {
57
- const url = repo.url.trim();
58
- return url.length > 0 ? Option.some(url) : Option.none();
59
- }
60
- return Option.none();
45
+ const manifest = yield* LenientManifest.parse(raw.value).pipe(Effect.map(Option.some), Effect.catchTag("PackageJsonSyntaxError", (cause) => cause.reason === "not-an-object" ? Effect.succeed(Option.none()) : Effect.fail(new ProjectIdentitySourceError({
46
+ source: "package-json",
47
+ path: packageJsonPath,
48
+ detail: detailOf(cause),
49
+ cause
50
+ }))));
51
+ return Option.flatMap(manifest, (m) => Option.fromNullishOr(repositoryUrlOf(m.repository)));
61
52
  });
62
53
  const collectCandidates = (workspaceRoot, options) => Effect.gen(function* () {
63
54
  const tomlConfig = yield* (yield* VitestAgentConfigFile).loadOrDefault(new VitestAgentConfig({})).pipe(Effect.mapError((cause) => new ProjectIdentitySourceError({
@@ -76,7 +67,16 @@ const collectCandidates = (workspaceRoot, options) => Effect.gen(function* () {
76
67
  const packageJsonRepoUrl = root ? yield* readPackageRepoUrl(root.packageJsonPath) : Option.none();
77
68
  const candidates = {};
78
69
  if (options.projectId !== void 0) candidates.explicit = options.projectId;
79
- if (tomlConfig.projectKey !== void 0) candidates.toml = tomlConfig.projectKey;
70
+ if (tomlConfig.projectKey !== void 0 && tomlConfig.projectKey.trim() !== "") {
71
+ const segment = AppDirs.segment(tomlConfig.projectKey);
72
+ if (Result.isFailure(segment)) return yield* new ProjectIdentitySourceError({
73
+ source: "toml",
74
+ path: workspaceRoot,
75
+ detail: `projectKey ${JSON.stringify(tomlConfig.projectKey)} is not a usable directory name (${segment.failure.message})`,
76
+ cause: segment.failure
77
+ });
78
+ candidates.toml = tomlConfig.projectKey;
79
+ }
80
80
  const remoteValue = Option.getOrUndefined(gitRemote);
81
81
  if (remoteValue !== void 0) candidates.gitRemote = remoteValue;
82
82
  const repoUrlValue = Option.getOrUndefined(packageJsonRepoUrl);
@@ -121,13 +121,13 @@ const ProjectIdentityLive = Layer.effect(ProjectIdentity, Effect.gen(function* (
121
121
  const fs = yield* FileSystem.FileSystem;
122
122
  const discovery = yield* WorkspaceDiscovery;
123
123
  const configFile = yield* VitestAgentConfigFile;
124
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
125
- return { resolve: (workspaceRoot, options = {}) => collectCandidates(workspaceRoot, options).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(WorkspaceDiscovery, discovery), Effect.provideService(VitestAgentConfigFile, configFile), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.flatMap((candidates) => {
124
+ const git = yield* Git;
125
+ return { resolve: (workspaceRoot, options = {}) => collectCandidates(workspaceRoot, options).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(WorkspaceDiscovery, discovery), Effect.provideService(VitestAgentConfigFile, configFile), Effect.provideService(Git, git), Effect.flatMap((candidates) => {
126
126
  const result = resolveProjectIdentityFromCandidates(candidates);
127
127
  if (result === null) return Effect.fail(new ProjectIdentityNotResolvableError({ tried: candidatesToTriedList(candidates) }));
128
128
  return Effect.succeed(result);
129
129
  })) };
130
- }));
130
+ })).pipe(Layer.provide(Git.layer));
131
131
  /**
132
132
  * Build a Test layer that returns a fixed `ResolvedIdentity` (or
133
133
  * fails with the given `ProjectIdentityError`). Use in unit tests
@@ -1,73 +1,59 @@
1
1
  import { AgentContext, RunContext, RunContextService } from "../services/RunContext.js";
2
- import { probeHostMetadataFromEnv } from "@vitest-agent/sdk";
2
+ import { probeHostMetadataFromEnv } from "../utils/host-metadata.js";
3
+ import { Git } from "@effected/git";
3
4
  import { Effect, Layer, Option } from "effect";
4
- import { ChildProcess, ChildProcessSpawner } from "effect/process";
5
5
 
6
6
  // src/layers/RunContextLive.ts
7
- const runGit = (spawner, cwd, args) => spawner.string(ChildProcess.make("git", args).pipe(ChildProcess.setCwd(cwd))).pipe(Effect.map((output) => {
8
- const trimmed = output.trim();
9
- return trimmed.length > 0 ? Option.some(trimmed) : Option.none();
10
- }), Effect.catch(() => Effect.succeed(Option.none())));
11
- const captureRunContextEffect = (cwd, spawner, env) => Effect.gen(function* () {
12
- const branch = yield* runGit(spawner, cwd, [
13
- "rev-parse",
14
- "--abbrev-ref",
15
- "HEAD"
16
- ]);
17
- const sha = yield* runGit(spawner, cwd, ["rev-parse", "HEAD"]);
18
- const dirtyOut = yield* runGit(spawner, cwd, ["status", "--porcelain"]);
19
- const upstream = yield* runGit(spawner, cwd, [
20
- "rev-parse",
21
- "--abbrev-ref",
22
- "@{upstream}"
23
- ]);
24
- const worktree = yield* runGit(spawner, cwd, ["rev-parse", "--show-toplevel"]);
25
- const dirtyValue = Option.match(dirtyOut, {
26
- onNone: () => null,
27
- onSome: (out) => out.length > 0
28
- });
7
+ /** Every git failure (not a repository, no `git` on PATH, an unborn HEAD) is "no answer". */
8
+ const orNull = (effect) => effect.pipe(Effect.orElseSucceed(() => null));
9
+ const DETACHED_HEAD = "HEAD";
10
+ const branchOf = (git, cwd) => orNull(git.currentBranch(cwd).pipe(Effect.map(Option.getOrElse(() => DETACHED_HEAD))));
11
+ const shaOf = (git, cwd) => orNull(git.revParse(cwd, "HEAD"));
12
+ const toplevelOf = (git, cwd) => orNull(git.repoRoot(cwd));
13
+ const dirtyOf = (git, cwd) => orNull(git.status(cwd, { untrackedFiles: "normal" }).pipe(Effect.map((entries) => entries.length > 0)));
14
+ const upstreamOf = (git, cwd) => orNull(git.upstream(cwd).pipe(Effect.map((upstream) => Option.getOrNull(Option.map(upstream, (u) => u.name)))));
15
+ const captureRunContextEffect = (cwd, git, env) => Effect.gen(function* () {
16
+ const gitBranch = yield* branchOf(git, cwd);
17
+ const gitCommitSha = yield* shaOf(git, cwd);
18
+ const gitDirty = yield* dirtyOf(git, cwd);
19
+ const gitUpstream = yield* upstreamOf(git, cwd);
20
+ const gitWorktreeDir = yield* toplevelOf(git, cwd);
29
21
  const host = probeHostMetadataFromEnv(env);
30
22
  return new RunContext({
31
- gitBranch: Option.getOrNull(branch),
32
- gitCommitSha: Option.getOrNull(sha),
33
- gitDirty: dirtyValue,
34
- gitUpstream: Option.getOrNull(upstream),
35
- gitWorktreeDir: Option.getOrNull(worktree),
23
+ gitBranch,
24
+ gitCommitSha,
25
+ gitDirty,
26
+ gitUpstream,
27
+ gitWorktreeDir,
36
28
  hostSource: host.source,
37
29
  hostValue: host.value,
38
30
  hostMetadata: host.metadata
39
31
  });
40
32
  });
41
- const captureAgentContextEffect = (cwd, spawner) => Effect.gen(function* () {
42
- const branch = yield* runGit(spawner, cwd, [
43
- "rev-parse",
44
- "--abbrev-ref",
45
- "HEAD"
46
- ]);
47
- const sha = yield* runGit(spawner, cwd, ["rev-parse", "HEAD"]);
48
- const worktree = yield* runGit(spawner, cwd, ["rev-parse", "--show-toplevel"]);
33
+ const captureAgentContextEffect = (cwd, git) => Effect.gen(function* () {
49
34
  return new AgentContext({
50
- startGitBranch: Option.getOrNull(branch),
51
- startGitCommitSha: Option.getOrNull(sha),
52
- startWorktreeDir: Option.getOrNull(worktree)
35
+ startGitBranch: yield* branchOf(git, cwd),
36
+ startGitCommitSha: yield* shaOf(git, cwd),
37
+ startWorktreeDir: yield* toplevelOf(git, cwd)
53
38
  });
54
39
  });
55
40
  /**
56
41
  * Live layer. Requires `ChildProcessSpawner` (provided by
57
- * `NodeServices.layer` at the entry point). Host-metadata probes read
58
- * the injected `env` map — the env walk is pure and the map is captured
59
- * once when the layer is built.
42
+ * `NodeServices.layer` at the entry point), which feeds `@effected/git`'s
43
+ * `Git` for branch / sha / dirty / upstream / toplevel. Every git failure is
44
+ * recorded as `null`. Host metadata comes from `probeHostMetadataFromEnv`
45
+ * over the injected `env` map, captured once when the layer is built.
60
46
  *
61
47
  * @param env - the environment map to probe (the front end passes `process.env`)
62
48
  * @public
63
49
  */
64
50
  const RunContextLive = (env) => Layer.effect(RunContextService, Effect.gen(function* () {
65
- const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
51
+ const git = yield* Git;
66
52
  return {
67
- captureRunContext: (cwd) => captureRunContextEffect(cwd, spawner, env),
68
- captureAgentContext: (cwd) => captureAgentContextEffect(cwd, spawner)
53
+ captureRunContext: (cwd) => captureRunContextEffect(cwd, git, env),
54
+ captureAgentContext: (cwd) => captureAgentContextEffect(cwd, git)
69
55
  };
70
- }));
56
+ })).pipe(Layer.provide(Git.layer));
71
57
  /**
72
58
  * Build a Test layer that returns fixed `RunContext` and
73
59
  * `AgentContext` values regardless of `cwd`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/engine",
3
- "version": "2.0.1",
3
+ "version": "3.0.0",
4
4
  "private": false,
5
5
  "description": "Shared services, layers, SQLite data layer, migrations and platform resolution for the vitest-agent CLI and MCP server.",
6
6
  "keywords": [
@@ -42,20 +42,22 @@
42
42
  "dependencies": {
43
43
  "@effect/platform-node": "^4.0.6",
44
44
  "@effect/sql-sqlite-node": "^4.0.6",
45
- "@effected/app": "^0.27.0",
46
- "@effected/cli": "^0.22.1",
47
- "@effected/config-file": "^0.20.0",
48
- "@effected/engine": "^0.8.0",
49
- "@effected/env": "^0.5.0",
50
- "@effected/glob": "^0.14.0",
51
- "@effected/jsonc": "^0.20.0",
52
- "@effected/store": "^0.19.0",
45
+ "@effected/app": "^0.28.0",
46
+ "@effected/cli": "^0.23.0",
47
+ "@effected/config-file": "^0.21.0",
48
+ "@effected/engine": "^0.9.0",
49
+ "@effected/env": "^0.6.0",
50
+ "@effected/git": "^0.26.0",
51
+ "@effected/glob": "^0.15.0",
52
+ "@effected/jsonc": "^0.20.1",
53
+ "@effected/package-json": "^0.26.1",
54
+ "@effected/store": "^0.20.0",
53
55
  "@effected/toml": "^0.16.0",
54
- "@effected/walker": "^0.20.0",
55
- "@effected/workspaces": "^0.38.1",
56
- "@effected/xdg": "^0.15.0",
56
+ "@effected/walker": "^0.21.0",
57
+ "@effected/workspaces": "^0.39.1",
58
+ "@effected/xdg": "^0.16.0",
57
59
  "@effected/yaml": "^0.25.1",
58
- "@vitest-agent/sdk": "8.0.0",
60
+ "@vitest-agent/sdk": "9.0.0",
59
61
  "effect": "^4.0.6"
60
62
  },
61
63
  "engines": {
package/platform.js CHANGED
@@ -5,7 +5,7 @@ import { LoggerLive } from "./layers/LoggerLive.js";
5
5
  import { OutputPipelineLive } from "./layers/OutputPipelineLive.js";
6
6
  import { ProjectDiscoveryLive } from "./layers/ProjectDiscoveryLive.js";
7
7
  import { PROJECT_MIGRATIONS } from "./migrations/index.js";
8
- import { LEDGER_OPTIONS, toStoreMigrations } from "./stores.js";
8
+ import { BUSY_TIMEOUT, LEDGER_OPTIONS, applyConnectionPragmas } from "./stores.js";
9
9
  import { Context, Effect, Layer } from "effect";
10
10
  import { SqlClient } from "effect/sql/SqlClient";
11
11
  import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -32,11 +32,17 @@ const NodePlatformLayer = NodeServices.layer;
32
32
  * @public
33
33
  */
34
34
  const makeSqliteStack = (filename, migrations = PROJECT_MIGRATIONS) => {
35
- const SqliteLayer = layer({ filename }).pipe(Layer.tap((context) => Context.get(context, SqlClient)`PRAGMA synchronous = NORMAL`));
35
+ const SqliteLayer = layer({
36
+ filename,
37
+ busyTimeout: BUSY_TIMEOUT
38
+ }).pipe(Layer.tap((context) => {
39
+ const sql = Context.get(context, SqlClient);
40
+ return Effect.andThen(sql`PRAGMA synchronous = NORMAL`, applyConnectionPragmas(sql));
41
+ }));
36
42
  return {
37
43
  SqliteLayer,
38
44
  MigratorLayer: Layer.effectDiscard(Effect.void).pipe(Layer.provide(Store.layer({
39
- migrations: toStoreMigrations(migrations),
45
+ migrations: Store.fromMigratorRecord(migrations),
40
46
  ...LEDGER_OPTIONS
41
47
  }).pipe(Layer.provide(SqliteLayer))))
42
48
  };
@@ -1,10 +1,12 @@
1
1
  import { APP_NAMESPACE, DATA_FALLBACK_DIR } from "../layers/PathResolutionLive.js";
2
2
  import migration from "../migrations/registry_0001_initial.js";
3
- import { LEDGER_OPTIONS, toStoreMigrations } from "../stores.js";
3
+ import { LEDGER_OPTIONS } from "../stores.js";
4
4
  import { ProjectIdentityNotResolvableError } from "@vitest-agent/sdk";
5
- import { ConfigProvider, Effect, FileSystem, Layer, Path } from "effect";
5
+ import { Effect, FileSystem, Layer, Option, Path } from "effect";
6
6
  import { AppDirs, Xdg } from "@effected/xdg";
7
+ import { Store } from "@effected/store";
7
8
  import { AppStore } from "@effected/app";
9
+ import { LaunchContext } from "@effected/engine";
8
10
 
9
11
  // src/programs/hook-paths.ts
10
12
  /**
@@ -36,39 +38,43 @@ const REGISTRY_DB_FILENAME = "registry.db";
36
38
  const REGISTRY_STORE_OPTIONS = {
37
39
  filename: REGISTRY_DB_FILENAME,
38
40
  directory: "data",
39
- migrations: toStoreMigrations({ "0001_initial": migration }),
41
+ migrations: Store.fromMigratorRecord({ "0001_initial": migration }),
40
42
  ...LEDGER_OPTIONS
41
43
  };
42
44
  /** Directory under the home dir that holds the per-client `sessions.db` fallback. */
43
45
  const SESSION_MAP_HOME_DIR = `.${APP_NAMESPACE}`;
44
46
  const nonEmpty = (value) => value !== void 0 && value.length > 0 ? value : void 0;
45
47
  /**
46
- * `AppDirs` for the hook family, resolved from the caller's `env` map.
47
- *
48
- * `Xdg.layer` reads `HOME` and `XDG_DATA_HOME` through the ambient
49
- * `ConfigProvider`, so the env map is installed as that provider — no
50
- * `process.env` read. `USERPROFILE` stands in for `HOME` when the latter is
51
- * unset (Windows), matching the session-map fallback order below.
52
- *
48
+ * The home directory the hook family falls back on: `HOME`, then
49
+ * `USERPROFILE`, on every platform. Deliberately not `@effected/xdg`'s win32
50
+ * order (`USERPROFILE` first): under Git Bash / MSYS the two differ, and
51
+ * switching would move an existing `sessions.db`.
52
+ */
53
+ const homeOf = (env) => nonEmpty(env.HOME) ?? nonEmpty(env.USERPROFILE);
54
+ /**
55
+ * `AppDirs` for the hook family, resolved from the caller's `env` map
56
+ * through `Xdg.layerFromEnv` — no `process.env` read. `USERPROFILE` stands
57
+ * in for `HOME` when the latter is unset (Windows), matching the
58
+ * session-map fallback order below.
53
59
  */
54
60
  const hookAppDirs = (env) => {
55
- const home = nonEmpty(env.HOME) ?? nonEmpty(env.USERPROFILE);
56
- const provider = ConfigProvider.fromEnvRecord({
57
- ...env,
58
- ...home !== void 0 && { HOME: home }
59
- });
60
- const XdgLive = Xdg.layer.pipe(Layer.provide(ConfigProvider.layer(provider)));
61
+ const home = homeOf(env);
61
62
  return AppDirs.layer({
62
63
  namespace: APP_NAMESPACE,
63
64
  fallbackDir: DATA_FALLBACK_DIR
64
- }).pipe(Layer.provide(XdgLive));
65
+ }).pipe(Layer.provide(Xdg.layerFromEnv({
66
+ ...env,
67
+ ...home !== void 0 && { HOME: home }
68
+ })));
65
69
  };
66
70
  /**
67
71
  * Resolve the per-client `sessions.db` path.
68
72
  *
69
73
  * Precedence: the `CLAUDE_PLUGIN_DATA` env var, then
70
74
  * `VITEST_AGENT_SESSION_MAP_DIR`, then `~/.vitest-agent/` (`HOME`, then
71
- * `USERPROFILE`). The chosen directory is created. Fails with
75
+ * `USERPROFILE`). The two variables are read through `@effected/engine`'s
76
+ * `LaunchContext.resolve`: a value is trimmed, and a blank or unsubstituted
77
+ * `${...}` placeholder value counts as unset. The chosen directory is created. Fails with
72
78
  * `ProjectIdentityNotResolvableError` when no home directory is resolvable.
73
79
  *
74
80
  * @param env - the environment map to consult (the front end passes `process.env`)
@@ -78,8 +84,11 @@ const hookAppDirs = (env) => {
78
84
  const resolveSessionMapPath = (env) => Effect.gen(function* () {
79
85
  const fs = yield* FileSystem.FileSystem;
80
86
  const path = yield* Path.Path;
81
- const home = nonEmpty(env.HOME) ?? nonEmpty(env.USERPROFILE);
82
- 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);
87
+ const home = homeOf(env);
88
+ const dir = LaunchContext.resolve({
89
+ env,
90
+ keys: ["CLAUDE_PLUGIN_DATA", "VITEST_AGENT_SESSION_MAP_DIR"]
91
+ }).pipe(Option.getOrElse(() => home !== void 0 ? path.join(home, SESSION_MAP_HOME_DIR) : void 0));
83
92
  if (dir === void 0) return yield* new ProjectIdentityNotResolvableError({ tried: [
84
93
  {
85
94
  source: "CLAUDE_PLUGIN_DATA",
@@ -5,7 +5,7 @@ import { LoggerLive } from "../layers/LoggerLive.js";
5
5
  import { PerClientSessionMapWriterLive } from "../layers/PerClientSessionMapLive.js";
6
6
  import { RunContextLive } from "../layers/RunContextLive.js";
7
7
  import migration from "../migrations/session_map_0001_initial.js";
8
- import { LEDGER_OPTIONS, RegistryStore, SessionMapStore, toStoreMigrations } from "../stores.js";
8
+ import { CONNECTION_OPTIONS, LEDGER_OPTIONS, RegistryStore, SessionMapStore } from "../stores.js";
9
9
  import { NodePlatformLayer, makeSqliteStack } from "../platform.js";
10
10
  import { REGISTRY_STORE_OPTIONS } from "./hook-paths.js";
11
11
  import { Layer } from "effect";
@@ -13,6 +13,30 @@ import { Store } from "@effected/store";
13
13
 
14
14
  // src/programs/platform-sidecar.ts
15
15
  /**
16
+ * The per-client session map store (`sessions.db`) at `filename`, migrated,
17
+ * with the shared per-connection settings applied on open.
18
+ *
19
+ * @internal
20
+ */
21
+ const SessionMapStoreLive = (filename) => Store.layerSqliteAs(SessionMapStore, {
22
+ filename,
23
+ migrations: Store.fromMigratorRecord({ "0001_initial": migration }),
24
+ ...LEDGER_OPTIONS,
25
+ ...CONNECTION_OPTIONS
26
+ });
27
+ /**
28
+ * The global discovery registry store (`registry.db`) at `filename`, migrated,
29
+ * with the shared per-connection settings applied on open.
30
+ *
31
+ * @internal
32
+ */
33
+ const RegistryStoreLive = (filename) => Store.layerSqliteAs(RegistryStore, {
34
+ filename,
35
+ migrations: REGISTRY_STORE_OPTIONS.migrations,
36
+ ...LEDGER_OPTIONS,
37
+ ...CONNECTION_OPTIONS
38
+ });
39
+ /**
16
40
  * Build the sidecar Live layer for the supplied SQLite paths.
17
41
  *
18
42
  * Each store gets its own `SqlClient` connection and its own migration
@@ -28,17 +52,9 @@ import { Store } from "@effected/store";
28
52
  const SidecarPlatformLive = (paths, env) => {
29
53
  const project = makeSqliteStack(paths.perProjectDbPath);
30
54
  const ProjectStoreLayer = Layer.mergeAll(DataStoreLive.pipe(Layer.provide(project.SqliteLayer)), DataReaderLive.pipe(Layer.provide(project.SqliteLayer)), project.MigratorLayer);
31
- const SessionMapLayer = PerClientSessionMapWriterLive.pipe(Layer.provide(Store.sqlClient(SessionMapStore)), Layer.provide(Store.layerSqliteAs(SessionMapStore, {
32
- filename: paths.sessionMapDbPath,
33
- migrations: toStoreMigrations({ "0001_initial": migration }),
34
- ...LEDGER_OPTIONS
35
- })));
36
- const RegistryLayer = DiscoveryRegistryLive.pipe(Layer.provide(Store.sqlClient(RegistryStore)), Layer.provide(Store.layerSqliteAs(RegistryStore, {
37
- filename: paths.registryDbPath,
38
- migrations: REGISTRY_STORE_OPTIONS.migrations,
39
- ...LEDGER_OPTIONS
40
- })));
55
+ const SessionMapLayer = PerClientSessionMapWriterLive.pipe(Layer.provide(Store.sqlClient(SessionMapStore)), Layer.provide(SessionMapStoreLive(paths.sessionMapDbPath)));
56
+ const RegistryLayer = DiscoveryRegistryLive.pipe(Layer.provide(Store.sqlClient(RegistryStore)), Layer.provide(RegistryStoreLive(paths.registryDbPath)));
41
57
  return Layer.mergeAll(ProjectStoreLayer, SessionMapLayer, RegistryLayer, RunContextLive(env)).pipe(Layer.provideMerge(NodePlatformLayer), Layer.provideMerge(LoggerLive()));
42
58
  };
43
59
 
44
- export { SidecarPlatformLive };
60
+ export { RegistryStoreLive, SessionMapStoreLive, SidecarPlatformLive };
@@ -1,6 +1,7 @@
1
1
  import { DataReader } from "../services/DataReader.js";
2
2
  import { DataStore } from "../services/DataStore.js";
3
3
  import { Effect, FileSystem, Option } from "effect";
4
+ import { LenientManifest } from "@effected/package-json";
4
5
 
5
6
  // src/programs/resolve-session-for-recording.ts
6
7
  /**
@@ -34,10 +35,11 @@ const resolveSessionForRecording = (input) => Effect.gen(function* () {
34
35
  return Option.getOrThrowWith(created, () => /* @__PURE__ */ new Error(`upsertSession succeeded but row not found for ${input.chatId}`));
35
36
  });
36
37
  /**
37
- * Best-effort `package.json#name` read through the injected `FileSystem`.
38
+ * Best-effort `package.json#name` read through the injected `FileSystem`
39
+ * (`@effected/package-json`'s `LenientManifest`: a non-string name is absent).
38
40
  * Any read or parse failure resolves to `undefined` — the caller falls
39
41
  * back to `"unknown"`, exactly as the former sync `readFileSync` probe did.
40
42
  */
41
- const readPackageName = (fs, cwd) => fs.readFileString(`${cwd}/package.json`).pipe(Effect.flatMap((raw) => Effect.try(() => JSON.parse(raw))), Effect.map((parsed) => typeof parsed.name === "string" ? parsed.name : void 0), Effect.catch(() => Effect.succeed(void 0)));
43
+ const readPackageName = (fs, cwd) => fs.readFileString(`${cwd}/package.json`).pipe(Effect.flatMap(LenientManifest.parse), Effect.map((manifest) => manifest.name), Effect.orElseSucceed(() => void 0));
42
44
 
43
45
  export { resolveSessionForRecording };
@@ -1,41 +1,13 @@
1
+ import { Result } from "effect";
2
+ import { ShellExports } from "@effected/env";
1
3
  import { join, resolve } from "node:path";
2
4
  import { readFileSync, readdirSync, statSync } from "node:fs";
3
5
 
4
6
  // src/programs/session-env.ts
5
- const EXPORT_LINE = /^export ([A-Z_][A-Z0-9_]*)=(.*)$/;
6
- /**
7
- * Undo the `printf '%q'` quoting the SessionStart hook applies to export
8
- * values. UUIDs and plain paths arrive bare; values with specials arrive
9
- * as `$'...'`, `'...'`, `"..."`, or backslash-escaped words.
10
- */
11
- const unquote = (raw) => {
12
- let v = raw.trim();
13
- if (v.startsWith("$'") && v.endsWith("'") && v.length >= 3) v = v.slice(2, -1);
14
- else if (v.startsWith("'") && v.endsWith("'") || v.startsWith("\"") && v.endsWith("\"")) {
15
- if (v.length >= 2) v = v.slice(1, -1);
16
- }
17
- return v.replace(/\\(.)/g, "$1");
18
- };
19
- /**
20
- * Parse `export KEY=value` lines from a session-env hook file into a
21
- * plain record. Non-export lines are ignored.
22
- *
23
- * @param content - the raw text of a session-env hook file
24
- * @returns a record of export names to unquoted values
25
- * @public
26
- */
27
- const parseSessionEnvExports = (content) => {
28
- const out = {};
29
- for (const line of content.split("\n")) {
30
- const m = EXPORT_LINE.exec(line.trim());
31
- if (m?.[1] !== void 0 && m[2] !== void 0) out[m[1]] = unquote(m[2]);
32
- }
33
- return out;
34
- };
35
7
  /** The default {@link SessionEnvFileSystem}: the real disk through `node:fs`. */
36
8
  const nodeSessionEnvFileSystem = {
37
9
  readDirectory: (path) => readdirSync(path),
38
- readFile: (path) => readFileSync(path, "utf8"),
10
+ readFile: (path) => readFileSync(path),
39
11
  mtimeMs: (path) => statSync(path).mtimeMs
40
12
  };
41
13
  /**
@@ -71,7 +43,9 @@ const recoverSessionContextFromSessionEnv = (opts) => {
71
43
  const file = join(root, entry, "vitest-agent-hook.sh");
72
44
  try {
73
45
  const mtimeMs = fs.mtimeMs(file);
74
- const env = parseSessionEnvExports(fs.readFile(file));
46
+ const parsed = ShellExports.parseBytesResult(fs.readFile(file));
47
+ if (Result.isFailure(parsed)) continue;
48
+ const env = parsed.success;
75
49
  const chatId = env.VITEST_AGENT_CHAT_ID;
76
50
  const conversationId = env.VITEST_AGENT_CONVERSATION_ID;
77
51
  const mainAgentId = env.VITEST_AGENT_MAIN_AGENT_ID ?? env.VITEST_AGENT_AGENT_ID;
@@ -90,4 +64,4 @@ const recoverSessionContextFromSessionEnv = (opts) => {
90
64
  return best === null ? null : best.ctx;
91
65
  };
92
66
 
93
- export { parseSessionEnvExports, recoverSessionContextFromSessionEnv };
67
+ export { recoverSessionContextFromSessionEnv };
@@ -1,9 +1,17 @@
1
- import { canonicalizeGitUrl, gitUrlToProjectKey, normalizeWorkspaceKey } from "@vitest-agent/sdk";
2
- import { Context } from "effect";
1
+ import { canonicalizeGitUrl, gitUrlToProjectKey } from "@vitest-agent/sdk";
2
+ import { Context, Result } from "effect";
3
+ import { AppDirs } from "@effected/xdg";
3
4
 
4
5
  // src/services/ProjectIdentity.ts
5
6
  const isUseful = (value) => value !== void 0 && value.trim().length > 0;
6
7
  /**
8
+ * The project key a name maps to: `@effected/xdg`'s `AppDirs.segment`, the
9
+ * former `normalizeWorkspaceKey` rules byte for byte. `null` when the name
10
+ * maps to `.` or `..`, which would name a directory outside the data root;
11
+ * the resolver then falls through to the next source.
12
+ */
13
+ const keyOf = (name) => Result.getOrNull(AppDirs.segment(name));
14
+ /**
7
15
  * Pure priority resolver. Returns `null` when no candidate produces a
8
16
  * usable value — the caller wraps `null` into a tagged error.
9
17
  *
@@ -15,16 +23,22 @@ const isUseful = (value) => value !== void 0 && value.trim().length > 0;
15
23
  * @public
16
24
  */
17
25
  const resolveProjectIdentityFromCandidates = (candidates) => {
18
- if (isUseful(candidates.explicit)) return {
19
- projectKey: normalizeWorkspaceKey(candidates.explicit),
20
- canonicalForm: candidates.explicit,
21
- source: "explicit"
22
- };
23
- if (isUseful(candidates.toml)) return {
24
- projectKey: normalizeWorkspaceKey(candidates.toml),
25
- canonicalForm: candidates.toml,
26
- source: "toml"
27
- };
26
+ if (isUseful(candidates.explicit)) {
27
+ const key = keyOf(candidates.explicit);
28
+ if (key !== null) return {
29
+ projectKey: key,
30
+ canonicalForm: candidates.explicit,
31
+ source: "explicit"
32
+ };
33
+ }
34
+ if (isUseful(candidates.toml)) {
35
+ const key = keyOf(candidates.toml);
36
+ if (key !== null) return {
37
+ projectKey: key,
38
+ canonicalForm: candidates.toml,
39
+ source: "toml"
40
+ };
41
+ }
28
42
  if (isUseful(candidates.gitRemote)) {
29
43
  const canonical = canonicalizeGitUrl(candidates.gitRemote);
30
44
  const key = gitUrlToProjectKey(candidates.gitRemote);
@@ -43,11 +57,14 @@ const resolveProjectIdentityFromCandidates = (candidates) => {
43
57
  source: "package-repository"
44
58
  };
45
59
  }
46
- if (isUseful(candidates.packageJsonName)) return {
47
- projectKey: normalizeWorkspaceKey(candidates.packageJsonName),
48
- canonicalForm: candidates.packageJsonName,
49
- source: "package-name"
50
- };
60
+ if (isUseful(candidates.packageJsonName)) {
61
+ const key = keyOf(candidates.packageJsonName);
62
+ if (key !== null) return {
63
+ projectKey: key,
64
+ canonicalForm: candidates.packageJsonName,
65
+ source: "package-name"
66
+ };
67
+ }
51
68
  return null;
52
69
  };
53
70
  /**