@bjoernrochel/sevro 0.1.0-rc.7 → 1.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/CONTRIBUTING.md +22 -6
- package/README.md +5 -1
- package/docs/README.md +19 -15
- package/docs/architecture.md +138 -17
- package/docs/assets/README.md +5 -4
- package/docs/builtin-graders-v1.md +17 -3
- package/docs/codex-host.md +155 -49
- package/docs/contracts.md +2 -1
- package/docs/creating-cases.md +9 -2
- package/docs/development-cli.md +187 -19
- package/docs/documentation-quality.md +12 -2
- package/docs/evaluation-workflow.md +110 -0
- package/docs/extension-protocol-v1.md +26 -3
- package/docs/getting-started.md +23 -2
- package/docs/guide-evaluation.md +33 -1
- package/docs/identity-v1.md +25 -2
- package/docs/installing.md +11 -11
- package/docs/native-controls.md +76 -5
- package/docs/native-hosts.md +196 -7
- package/docs/reading-results.md +102 -0
- package/docs/recipes.md +489 -0
- package/docs/releases.md +31 -12
- package/docs/report-v1.md +29 -1
- package/docs/results-v1.md +141 -4
- package/docs/run-settings-v1.md +147 -0
- package/docs/runtime-v1.md +307 -18
- package/docs/troubleshooting.md +12 -12
- package/docs/typescript-quality.md +89 -26
- package/examples/recipes/candidate.ts +26 -0
- package/examples/recipes/cases/coding-change.json +21 -0
- package/examples/recipes/cases/host-tools.json +21 -0
- package/examples/recipes/cases/mise.json +21 -0
- package/examples/recipes/cases/node.json +21 -0
- package/examples/recipes/cases/python-uv.json +21 -0
- package/examples/recipes/cases/semantic.json +17 -0
- package/examples/recipes/coding-change/check.ts +23 -0
- package/examples/recipes/extension-artifacts.ts +65 -0
- package/examples/recipes/extension-methods.ts +174 -0
- package/examples/recipes/extension.ts +37 -0
- package/examples/recipes/repository-skill/.agents/skills/startup-guide/SKILL.md +7 -0
- package/examples/recipes/repository-skill/.claude/skills/startup-guide/SKILL.md +7 -0
- package/examples/recipes/repository-skill/README.md +3 -0
- package/examples/recipes/run-inputs.ts +188 -0
- package/examples/recipes/run-options.ts +131 -0
- package/examples/recipes/run.ts +63 -0
- package/examples/recipes/runtime/empty.json +1 -0
- package/examples/recipes/runtime/goals.json +1 -0
- package/examples/recipes/runtime/mise.json +6 -0
- package/examples/recipes/runtime/path.json +1 -0
- package/examples/recipes/runtime/plugins.json +4 -0
- package/examples/recipes/runtime/python-uv.json +10 -0
- package/examples/recipes/semantic-judge.ts +29 -0
- package/examples/templates/coding/adapter.ts +17 -0
- package/examples/templates/coding/case.json +15 -0
- package/examples/templates/repository-skill/.agents/skills/startup-guide/SKILL.md +6 -0
- package/examples/templates/repository-skill/extension-data.ts +64 -0
- package/examples/templates/repository-skill/extension.ts +31 -0
- package/examples/templates/response/adapter.ts +12 -0
- package/examples/templates/response/case.json +13 -0
- package/package.json +11 -5
- package/schemas/report-v1.schema.json +9 -0
- package/schemas/run-evidence-v1.schema.json +2 -1
- package/schemas/runtime-v1.schema.json +63 -7
- package/src/advisory.ts +2 -2
- package/src/candidate-artifacts.ts +206 -0
- package/src/candidate-codex-state.ts +185 -0
- package/src/case-validation.ts +73 -50
- package/src/cli/cancellation.ts +20 -0
- package/src/cli/command.ts +87 -0
- package/src/cli/completion.ts +15 -0
- package/src/cli/demo.ts +60 -0
- package/src/cli/evaluation.ts +55 -0
- package/src/cli/extension.ts +79 -0
- package/src/cli/help.ts +198 -0
- package/src/cli/hosts.ts +55 -0
- package/src/cli/init-settings.ts +30 -0
- package/src/cli/init.ts +111 -0
- package/src/{cli-invocation.ts → cli/invocation.ts} +214 -218
- package/src/cli/loading.ts +151 -0
- package/src/cli/preparation.ts +154 -0
- package/src/cli/presentation.ts +93 -0
- package/src/cli/progress.ts +6 -0
- package/src/cli/run-manifest.ts +128 -0
- package/src/cli/run-options.ts +403 -0
- package/src/cli/run-overrides.ts +69 -0
- package/src/cli/settings.ts +154 -0
- package/src/cli/summary.ts +43 -0
- package/src/cli.ts +13 -591
- package/src/contract-types.ts +94 -0
- package/src/engine.ts +13 -5
- package/src/evaluation/advisory.ts +150 -0
- package/src/{evaluation-check-plan.ts → evaluation/check-plan.ts} +103 -67
- package/src/evaluation/collection.ts +88 -0
- package/src/evaluation/context.ts +10 -0
- package/src/{evaluation-grading-host.ts → evaluation/grading-host.ts} +5 -5
- package/src/evaluation/grading.ts +235 -0
- package/src/{evaluation-invocation-plan.ts → evaluation/invocation-plan.ts} +147 -58
- package/src/{evaluation-plan.ts → evaluation/plan.ts} +208 -141
- package/src/evaluation/progress.ts +19 -0
- package/src/evaluation/resources.ts +241 -0
- package/src/evaluation/run.ts +206 -0
- package/src/{evaluation-semantic.ts → evaluation/semantic.ts} +133 -115
- package/src/evaluation/trial.ts +800 -0
- package/src/evaluation/types.ts +207 -0
- package/src/evidence/host-results.ts +85 -0
- package/src/evidence/retained-artifacts.ts +18 -0
- package/src/evidence/trial-writer.ts +203 -0
- package/src/evidence/usage.ts +17 -0
- package/src/extension-client.ts +34 -96
- package/src/extension-session.ts +24 -48
- package/src/{advisory-fixture.ts → fixtures/advisory.ts} +15 -76
- package/src/{fixture-bin.ts → fixtures/bin.ts} +1 -1
- package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
- package/src/fixtures/create.ts +183 -0
- package/src/{generated-fixture.ts → fixtures/generated.ts} +10 -53
- package/src/{git-hooks.ts → fixtures/git-hooks.ts} +1 -1
- package/src/{preparation.ts → fixtures/preparation.ts} +6 -16
- package/src/{repository-fixture.ts → fixtures/repository.ts} +30 -64
- package/src/{fixture-setup.ts → fixtures/setup.ts} +33 -51
- package/src/{fixture-tools.ts → fixtures/tools.ts} +1 -1
- package/src/fixtures/workspace-state.ts +212 -0
- package/src/generated/cli-result.d.cts +2 -1
- package/src/generated/extension.d.cts +2 -1
- package/src/generated/report.cjs +1 -1
- package/src/generated/report.d.cts +2 -1
- package/src/generated/run-evidence.cjs +1 -1
- package/src/generated/run-evidence.d.cts +2 -1
- package/src/generated/runtime.cjs +1 -1
- package/src/generated/runtime.d.cts +2 -1
- package/src/git.ts +93 -0
- package/src/graders/git-head.ts +29 -84
- package/src/graders/shell.ts +57 -68
- package/src/host-diagnostic-redaction.ts +52 -0
- package/src/host-diagnostics.ts +331 -0
- package/src/hosts/claude-continuation.ts +57 -40
- package/src/hosts/claude-credential.ts +160 -63
- package/src/hosts/claude-events.ts +34 -18
- package/src/hosts/claude-keychain.ts +78 -21
- package/src/hosts/claude-login-recovery.ts +85 -0
- package/src/hosts/claude-login-refresh.ts +178 -0
- package/src/hosts/claude-login-store.ts +162 -0
- package/src/hosts/claude-login.ts +107 -0
- package/src/hosts/claude-native-goal.ts +15 -12
- package/src/hosts/claude-process.ts +10 -50
- package/src/hosts/claude-refresh-lock.ts +81 -0
- package/src/hosts/claude-repository-invocation.ts +9 -34
- package/src/hosts/claude-settings.ts +91 -14
- package/src/hosts/claude-skill-activations.ts +169 -0
- package/src/hosts/claude-state.ts +46 -16
- package/src/hosts/claude-tool-calls.ts +13 -10
- package/src/hosts/claude.ts +122 -73
- package/src/hosts/codex-app-server.ts +78 -52
- package/src/hosts/codex-configuration.ts +5 -10
- package/src/hosts/codex-exec-controls.ts +516 -0
- package/src/hosts/codex-marketplace.ts +12 -10
- package/src/hosts/codex-native-calls.ts +15 -122
- package/src/hosts/codex-process.ts +10 -54
- package/src/hosts/codex-profile.ts +50 -21
- package/src/hosts/codex-session-files.ts +1 -9
- package/src/hosts/codex-state.ts +141 -19
- package/src/hosts/codex.ts +221 -179
- package/src/hosts/isolation-common.ts +15 -11
- package/src/hosts/isolation-roots.ts +13 -20
- package/src/hosts/linux-sandbox.ts +27 -3
- package/src/hosts/mac-sandbox.ts +111 -18
- package/src/hosts/native-controls.ts +72 -22
- package/src/hosts/native-request.ts +55 -0
- package/src/hosts/process-lifecycle.ts +221 -45
- package/src/hosts/protected-worktrees.ts +1 -1
- package/src/hosts/skill-hook-catalog.ts +131 -0
- package/src/hosts/skill-hook-handler.ts +374 -0
- package/src/hosts/skill-hooks.ts +577 -0
- package/src/instrumentation.ts +7 -8
- package/src/native-transcript-state.ts +22 -3
- package/src/native-transcript-view.ts +52 -13
- package/src/native-transcripts.ts +34 -15
- package/src/paths.ts +22 -0
- package/src/process-lifecycle.ts +150 -0
- package/src/provenance.ts +27 -7
- package/src/prune.ts +300 -0
- package/src/report.ts +63 -36
- package/src/resolved-case.ts +1 -1
- package/src/results-lock.ts +42 -0
- package/src/results.ts +14 -18
- package/src/run-owner.ts +4 -4
- package/src/{runtime-config.ts → runtime/config.ts} +182 -44
- package/src/{runtime-discovery-apple.ts → runtime/discovery-apple.ts} +3 -3
- package/src/{runtime-discovery-providers.ts → runtime/discovery-providers.ts} +3 -9
- package/src/{runtime-discovery.ts → runtime/discovery.ts} +4 -4
- package/src/{runtime-hook-processes.ts → runtime/hook-processes.ts} +1 -1
- package/src/{runtime-hooks.ts → runtime/hooks.ts} +33 -22
- package/src/{runtime-paths.ts → runtime/paths.ts} +6 -18
- package/src/{runtime-query.ts → runtime/query.ts} +5 -5
- package/src/{runtime-seeds.ts → runtime/seeds.ts} +3 -3
- package/src/runtime/shared-cache.ts +130 -0
- package/src/{runtime-state.ts → runtime/state.ts} +28 -11
- package/src/schema-types.ts +73 -161
- package/src/shell.ts +4 -0
- package/src/transcript-retention.ts +146 -0
- package/src/value-guards.ts +22 -0
- package/docs/assets/sevro-logo.png +0 -0
- package/docs/assets/sevro-symbol.svg +0 -1
- package/docs/assets/sevro-wordmark.svg +0 -1
- package/docs/cache-ownership-validation.md +0 -49
- package/docs/claude-continuation-validation.md +0 -47
- package/docs/concurrency-validation.md +0 -86
- package/docs/configuration-root-validation.md +0 -70
- package/docs/engine-slice.md +0 -114
- package/docs/evidence/runtime-startup-30/README.md +0 -185
- package/docs/evidence/runtime-startup-30/after.json +0 -319
- package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +0 -2
- package/docs/evidence/runtime-startup-30/before.json +0 -403
- package/docs/evidence/runtime-startup-30/consumer-smoke.json +0 -63
- package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +0 -3
- package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +0 -3
- package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +0 -20
- package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +0 -2
- package/docs/evidence.md +0 -17
- package/docs/fixture-cleanup-validation.md +0 -70
- package/docs/guide-verification.json +0 -239
- package/src/evaluation-context.ts +0 -257
- package/src/evaluation-fixture.ts +0 -313
- package/src/evaluation-run.ts +0 -198
- package/src/evaluation-trial.ts +0 -946
- package/src/evaluation-types.ts +0 -149
- package/src/hosts/claude-nested-skills.ts +0 -272
- package/src/hosts/codex-command-output.ts +0 -510
- package/src/hosts/codex-executor-command.ts +0 -291
- package/src/hosts/codex-skill-reads.ts +0 -609
- /package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +0 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { isRecord, isStringArray } from "
|
|
2
|
-
import { isMissingFile } from "./
|
|
1
|
+
import { isRecord, isStringArray } from "../value-guards";
|
|
2
|
+
import { isMissingFile } from "./tools";
|
|
3
3
|
import { lstat, mkdir, realpath, stat, writeFile } from "node:fs/promises";
|
|
4
|
-
import { isAbsolute, join
|
|
4
|
+
import { isAbsolute, join } from "node:path";
|
|
5
|
+
import { insideRoot } from "../paths";
|
|
6
|
+
import { runGit } from "../git";
|
|
5
7
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { canonicalJson } from "
|
|
7
|
-
import { prepareFixtureBin } from "./
|
|
8
|
+
import { canonicalJson } from "../identity";
|
|
9
|
+
import { prepareFixtureBin } from "./bin";
|
|
8
10
|
import { prepareGitHooks } from "./git-hooks";
|
|
9
11
|
import type { PreparationSources } from "./preparation";
|
|
10
12
|
import { fixtureParts } from "./preparation";
|
|
@@ -167,53 +169,16 @@ export interface RepositorySource {
|
|
|
167
169
|
revision: string;
|
|
168
170
|
}
|
|
169
171
|
|
|
170
|
-
async function boundedText(
|
|
171
|
-
stream: ReadableStream<Uint8Array>,
|
|
172
|
-
): Promise<string> {
|
|
173
|
-
const chunks: Uint8Array[] = [];
|
|
174
|
-
let size = 0;
|
|
175
|
-
for await (const chunk of stream) {
|
|
176
|
-
size += chunk.byteLength;
|
|
177
|
-
if (size > MAX_GIT_OUTPUT_BYTES)
|
|
178
|
-
throw new Error("repository fixture Git output is too large");
|
|
179
|
-
chunks.push(chunk);
|
|
180
|
-
}
|
|
181
|
-
return new TextDecoder("utf-8", { fatal: true }).decode(
|
|
182
|
-
Buffer.concat(chunks),
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
172
|
async function git(args: string[], cwd: string): Promise<string> {
|
|
187
|
-
const
|
|
173
|
+
const stdout = await runGit({
|
|
188
174
|
cwd,
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null",
|
|
195
|
-
GIT_TERMINAL_PROMPT: "0",
|
|
196
|
-
GIT_AUTHOR_NAME: "Sevro Fixture",
|
|
197
|
-
GIT_AUTHOR_EMAIL: "fixture@sevro.invalid",
|
|
198
|
-
GIT_COMMITTER_NAME: "Sevro Fixture",
|
|
199
|
-
GIT_COMMITTER_EMAIL: "fixture@sevro.invalid",
|
|
200
|
-
GIT_AUTHOR_DATE: "2000-01-01T00:00:00+00:00",
|
|
201
|
-
GIT_COMMITTER_DATE: "2000-01-01T00:00:00+00:00",
|
|
202
|
-
},
|
|
175
|
+
args,
|
|
176
|
+
identity: "fixture",
|
|
177
|
+
label: "repository fixture",
|
|
178
|
+
maxOutputBytes: MAX_GIT_OUTPUT_BYTES,
|
|
179
|
+
validateUtf8: true,
|
|
203
180
|
});
|
|
204
|
-
|
|
205
|
-
const [stdout, , code] = await Promise.all([
|
|
206
|
-
boundedText(proc.stdout),
|
|
207
|
-
boundedText(proc.stderr),
|
|
208
|
-
proc.exited,
|
|
209
|
-
]);
|
|
210
|
-
if (code !== 0) throw new Error("repository fixture Git operation failed");
|
|
211
|
-
return stdout.trim();
|
|
212
|
-
} catch (error) {
|
|
213
|
-
proc.kill("SIGKILL");
|
|
214
|
-
await proc.exited;
|
|
215
|
-
throw error;
|
|
216
|
-
}
|
|
181
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(stdout).trim();
|
|
217
182
|
}
|
|
218
183
|
|
|
219
184
|
/** Resolve a declared local repository without accepting a dirty source tree. */
|
|
@@ -261,8 +226,7 @@ async function canonicalRepositorySource(selected: {
|
|
|
261
226
|
}
|
|
262
227
|
}
|
|
263
228
|
function requireContainedRepository(root: string, path: string): void {
|
|
264
|
-
|
|
265
|
-
if (child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child))
|
|
229
|
+
if (!insideRoot(root, path))
|
|
266
230
|
throw new Error("repository source escapes its declared root");
|
|
267
231
|
}
|
|
268
232
|
async function repositoryCommit(path: string): Promise<string> {
|
|
@@ -272,10 +236,18 @@ async function repositoryCommit(path: string): Promise<string> {
|
|
|
272
236
|
const revision = await git(["rev-parse", "--verify", "HEAD^{commit}"], path);
|
|
273
237
|
if (!/^[a-f0-9]{40,64}$/.test(revision))
|
|
274
238
|
throw new Error("repository source has no valid commit");
|
|
275
|
-
if (
|
|
239
|
+
if (
|
|
240
|
+
await git(
|
|
241
|
+
[
|
|
242
|
+
"status",
|
|
243
|
+
"--porcelain=v1",
|
|
244
|
+
"--untracked-files=all",
|
|
245
|
+
"--ignore-submodules=none",
|
|
246
|
+
],
|
|
247
|
+
path,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
276
250
|
throw new Error("repository source has uncommitted changes");
|
|
277
|
-
if (await git(["submodule", "status"], path))
|
|
278
|
-
throw new Error("repository submodules are not supported");
|
|
279
251
|
return revision;
|
|
280
252
|
}
|
|
281
253
|
function requireRepositorySnapshot(
|
|
@@ -306,6 +278,7 @@ export async function cloneRepositorySource(
|
|
|
306
278
|
"--local",
|
|
307
279
|
"--no-hardlinks",
|
|
308
280
|
"--no-checkout",
|
|
281
|
+
"--no-recurse-submodules",
|
|
309
282
|
"--quiet",
|
|
310
283
|
current.path,
|
|
311
284
|
workspace,
|
|
@@ -375,25 +348,18 @@ export async function applyRepositoryOverlay(
|
|
|
375
348
|
});
|
|
376
349
|
await stageRepositoryOverlay(fixture, workspace, Object.keys(files));
|
|
377
350
|
}
|
|
378
|
-
function disabledHooksArgs(): string[] {
|
|
379
|
-
return [
|
|
380
|
-
"-c",
|
|
381
|
-
`core.hooksPath=${process.platform === "win32" ? "NUL" : "/dev/null"}`,
|
|
382
|
-
];
|
|
383
|
-
}
|
|
384
351
|
async function stageRepositoryOverlay(
|
|
385
352
|
fixture: RepositoryFixture,
|
|
386
353
|
workspace: string,
|
|
387
354
|
paths: string[],
|
|
388
355
|
): Promise<void> {
|
|
389
|
-
const noHooks = disabledHooksArgs();
|
|
390
356
|
if (fixture.commitFiles) {
|
|
391
|
-
await git([
|
|
357
|
+
await git(["add", "--", ...paths], workspace);
|
|
392
358
|
await git(
|
|
393
|
-
[
|
|
359
|
+
["commit", "--quiet", "-m", "Add evaluation scaffolding"],
|
|
394
360
|
workspace,
|
|
395
361
|
);
|
|
396
362
|
}
|
|
397
363
|
if (fixture.staged?.length)
|
|
398
|
-
await git([
|
|
364
|
+
await git(["add", "--", ...fixture.staged], workspace);
|
|
399
365
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
1
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { delimiter, isAbsolute, join } from "node:path";
|
|
4
|
-
import { isRecord } from "
|
|
4
|
+
import { isRecord } from "../value-guards";
|
|
5
|
+
import { requireCandidateRuntime } from "../candidate-artifacts";
|
|
6
|
+
import {
|
|
7
|
+
ProcessDeadline,
|
|
8
|
+
terminateOwnedProcessGroup,
|
|
9
|
+
} from "../process-lifecycle";
|
|
5
10
|
|
|
6
11
|
const MAX_COMMAND_BYTES = 64 * 1024;
|
|
7
12
|
const MAX_ENV_BYTES = 16 * 1024;
|
|
@@ -103,27 +108,12 @@ export function prepareFixtureSetup(value: unknown): FixtureSetup | null {
|
|
|
103
108
|
return { command, environment };
|
|
104
109
|
}
|
|
105
110
|
|
|
106
|
-
function stopProcess(proc: Bun.Subprocess): void {
|
|
107
|
-
if (process.platform !== "win32") {
|
|
108
|
-
try {
|
|
109
|
-
process.kill(-proc.pid, "SIGKILL");
|
|
110
|
-
return;
|
|
111
|
-
} catch {
|
|
112
|
-
// The process group may already be gone.
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
try {
|
|
116
|
-
proc.kill("SIGKILL");
|
|
117
|
-
} catch {
|
|
118
|
-
// The process already exited.
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
111
|
interface FixtureSetupOptions {
|
|
123
112
|
workspace: string;
|
|
124
113
|
projectRoot: string;
|
|
125
114
|
fixtureBinDir?: string;
|
|
126
115
|
signal?: AbortSignal;
|
|
116
|
+
candidateRuntimeRoot?: string;
|
|
127
117
|
}
|
|
128
118
|
|
|
129
119
|
function setupPath(directory: string | undefined): string {
|
|
@@ -146,7 +136,9 @@ function executionEnvironment(
|
|
|
146
136
|
);
|
|
147
137
|
return {
|
|
148
138
|
PATH: setupPath(options.fixtureBinDir),
|
|
149
|
-
HOME:
|
|
139
|
+
HOME: options.candidateRuntimeRoot
|
|
140
|
+
? join(options.candidateRuntimeRoot, "home")
|
|
141
|
+
: home,
|
|
150
142
|
TMPDIR: home,
|
|
151
143
|
LANG: process.env.LANG ?? "C",
|
|
152
144
|
GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null",
|
|
@@ -164,13 +156,23 @@ function executionEnvironment(
|
|
|
164
156
|
|
|
165
157
|
class FixtureSetupExecution {
|
|
166
158
|
private proc: Bun.Subprocess | undefined;
|
|
167
|
-
private
|
|
168
|
-
private cancel: (() => void) | undefined;
|
|
159
|
+
private readonly deadline: ProcessDeadline;
|
|
169
160
|
constructor(
|
|
170
161
|
private readonly setup: FixtureSetup,
|
|
171
162
|
private readonly options: FixtureSetupOptions,
|
|
172
163
|
private readonly home: string,
|
|
173
|
-
) {
|
|
164
|
+
) {
|
|
165
|
+
this.deadline = new ProcessDeadline({
|
|
166
|
+
timeoutMs: TIMEOUT_MS,
|
|
167
|
+
signal: options.signal,
|
|
168
|
+
interruption: (reason) =>
|
|
169
|
+
new Error(
|
|
170
|
+
reason === "timeout"
|
|
171
|
+
? "fixture setup timed out"
|
|
172
|
+
: "fixture setup cancelled",
|
|
173
|
+
),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
174
176
|
|
|
175
177
|
async run(): Promise<void> {
|
|
176
178
|
this.proc = Bun.spawn(this.setup.command, {
|
|
@@ -181,44 +183,18 @@ class FixtureSetupExecution {
|
|
|
181
183
|
stdout: "ignore",
|
|
182
184
|
stderr: "ignore",
|
|
183
185
|
});
|
|
184
|
-
const code = await
|
|
185
|
-
this.proc.exited,
|
|
186
|
-
this.timeout(),
|
|
187
|
-
this.aborted(),
|
|
188
|
-
]);
|
|
186
|
+
const code = await this.deadline.wait(this.proc.exited);
|
|
189
187
|
if (code !== 0) throw new Error(`fixture setup failed (${code})`);
|
|
190
188
|
}
|
|
191
189
|
|
|
192
|
-
private timeout(): Promise<never> {
|
|
193
|
-
return new Promise((_resolve, reject) => {
|
|
194
|
-
this.timer = setTimeout(() => {
|
|
195
|
-
reject(new Error("fixture setup timed out"));
|
|
196
|
-
}, TIMEOUT_MS);
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
private aborted(): Promise<never> {
|
|
201
|
-
return new Promise((_resolve, reject) => {
|
|
202
|
-
const signal = this.options.signal;
|
|
203
|
-
if (!signal) return;
|
|
204
|
-
this.cancel = () => {
|
|
205
|
-
reject(new Error("fixture setup cancelled"));
|
|
206
|
-
};
|
|
207
|
-
if (signal.aborted) this.cancel();
|
|
208
|
-
else signal.addEventListener("abort", this.cancel, { once: true });
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
|
|
212
190
|
async stop(): Promise<void> {
|
|
213
191
|
if (!this.proc) return;
|
|
214
|
-
|
|
192
|
+
terminateOwnedProcessGroup(this.proc);
|
|
215
193
|
await this.proc.exited;
|
|
216
194
|
}
|
|
217
195
|
|
|
218
196
|
async dispose(): Promise<void> {
|
|
219
|
-
|
|
220
|
-
if (this.cancel)
|
|
221
|
-
this.options.signal?.removeEventListener("abort", this.cancel);
|
|
197
|
+
this.deadline.dispose();
|
|
222
198
|
await rm(this.home, { recursive: true, force: true });
|
|
223
199
|
}
|
|
224
200
|
}
|
|
@@ -229,6 +205,12 @@ export async function runFixtureSetup(
|
|
|
229
205
|
options: FixtureSetupOptions,
|
|
230
206
|
): Promise<void> {
|
|
231
207
|
if (options.signal?.aborted) throw new Error("fixture setup cancelled");
|
|
208
|
+
await requireCandidateRuntime(options.candidateRuntimeRoot);
|
|
209
|
+
if (options.candidateRuntimeRoot)
|
|
210
|
+
await mkdir(join(options.candidateRuntimeRoot, "home"), {
|
|
211
|
+
recursive: true,
|
|
212
|
+
mode: 0o700,
|
|
213
|
+
});
|
|
232
214
|
const home = await mkdtemp(join(tmpdir(), "sevro-setup-"));
|
|
233
215
|
const execution = new FixtureSetupExecution(setup, options, home);
|
|
234
216
|
try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { chmod, lstat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { isRecord } from "
|
|
3
|
+
import { isRecord } from "../value-guards";
|
|
4
4
|
|
|
5
5
|
export function isMissingFile(error: unknown): boolean {
|
|
6
6
|
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
mkdtemp,
|
|
7
|
+
realpath,
|
|
8
|
+
readFile,
|
|
9
|
+
readdir,
|
|
10
|
+
rm,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { tmpdir, userInfo } from "node:os";
|
|
14
|
+
import { basename, dirname, join } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
ownerStatus,
|
|
17
|
+
processIdentity,
|
|
18
|
+
validProcessIdentity,
|
|
19
|
+
type ProcessIdentity,
|
|
20
|
+
} from "../run-owner";
|
|
21
|
+
import { isRecord } from "../value-guards";
|
|
22
|
+
import { isMissingFile } from "./tools";
|
|
23
|
+
import { atomicWriteJson } from "../storage";
|
|
24
|
+
import { clearFixtureContents } from "./cleanup";
|
|
25
|
+
|
|
26
|
+
const RETENTION_MS = 24 * 60 * 60_000;
|
|
27
|
+
interface WorkspaceOwner {
|
|
28
|
+
format: "sevro.workspace-owner.v1";
|
|
29
|
+
owner: ProcessIdentity;
|
|
30
|
+
state: "active" | "retained";
|
|
31
|
+
timestamp: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One stable boundary protects both existing and subsequently allocated peers. */
|
|
35
|
+
export function workspaceStatePath(): string {
|
|
36
|
+
const base = process.platform === "linux" ? "/var/tmp" : tmpdir();
|
|
37
|
+
return join(realpathSync(base), "sevro-workspaces-private");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function workspaceStateParent(): Promise<string> {
|
|
41
|
+
const root = workspaceStatePath();
|
|
42
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
43
|
+
const info = await lstat(root);
|
|
44
|
+
if (
|
|
45
|
+
!info.isDirectory() ||
|
|
46
|
+
info.uid !== userInfo().uid ||
|
|
47
|
+
(info.mode & 0o077) !== 0
|
|
48
|
+
)
|
|
49
|
+
throw new Error("workspace namespace must be an owned private directory");
|
|
50
|
+
return realpath(root);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function allocateWorkspace(): Promise<string> {
|
|
54
|
+
const owner = processIdentity(process.pid);
|
|
55
|
+
if (!owner) throw new Error("cannot verify workspace owner identity");
|
|
56
|
+
const parent = await workspaceStateParent();
|
|
57
|
+
await workspaceMetadataDirectory(parent);
|
|
58
|
+
const workspace = await realpath(await mkdtemp(join(parent, "case-")));
|
|
59
|
+
try {
|
|
60
|
+
await writeFile(
|
|
61
|
+
workspaceOwnerPath(workspace),
|
|
62
|
+
JSON.stringify({
|
|
63
|
+
format: "sevro.workspace-owner.v1",
|
|
64
|
+
owner,
|
|
65
|
+
state: "active",
|
|
66
|
+
timestamp: Date.now(),
|
|
67
|
+
}),
|
|
68
|
+
{ mode: 0o600, flag: "wx" },
|
|
69
|
+
);
|
|
70
|
+
return workspace;
|
|
71
|
+
} catch (cause) {
|
|
72
|
+
await rm(workspace, { recursive: true, force: true });
|
|
73
|
+
throw cause;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function workspaceMetadataDirectory(parent: string): Promise<string> {
|
|
78
|
+
const root = join(parent, ".owners");
|
|
79
|
+
await mkdir(root, { mode: 0o700, recursive: true });
|
|
80
|
+
const metadata = await lstat(root);
|
|
81
|
+
if (!ownedDirectory(metadata) || (metadata.mode & 0o077) !== 0)
|
|
82
|
+
throw new Error("workspace metadata must be an owned private directory");
|
|
83
|
+
return root;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function workspaceOwnerPath(workspace: string): string {
|
|
87
|
+
return join(dirname(workspace), ".owners", `${basename(workspace)}.json`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validWorkspaceLifecycle(value: Record<string, unknown>): boolean {
|
|
91
|
+
return (
|
|
92
|
+
(value.state === "active" || value.state === "retained") &&
|
|
93
|
+
typeof value.timestamp === "number" &&
|
|
94
|
+
Number.isFinite(value.timestamp)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validWorkspaceOwner(value: unknown): value is WorkspaceOwner {
|
|
99
|
+
return (
|
|
100
|
+
isRecord(value) &&
|
|
101
|
+
value.format === "sevro.workspace-owner.v1" &&
|
|
102
|
+
validWorkspaceLifecycle(value) &&
|
|
103
|
+
validProcessIdentity(value.owner)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function privateOwnerFile(info: Stats): boolean {
|
|
108
|
+
return (
|
|
109
|
+
info.isFile() &&
|
|
110
|
+
info.uid === userInfo().uid &&
|
|
111
|
+
info.size <= 4096 &&
|
|
112
|
+
(info.mode & 0o077) === 0
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function readWorkspaceOwner(workspace: string): Promise<unknown> {
|
|
117
|
+
const path = workspaceOwnerPath(workspace);
|
|
118
|
+
if (!privateOwnerFile(await lstat(path))) return null;
|
|
119
|
+
return JSON.parse(await readFile(path, "utf8")) as unknown;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function workspaceOwner(
|
|
123
|
+
workspace: string,
|
|
124
|
+
): Promise<WorkspaceOwner | null> {
|
|
125
|
+
try {
|
|
126
|
+
const value = await readWorkspaceOwner(workspace);
|
|
127
|
+
return validWorkspaceOwner(value) ? value : null;
|
|
128
|
+
} catch {
|
|
129
|
+
// Unreadable or malformed ownership cannot authorize temporary-data deletion.
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function retainWorkspace(workspace: string): Promise<void> {
|
|
135
|
+
if (!workspaceBoundaryException(workspaceStatePath(), workspace).length)
|
|
136
|
+
return;
|
|
137
|
+
const record = await workspaceOwner(workspace);
|
|
138
|
+
if (!record || record.state === "retained") return;
|
|
139
|
+
await atomicWriteJson(workspaceOwnerPath(workspace), {
|
|
140
|
+
...record,
|
|
141
|
+
state: "retained",
|
|
142
|
+
timestamp: Date.now(),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function removeWorkspace(workspace: string): Promise<void> {
|
|
147
|
+
await rm(workspace, { recursive: true, force: true });
|
|
148
|
+
if (workspaceBoundaryException(workspaceStatePath(), workspace).length)
|
|
149
|
+
await rm(workspaceOwnerPath(workspace), { force: true });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function expireWorkspace(
|
|
153
|
+
workspace: string,
|
|
154
|
+
cutoff: number,
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
const record = await workspaceOwner(workspace);
|
|
157
|
+
if (!record || !expiredOwner(record, cutoff)) return;
|
|
158
|
+
const info = await lstat(workspace).catch((cause: unknown) => {
|
|
159
|
+
if (isMissingFile(cause)) return null;
|
|
160
|
+
throw cause;
|
|
161
|
+
});
|
|
162
|
+
if (info && !ownedDirectory(info)) return;
|
|
163
|
+
await clearExpiredWorkspace(workspace, info);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function clearExpiredWorkspace(
|
|
167
|
+
workspace: string,
|
|
168
|
+
info: Stats | null,
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
if (info) await clearFixtureContents(workspace);
|
|
171
|
+
await removeWorkspace(workspace);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function ownedDirectory(info: Stats): boolean {
|
|
175
|
+
return info.isDirectory() && info.uid === userInfo().uid;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function expiredOwner(record: WorkspaceOwner, cutoff: number): boolean {
|
|
179
|
+
return (
|
|
180
|
+
record.timestamp < cutoff &&
|
|
181
|
+
(record.state === "retained" || ownerStatus(record.owner) === "dead")
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Reclaim only owned inactive temporary data; evidence directories are never scanned. */
|
|
186
|
+
export async function cleanupExpiredWorkspaces(): Promise<void> {
|
|
187
|
+
const parent = await workspaceStateParent();
|
|
188
|
+
const metadata = await workspaceMetadataDirectory(parent);
|
|
189
|
+
const cutoff = Date.now() - RETENTION_MS;
|
|
190
|
+
for (const entry of await readdir(metadata, { withFileTypes: true }))
|
|
191
|
+
if (entry.isFile() && /^case-[a-zA-Z0-9]+\.json$/.test(entry.name)) {
|
|
192
|
+
const workspace = join(parent, entry.name.slice(0, -5));
|
|
193
|
+
await expireWorkspace(workspace, cutoff).catch((cause: unknown) => {
|
|
194
|
+
if (!isMissingFile(cause))
|
|
195
|
+
console.warn(
|
|
196
|
+
`temporary workspace cleanup did not complete: ${workspace}`,
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Only a runner-owned direct child can be an exception to this deny boundary. */
|
|
203
|
+
export function workspaceBoundaryException(
|
|
204
|
+
root: string,
|
|
205
|
+
workspace?: string,
|
|
206
|
+
): string[] {
|
|
207
|
+
return root === workspaceStatePath() &&
|
|
208
|
+
workspace !== undefined &&
|
|
209
|
+
dirname(workspace) === root
|
|
210
|
+
? [workspace]
|
|
211
|
+
: [];
|
|
212
|
+
}
|