@vitest-agent/engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/index.d.ts +2679 -0
  4. package/index.js +63 -0
  5. package/layers/ConfigLive.js +46 -0
  6. package/layers/DataReaderLive.js +1923 -0
  7. package/layers/DataStoreLive.js +1283 -0
  8. package/layers/DetailResolverLive.js +9 -0
  9. package/layers/DiscoveryRegistryLive.js +70 -0
  10. package/layers/EnvironmentDetectorLive.js +36 -0
  11. package/layers/EnvironmentDetectorTest.js +13 -0
  12. package/layers/ExecutorResolverLive.js +9 -0
  13. package/layers/FormatSelectorLive.js +20 -0
  14. package/layers/HistoryTrackerLive.js +49 -0
  15. package/layers/HistoryTrackerTest.js +25 -0
  16. package/layers/LoggerLive.js +70 -0
  17. package/layers/OutputPipelineLive.js +20 -0
  18. package/layers/OutputRendererLive.js +23 -0
  19. package/layers/PathResolutionLive.js +46 -0
  20. package/layers/PerClientSessionMapLive.js +145 -0
  21. package/layers/ProjectDiscoveryLive.js +67 -0
  22. package/layers/ProjectDiscoveryTest.js +12 -0
  23. package/layers/ProjectIdentityLive.js +95 -0
  24. package/layers/RunContextLive.js +82 -0
  25. package/lib/format-triage.js +103 -0
  26. package/lib/format-wrapup.js +67 -0
  27. package/migrations/0001_initial.js +824 -0
  28. package/migrations/0002_test_artifacts.js +30 -0
  29. package/migrations/index.js +27 -0
  30. package/migrations/registry_0001_initial.js +39 -0
  31. package/migrations/session_map_0001_initial.js +38 -0
  32. package/package.json +59 -0
  33. package/platform.js +58 -0
  34. package/programs/end-agent.js +19 -0
  35. package/programs/hook-paths.js +115 -0
  36. package/programs/platform-sidecar.js +37 -0
  37. package/programs/record-session.js +49 -0
  38. package/programs/record-tdd-artifact.js +119 -0
  39. package/programs/record-turn.js +61 -0
  40. package/programs/record-workspace-changes.js +57 -0
  41. package/programs/register-agent.js +85 -0
  42. package/programs/resolve-session-for-recording.js +44 -0
  43. package/programs/session-env.js +85 -0
  44. package/project-dir.js +32 -0
  45. package/services/Config.js +15 -0
  46. package/services/DataReader.js +8 -0
  47. package/services/DataStore.js +15 -0
  48. package/services/DetailResolver.js +8 -0
  49. package/services/DiscoveryRegistry.js +8 -0
  50. package/services/EnvironmentDetector.js +8 -0
  51. package/services/ExecutorResolver.js +8 -0
  52. package/services/FormatSelector.js +8 -0
  53. package/services/HistoryTracker.js +20 -0
  54. package/services/OutputRenderer.js +8 -0
  55. package/services/PerClientSessionMap.js +22 -0
  56. package/services/ProjectDiscovery.js +8 -0
  57. package/services/ProjectIdentity.js +62 -0
  58. package/services/RunContext.js +53 -0
  59. package/services/idempotency.js +58 -0
  60. package/sql/assemblers.js +69 -0
  61. package/testing/layers.js +16 -0
  62. package/testing.d.ts +1101 -0
  63. package/testing.js +243 -0
  64. package/tsdoc-metadata.json +11 -0
  65. package/utils/ensure-migrated.js +47 -0
  66. package/utils/failure-signature.js +34 -0
  67. package/utils/resolve-data-path.js +64 -0
  68. package/utils/resolve-project-key-from-cwd.js +52 -0
  69. package/utils/resolve-workspace-key.js +41 -0
  70. package/version.js +6 -0
@@ -0,0 +1,57 @@
1
+ import { DataStore } from "../services/DataStore.js";
2
+ import { Effect } from "effect";
3
+ import { SqlClient } from "effect/unstable/sql/SqlClient";
4
+
5
+ //#region src/programs/record-workspace-changes.ts
6
+ /**
7
+ * `ProjectRunSummary` doesn't expose `lastRunId`, so we query it directly
8
+ * via SqlClient. This is the run id of the most-recent test run for a
9
+ * given project (or any project, when `project` is unspecified). Used
10
+ * only as a best-effort association target for `run_changed_files`.
11
+ */
12
+ const findLatestRunId = (project) => Effect.gen(function* () {
13
+ const sql = yield* SqlClient;
14
+ const rows = project !== void 0 ? yield* sql`
15
+ SELECT id FROM test_runs
16
+ WHERE project = ${project}
17
+ ORDER BY timestamp DESC LIMIT 1
18
+ ` : yield* sql`
19
+ SELECT id FROM test_runs
20
+ ORDER BY timestamp DESC LIMIT 1
21
+ `;
22
+ return rows.length === 0 ? null : rows[0].id;
23
+ }).pipe(Effect.orElseSucceed(() => null));
24
+ /**
25
+ * Record a commit and, best-effort, associate its changed files with the
26
+ * most recent test run for the project.
27
+ *
28
+ * @param input - the commit and its changed files
29
+ * @public
30
+ */
31
+ const recordRunWorkspaceChangesEffect = (input) => Effect.gen(function* () {
32
+ const store = yield* DataStore;
33
+ yield* store.writeCommit({
34
+ sha: input.sha,
35
+ ...input.parentSha !== void 0 && { parentSha: input.parentSha },
36
+ ...input.message !== void 0 && { message: input.message },
37
+ ...input.author !== void 0 && { author: input.author },
38
+ ...input.committedAt !== void 0 && { committedAt: input.committedAt },
39
+ ...input.branch !== void 0 && { branch: input.branch }
40
+ });
41
+ const runId = yield* findLatestRunId(input.project);
42
+ if (runId !== null && input.files.length > 0) yield* store.writeRunChangedFiles({
43
+ runId,
44
+ files: input.files.map((f) => ({
45
+ filePath: f.filePath,
46
+ changeKind: f.changeKind,
47
+ commitSha: input.sha
48
+ }))
49
+ });
50
+ return {
51
+ sha: input.sha,
52
+ fileRowsWritten: runId !== null ? input.files.length : 0
53
+ };
54
+ });
55
+
56
+ //#endregion
57
+ export { recordRunWorkspaceChangesEffect };
@@ -0,0 +1,85 @@
1
+ import { DataReader } from "../services/DataReader.js";
2
+ import { DataStore } from "../services/DataStore.js";
3
+ import { PerClientSessionMapWriter } from "../services/PerClientSessionMap.js";
4
+ import { RunContextService } from "../services/RunContext.js";
5
+ import { deriveIdempotencyKey } from "../services/idempotency.js";
6
+ import { Effect, Option } from "effect";
7
+
8
+ //#region src/programs/register-agent.ts
9
+ const deriveDefaultClientNonce = (input) => {
10
+ const parent = input.parentAgentId ?? "__ROOT__";
11
+ return `${input.hostSessionId}|${input.agentType}|${parent}`;
12
+ };
13
+ /**
14
+ * End-to-end agent registration.
15
+ *
16
+ * Returns the canonical `agentId` + the conversation it belongs to +
17
+ * the resolved `idempotencyKey`. The `idempotencyHit` flag is `true`
18
+ * when the per-project store recovered an existing agent row instead
19
+ * of inserting a new one.
20
+ *
21
+ * @param input - registration inputs including host identifiers and agent metadata
22
+ * @returns an Effect resolving to `RegisterAgentProgramOutput`
23
+ * @public
24
+ */
25
+ const registerAgentEffect = (input) => Effect.gen(function* () {
26
+ const sessionMap = yield* PerClientSessionMapWriter;
27
+ const reader = yield* DataReader;
28
+ const store = yield* DataStore;
29
+ const ctx = yield* RunContextService;
30
+ const conversationId = yield* sessionMap.mapConversation(input.transcriptPath);
31
+ const sessionMapping = yield* sessionMap.mapSession({
32
+ hostSessionId: input.hostSessionId,
33
+ conversationId,
34
+ projectKey: input.projectKey,
35
+ projectDir: input.cwd
36
+ });
37
+ const existingSession = yield* reader.getSessionByChatId(input.hostSessionId);
38
+ const sessionRowId = yield* Option.match(existingSession, {
39
+ onNone: () => store.writeSession({
40
+ chatId: input.hostSessionId,
41
+ project: input.projectKey,
42
+ cwd: input.cwd,
43
+ agentKind: input.parentAgentId === void 0 ? "main" : "subagent",
44
+ agentType: input.agentType,
45
+ triageWasNonEmpty: false,
46
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
47
+ conversationId
48
+ }),
49
+ onSome: (s) => Effect.succeed(s.id)
50
+ });
51
+ yield* store.setSessionConversationIdIfNull({
52
+ sessionId: sessionRowId,
53
+ conversationId
54
+ });
55
+ const agentContext = yield* ctx.captureAgentContext(input.cwd);
56
+ const clientNonce = input.clientNonce ?? deriveDefaultClientNonce(input);
57
+ const idempotencyKey = deriveIdempotencyKey({
58
+ agentType: input.agentType,
59
+ parentAgentId: input.parentAgentId ?? null,
60
+ clientNonce
61
+ });
62
+ const result = yield* store.registerAgent({
63
+ sessionId: sessionRowId,
64
+ agentType: input.agentType,
65
+ parentAgentId: input.parentAgentId ?? null,
66
+ conversationId,
67
+ startedAt: Math.floor(Date.now() / 1e3),
68
+ ...agentContext.startGitBranch !== null && { startGitBranch: agentContext.startGitBranch },
69
+ ...agentContext.startGitCommitSha !== null && { startGitCommitSha: agentContext.startGitCommitSha },
70
+ ...agentContext.startWorktreeDir !== null && { startWorktreeDir: agentContext.startWorktreeDir },
71
+ idempotencyKey,
72
+ agentId: sessionMapping.mainAgentId
73
+ });
74
+ const isHit = result._tag === "IdempotencyHit";
75
+ return {
76
+ agentId: isHit ? result.existingAgentId : result.agentId,
77
+ conversationId,
78
+ mainAgentId: sessionMapping.mainAgentId,
79
+ idempotencyKey,
80
+ idempotencyHit: isHit
81
+ };
82
+ });
83
+
84
+ //#endregion
85
+ export { registerAgentEffect };
@@ -0,0 +1,44 @@
1
+ import { DataReader } from "../services/DataReader.js";
2
+ import { DataStore } from "../services/DataStore.js";
3
+ import { Effect, FileSystem, Option } from "effect";
4
+
5
+ //#region src/programs/resolve-session-for-recording.ts
6
+ /**
7
+ * Resolve the `sessions` row for a hook-delivered chat id: exact match,
8
+ * then the synthetic `-subagent-` prefix, then an idempotent bootstrap of
9
+ * a `main` row so recording never fails with "Unknown chat_id".
10
+ *
11
+ * @param input - the chat id and ambient bootstrap inputs
12
+ * @returns the resolved (or newly created) session row
13
+ * @public
14
+ */
15
+ const resolveSessionForRecording = (input) => Effect.gen(function* () {
16
+ const reader = yield* DataReader;
17
+ const store = yield* DataStore;
18
+ const fs = yield* FileSystem.FileSystem;
19
+ const exact = yield* reader.getSessionByChatId(input.chatId);
20
+ if (Option.isSome(exact)) return exact.value;
21
+ const synthetic = yield* reader.findSessionsByChatPrefix(`${input.chatId}-subagent-`);
22
+ if (synthetic.length > 0) return synthetic[0];
23
+ const cwd = input.cwd;
24
+ const project = input.project ?? (yield* readPackageName(fs, cwd)) ?? "unknown";
25
+ yield* store.upsertSession({
26
+ chatId: input.chatId,
27
+ project,
28
+ cwd,
29
+ agentKind: "main",
30
+ triageWasNonEmpty: false,
31
+ startedAt: input.recordedAt
32
+ });
33
+ const created = yield* reader.getSessionByChatId(input.chatId);
34
+ return Option.getOrThrowWith(created, () => /* @__PURE__ */ new Error(`upsertSession succeeded but row not found for ${input.chatId}`));
35
+ });
36
+ /**
37
+ * Best-effort `package.json#name` read through the injected `FileSystem`.
38
+ * Any read or parse failure resolves to `undefined` — the caller falls
39
+ * back to `"unknown"`, exactly as the former sync `readFileSync` probe did.
40
+ */
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)));
42
+
43
+ //#endregion
44
+ export { resolveSessionForRecording };
@@ -0,0 +1,85 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+
4
+ //#region 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
+ /**
36
+ * Recover a {@link SessionContext} from the newest session-env hook file
37
+ * whose `VITEST_AGENT_PROJECT_DIR` matches `projectDir`.
38
+ *
39
+ * Returns `null` when the session-env root is missing, unreadable, or no
40
+ * session dir matches the project. Never throws — recovery is best-effort
41
+ * and callers fall back to their existing null-context behavior.
42
+ *
43
+ * Synchronous by contract: the MCP server calls it from the lazy
44
+ * `SessionContextRef.get()` recover thunk, which is itself synchronous.
45
+ *
46
+ * @param opts - `projectDir` to match against; `homeDir` is the caller's
47
+ * home directory (ambient input — the MCP bin passes `os.homedir()`),
48
+ * under which `.claude/session-env` is read
49
+ * @public
50
+ */
51
+ const recoverSessionContextFromSessionEnv = (opts) => {
52
+ const root = join(opts.homeDir, ".claude", "session-env");
53
+ const wantDir = resolve(opts.projectDir);
54
+ let entries;
55
+ try {
56
+ entries = readdirSync(root);
57
+ } catch {
58
+ return null;
59
+ }
60
+ let best = null;
61
+ for (const entry of entries) {
62
+ const file = join(root, entry, "vitest-agent-hook.sh");
63
+ try {
64
+ const st = statSync(file);
65
+ const env = parseSessionEnvExports(readFileSync(file, "utf8"));
66
+ const chatId = env.VITEST_AGENT_CHAT_ID;
67
+ const conversationId = env.VITEST_AGENT_CONVERSATION_ID;
68
+ const mainAgentId = env.VITEST_AGENT_MAIN_AGENT_ID ?? env.VITEST_AGENT_AGENT_ID;
69
+ const fileProjectDir = env.VITEST_AGENT_PROJECT_DIR;
70
+ if (chatId === void 0 || chatId.length === 0 || conversationId === void 0 || conversationId.length === 0 || mainAgentId === void 0 || mainAgentId.length === 0 || fileProjectDir === void 0 || resolve(fileProjectDir) !== wantDir) continue;
71
+ if (best === null || st.mtimeMs > best.mtimeMs) best = {
72
+ mtimeMs: st.mtimeMs,
73
+ ctx: {
74
+ chatId,
75
+ conversationId,
76
+ mainAgentId
77
+ }
78
+ };
79
+ } catch {}
80
+ }
81
+ return best === null ? null : best.ctx;
82
+ };
83
+
84
+ //#endregion
85
+ export { parseSessionEnvExports, recoverSessionContextFromSessionEnv };
package/project-dir.js ADDED
@@ -0,0 +1,32 @@
1
+ //#region src/project-dir.ts
2
+ /**
3
+ * Environment variables that may override the project directory, in
4
+ * precedence order (highest first).
5
+ */
6
+ const PROJECT_DIR_ENV_KEYS = [
7
+ "VITEST_AGENT_PROJECT_DIR",
8
+ "VITEST_AGENT_REPORTER_PROJECT_DIR",
9
+ "CLAUDE_PROJECT_DIR"
10
+ ];
11
+ /**
12
+ * Resolve the project directory the front ends anchor on.
13
+ *
14
+ * Precedence: `VITEST_AGENT_PROJECT_DIR` (hook-driven override), then
15
+ * `VITEST_AGENT_REPORTER_PROJECT_DIR` (the plugin-spawned MCP server's
16
+ * hand-off), then `CLAUDE_PROJECT_DIR` (Claude Code's own convention),
17
+ * then `cwd`. An empty string counts as unset. Pure: both the env map and
18
+ * the working directory are injected by the caller.
19
+ *
20
+ * @param input - the env map and the fallback working directory
21
+ * @public
22
+ */
23
+ const resolveProjectDir = (input) => {
24
+ for (const key of PROJECT_DIR_ENV_KEYS) {
25
+ const value = input.env[key];
26
+ if (value !== void 0 && value !== "") return value;
27
+ }
28
+ return input.cwd;
29
+ };
30
+
31
+ //#endregion
32
+ export { resolveProjectDir };
@@ -0,0 +1,15 @@
1
+ import { ConfigFile } from "@effected/config-file";
2
+
3
+ //#region src/services/Config.ts
4
+ /**
5
+ * Typed service class (Context tag) for the vitest-agent config file service.
6
+ *
7
+ * Both runtime packages (reporter, MCP) yield this tag to access the loaded
8
+ * `VitestAgentConfig`. The live layer is built per `projectDir` via
9
+ * `ConfigLive(projectDir)`.
10
+ * @public
11
+ */
12
+ var VitestAgentConfigFile = class extends ConfigFile.Service()("vitest-agent/Config") {};
13
+
14
+ //#endregion
15
+ export { VitestAgentConfigFile };
@@ -0,0 +1,8 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/DataReader.ts
4
+ /** @public */
5
+ var DataReader = class extends Context.Service()("vitest-agent/DataReader") {};
6
+
7
+ //#endregion
8
+ export { DataReader };
@@ -0,0 +1,15 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/DataStore.ts
4
+ /**
5
+ * Ceiling on an inline attachment body. Vitest has already copied file
6
+ * attachments into `.vitest/attachments/`, so a large body is referenced
7
+ * by `path` rather than duplicated into `data.db`.
8
+ * @public
9
+ */
10
+ const INLINE_ATTACHMENT_BODY_CAP_BYTES = 65536;
11
+ /** @public */
12
+ var DataStore = class extends Context.Service()("vitest-agent/DataStore") {};
13
+
14
+ //#endregion
15
+ export { DataStore, INLINE_ATTACHMENT_BODY_CAP_BYTES };
@@ -0,0 +1,8 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/DetailResolver.ts
4
+ /** @public */
5
+ var DetailResolver = class extends Context.Service()("vitest-agent/DetailResolver") {};
6
+
7
+ //#endregion
8
+ export { DetailResolver };
@@ -0,0 +1,8 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/DiscoveryRegistry.ts
4
+ /** @public */
5
+ var DiscoveryRegistry = class extends Context.Service()("vitest-agent/DiscoveryRegistry") {};
6
+
7
+ //#endregion
8
+ export { DiscoveryRegistry };
@@ -0,0 +1,8 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/EnvironmentDetector.ts
4
+ /** @public */
5
+ var EnvironmentDetector = class extends Context.Service()("vitest-agent/EnvironmentDetector") {};
6
+
7
+ //#endregion
8
+ export { EnvironmentDetector };
@@ -0,0 +1,8 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/ExecutorResolver.ts
4
+ /** @public */
5
+ var ExecutorResolver = class extends Context.Service()("vitest-agent/ExecutorResolver") {};
6
+
7
+ //#endregion
8
+ export { ExecutorResolver };
@@ -0,0 +1,8 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/FormatSelector.ts
4
+ /** @public */
5
+ var FormatSelector = class extends Context.Service()("vitest-agent/FormatSelector") {};
6
+
7
+ //#endregion
8
+ export { FormatSelector };
@@ -0,0 +1,20 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/HistoryTracker.ts
4
+ /**
5
+ * Builds the composite (modulePath, fullName) key used to key the internal
6
+ * testMap and the returned classifications Map, so identically-named tests
7
+ * in different files classify independently instead of colliding.
8
+ *
9
+ * Uses `JSON.stringify` for an injective encoding: a plain delimiter such as
10
+ * a space is not collision-proof (e.g. `("a b", "c")` and `("a", "b c")` would
11
+ * both yield `"a b c"`), which would reintroduce the very collision class this
12
+ * key exists to prevent.
13
+ * @public
14
+ */
15
+ const historyKey = (modulePath, fullName) => JSON.stringify([modulePath, fullName]);
16
+ /** @public */
17
+ var HistoryTracker = class extends Context.Service()("vitest-agent/HistoryTracker") {};
18
+
19
+ //#endregion
20
+ export { HistoryTracker, historyKey };
@@ -0,0 +1,8 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/OutputRenderer.ts
4
+ /** @public */
5
+ var OutputRenderer = class extends Context.Service()("vitest-agent/OutputRenderer") {};
6
+
7
+ //#endregion
8
+ export { OutputRenderer };
@@ -0,0 +1,22 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/PerClientSessionMap.ts
4
+ /**
5
+ * Read-only side of the session map.
6
+ *
7
+ * Provided by `PerClientSessionMapReaderLive` (opens SQLite with
8
+ * `?mode=ro`). MCP server depends on this tag — calls to write
9
+ * methods would be a type error.
10
+ * @public
11
+ */
12
+ var PerClientSessionMapReader = class extends Context.Service()("vitest-agent/PerClientSessionMapReader") {};
13
+ /**
14
+ * Read-write side of the session map. Sidecar provides this; the
15
+ * Writer layer also satisfies the Reader tag so consumers of either
16
+ * one resolve.
17
+ * @public
18
+ */
19
+ var PerClientSessionMapWriter = class extends Context.Service()("vitest-agent/PerClientSessionMapWriter") {};
20
+
21
+ //#endregion
22
+ export { PerClientSessionMapReader, PerClientSessionMapWriter };
@@ -0,0 +1,8 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/ProjectDiscovery.ts
4
+ /** @public */
5
+ var ProjectDiscovery = class extends Context.Service()("vitest-agent/ProjectDiscovery") {};
6
+
7
+ //#endregion
8
+ export { ProjectDiscovery };
@@ -0,0 +1,62 @@
1
+ import { canonicalizeGitUrl, gitUrlToProjectKey, normalizeWorkspaceKey } from "@vitest-agent/sdk";
2
+ import { Context } from "effect";
3
+
4
+ //#region src/services/ProjectIdentity.ts
5
+ const isUseful = (value) => value !== void 0 && value.trim().length > 0;
6
+ /**
7
+ * Pure priority resolver. Returns `null` when no candidate produces a
8
+ * usable value — the caller wraps `null` into a tagged error.
9
+ *
10
+ * For non-git sources the `canonicalForm` equals the original
11
+ * (un-normalized) input so the discovery registry can display
12
+ * `@spencerbeggs/vitest-agent` rather than the underscored form. For
13
+ * git sources, `canonicalForm` is the slash-preserving `host/path`
14
+ * shape from `canonicalizeGitUrl`.
15
+ * @public
16
+ */
17
+ 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
+ };
28
+ if (isUseful(candidates.gitRemote)) {
29
+ const canonical = canonicalizeGitUrl(candidates.gitRemote);
30
+ const key = gitUrlToProjectKey(candidates.gitRemote);
31
+ if (canonical !== null && key !== null) return {
32
+ projectKey: key,
33
+ canonicalForm: canonical,
34
+ source: "git-remote"
35
+ };
36
+ }
37
+ if (isUseful(candidates.packageJsonRepoUrl)) {
38
+ const canonical = canonicalizeGitUrl(candidates.packageJsonRepoUrl);
39
+ const key = gitUrlToProjectKey(candidates.packageJsonRepoUrl);
40
+ if (canonical !== null && key !== null) return {
41
+ projectKey: key,
42
+ canonicalForm: canonical,
43
+ source: "package-repository"
44
+ };
45
+ }
46
+ if (isUseful(candidates.packageJsonName)) return {
47
+ projectKey: normalizeWorkspaceKey(candidates.packageJsonName),
48
+ canonicalForm: candidates.packageJsonName,
49
+ source: "package-name"
50
+ };
51
+ return null;
52
+ };
53
+ /**
54
+ * Service tag. The Live layer (in `layers/ProjectIdentityLive.ts`)
55
+ * wires up the I/O sources; tests provide a stub that returns a
56
+ * pre-built `ResolvedIdentity`.
57
+ * @public
58
+ */
59
+ var ProjectIdentity = class extends Context.Service()("vitest-agent/ProjectIdentity") {};
60
+
61
+ //#endregion
62
+ export { ProjectIdentity, resolveProjectIdentityFromCandidates };
@@ -0,0 +1,53 @@
1
+ import { Context, Schema } from "effect";
2
+
3
+ //#region src/services/RunContext.ts
4
+ /**
5
+ * Per-run snapshot of the workspace's git state and host context.
6
+ * Every field is nullable: a non-git workspace has all-NULL git
7
+ * fields, a CI run with no terminal probe match has all-NULL host
8
+ * fields, and a worker that can't read upstream info has a NULL
9
+ * `gitUpstream` while everything else is set.
10
+ *
11
+ * Declared as a `Schema.Class` so callers can `new RunContext({...})`
12
+ * and `Schema.encode(...)` for the SQLite row mapping.
13
+ * @public
14
+ */
15
+ var RunContext = class extends Schema.Class("vitest-agent/RunContext")({
16
+ gitBranch: Schema.NullOr(Schema.String),
17
+ gitCommitSha: Schema.NullOr(Schema.String),
18
+ gitDirty: Schema.NullOr(Schema.Boolean),
19
+ gitUpstream: Schema.NullOr(Schema.String),
20
+ gitWorktreeDir: Schema.NullOr(Schema.String),
21
+ hostSource: Schema.NullOr(Schema.String),
22
+ hostValue: Schema.NullOr(Schema.String),
23
+ hostMetadata: Schema.NullOr(Schema.Unknown)
24
+ }) {};
25
+ /**
26
+ * Subset of {@link RunContext} captured at agent registration. Only
27
+ * the inheritable git context — branch, sha, worktree path. Mid-run
28
+ * dirty-tree state and upstream tracking are per-run concerns.
29
+ * @public
30
+ */
31
+ var AgentContext = class extends Schema.Class("vitest-agent/AgentContext")({
32
+ startGitBranch: Schema.NullOr(Schema.String),
33
+ startGitCommitSha: Schema.NullOr(Schema.String),
34
+ startWorktreeDir: Schema.NullOr(Schema.String)
35
+ }) {};
36
+ /**
37
+ * Service tag. The Live layer captures real I/O; the Test layer
38
+ * returns whatever the test fixture passes.
39
+ * @public
40
+ */
41
+ var RunContext$ = class extends Context.Service()("vitest-agent/RunContextService") {};
42
+ /**
43
+ * Re-export the service tag under a less-collision-prone name. The
44
+ * `RunContext` Schema.Class above shares its symbol-name with the
45
+ * service tag concept; the `$` suffix on the Context.Tag class name
46
+ * keeps both addressable from the same module.
47
+ *
48
+ * @public
49
+ */
50
+ const RunContextService = RunContext$;
51
+
52
+ //#endregion
53
+ export { AgentContext, RunContext, RunContext$, RunContextService };
@@ -0,0 +1,58 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ //#region src/services/idempotency.ts
4
+ /**
5
+ * Sentinel substituted for `parentAgentId` when the value is `null`.
6
+ * Avoids ambiguity around hashing the literal string "null" or empty
7
+ * string, both of which could collide with caller-supplied values.
8
+ * @public
9
+ */
10
+ const IDEMPOTENCY_ROOT_SENTINEL = "__ROOT__";
11
+ /**
12
+ * Derive the canonical idempotency key for an `agents` row.
13
+ *
14
+ * Algorithm:
15
+ * sha256(agentType + "|" + (parentAgentId ?? "__ROOT__") + "|" + clientNonce)
16
+ * then base32 (RFC 4648 alphabet, lowercased) truncated to 26 chars.
17
+ *
18
+ * 26 base32 chars = 130 bits, far beyond the birthday-bound for the
19
+ * scoping (UNIQUE per session). The base32 alphabet is a-z2-7 — all
20
+ * lowercase to keep the column lexicographically friendly and
21
+ * case-insensitive on filesystems that we never hit but might in
22
+ * future debugging.
23
+ * @public
24
+ */
25
+ const deriveIdempotencyKey = (input) => {
26
+ const parent = input.parentAgentId ?? "__ROOT__";
27
+ const message = `${input.agentType}|${parent}|${input.clientNonce}`;
28
+ const digest = createHash("sha256").update(message).digest();
29
+ return base32Encode(digest).slice(0, 26);
30
+ };
31
+ const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
32
+ /**
33
+ * RFC 4648 base32 encoder, lowercase, no padding. Produces ceil(8n/5)
34
+ * characters from an `n`-byte input. SHA-256 output (32 bytes) yields
35
+ * 52 base32 chars; the caller truncates to 26.
36
+ */
37
+ const base32Encode = (bytes) => {
38
+ let output = "";
39
+ let buffer = 0;
40
+ let bitsInBuffer = 0;
41
+ for (const byte of bytes) {
42
+ buffer = buffer << 8 | byte;
43
+ bitsInBuffer += 8;
44
+ while (bitsInBuffer >= 5) {
45
+ bitsInBuffer -= 5;
46
+ const index = buffer >> bitsInBuffer & 31;
47
+ output += BASE32_ALPHABET[index];
48
+ }
49
+ }
50
+ if (bitsInBuffer > 0) {
51
+ const index = buffer << 5 - bitsInBuffer & 31;
52
+ output += BASE32_ALPHABET[index];
53
+ }
54
+ return output;
55
+ };
56
+
57
+ //#endregion
58
+ export { IDEMPOTENCY_ROOT_SENTINEL, deriveIdempotencyKey };