@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.
- package/index.d.ts +144 -40
- package/index.js +7 -4
- package/layers/ConfigLive.js +31 -4
- package/layers/DataStoreLive.js +2 -1
- package/layers/EnvironmentDetectorLive.js +3 -6
- package/layers/LoggerLive.js +6 -14
- package/layers/ProjectDiscoveryLive.js +25 -23
- package/layers/ProjectIdentityLive.js +36 -36
- package/layers/RunContextLive.js +33 -47
- package/package.json +15 -13
- package/platform.js +9 -3
- package/programs/hook-paths.js +29 -20
- package/programs/platform-sidecar.js +28 -12
- package/programs/resolve-session-for-recording.js +4 -2
- package/programs/session-env.js +7 -33
- package/services/ProjectIdentity.js +34 -17
- package/stores.js +27 -18
- package/utils/ensure-migrated.js +33 -37
- package/utils/format-fatal-error.js +55 -0
- package/utils/host-metadata.js +101 -0
- package/utils/package-manifest.js +21 -0
- package/utils/resolve-data-path.js +21 -4
- package/utils/resolve-project-key-from-cwd.js +26 -35
- package/utils/resolve-workspace-key.js +8 -4
- package/version.js +1 -1
|
@@ -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 {
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
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
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
125
|
-
return { resolve: (workspaceRoot, options = {}) => collectCandidates(workspaceRoot, options).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(WorkspaceDiscovery, discovery), Effect.provideService(VitestAgentConfigFile, configFile), Effect.provideService(
|
|
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
|
package/layers/RunContextLive.js
CHANGED
|
@@ -1,73 +1,59 @@
|
|
|
1
1
|
import { AgentContext, RunContext, RunContextService } from "../services/RunContext.js";
|
|
2
|
-
import { probeHostMetadataFromEnv } from "
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
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
|
|
32
|
-
gitCommitSha
|
|
33
|
-
gitDirty
|
|
34
|
-
gitUpstream
|
|
35
|
-
gitWorktreeDir
|
|
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,
|
|
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:
|
|
51
|
-
startGitCommitSha:
|
|
52
|
-
startWorktreeDir:
|
|
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)
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
51
|
+
const git = yield* Git;
|
|
66
52
|
return {
|
|
67
|
-
captureRunContext: (cwd) => captureRunContextEffect(cwd,
|
|
68
|
-
captureAgentContext: (cwd) => captureAgentContextEffect(cwd,
|
|
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": "
|
|
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.
|
|
46
|
-
"@effected/cli": "^0.
|
|
47
|
-
"@effected/config-file": "^0.
|
|
48
|
-
"@effected/engine": "^0.
|
|
49
|
-
"@effected/env": "^0.
|
|
50
|
-
"@effected/
|
|
51
|
-
"@effected/
|
|
52
|
-
"@effected/
|
|
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.
|
|
55
|
-
"@effected/workspaces": "^0.
|
|
56
|
-
"@effected/xdg": "^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": "
|
|
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,
|
|
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({
|
|
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:
|
|
45
|
+
migrations: Store.fromMigratorRecord(migrations),
|
|
40
46
|
...LEDGER_OPTIONS
|
|
41
47
|
}).pipe(Layer.provide(SqliteLayer))))
|
|
42
48
|
};
|
package/programs/hook-paths.js
CHANGED
|
@@ -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
|
|
3
|
+
import { LEDGER_OPTIONS } from "../stores.js";
|
|
4
4
|
import { ProjectIdentityNotResolvableError } from "@vitest-agent/sdk";
|
|
5
|
-
import {
|
|
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:
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* `
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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 =
|
|
82
|
-
const dir =
|
|
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
|
|
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(
|
|
32
|
-
|
|
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(
|
|
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 };
|
package/programs/session-env.js
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
67
|
+
export { recoverSessionContextFromSessionEnv };
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import { canonicalizeGitUrl, gitUrlToProjectKey
|
|
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))
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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))
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
/**
|