@vitest-agent/engine 3.0.1 → 3.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/index.d.ts CHANGED
@@ -1565,7 +1565,7 @@ declare const ProjectDiscovery_base: Context.ServiceClass<ProjectDiscovery, "vit
1565
1565
  /** @public */
1566
1566
  export declare class ProjectDiscovery extends ProjectDiscovery_base {}
1567
1567
  /** @public */
1568
- export declare const ProjectDiscoveryLive: Layer.Layer<ProjectDiscovery, never, FileSystem.FileSystem>;
1568
+ export declare const ProjectDiscoveryLive: Layer.Layer<ProjectDiscovery, never, FileSystem.FileSystem | Path.Path>;
1569
1569
  /** @public */
1570
1570
  export declare const ProjectDiscoveryTest: {
1571
1571
  readonly layer: (entries: ReadonlyArray<TestFileEntry>) => Layer.Layer<ProjectDiscovery>;
@@ -1865,8 +1865,8 @@ export interface SqliteStack {
1865
1865
  }
1866
1866
  /**
1867
1867
  * The Node platform services every SQLite stack and every Live layer share.
1868
- * `NodeServices.layer` aggregates FileSystem | Path | ChildProcessSpawner |
1869
- * Crypto | Stdio | Terminal.
1868
+ * `NodeServices.layer` aggregates ChildProcessSpawner | FileSystem | Path |
1869
+ * Stdio | Terminal; since Effect 4.1 it carries no crypto service.
1870
1870
  * @public
1871
1871
  */
1872
1872
  export declare const NodePlatformLayer: Layer.Layer<NodeServices.NodeServices, never, never>;
@@ -2207,7 +2207,7 @@ export interface RecordTddArtifactResult {
2207
2207
  * @param input - the artifact to record, keyed by host chat id
2208
2208
  * @public
2209
2209
  */
2210
- export declare const recordTddArtifactEffect: (input: RecordTddArtifactInput) => Effect.Effect<RecordTddArtifactResult, DataStoreError | Error, DataReader | DataStore | FileSystem.FileSystem>;
2210
+ export declare const recordTddArtifactEffect: (input: RecordTddArtifactInput) => Effect.Effect<RecordTddArtifactResult, DataStoreError | Error, DataReader | DataStore | FileSystem.FileSystem | Path.Path>;
2211
2211
  /**
2212
2212
  * Input for {@link recordTddArtifactByTaskIdEffect}: an artifact keyed by an
2213
2213
  * explicit TDD task id.
@@ -2290,7 +2290,7 @@ export interface DispatchRecordTddArtifactInput {
2290
2290
  * @param input - the artifact plus either a chat id or a TDD task id
2291
2291
  * @public
2292
2292
  */
2293
- export declare const dispatchRecordTddArtifactEffect: (input: DispatchRecordTddArtifactInput) => Effect.Effect<RecordTddArtifactResult, DataStoreError | Error, DataReader | DataStore | FileSystem.FileSystem>;
2293
+ export declare const dispatchRecordTddArtifactEffect: (input: DispatchRecordTddArtifactInput) => Effect.Effect<RecordTddArtifactResult, DataStoreError | Error, DataReader | DataStore | FileSystem.FileSystem | Path.Path>;
2294
2294
  /**
2295
2295
  * Outcome of {@link parseAndValidateTurnPayload}: the decoded `TurnPayload`
2296
2296
  * on success, or a human-readable error string.
@@ -2308,6 +2308,9 @@ export type ParseResult = {
2308
2308
  * Parse a raw JSON string and validate it against the `TurnPayload` schema
2309
2309
  * without throwing.
2310
2310
  *
2311
+ * Text that is not JSON at all reads `Invalid JSON: …`; JSON whose body is
2312
+ * not a `TurnPayload` reads `Invalid TurnPayload: …`.
2313
+ *
2311
2314
  * @param raw - the JSON text delivered by the hook
2312
2315
  * @returns the decoded payload, or an error describing why it was rejected
2313
2316
  * @public
@@ -2350,7 +2353,7 @@ export interface RecordTurnInput {
2350
2353
  */
2351
2354
  export declare const recordTurnEffect: (input: RecordTurnInput) => Effect.Effect<{
2352
2355
  turnId: number;
2353
- }, Error, DataReader | DataStore | FileSystem.FileSystem>;
2356
+ }, Error, DataReader | DataStore | FileSystem.FileSystem | Path.Path>;
2354
2357
  /**
2355
2358
  * Input for {@link recordRunWorkspaceChangesEffect}: one commit plus the
2356
2359
  * files it touched.
@@ -2485,7 +2488,7 @@ export interface ResolveSessionForRecordingInput {
2485
2488
  * @returns the resolved (or newly created) session row
2486
2489
  * @public
2487
2490
  */
2488
- export declare const resolveSessionForRecording: (input: ResolveSessionForRecordingInput) => Effect.Effect<SessionDetail, DataStoreError, DataReader | DataStore | FileSystem.FileSystem>;
2491
+ export declare const resolveSessionForRecording: (input: ResolveSessionForRecordingInput) => Effect.Effect<SessionDetail, DataStoreError, DataReader | DataStore | FileSystem.FileSystem | Path.Path>;
2489
2492
  /**
2490
2493
  * Recovered session attribution context — the canonical UUIDs the
2491
2494
  * SessionStart hook wrote to `${CLAUDE_ENV_FILE}` (auto-sourced into the
@@ -2854,7 +2857,7 @@ export interface ResolveDataPathOptions {
2854
2857
  * @param options - Optional programmatic overrides.
2855
2858
  * @public
2856
2859
  */
2857
- export declare const resolveDataPath: (projectDir: string, options?: ResolveDataPathOptions) => Effect.Effect<string, import("@effected/xdg").AppDirsError | import("effect/PlatformError").PlatformError | ProjectIdentitySourceError | import("@effected/config-file").ConfigReadError<import("@effected/config-file").ConfigCodecError>, AppDirs$1 | FileSystem.FileSystem | VitestAgentConfigFile>;
2860
+ export declare const resolveDataPath: (projectDir: string, options?: ResolveDataPathOptions) => Effect.Effect<string, import("@effected/xdg").AppDirsError | import("effect/PlatformError").PlatformError | ProjectIdentitySourceError | import("@effected/config-file").ConfigReadError<import("@effected/config-file").ConfigCodecError>, AppDirs$1 | FileSystem.FileSystem | import("effect/Path").Path | VitestAgentConfigFile>;
2858
2861
  /**
2859
2862
  * Compute the project key for a workspace by reading `package.json`
2860
2863
  * fields directly. Returns the canonical `host__path` form when a git
@@ -2864,15 +2867,17 @@ export declare const resolveDataPath: (projectDir: string, options?: ResolveData
2864
2867
  * (or `"anonymous-project"`) so callers don't have to handle the
2865
2868
  * empty case.
2866
2869
  *
2867
- * Synchronous and reads the real disk through `node:fs`; Effect callers
2868
- * that already hold a `FileSystem` use
2869
- * {@link resolveProjectKeyFromCwdEffect}, which applies the same rules.
2870
+ * Synchronous and reads the real disk through `node:fs`: it runs
2871
+ * {@link resolveProjectKeyFromCwdEffect} under `Effect.runSync` over a
2872
+ * read-only synchronous `FileSystem` and the host's Node `Path`. Effect
2873
+ * callers that already hold a `FileSystem` and `Path` use the Effect form
2874
+ * directly.
2870
2875
  * @public
2871
2876
  */
2872
2877
  export declare const resolveProjectKeyFromCwd: (cwd: string) => string;
2873
2878
  /**
2874
2879
  * {@link resolveProjectKeyFromCwd} read through the ambient
2875
- * `FileSystem` service instead of `node:fs` — same upward walk to the
2880
+ * `FileSystem` and `Path` services instead of `node:fs` — same upward walk to the
2876
2881
  * nearest `package.json` (`@effected/walker`'s `Walker.ascend` +
2877
2882
  * `Walker.findUpward`), same precedence, same basename fallback.
2878
2883
  *
@@ -2883,7 +2888,7 @@ export declare const resolveProjectKeyFromCwd: (cwd: string) => string;
2883
2888
  * @param cwd - Absolute directory to start the upward walk from.
2884
2889
  * @public
2885
2890
  */
2886
- export declare const resolveProjectKeyFromCwdEffect: (cwd: string) => Effect.Effect<string, never, FileSystem.FileSystem>;
2891
+ export declare const resolveProjectKeyFromCwdEffect: (cwd: string) => Effect.Effect<string, never, FileSystem.FileSystem | Path.Path>;
2887
2892
  /**
2888
2893
  * Resolve the normalized workspace key for the workspace containing
2889
2894
  * `projectDir`.
@@ -1,10 +1,10 @@
1
1
  import { DataStore, INLINE_ATTACHMENT_BODY_CAP_BYTES } from "../services/DataStore.js";
2
2
  import { PersistBatch, withPersistSavepoint } from "../utils/persist-transaction.js";
3
- import { Agent, AgentNotFoundError, BehaviorNotFoundError, DataStoreError, GoalNotFoundError, IdempotencyHit, IllegalStatusTransitionError, RegistrationConflictError, TddTaskAlreadyEndedError, TddTaskNotFoundError, coerceErrorText, extractSqlReason } from "@vitest-agent/sdk";
4
- import { Clock, Effect, Layer, Option } from "effect";
3
+ import { Agent, AgentNotFoundError, BehaviorNotFoundError, DataStoreError, FileEditPayload, GoalNotFoundError, IdempotencyHit, IllegalStatusTransitionError, RegistrationConflictError, TddTaskAlreadyEndedError, TddTaskNotFoundError, ToolResultPayload, coerceErrorText, extractSqlReason } from "@vitest-agent/sdk";
4
+ import { Clock, Effect, Layer, Option, Schema, Struct } from "effect";
5
5
  import { SqlClient } from "effect/sql/SqlClient";
6
6
  import { Buffer } from "node:buffer";
7
- import { randomUUID } from "node:crypto";
7
+ import { SecureRandom } from "effect/crypto";
8
8
 
9
9
  // src/layers/DataStoreLive.ts
10
10
  /**
@@ -50,6 +50,8 @@ const behaviorRowFromDb = (row) => ({
50
50
  createdAt: row.created_at
51
51
  });
52
52
  const boolToInt = (v) => v === void 0 ? null : v ? 1 : 0;
53
+ const decodeFileEditPayload = Schema.decodeUnknownEffect(Schema.fromJsonString(FileEditPayload.mapFields(Struct.omit(["type"]))));
54
+ const decodeToolResultPayload = Schema.decodeUnknownEffect(Schema.fromJsonString(ToolResultPayload.mapFields(Struct.omit(["type"]))));
53
55
  /** @public */
54
56
  const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
55
57
  const sql = yield* SqlClient;
@@ -456,45 +458,42 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
456
458
  if (input.turnNo !== void 0) yield* sql`INSERT INTO turns (session_id, turn_no, type, payload, occurred_at) VALUES (${input.sessionId}, ${input.turnNo}, ${input.type}, ${input.payload}, ${input.occurredAt})`;
457
459
  else yield* sql`INSERT INTO turns (session_id, turn_no, type, payload, occurred_at) SELECT ${input.sessionId}, COALESCE(MAX(turn_no), 0) + 1, ${input.type}, ${input.payload}, ${input.occurredAt} FROM turns WHERE session_id = ${input.sessionId}`;
458
460
  const turnId = (yield* sql`SELECT last_insert_rowid() as id`)[0].id;
459
- if (input.type === "file_edit" || input.type === "tool_result") {
460
- const payload = yield* Effect.try({
461
- try: () => JSON.parse(input.payload),
462
- catch: (e) => new DataStoreError({
463
- operation: "write",
464
- table: input.type === "file_edit" ? "file_edits" : "tool_invocations",
465
- reason: `invalid turn payload JSON: ${e.message}`
466
- })
467
- });
468
- if (input.type === "file_edit") {
469
- const filePath = payload.file_path;
470
- yield* sql`INSERT OR IGNORE INTO files (path) VALUES (${filePath})`;
471
- const fileId = (yield* sql`SELECT id FROM files WHERE path = ${filePath}`)[0].id;
472
- yield* sql`
473
- INSERT INTO file_edits (turn_id, file_id, edit_kind, lines_added, lines_removed, diff)
474
- VALUES (
475
- ${turnId},
476
- ${fileId},
477
- ${payload.edit_kind},
478
- ${payload.lines_added ?? null},
479
- ${payload.lines_removed ?? null},
480
- ${payload.diff ?? null}
481
- )
482
- `;
483
- } else {
484
- const rawToolName = payload.tool_name;
485
- const toolName = typeof rawToolName === "string" && rawToolName.startsWith("mcp__") ? rawToolName.split("__").at(-1) ?? rawToolName : rawToolName;
486
- yield* sql`
487
- INSERT INTO tool_invocations (turn_id, tool_name, params_hash, result_summary, duration_ms, success)
488
- VALUES (
489
- ${turnId},
490
- ${toolName},
491
- ${null},
492
- ${payload.result_summary ?? null},
493
- ${payload.duration_ms ?? null},
494
- ${boolToInt(payload.success) ?? 0}
495
- )
496
- `;
497
- }
461
+ const invalidPayload = (table) => (e) => new DataStoreError({
462
+ operation: "write",
463
+ table,
464
+ reason: `invalid turn payload: ${e.message}`
465
+ });
466
+ if (input.type === "file_edit") {
467
+ const payload = yield* decodeFileEditPayload(input.payload).pipe(Effect.mapError(invalidPayload("file_edits")));
468
+ const filePath = payload.file_path;
469
+ yield* sql`INSERT OR IGNORE INTO files (path) VALUES (${filePath})`;
470
+ const fileId = (yield* sql`SELECT id FROM files WHERE path = ${filePath}`)[0].id;
471
+ yield* sql`
472
+ INSERT INTO file_edits (turn_id, file_id, edit_kind, lines_added, lines_removed, diff)
473
+ VALUES (
474
+ ${turnId},
475
+ ${fileId},
476
+ ${payload.edit_kind},
477
+ ${payload.lines_added ?? null},
478
+ ${payload.lines_removed ?? null},
479
+ ${payload.diff ?? null}
480
+ )
481
+ `;
482
+ } else if (input.type === "tool_result") {
483
+ const payload = yield* decodeToolResultPayload(input.payload).pipe(Effect.mapError(invalidPayload("tool_invocations")));
484
+ const rawToolName = payload.tool_name;
485
+ const toolName = rawToolName.startsWith("mcp__") ? rawToolName.split("__").at(-1) ?? rawToolName : rawToolName;
486
+ yield* sql`
487
+ INSERT INTO tool_invocations (turn_id, tool_name, params_hash, result_summary, duration_ms, success)
488
+ VALUES (
489
+ ${turnId},
490
+ ${toolName},
491
+ ${null},
492
+ ${payload.result_summary ?? null},
493
+ ${payload.duration_ms ?? null},
494
+ ${boolToInt(payload.success) ?? 0}
495
+ )
496
+ `;
498
497
  }
499
498
  return turnId;
500
499
  })).pipe(Effect.annotateLogs("service", "DataStore"), Effect.mapError((e) => e instanceof DataStoreError ? e : new DataStoreError({
@@ -1295,7 +1294,7 @@ const DataStoreLive = Layer.effect(DataStore, Effect.gen(function* () {
1295
1294
  const parentSession = parentRows[0].session_id;
1296
1295
  if (parentSession !== input.sessionId && parentSession !== registeringParentSessionId) return yield* Effect.fail(new RegistrationConflictError({ reason: `parent agent ${input.parentAgentId} belongs to session ${parentSession}, which is neither the registering session ${input.sessionId} nor its parent session ${registeringParentSessionId ?? "(none)"}` }));
1297
1296
  }
1298
- const agentId = input.agentId ?? randomUUID();
1297
+ const agentId = input.agentId ?? SecureRandom.uuidV4Unsafe();
1299
1298
  yield* sql`
1300
1299
  INSERT INTO agents (
1301
1300
  agent_id, session_id, parent_agent_id, conversation_id, agent_type,
@@ -2,7 +2,7 @@ import { PerClientSessionMapReader, PerClientSessionMapWriter } from "../service
2
2
  import { DataStoreError, extractSqlReason } from "@vitest-agent/sdk";
3
3
  import { Effect, Layer, Option } from "effect";
4
4
  import { SqlClient } from "effect/sql/SqlClient";
5
- import { randomUUID } from "node:crypto";
5
+ import { SecureRandom } from "effect/crypto";
6
6
  import { basename } from "node:path";
7
7
 
8
8
  // src/layers/PerClientSessionMapLive.ts
@@ -83,7 +83,7 @@ const PerClientSessionMapWriterLive = Layer.effect(PerClientSessionMapWriter, Ef
83
83
  yield* sql`UPDATE conversation_map SET last_seen_at = ${now} WHERE transcript_uuid = ${uuid}`;
84
84
  return existing[0].conversation_id;
85
85
  }
86
- const conversationId = randomUUID();
86
+ const conversationId = SecureRandom.uuidV4Unsafe();
87
87
  yield* sql`
88
88
  INSERT INTO conversation_map (transcript_uuid, transcript_path, conversation_id, first_seen_at, last_seen_at)
89
89
  VALUES (${uuid}, ${transcriptPath}, ${conversationId}, ${now}, ${now})
@@ -102,7 +102,7 @@ const PerClientSessionMapWriterLive = Layer.effect(PerClientSessionMapWriter, Ef
102
102
  mainAgentId: existing[0].main_agent_id,
103
103
  conversationId: existing[0].conversation_id
104
104
  };
105
- const mainAgentId = randomUUID();
105
+ const mainAgentId = SecureRandom.uuidV4Unsafe();
106
106
  const now = Date.now();
107
107
  yield* sql`
108
108
  INSERT INTO session_map (
@@ -1,6 +1,7 @@
1
1
  import { ProjectDiscovery } from "../services/ProjectDiscovery.js";
2
2
  import { DiscoveryError, NON_DISCOVERABLE_DIRS } from "@vitest-agent/sdk";
3
3
  import { Effect, FileSystem, Layer, Path } from "effect";
4
+ import { ErrorText } from "@effected/engine";
4
5
  import { GlobPattern, GlobPatternOptions } from "@effected/glob";
5
6
  import { descend } from "@effected/walker";
6
7
 
@@ -17,39 +18,39 @@ function testFileToSource(testFile) {
17
18
  return testFile.replace(/\.(test|spec)\.(ts|tsx)$/, ".$2");
18
19
  }
19
20
  /**
20
- * Every test file under `rootDir`, as `${rootDir}/<relative>` paths sorted by
21
- * relative path. `@effected/walker`'s `descend` follows directory symlinks
21
+ * Every test file under `rootDir`, each joined to `rootDir` with the ambient
22
+ * `Path` (backslashes on win32), sorted by relative path. `@effected/walker`'s `descend` follows directory symlinks
22
23
  * (as the former `fs.stat`-based walk did) under its ancestor-chain cycle
23
24
  * guard, so a link back to an ancestor is skipped instead of recursing until
24
25
  * the OS answers `ELOOP` and failing the whole discovery.
25
26
  */
26
27
  const walkTestFiles = (rootDir) => Effect.gen(function* () {
27
28
  const pattern = yield* GlobPattern.compile(TEST_FILE_GLOB, TEST_FILE_GLOB_OPTIONS);
28
- const relative = yield* descend(pattern, {
29
+ const path = yield* Path.Path;
30
+ return (yield* descend(pattern, {
29
31
  cwd: rootDir,
30
32
  prune: SKIP_DIRS,
31
33
  followSymlinks: true
32
- });
33
- const base = rootDir.endsWith("/") ? rootDir.slice(0, -1) : rootDir;
34
- return relative.map((rel) => `${base}/${rel}`);
35
- }).pipe(Effect.provide(Path.layer));
34
+ })).map((rel) => path.join(rootDir, rel));
35
+ });
36
36
  /** @public */
37
37
  const ProjectDiscoveryLive = Layer.effect(ProjectDiscovery, Effect.gen(function* () {
38
38
  const fs = yield* FileSystem.FileSystem;
39
+ const path = yield* Path.Path;
39
40
  const mapTestToSource = (testFile) => Effect.gen(function* () {
40
41
  const sourceFile = testFileToSource(testFile);
41
42
  return (yield* fs.exists(sourceFile)) ? [sourceFile] : [];
42
43
  }).pipe(Effect.mapError((error) => new DiscoveryError({
43
44
  operation: "stat",
44
45
  path: testFile,
45
- reason: String(error)
46
+ reason: ErrorText.describe(error)
46
47
  })));
47
48
  return {
48
49
  discoverTestFiles: (rootDir) => Effect.gen(function* () {
49
- const testFiles = yield* walkTestFiles(rootDir).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.mapError((error) => new DiscoveryError({
50
+ const testFiles = yield* walkTestFiles(rootDir).pipe(Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), Effect.mapError((error) => new DiscoveryError({
50
51
  operation: "glob",
51
52
  path: rootDir,
52
- reason: String(error)
53
+ reason: ErrorText.describe(error)
53
54
  })));
54
55
  const entries = [];
55
56
  for (const testFile of testFiles) {
@@ -6,11 +6,10 @@ import { ProjectIdentityNotResolvableError, ProjectIdentitySourceError, VitestAg
6
6
  import { Effect, FileSystem, Layer, Option, Result } from "effect";
7
7
  import { WorkspaceDiscovery } from "@effected/workspaces";
8
8
  import { AppDirs } from "@effected/xdg";
9
+ import { ErrorText } from "@effected/engine";
9
10
  import { LenientManifest } from "@effected/package-json";
10
11
 
11
12
  // src/layers/ProjectIdentityLive.ts
12
- /** The one-line text of a kit failure, for `ProjectIdentitySourceError.detail`. */
13
- const detailOf = (cause) => cause instanceof Error && cause.message.length > 0 ? cause.message : String(cause);
14
13
  /**
15
14
  * The git remote is an OPTIONAL source: a repository with no
16
15
  * `remote.origin.url`, a directory that is not a git checkout, or a machine
@@ -38,14 +37,14 @@ const readPackageRepoUrl = (packageJsonPath) => Effect.gen(function* () {
38
37
  const raw = yield* (yield* FileSystem.FileSystem).readFileString(packageJsonPath).pipe(Effect.map(Option.some), Effect.catchTag("PlatformError", (error) => error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(new ProjectIdentitySourceError({
39
38
  source: "package-json",
40
39
  path: packageJsonPath,
41
- detail: detailOf(error),
40
+ detail: ErrorText.describe(error),
42
41
  cause: error
43
42
  }))));
44
43
  if (Option.isNone(raw)) return Option.none();
45
44
  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
45
  source: "package-json",
47
46
  path: packageJsonPath,
48
- detail: detailOf(cause),
47
+ detail: ErrorText.describe(cause),
49
48
  cause
50
49
  }))));
51
50
  return Option.flatMap(manifest, (m) => Option.fromNullishOr(repositoryUrlOf(m.repository)));
@@ -54,14 +53,14 @@ const collectCandidates = (workspaceRoot, options) => Effect.gen(function* () {
54
53
  const tomlConfig = yield* (yield* VitestAgentConfigFile).loadOrDefault(new VitestAgentConfig({})).pipe(Effect.mapError((cause) => new ProjectIdentitySourceError({
55
54
  source: "toml",
56
55
  path: cause._tag === "ConfigValidationError" ? Option.getOrElse(cause.path, () => workspaceRoot) : cause.path ?? workspaceRoot,
57
- detail: detailOf(cause),
56
+ detail: ErrorText.describe(cause),
58
57
  cause
59
58
  })));
60
59
  const gitRemote = yield* readGitRemote(workspaceRoot);
61
60
  const root = (yield* (yield* WorkspaceDiscovery).listPackagesIn(workspaceRoot).pipe(Effect.catchTag("WorkspaceRootNotFoundError", () => Effect.succeed([])), Effect.mapError((cause) => new ProjectIdentitySourceError({
62
61
  source: "workspace",
63
62
  path: cause._tag === "WorkspacePatternError" ? cause.root : cause.path,
64
- detail: detailOf(cause),
63
+ detail: ErrorText.describe(cause),
65
64
  cause
66
65
  })))).find((pkg) => pkg.isRootWorkspace);
67
66
  const packageJsonRepoUrl = root ? yield* readPackageRepoUrl(root.packageJsonPath) : Option.none();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/engine",
3
- "version": "3.0.1",
3
+ "version": "3.1.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": [
@@ -40,25 +40,26 @@
40
40
  "./package.json": "./package.json"
41
41
  },
42
42
  "dependencies": {
43
- "@effect/platform-node": "^4.0.7",
44
- "@effect/sql-sqlite-node": "^4.0.7",
45
- "@effected/app": "^0.29.0",
46
- "@effected/cli": "^0.24.0",
47
- "@effected/config-file": "^0.22.0",
48
- "@effected/engine": "^0.10.0",
49
- "@effected/env": "^0.7.0",
50
- "@effected/git": "^0.27.0",
51
- "@effected/glob": "^0.16.0",
52
- "@effected/jsonc": "^0.21.0",
53
- "@effected/package-json": "^0.27.0",
54
- "@effected/store": "^0.21.0",
55
- "@effected/toml": "^0.17.0",
56
- "@effected/walker": "^0.22.0",
57
- "@effected/workspaces": "^0.40.0",
58
- "@effected/xdg": "^0.17.0",
59
- "@effected/yaml": "^0.26.0",
60
- "@vitest-agent/sdk": "9.0.1",
61
- "effect": "^4.0.7"
43
+ "@effect/platform-node": "~4.1.1",
44
+ "@effect/sql-sqlite-node": "~4.1.1",
45
+ "@effected/app": "^0.30.0",
46
+ "@effected/cli": "^0.25.0",
47
+ "@effected/config-file": "^0.23.0",
48
+ "@effected/engine": "^0.11.0",
49
+ "@effected/env": "^0.9.0",
50
+ "@effected/git": "^0.29.0",
51
+ "@effected/glob": "^0.17.0",
52
+ "@effected/jsonc": "^0.22.0",
53
+ "@effected/memfs": "^0.22.0",
54
+ "@effected/package-json": "^0.28.0",
55
+ "@effected/store": "^0.22.0",
56
+ "@effected/toml": "^0.18.0",
57
+ "@effected/walker": "^0.24.0",
58
+ "@effected/workspaces": "^0.41.0",
59
+ "@effected/xdg": "^0.18.0",
60
+ "@effected/yaml": "^0.27.0",
61
+ "@vitest-agent/sdk": "9.0.2",
62
+ "effect": "~4.1.1"
62
63
  },
63
64
  "engines": {
64
65
  "node": ">=24.11.0"
package/platform.js CHANGED
@@ -15,8 +15,8 @@ import { Store } from "@effected/store";
15
15
  // src/platform.ts
16
16
  /**
17
17
  * The Node platform services every SQLite stack and every Live layer share.
18
- * `NodeServices.layer` aggregates FileSystem | Path | ChildProcessSpawner |
19
- * Crypto | Stdio | Terminal.
18
+ * `NodeServices.layer` aggregates ChildProcessSpawner | FileSystem | Path |
19
+ * Stdio | Terminal; since Effect 4.1 it carries no crypto service.
20
20
  * @public
21
21
  */
22
22
  const NodePlatformLayer = NodeServices.layer;
@@ -4,9 +4,9 @@ import { LEDGER_OPTIONS } from "../stores.js";
4
4
  import { ProjectIdentityNotResolvableError } from "@vitest-agent/sdk";
5
5
  import { Effect, FileSystem, Layer, Option, Path } from "effect";
6
6
  import { AppDirs, Xdg } from "@effected/xdg";
7
+ import { LaunchContext } from "@effected/engine";
7
8
  import { Store } from "@effected/store";
8
9
  import { AppStore } from "@effected/app";
9
- import { LaunchContext } from "@effected/engine";
10
10
 
11
11
  // src/programs/hook-paths.ts
12
12
  /**
@@ -1,37 +1,31 @@
1
1
  import { DataStore } from "../services/DataStore.js";
2
2
  import { resolveSessionForRecording } from "./resolve-session-for-recording.js";
3
3
  import { TurnPayload } from "@vitest-agent/sdk";
4
- import { Effect, Schema } from "effect";
5
- import { ErrorText } from "@effected/engine";
4
+ import { Effect, Result, Schema } from "effect";
6
5
 
7
6
  // src/programs/record-turn.ts
7
+ const decodeTurnPayload = Schema.decodeUnknownResult(Schema.fromJsonString(TurnPayload));
8
8
  /**
9
9
  * Parse a raw JSON string and validate it against the `TurnPayload` schema
10
10
  * without throwing.
11
11
  *
12
+ * Text that is not JSON at all reads `Invalid JSON: …`; JSON whose body is
13
+ * not a `TurnPayload` reads `Invalid TurnPayload: …`.
14
+ *
12
15
  * @param raw - the JSON text delivered by the hook
13
16
  * @returns the decoded payload, or an error describing why it was rejected
14
17
  * @public
15
18
  */
16
19
  const parseAndValidateTurnPayload = (raw) => {
17
- let parsed;
18
- try {
19
- parsed = JSON.parse(raw);
20
- } catch (e) {
21
- return {
22
- ok: false,
23
- error: `Invalid JSON: ${ErrorText.describe(e)}`
24
- };
25
- }
26
- const decoded = Schema.decodeUnknownResult(TurnPayload)(parsed);
27
- if (decoded._tag === "Failure") return {
28
- ok: false,
29
- error: `Invalid TurnPayload: ${decoded.failure.message}`
30
- };
31
- return {
20
+ const decoded = decodeTurnPayload(raw);
21
+ if (Result.isSuccess(decoded)) return {
32
22
  ok: true,
33
23
  payload: decoded.success
34
24
  };
25
+ return {
26
+ ok: false,
27
+ error: `${decoded.failure.issue._tag === "Encoding" ? "Invalid JSON" : "Invalid TurnPayload"}: ${decoded.failure.message}`
28
+ };
35
29
  };
36
30
  /**
37
31
  * Validate a turn payload and write it as a `turns` row under the session
@@ -1,6 +1,6 @@
1
1
  import { DataReader } from "../services/DataReader.js";
2
2
  import { DataStore } from "../services/DataStore.js";
3
- import { Effect, FileSystem, Option } from "effect";
3
+ import { Effect, FileSystem, Option, Path } from "effect";
4
4
  import { LenientManifest } from "@effected/package-json";
5
5
 
6
6
  // src/programs/resolve-session-for-recording.ts
@@ -17,12 +17,13 @@ const resolveSessionForRecording = (input) => Effect.gen(function* () {
17
17
  const reader = yield* DataReader;
18
18
  const store = yield* DataStore;
19
19
  const fs = yield* FileSystem.FileSystem;
20
+ const path = yield* Path.Path;
20
21
  const exact = yield* reader.getSessionByChatId(input.chatId);
21
22
  if (Option.isSome(exact)) return exact.value;
22
23
  const synthetic = yield* reader.findSessionsByChatPrefix(`${input.chatId}-subagent-`);
23
24
  if (synthetic.length > 0) return synthetic[0];
24
25
  const cwd = input.cwd;
25
- const project = input.project ?? (yield* readPackageName(fs, cwd)) ?? "unknown";
26
+ const project = input.project ?? (yield* readPackageName(fs, path.join(cwd, "package.json"))) ?? "unknown";
26
27
  yield* store.upsertSession({
27
28
  chatId: input.chatId,
28
29
  project,
@@ -40,6 +41,6 @@ const resolveSessionForRecording = (input) => Effect.gen(function* () {
40
41
  * Any read or parse failure resolves to `undefined` — the caller falls
41
42
  * back to `"unknown"`, exactly as the former sync `readFileSync` probe did.
42
43
  */
43
- const readPackageName = (fs, cwd) => fs.readFileString(`${cwd}/package.json`).pipe(Effect.flatMap(LenientManifest.parse), Effect.map((manifest) => manifest.name), Effect.orElseSucceed(() => void 0));
44
+ const readPackageName = (fs, packageJsonPath) => fs.readFileString(packageJsonPath).pipe(Effect.flatMap(LenientManifest.parse), Effect.map((manifest) => manifest.name), Effect.orElseSucceed(() => void 0));
44
45
 
45
46
  export { resolveSessionForRecording };
@@ -1,19 +1,14 @@
1
1
  import { repositoryUrlOf } from "./package-manifest.js";
2
2
  import { gitUrlToProjectKey } from "@vitest-agent/sdk";
3
- import { Effect, FileSystem, Option, Path, Result } from "effect";
3
+ import { Effect, FileSystem, Layer, Option, Path, Result } from "effect";
4
+ import * as NodePath from "@effect/platform-node/NodePath";
4
5
  import { AppDirs } from "@effected/xdg";
5
- import { join } from "node:path";
6
6
  import { Walker } from "@effected/walker";
7
7
  import { LenientManifest } from "@effected/package-json";
8
- import { readFileSync } from "node:fs";
9
- import { nodeWalkerSyncOps } from "@effected/walker/node-sync";
8
+ import { NodeSyncFileSystem } from "@effected/memfs/node-sync";
10
9
 
11
10
  // src/utils/resolve-project-key-from-cwd.ts
12
11
  const PACKAGE_JSON = "package.json";
13
- const findNearestPackageJson = (startDir) => {
14
- const dirs = Walker.ascendSync(startDir, { path: nodeWalkerSyncOps.path });
15
- return Option.getOrNull(Walker.findUpwardSync(dirs, (dir) => [join(dir, PACKAGE_JSON)], nodeWalkerSyncOps));
16
- };
17
12
  /**
18
13
  * The key a `package.json` body names: the canonical `host__path` form of
19
14
  * `repository.url` when present and canonicalizable, else the normalized
@@ -32,10 +27,11 @@ const keyFromPackageJson = (content) => {
32
27
  if (manifest.name !== void 0) return Result.getOrNull(AppDirs.segment(manifest.name));
33
28
  return null;
34
29
  };
35
- const basenameKey = (cwd) => {
36
- const basename = cwd.split("/").filter((segment) => segment.length > 0).pop() ?? "anonymous-project";
37
- return Result.getOrElse(AppDirs.segment(basename), () => "anonymous-project");
30
+ const basenameKey = (path, cwd) => {
31
+ const basename = path.basename(cwd);
32
+ return basename.length === 0 ? "anonymous-project" : Result.getOrElse(AppDirs.segment(basename), () => "anonymous-project");
38
33
  };
34
+ const NodeSyncPlatform = Layer.merge(NodeSyncFileSystem.layer, NodePath.layer);
39
35
  /**
40
36
  * Compute the project key for a workspace by reading `package.json`
41
37
  * fields directly. Returns the canonical `host__path` form when a git
@@ -45,26 +41,17 @@ const basenameKey = (cwd) => {
45
41
  * (or `"anonymous-project"`) so callers don't have to handle the
46
42
  * empty case.
47
43
  *
48
- * Synchronous and reads the real disk through `node:fs`; Effect callers
49
- * that already hold a `FileSystem` use
50
- * {@link resolveProjectKeyFromCwdEffect}, which applies the same rules.
44
+ * Synchronous and reads the real disk through `node:fs`: it runs
45
+ * {@link resolveProjectKeyFromCwdEffect} under `Effect.runSync` over a
46
+ * read-only synchronous `FileSystem` and the host's Node `Path`. Effect
47
+ * callers that already hold a `FileSystem` and `Path` use the Effect form
48
+ * directly.
51
49
  * @public
52
50
  */
53
- const resolveProjectKeyFromCwd = (cwd) => {
54
- const pkgPath = findNearestPackageJson(cwd);
55
- if (pkgPath !== null) {
56
- let content = null;
57
- try {
58
- content = readFileSync(pkgPath, "utf-8");
59
- } catch {}
60
- const key = content === null ? null : keyFromPackageJson(content);
61
- if (key !== null) return key;
62
- }
63
- return basenameKey(cwd);
64
- };
51
+ const resolveProjectKeyFromCwd = (cwd) => Effect.runSync(resolveProjectKeyFromCwdEffect(cwd).pipe(Effect.provide(NodeSyncPlatform)));
65
52
  /**
66
53
  * {@link resolveProjectKeyFromCwd} read through the ambient
67
- * `FileSystem` service instead of `node:fs` — same upward walk to the
54
+ * `FileSystem` and `Path` services instead of `node:fs` — same upward walk to the
68
55
  * nearest `package.json` (`@effected/walker`'s `Walker.ascend` +
69
56
  * `Walker.findUpward`), same precedence, same basename fallback.
70
57
  *
@@ -77,11 +64,12 @@ const resolveProjectKeyFromCwd = (cwd) => {
77
64
  */
78
65
  const resolveProjectKeyFromCwdEffect = (cwd) => Effect.gen(function* () {
79
66
  const fs = yield* FileSystem.FileSystem;
67
+ const path = yield* Path.Path;
80
68
  const dirs = yield* Walker.ascend(cwd);
81
- const found = yield* Walker.findUpward(dirs, (dir) => [join(dir, PACKAGE_JSON)]);
82
- if (Option.isNone(found)) return basenameKey(cwd);
69
+ const found = yield* Walker.findUpward(dirs, (dir) => [path.join(dir, PACKAGE_JSON)]);
70
+ if (Option.isNone(found)) return basenameKey(path, cwd);
83
71
  const content = yield* fs.readFileString(found.value).pipe(Effect.orElseSucceed(() => null));
84
- return (content === null ? null : keyFromPackageJson(content)) ?? basenameKey(cwd);
85
- }).pipe(Effect.provide(Path.layer));
72
+ return (content === null ? null : keyFromPackageJson(content)) ?? basenameKey(path, cwd);
73
+ });
86
74
 
87
75
  export { resolveProjectKeyFromCwd, resolveProjectKeyFromCwdEffect };
package/version.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
2
  /** Build-time injected package version (see architecture.md D36). @public */
3
- const CURRENT_ENGINE_VERSION = "3.0.1";
3
+ const CURRENT_ENGINE_VERSION = "3.1.0";
4
4
 
5
5
  export { CURRENT_ENGINE_VERSION };