@bjoernrochel/sevro 0.1.0-rc.2 → 0.1.0-rc.21
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 +170 -0
- package/LICENSE +63 -136
- package/README.md +83 -66
- package/docs/README.md +44 -0
- package/docs/acknowledgements.md +14 -0
- package/docs/architecture.md +146 -0
- package/docs/assets/README.md +27 -0
- package/docs/assets/sevro-logo.png +0 -0
- package/docs/assets/sevro-symbol.svg +1 -0
- package/docs/assets/sevro-wordmark.svg +1 -0
- package/docs/builtin-graders-v1.md +41 -13
- package/docs/codex-host.md +184 -52
- package/docs/contracts.md +19 -0
- package/docs/creating-cases.md +139 -0
- package/docs/development-cli.md +190 -30
- package/docs/documentation-quality.md +61 -0
- package/docs/evaluation-workflow.md +110 -0
- package/docs/evidence/code-mode-controls-71.md +64 -0
- package/docs/evidence/code-mode-programs-85.md +64 -0
- package/docs/evidence/codex-state-73/README.md +204 -0
- package/docs/evidence/codex-state-73/darrow-migration.patch +182 -0
- package/docs/evidence/codex-state-73/results.json +661 -0
- package/docs/evidence/local-http-74.md +104 -0
- package/docs/evidence/runtime-startup-30/README.md +185 -0
- package/docs/evidence/runtime-startup-30/after.json +319 -0
- package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +2 -0
- package/docs/evidence/runtime-startup-30/before.json +403 -0
- package/docs/evidence/runtime-startup-30/consumer-smoke.json +63 -0
- package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +3 -0
- package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +3 -0
- package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +20 -0
- package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +2 -0
- package/docs/evidence/skill-reads-70/results.json +823 -0
- package/docs/evidence.md +21 -0
- package/docs/extension-protocol-v1.md +50 -16
- package/docs/fixture-cleanup-validation.md +3 -0
- package/docs/getting-started.md +102 -0
- package/docs/guide-evaluation.md +89 -0
- package/docs/guide-verification.json +239 -0
- package/docs/identity-v1.md +35 -1
- package/docs/installing.md +88 -0
- package/docs/licenses/BUSL-1.1-0.1.0-rc.2.txt +159 -0
- package/docs/licensing.md +42 -0
- package/docs/native-controls.md +76 -5
- package/docs/native-hosts.md +228 -0
- package/docs/reading-results.md +141 -0
- package/docs/recipes.md +489 -0
- package/docs/releases.md +81 -40
- package/docs/report-v1.md +29 -1
- package/docs/results-v1.md +180 -0
- package/docs/run-settings-v1.md +147 -0
- package/docs/running-evaluations.md +31 -0
- package/docs/runtime-v1.md +614 -0
- package/docs/specs/repository-guide.md +58 -0
- package/docs/troubleshooting.md +30 -0
- package/docs/typescript-quality.md +234 -0
- package/docs/usability-study.md +114 -0
- package/examples/basic/host.ts +2 -2
- 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 +39 -6
- package/schemas/report-v1.schema.json +9 -0
- package/schemas/run-evidence-v1.schema.json +2 -1
- package/schemas/runtime-v1.schema.json +193 -0
- package/src/advisory.ts +10 -5
- package/src/candidate-artifacts.ts +208 -0
- package/src/candidate-codex-state.ts +185 -0
- package/src/case-validation.ts +105 -0
- package/src/cli/cancellation.ts +20 -0
- package/src/cli/command.ts +81 -0
- package/src/cli/completion.ts +15 -0
- package/src/cli/demo.ts +59 -0
- package/src/cli/evaluation.ts +55 -0
- package/src/cli/extension.ts +79 -0
- package/src/cli/help.ts +222 -0
- package/src/cli/hosts.ts +59 -0
- package/src/cli/init-settings.ts +30 -0
- package/src/cli/init.ts +111 -0
- package/src/cli/invocation.ts +627 -0
- package/src/cli/loading.ts +151 -0
- package/src/cli/preparation.ts +163 -0
- package/src/cli/presentation.ts +91 -0
- package/src/cli/progress.ts +6 -0
- package/src/cli/run-manifest.ts +133 -0
- package/src/cli/run-options.ts +101 -0
- package/src/cli/run-overrides.ts +83 -0
- package/src/cli/settings.ts +160 -0
- package/src/cli/summary.ts +43 -0
- package/src/cli.ts +13 -843
- package/src/contract-types.ts +94 -0
- package/src/engine.ts +19 -2373
- package/src/evaluation/advisory.ts +150 -0
- package/src/evaluation/check-plan.ts +510 -0
- package/src/evaluation/collection.ts +88 -0
- package/src/evaluation/context.ts +10 -0
- package/src/evaluation/grading-host.ts +39 -0
- package/src/evaluation/grading.ts +258 -0
- package/src/evaluation/invocation-plan.ts +548 -0
- package/src/evaluation/plan.ts +736 -0
- package/src/evaluation/progress.ts +19 -0
- package/src/evaluation/resources.ts +241 -0
- package/src/evaluation/run.ts +206 -0
- package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +3 -1
- package/src/evaluation/semantic.ts +312 -0
- package/src/evaluation/trial.ts +866 -0
- package/src/evaluation/types.ts +207 -0
- package/src/evidence/host-results.ts +92 -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 +228 -148
- package/src/extension-session.ts +326 -195
- package/src/fixtures/advisory.ts +338 -0
- package/src/fixtures/bin.ts +57 -0
- package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
- package/src/fixtures/create.ts +187 -0
- package/src/fixtures/generated.ts +300 -0
- package/src/fixtures/git-hooks.ts +24 -0
- package/src/fixtures/preparation.ts +270 -0
- package/src/{repository-fixture.ts → fixtures/repository.ts} +207 -85
- package/src/fixtures/setup.ts +224 -0
- package/src/fixtures/tools.ts +88 -0
- package/src/fixtures/workspace-state.ts +212 -0
- package/src/generated/cli-result.cjs +2 -0
- package/src/generated/cli-result.d.cts +3 -0
- package/src/generated/extension.cjs +2 -0
- package/src/generated/extension.d.cts +3 -0
- package/src/generated/report.cjs +2 -0
- package/src/generated/report.d.cts +3 -0
- package/src/generated/run-evidence.cjs +2 -0
- package/src/generated/run-evidence.d.cts +3 -0
- package/src/generated/runtime.cjs +2 -0
- package/src/generated/runtime.d.cts +3 -0
- package/src/graders/artifact.ts +56 -23
- package/src/graders/git-head.ts +79 -75
- package/src/graders/output.ts +222 -140
- package/src/graders/semantic.ts +102 -43
- package/src/graders/shell.ts +379 -207
- package/src/host-diagnostic-redaction.ts +52 -0
- package/src/host-diagnostics.ts +319 -0
- package/src/hosts/claude-continuation.ts +63 -18
- package/src/hosts/claude-credential.ts +43 -62
- package/src/hosts/claude-events.ts +86 -51
- package/src/hosts/claude-keychain.ts +59 -0
- package/src/hosts/claude-native-goal.ts +57 -33
- package/src/hosts/claude-process.ts +111 -0
- package/src/hosts/claude-repository-invocation.ts +208 -117
- package/src/hosts/claude-settings.ts +240 -21
- package/src/hosts/claude-skill-activations.ts +166 -0
- package/src/hosts/claude-state.ts +403 -0
- package/src/hosts/claude-tool-calls.ts +171 -106
- package/src/hosts/claude.ts +561 -451
- package/src/hosts/codex-app-server.ts +326 -178
- package/src/hosts/codex-configuration.ts +44 -18
- package/src/hosts/codex-events.ts +97 -60
- package/src/hosts/codex-exec-controls.ts +516 -0
- package/src/hosts/codex-invocation.ts +70 -0
- package/src/hosts/codex-marketplace.ts +283 -0
- package/src/hosts/codex-native-calls.ts +546 -500
- package/src/hosts/codex-process.ts +202 -0
- package/src/hosts/codex-profile.ts +257 -28
- package/src/hosts/codex-session-files.ts +106 -0
- package/src/hosts/codex-state.ts +492 -0
- package/src/hosts/codex.ts +783 -832
- package/src/hosts/isolation-common.ts +94 -0
- package/src/hosts/isolation-roots.ts +54 -27
- package/src/hosts/isolation.ts +10 -0
- package/src/hosts/linux-sandbox.ts +159 -0
- package/src/hosts/mac-sandbox.ts +187 -61
- package/src/hosts/native-controls.ts +152 -62
- package/src/hosts/process-lifecycle.ts +37 -0
- package/src/hosts/protected-worktrees.ts +51 -19
- package/src/hosts/skill-hook-catalog.ts +131 -0
- package/src/hosts/skill-hook-handler.ts +374 -0
- package/src/hosts/skill-hooks.ts +580 -0
- package/src/hosts/workspace-fingerprint.ts +55 -35
- package/src/identity.ts +102 -48
- package/src/instrumentation.ts +109 -48
- package/src/native-transcript-state.ts +131 -0
- package/src/native-transcript-view.ts +90 -0
- package/src/native-transcripts.ts +286 -0
- package/src/process-lifecycle.ts +150 -0
- package/src/provenance.ts +161 -81
- package/src/prune.ts +215 -0
- package/src/report.ts +172 -140
- package/src/resolved-case.ts +33 -0
- package/src/results-lock.ts +29 -0
- package/src/results.ts +133 -86
- package/src/run-owner.ts +154 -63
- package/src/runtime/config.ts +456 -0
- package/src/runtime/discovery-apple.ts +90 -0
- package/src/runtime/discovery-providers.ts +63 -0
- package/src/runtime/discovery.ts +70 -0
- package/src/runtime/hook-processes.ts +97 -0
- package/src/runtime/hooks.ts +476 -0
- package/src/runtime/paths.ts +313 -0
- package/src/runtime/query.ts +85 -0
- package/src/runtime/seeds.ts +134 -0
- package/src/runtime/shared-cache.ts +143 -0
- package/src/runtime/state.ts +143 -0
- package/src/schema-types.ts +198 -0
- package/src/schema.ts +15 -19
- package/src/transcript-retention.ts +146 -0
- package/src/value-guards.ts +20 -0
- package/src/advisory-fixture.ts +0 -282
- package/src/fixture-bin.ts +0 -87
- package/src/fixture-setup.ts +0 -163
- package/src/generated-fixture.ts +0 -220
- package/src/git-hooks.ts +0 -63
- package/src/hosts/claude-nested-skills.ts +0 -218
- package/src/hosts/codex-command-output.ts +0 -399
- package/src/hosts/codex-executor-command.ts +0 -195
- package/src/hosts/codex-skill-reads.ts +0 -391
- package/src/preparation.ts +0 -211
package/src/provenance.ts
CHANGED
|
@@ -1,42 +1,57 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
2
|
import { lstat, readFile, readdir, readlink, realpath } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
3
4
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
5
|
import { pathToFileURL } from "node:url";
|
|
5
6
|
import packageJson from "../package.json";
|
|
6
|
-
import { hashJson } from "./identity";
|
|
7
|
+
import { hashJson, sha256 } from "./identity";
|
|
8
|
+
import { insideRuntimeRoot } from "./runtime/paths";
|
|
7
9
|
|
|
8
10
|
const MAX_DIRTY_BYTES = 32 * 1024 * 1024;
|
|
9
11
|
const MAX_BUILD_BYTES = 64 * 1024 * 1024;
|
|
10
12
|
const MAX_BUILD_FILES = 4096;
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
type BuildFile = { path: string; sha256: string };
|
|
15
|
+
type ProjectFile = BuildFile & { executable: boolean };
|
|
16
|
+
function fileOrder(left: BuildFile, right: BuildFile): number {
|
|
17
|
+
return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
async function collect(path: string): Promise<void> {
|
|
24
|
-
const absolute = join(canonicalRoot, path);
|
|
25
|
-
const info = await lstat(absolute);
|
|
20
|
+
class BuildSnapshot {
|
|
21
|
+
readonly files: BuildFile[] = [];
|
|
22
|
+
private bytes = 0;
|
|
23
|
+
constructor(private root: string) {}
|
|
24
|
+
async collect(path: string): Promise<void> {
|
|
25
|
+
const absolute = join(this.root, path),
|
|
26
|
+
info = await lstat(absolute);
|
|
26
27
|
if (info.isSymbolicLink())
|
|
27
28
|
throw new Error("package build contains a symbolic link");
|
|
28
29
|
if (info.isDirectory()) {
|
|
29
30
|
for (const entry of (await readdir(absolute)).sort())
|
|
30
|
-
await collect(join(path, entry));
|
|
31
|
+
await this.collect(join(path, entry));
|
|
31
32
|
return;
|
|
32
33
|
}
|
|
34
|
+
await this.collectFile(path, absolute, info);
|
|
35
|
+
}
|
|
36
|
+
private async collectFile(
|
|
37
|
+
path: string,
|
|
38
|
+
absolute: string,
|
|
39
|
+
info: Stats,
|
|
40
|
+
): Promise<void> {
|
|
33
41
|
if (!info.isFile()) throw new Error("package build contains a non-file");
|
|
34
42
|
const bytes = await readFile(absolute);
|
|
35
|
-
|
|
36
|
-
if (files.length >= MAX_BUILD_FILES ||
|
|
43
|
+
this.bytes += bytes.byteLength;
|
|
44
|
+
if (this.files.length >= MAX_BUILD_FILES || this.bytes > MAX_BUILD_BYTES)
|
|
37
45
|
throw new Error("package build exceeds the size limit");
|
|
38
|
-
files.push({ path: path.split(sep).join("/"), sha256: sha256(bytes) });
|
|
46
|
+
this.files.push({ path: path.split(sep).join("/"), sha256: sha256(bytes) });
|
|
39
47
|
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Digest the packaged runtime and public contract files in path order. */
|
|
51
|
+
export async function packageBuildDigest(
|
|
52
|
+
root = join(import.meta.dir, ".."),
|
|
53
|
+
): Promise<string> {
|
|
54
|
+
const snapshot = new BuildSnapshot(await realpath(root));
|
|
40
55
|
for (const path of [
|
|
41
56
|
"package.json",
|
|
42
57
|
"README.md",
|
|
@@ -45,57 +60,80 @@ export async function packageBuildDigest(
|
|
|
45
60
|
"schemas",
|
|
46
61
|
"src",
|
|
47
62
|
])
|
|
48
|
-
await collect(path);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
await snapshot.collect(path);
|
|
64
|
+
return hashJson({
|
|
65
|
+
format: "sevro.build.v1",
|
|
66
|
+
files: snapshot.files.sort(fileOrder),
|
|
67
|
+
});
|
|
53
68
|
}
|
|
54
69
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
70
|
-
const files: { path: string; sha256: string; executable: boolean }[] = [];
|
|
71
|
-
let totalBytes = 0;
|
|
72
|
-
async function collect(path: string): Promise<void> {
|
|
73
|
-
if (path === ".git" || excluded.has(path)) return;
|
|
74
|
-
const absolute = join(projectRoot, path);
|
|
75
|
-
const info = await lstat(absolute);
|
|
70
|
+
class ProjectSnapshot {
|
|
71
|
+
readonly files: ProjectFile[] = [];
|
|
72
|
+
private bytes = 0;
|
|
73
|
+
constructor(
|
|
74
|
+
private root: string,
|
|
75
|
+
private excluded: Set<string>,
|
|
76
|
+
) {}
|
|
77
|
+
private isExcluded(path: string): boolean {
|
|
78
|
+
return path === ".git" || this.excluded.has(path);
|
|
79
|
+
}
|
|
80
|
+
async collect(path: string): Promise<void> {
|
|
81
|
+
if (this.isExcluded(path)) return;
|
|
82
|
+
const absolute = join(this.root, path),
|
|
83
|
+
info = await lstat(absolute);
|
|
76
84
|
if (info.isSymbolicLink())
|
|
77
85
|
throw new Error("project snapshot contains a symbolic link");
|
|
78
86
|
if (info.isDirectory()) {
|
|
79
|
-
|
|
80
|
-
await collect(path === "" ? entry : join(path, entry));
|
|
87
|
+
await this.collectDirectory(path, absolute);
|
|
81
88
|
return;
|
|
82
89
|
}
|
|
90
|
+
await this.collectFile(path, absolute, info);
|
|
91
|
+
}
|
|
92
|
+
private async collectDirectory(
|
|
93
|
+
path: string,
|
|
94
|
+
absolute: string,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
for (const entry of (await readdir(absolute)).sort())
|
|
97
|
+
await this.collect(path === "" ? entry : join(path, entry));
|
|
98
|
+
}
|
|
99
|
+
private async collectFile(
|
|
100
|
+
path: string,
|
|
101
|
+
absolute: string,
|
|
102
|
+
info: Stats,
|
|
103
|
+
): Promise<void> {
|
|
83
104
|
if (!info.isFile()) throw new Error("project snapshot contains a non-file");
|
|
84
105
|
const bytes = await readFile(absolute);
|
|
85
|
-
|
|
86
|
-
if (files.length >= MAX_BUILD_FILES ||
|
|
106
|
+
this.bytes += bytes.byteLength;
|
|
107
|
+
if (this.files.length >= MAX_BUILD_FILES || this.bytes > MAX_BUILD_BYTES)
|
|
87
108
|
throw new Error("project snapshot exceeds the size limit");
|
|
88
|
-
files.push({
|
|
109
|
+
this.files.push({
|
|
89
110
|
path: path.split(sep).join("/"),
|
|
90
111
|
sha256: sha256(bytes),
|
|
91
112
|
executable: Boolean(info.mode & 0o111),
|
|
92
113
|
});
|
|
93
114
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
}
|
|
116
|
+
export async function projectIdentityDigest(
|
|
117
|
+
root: string,
|
|
118
|
+
provenance: { revision: string | null; dirtyPatchDigest: string | null },
|
|
119
|
+
excludedRoots: string[] = [],
|
|
120
|
+
): Promise<string> {
|
|
121
|
+
if (provenance.revision)
|
|
122
|
+
return hashJson({
|
|
123
|
+
format: "sevro.project.v1",
|
|
124
|
+
revision: provenance.revision,
|
|
125
|
+
dirtyPatchDigest: provenance.dirtyPatchDigest,
|
|
126
|
+
});
|
|
127
|
+
const projectRoot = resolve(root),
|
|
128
|
+
excluded = new Set(
|
|
129
|
+
excludedRoots.map((path) => relative(projectRoot, resolve(path))),
|
|
130
|
+
);
|
|
131
|
+
const snapshot = new ProjectSnapshot(projectRoot, excluded);
|
|
132
|
+
await snapshot.collect("");
|
|
133
|
+
return hashJson({
|
|
134
|
+
format: "sevro.project.v1",
|
|
135
|
+
files: snapshot.files.sort(fileOrder),
|
|
136
|
+
});
|
|
99
137
|
}
|
|
100
138
|
|
|
101
139
|
function git(root: string, ...arguments_: string[]): Buffer {
|
|
@@ -110,7 +148,10 @@ function git(root: string, ...arguments_: string[]): Buffer {
|
|
|
110
148
|
return Buffer.from(result.stdout);
|
|
111
149
|
}
|
|
112
150
|
|
|
113
|
-
async function dirtyPatchDigest(
|
|
151
|
+
async function dirtyPatchDigest(
|
|
152
|
+
gitRoot: string,
|
|
153
|
+
excludedRoots: string[],
|
|
154
|
+
): Promise<string | null> {
|
|
114
155
|
const trackedPatch = git(gitRoot, "diff", "--binary", "HEAD", "--");
|
|
115
156
|
const untrackedPaths = git(
|
|
116
157
|
gitRoot,
|
|
@@ -122,26 +163,17 @@ async function dirtyPatchDigest(gitRoot: string): Promise<string | null> {
|
|
|
122
163
|
.toString("utf8")
|
|
123
164
|
.split("\0")
|
|
124
165
|
.filter(Boolean)
|
|
166
|
+
.filter(
|
|
167
|
+
(path) =>
|
|
168
|
+
!excludedRoots.some((root) =>
|
|
169
|
+
insideRuntimeRoot(root, resolve(gitRoot, path)),
|
|
170
|
+
),
|
|
171
|
+
)
|
|
125
172
|
.sort();
|
|
126
173
|
let dirtyBytes = trackedPatch.byteLength;
|
|
127
174
|
const untracked: { path: string; sha256: string }[] = [];
|
|
128
175
|
for (const path of untrackedPaths) {
|
|
129
|
-
const
|
|
130
|
-
const relativePath = relative(gitRoot, absolute);
|
|
131
|
-
if (
|
|
132
|
-
isAbsolute(relativePath) ||
|
|
133
|
-
relativePath === ".." ||
|
|
134
|
-
relativePath.startsWith(`..${sep}`)
|
|
135
|
-
)
|
|
136
|
-
throw new Error("Git checkout has an unsafe untracked path");
|
|
137
|
-
const info = await lstat(absolute);
|
|
138
|
-
const bytes = info.isSymbolicLink()
|
|
139
|
-
? Buffer.from(await readlink(absolute), "utf8")
|
|
140
|
-
: info.isFile()
|
|
141
|
-
? await readFile(absolute)
|
|
142
|
-
: null;
|
|
143
|
-
if (!bytes)
|
|
144
|
-
throw new Error("Git checkout has an unsupported untracked entry");
|
|
176
|
+
const bytes = await untrackedIdentityBytes(gitRoot, path);
|
|
145
177
|
dirtyBytes += bytes.byteLength;
|
|
146
178
|
if (dirtyBytes > MAX_DIRTY_BYTES)
|
|
147
179
|
throw new Error("Git identity exceeds the size limit");
|
|
@@ -152,6 +184,24 @@ async function dirtyPatchDigest(gitRoot: string): Promise<string | null> {
|
|
|
152
184
|
: null;
|
|
153
185
|
}
|
|
154
186
|
|
|
187
|
+
function requireSafeUntrackedPath(root: string, absolute: string): void {
|
|
188
|
+
const path = relative(root, absolute);
|
|
189
|
+
if (isAbsolute(path) || path === ".." || path.startsWith(`..${sep}`))
|
|
190
|
+
throw new Error("Git checkout has an unsafe untracked path");
|
|
191
|
+
}
|
|
192
|
+
async function untrackedIdentityBytes(
|
|
193
|
+
root: string,
|
|
194
|
+
path: string,
|
|
195
|
+
): Promise<Buffer> {
|
|
196
|
+
const absolute = resolve(root, path);
|
|
197
|
+
requireSafeUntrackedPath(root, absolute);
|
|
198
|
+
const info = await lstat(absolute);
|
|
199
|
+
if (info.isSymbolicLink())
|
|
200
|
+
return Buffer.from(await readlink(absolute), "utf8");
|
|
201
|
+
if (info.isFile()) return readFile(absolute);
|
|
202
|
+
throw new Error("Git checkout has an unsupported untracked entry");
|
|
203
|
+
}
|
|
204
|
+
|
|
155
205
|
/** Local development identity; packaged use never calls Git. */
|
|
156
206
|
export async function checkoutProvenance(root: string, buildDigest: string) {
|
|
157
207
|
const canonicalRoot = await realpath(root);
|
|
@@ -169,27 +219,50 @@ export async function checkoutProvenance(root: string, buildDigest: string) {
|
|
|
169
219
|
source: "checkout" as const,
|
|
170
220
|
root: pathToFileURL(canonicalRoot).href,
|
|
171
221
|
revision,
|
|
172
|
-
dirtyPatchDigest: await dirtyPatchDigest(canonicalRoot),
|
|
222
|
+
dirtyPatchDigest: await dirtyPatchDigest(canonicalRoot, []),
|
|
173
223
|
buildDigest,
|
|
174
224
|
};
|
|
175
225
|
}
|
|
176
226
|
|
|
177
227
|
async function enclosingGitRoot(root: string): Promise<string | null> {
|
|
178
228
|
let path = root;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
await lstat(join(path, ".git"));
|
|
182
|
-
return path;
|
|
183
|
-
} catch (error) {
|
|
184
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
185
|
-
}
|
|
229
|
+
for (;;) {
|
|
230
|
+
if (await gitMetadataAt(path)) return path;
|
|
186
231
|
const parent = dirname(path);
|
|
187
232
|
if (parent === path) return null;
|
|
188
233
|
path = parent;
|
|
189
234
|
}
|
|
190
235
|
}
|
|
191
236
|
|
|
192
|
-
|
|
237
|
+
async function gitMetadataAt(path: string): Promise<boolean> {
|
|
238
|
+
try {
|
|
239
|
+
const metadataPath = join(path, ".git");
|
|
240
|
+
const metadata = await lstat(metadataPath);
|
|
241
|
+
return !(await emptyTemporaryGitPlaceholder(path, metadataPath, metadata));
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function emptyTemporaryGitPlaceholder(
|
|
249
|
+
directory: string,
|
|
250
|
+
metadataPath: string,
|
|
251
|
+
metadata: Stats,
|
|
252
|
+
): Promise<boolean> {
|
|
253
|
+
// Codex's Linux sandbox can leave an empty mount placeholder at /tmp/.git.
|
|
254
|
+
return (
|
|
255
|
+
process.platform === "linux" &&
|
|
256
|
+
(directory === "/tmp" || directory === tmpdir()) &&
|
|
257
|
+
metadata.isDirectory() &&
|
|
258
|
+
(await readdir(metadataPath)).length === 0
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function projectProvenance(
|
|
263
|
+
root: string,
|
|
264
|
+
excludedRoots?: string[],
|
|
265
|
+
) {
|
|
193
266
|
const canonicalRoot = await realpath(root);
|
|
194
267
|
const gitEntryRoot = await enclosingGitRoot(canonicalRoot);
|
|
195
268
|
if (!gitEntryRoot)
|
|
@@ -219,10 +292,17 @@ export async function projectProvenance(root: string) {
|
|
|
219
292
|
return {
|
|
220
293
|
root: pathToFileURL(canonicalRoot).href,
|
|
221
294
|
revision,
|
|
222
|
-
dirtyPatchDigest: await
|
|
295
|
+
dirtyPatchDigest: await projectDirtyDigest(gitRoot, excludedRoots),
|
|
223
296
|
};
|
|
224
297
|
}
|
|
225
298
|
|
|
299
|
+
async function projectDirtyDigest(
|
|
300
|
+
root: string,
|
|
301
|
+
excludedRoots: string[] | undefined,
|
|
302
|
+
) {
|
|
303
|
+
return dirtyPatchDigest(root, excludedRoots ?? []);
|
|
304
|
+
}
|
|
305
|
+
|
|
226
306
|
export async function runnerProvenance(
|
|
227
307
|
buildDigest: string,
|
|
228
308
|
checkoutRoot?: string,
|
package/src/prune.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { readFile, readdir, realpath, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { assertCliResult, assertRunEvidence } from "./schema";
|
|
5
|
+
import type { CliResultData, RunEvidenceData } from "./schema-types";
|
|
6
|
+
import { createReport } from "./report";
|
|
7
|
+
import { atomicWriteJson } from "./storage";
|
|
8
|
+
import { isRecord } from "./value-guards";
|
|
9
|
+
import { retainedFile, withResultsLock } from "./results-lock";
|
|
10
|
+
import { pruneNativeTranscripts } from "./transcript-retention";
|
|
11
|
+
|
|
12
|
+
interface RetainedResult {
|
|
13
|
+
directory: string;
|
|
14
|
+
result: CliResultData;
|
|
15
|
+
evidence: RunEvidenceData;
|
|
16
|
+
time: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type RetentionPolicy =
|
|
20
|
+
{ keep: number; cutoff: null } | { keep: null; cutoff: number };
|
|
21
|
+
|
|
22
|
+
const durationUnits: Record<string, number> = {
|
|
23
|
+
ms: 1,
|
|
24
|
+
s: 1000,
|
|
25
|
+
m: 60_000,
|
|
26
|
+
h: 3_600_000,
|
|
27
|
+
d: 86_400_000,
|
|
28
|
+
w: 604_800_000,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function duration(value: string): number {
|
|
32
|
+
const match = /^([1-9]\d*)(ms|s|m|h|d|w)$/.exec(value);
|
|
33
|
+
if (!match)
|
|
34
|
+
throw new Error(
|
|
35
|
+
"--older-than requires a positive integer and ms, s, m, h, d or w",
|
|
36
|
+
);
|
|
37
|
+
const amount =
|
|
38
|
+
Number.parseInt(value, 10) *
|
|
39
|
+
Number(durationUnits[value.replace(/^\d+/, "")]);
|
|
40
|
+
if (!Number.isSafeInteger(amount))
|
|
41
|
+
throw new Error("--older-than exceeds the safe duration range");
|
|
42
|
+
return amount;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function count(value: string): number {
|
|
46
|
+
const amount = Number(value);
|
|
47
|
+
if (!/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(amount))
|
|
48
|
+
throw new Error("--keep must be a positive safe integer");
|
|
49
|
+
return amount;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseInvocation(args: string[]) {
|
|
53
|
+
const { values } = parseArgs({
|
|
54
|
+
args,
|
|
55
|
+
strict: true,
|
|
56
|
+
options: {
|
|
57
|
+
"results-root": { type: "string" },
|
|
58
|
+
keep: { type: "string" },
|
|
59
|
+
latest: { type: "boolean" },
|
|
60
|
+
"older-than": { type: "string" },
|
|
61
|
+
"native-transcripts": { type: "boolean" },
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
return values;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function policy(values: ReturnType<typeof parseInvocation>): RetentionPolicy {
|
|
68
|
+
if (values.latest) return { keep: 1, cutoff: null };
|
|
69
|
+
if (values["older-than"] !== undefined)
|
|
70
|
+
return { keep: null, cutoff: Date.now() - duration(values["older-than"]) };
|
|
71
|
+
if (values.keep !== undefined)
|
|
72
|
+
return { keep: count(values.keep), cutoff: null };
|
|
73
|
+
return { keep: values["native-transcripts"] ? 0 : 10, cutoff: null };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function invocation(args: string[]) {
|
|
77
|
+
const values = parseInvocation(args);
|
|
78
|
+
const policies = [values.latest, values.keep, values["older-than"]].filter(
|
|
79
|
+
(value) => value !== undefined,
|
|
80
|
+
);
|
|
81
|
+
if (policies.length > 1) throw new Error("retention policies are exclusive");
|
|
82
|
+
if (values["results-root"] === "")
|
|
83
|
+
throw new Error("--results-root must not be empty");
|
|
84
|
+
return {
|
|
85
|
+
root: resolve(values["results-root"] ?? ".sevro/results"),
|
|
86
|
+
policy: policy(values),
|
|
87
|
+
nativeTranscripts: values["native-transcripts"] === true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function retainedResults(root: string): Promise<RetainedResult[]> {
|
|
92
|
+
const results: RetainedResult[] = [];
|
|
93
|
+
for (const directory of await resultDirectories(root)) {
|
|
94
|
+
const path = join(directory, "result.json");
|
|
95
|
+
await retainedFile(path);
|
|
96
|
+
const result: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
97
|
+
assertCliResult(result);
|
|
98
|
+
if (!(await finalized(root, result.runId))) continue;
|
|
99
|
+
if (result.evidencePath !== join(directory, "run.json"))
|
|
100
|
+
throw new Error(
|
|
101
|
+
`retained result references evidence outside its directory: ${path}`,
|
|
102
|
+
);
|
|
103
|
+
await retainedFile(result.evidencePath);
|
|
104
|
+
const evidence: unknown = JSON.parse(
|
|
105
|
+
await readFile(result.evidencePath, "utf8"),
|
|
106
|
+
);
|
|
107
|
+
assertRunEvidence(evidence);
|
|
108
|
+
await createReport([path]);
|
|
109
|
+
results.push({
|
|
110
|
+
directory,
|
|
111
|
+
result,
|
|
112
|
+
evidence,
|
|
113
|
+
time: (await stat(path)).mtimeMs,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return results.sort(
|
|
117
|
+
(left, right) =>
|
|
118
|
+
right.time - left.time || left.directory.localeCompare(right.directory),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function resultDirectories(parent: string): Promise<string[]> {
|
|
123
|
+
const paths: string[] = [];
|
|
124
|
+
for (const entry of await readdir(parent, { withFileTypes: true })) {
|
|
125
|
+
if (!entry.isDirectory()) continue;
|
|
126
|
+
const directory = join(parent, entry.name);
|
|
127
|
+
if (await retainedFile(join(directory, "result.json")))
|
|
128
|
+
paths.push(directory);
|
|
129
|
+
else paths.push(...(await resultDirectories(directory)));
|
|
130
|
+
}
|
|
131
|
+
return paths;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function finalized(root: string, runId: string | null): Promise<boolean> {
|
|
135
|
+
if (!runId || basename(runId) !== runId) return false;
|
|
136
|
+
const path = join(root, "active", `${runId}.json`);
|
|
137
|
+
if (!(await retainedFile(path))) return true;
|
|
138
|
+
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
139
|
+
if (!isRecord(value)) return false;
|
|
140
|
+
return finalizedRecord(value, runId);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function finalizedRecord(
|
|
144
|
+
value: Record<string, unknown>,
|
|
145
|
+
runId: string,
|
|
146
|
+
): boolean {
|
|
147
|
+
return (
|
|
148
|
+
value.format === "sevro.active-run.v1" &&
|
|
149
|
+
value.runId === runId &&
|
|
150
|
+
["complete", "interrupted", "diagnostic"].includes(String(value.status))
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function retainedByPolicy(results: RetainedResult[], policy: RetentionPolicy) {
|
|
155
|
+
const cutoff = policy.cutoff;
|
|
156
|
+
return cutoff === null
|
|
157
|
+
? results.slice(0, policy.keep)
|
|
158
|
+
: results.filter((result) => result.time >= cutoff);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function pruneResults(root: string, policy: RetentionPolicy) {
|
|
162
|
+
const results = await retainedResults(root);
|
|
163
|
+
const remaining = retainedByPolicy(results, policy);
|
|
164
|
+
const latest = remaining[0];
|
|
165
|
+
if (latest) await atomicWriteJson(join(root, "latest.json"), latest.result);
|
|
166
|
+
else await rm(join(root, "latest.json"), { force: true });
|
|
167
|
+
const removed = results.filter((result) => !remaining.includes(result));
|
|
168
|
+
for (const result of removed) await rm(result.directory, { recursive: true });
|
|
169
|
+
return { removed: removed.length, kept: remaining.length };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function pruneTranscripts(root: string, policy: RetentionPolicy) {
|
|
173
|
+
const results = await retainedResults(root);
|
|
174
|
+
const preserved = retainedByPolicy(results, policy);
|
|
175
|
+
let removed = 0;
|
|
176
|
+
for (const { evidence } of results.filter(
|
|
177
|
+
(item) => !preserved.includes(item),
|
|
178
|
+
)) {
|
|
179
|
+
removed += await pruneNativeTranscripts(evidence);
|
|
180
|
+
}
|
|
181
|
+
return { removed, kept: results.length };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function prune(options: ReturnType<typeof invocation>) {
|
|
185
|
+
const root = await realpath(options.root).catch((cause: unknown) => {
|
|
186
|
+
if ((cause as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
187
|
+
throw cause;
|
|
188
|
+
});
|
|
189
|
+
if (root === null) return { removed: 0, kept: 0 };
|
|
190
|
+
return withResultsLock(root, () =>
|
|
191
|
+
options.nativeTranscripts
|
|
192
|
+
? pruneTranscripts(root, options.policy)
|
|
193
|
+
: pruneResults(root, options.policy),
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Explicitly remove old retained runs while keeping report's latest index usable. */
|
|
198
|
+
export async function pruneCommand(args: string[]): Promise<number> {
|
|
199
|
+
try {
|
|
200
|
+
const options = invocation(args);
|
|
201
|
+
const result = await prune(options);
|
|
202
|
+
const unit = options.nativeTranscripts
|
|
203
|
+
? "native transcript bundle(s)"
|
|
204
|
+
: "result(s)";
|
|
205
|
+
process.stdout.write(
|
|
206
|
+
`Removed ${result.removed} ${unit}; kept ${result.kept}${options.nativeTranscripts ? " result(s)" : ""}.\n`,
|
|
207
|
+
);
|
|
208
|
+
return 0;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
process.stderr.write(
|
|
211
|
+
`sevro prune: ${error instanceof Error ? error.message : "pruning failed"}\n`,
|
|
212
|
+
);
|
|
213
|
+
return 64;
|
|
214
|
+
}
|
|
215
|
+
}
|