@vitest-agent/engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/index.d.ts +2679 -0
- package/index.js +63 -0
- package/layers/ConfigLive.js +46 -0
- package/layers/DataReaderLive.js +1923 -0
- package/layers/DataStoreLive.js +1283 -0
- package/layers/DetailResolverLive.js +9 -0
- package/layers/DiscoveryRegistryLive.js +70 -0
- package/layers/EnvironmentDetectorLive.js +36 -0
- package/layers/EnvironmentDetectorTest.js +13 -0
- package/layers/ExecutorResolverLive.js +9 -0
- package/layers/FormatSelectorLive.js +20 -0
- package/layers/HistoryTrackerLive.js +49 -0
- package/layers/HistoryTrackerTest.js +25 -0
- package/layers/LoggerLive.js +70 -0
- package/layers/OutputPipelineLive.js +20 -0
- package/layers/OutputRendererLive.js +23 -0
- package/layers/PathResolutionLive.js +46 -0
- package/layers/PerClientSessionMapLive.js +145 -0
- package/layers/ProjectDiscoveryLive.js +67 -0
- package/layers/ProjectDiscoveryTest.js +12 -0
- package/layers/ProjectIdentityLive.js +95 -0
- package/layers/RunContextLive.js +82 -0
- package/lib/format-triage.js +103 -0
- package/lib/format-wrapup.js +67 -0
- package/migrations/0001_initial.js +824 -0
- package/migrations/0002_test_artifacts.js +30 -0
- package/migrations/index.js +27 -0
- package/migrations/registry_0001_initial.js +39 -0
- package/migrations/session_map_0001_initial.js +38 -0
- package/package.json +59 -0
- package/platform.js +58 -0
- package/programs/end-agent.js +19 -0
- package/programs/hook-paths.js +115 -0
- package/programs/platform-sidecar.js +37 -0
- package/programs/record-session.js +49 -0
- package/programs/record-tdd-artifact.js +119 -0
- package/programs/record-turn.js +61 -0
- package/programs/record-workspace-changes.js +57 -0
- package/programs/register-agent.js +85 -0
- package/programs/resolve-session-for-recording.js +44 -0
- package/programs/session-env.js +85 -0
- package/project-dir.js +32 -0
- package/services/Config.js +15 -0
- package/services/DataReader.js +8 -0
- package/services/DataStore.js +15 -0
- package/services/DetailResolver.js +8 -0
- package/services/DiscoveryRegistry.js +8 -0
- package/services/EnvironmentDetector.js +8 -0
- package/services/ExecutorResolver.js +8 -0
- package/services/FormatSelector.js +8 -0
- package/services/HistoryTracker.js +20 -0
- package/services/OutputRenderer.js +8 -0
- package/services/PerClientSessionMap.js +22 -0
- package/services/ProjectDiscovery.js +8 -0
- package/services/ProjectIdentity.js +62 -0
- package/services/RunContext.js +53 -0
- package/services/idempotency.js +58 -0
- package/sql/assemblers.js +69 -0
- package/testing/layers.js +16 -0
- package/testing.d.ts +1101 -0
- package/testing.js +243 -0
- package/tsdoc-metadata.json +11 -0
- package/utils/ensure-migrated.js +47 -0
- package/utils/failure-signature.js +34 -0
- package/utils/resolve-data-path.js +64 -0
- package/utils/resolve-project-key-from-cwd.js +52 -0
- package/utils/resolve-workspace-key.js +41 -0
- package/version.js +6 -0
|
@@ -0,0 +1,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,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,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,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,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 };
|