self-bench 0.3.0 → 0.3.2
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/.dockerignore +10 -0
- package/Dockerfile +37 -0
- package/Dockerfile.sandbox +24 -0
- package/README.md +34 -26
- package/biome.json +18 -0
- package/bun.lock +1182 -0
- package/compose.yaml +85 -0
- package/dist/agent-smoke-main.js +1 -1
- package/dist/build-metadata.d.ts +2 -0
- package/dist/build-metadata.d.ts.map +1 -0
- package/dist/build-metadata.js +2 -0
- package/dist/build-metadata.js.map +1 -0
- package/dist/cli.js +18 -8
- package/dist/cli.js.map +1 -1
- package/dist/eval-main.js +1 -1
- package/dist/reaudit-main.js +1 -1
- package/dist/repair-main.js +1 -1
- package/dist/validate-main.js +1 -1
- package/docs/evaluations.md +67 -0
- package/docs/operations.md +178 -0
- package/docs/task-construction.md +94 -0
- package/package.json +28 -15
- package/scripts/verify-package.ts +57 -0
- package/scripts/write-build-metadata.ts +27 -0
- package/src/activities.ts +1236 -0
- package/src/agent-smoke-main.ts +63 -0
- package/src/agent-smoke.ts +132 -0
- package/src/api-main.ts +12 -0
- package/src/api.ts +239 -0
- package/src/artifacts.ts +361 -0
- package/src/audit.ts +106 -0
- package/src/build-metadata.ts +3 -0
- package/src/cli.ts +350 -0
- package/src/codex-review.ts +220 -0
- package/src/config.ts +117 -0
- package/src/contracts.ts +209 -0
- package/src/coupling.ts +259 -0
- package/src/docker-executor.ts +115 -0
- package/src/eval-main.ts +92 -0
- package/src/evaluate.ts +293 -0
- package/src/github.ts +26 -0
- package/src/harbor-results.ts +142 -0
- package/src/harbor-task.ts +528 -0
- package/src/hash.ts +5 -0
- package/src/modal-auth.ts +11 -0
- package/src/modal-executor.ts +176 -0
- package/src/parallel.ts +24 -0
- package/src/process.ts +165 -0
- package/src/provenance.ts +458 -0
- package/src/reaudit-main.ts +192 -0
- package/src/repair-main.ts +203 -0
- package/src/repair.ts +55 -0
- package/src/run-wait.ts +40 -0
- package/src/sandbox-author.ts +19 -0
- package/src/sandbox-repair.ts +160 -0
- package/src/sandbox-review.ts +17 -0
- package/src/sandbox-validation-repair.ts +174 -0
- package/src/sandbox.ts +51 -0
- package/src/subscription-auth.ts +67 -0
- package/src/temporal.ts +23 -0
- package/src/validate-main.ts +171 -0
- package/src/validation-repair.ts +94 -0
- package/src/worker-main.ts +32 -0
- package/src/workflow.ts +519 -0
- package/tsconfig.build.json +13 -0
- package/tsconfig.json +21 -0
package/src/contracts.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const commitSchema = z.string().regex(/^[0-9a-f]{40}$/i, "expected a full commit SHA");
|
|
4
|
+
|
|
5
|
+
export const repositoryRefSchema = z.object({
|
|
6
|
+
url: z.string().url(),
|
|
7
|
+
commit: commitSchema,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export type RepositoryRef = z.infer<typeof repositoryRefSchema>;
|
|
11
|
+
|
|
12
|
+
export const artifactRefSchema = z.object({
|
|
13
|
+
uri: z.string().min(1),
|
|
14
|
+
sha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
15
|
+
sizeBytes: z.number().int().nonnegative(),
|
|
16
|
+
contentType: z.string().min(1),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type ArtifactRef = z.infer<typeof artifactRefSchema>;
|
|
20
|
+
|
|
21
|
+
export const difficultySchema = z.enum(["easy", "medium", "hard"]);
|
|
22
|
+
export type Difficulty = z.infer<typeof difficultySchema>;
|
|
23
|
+
|
|
24
|
+
const candidateCountsSchema = z
|
|
25
|
+
.object({
|
|
26
|
+
easy: z.number().int().min(0).max(100),
|
|
27
|
+
medium: z.number().int().min(0).max(100),
|
|
28
|
+
hard: z.number().int().min(0).max(100),
|
|
29
|
+
})
|
|
30
|
+
.refine(({ easy, medium, hard }) => easy + medium + hard >= 1, {
|
|
31
|
+
message: "at least one candidate must be requested",
|
|
32
|
+
})
|
|
33
|
+
.refine(({ easy, medium, hard }) => easy + medium + hard <= 100, {
|
|
34
|
+
message: "at most 100 candidates may be requested",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const runRequestSchema = z.object({
|
|
38
|
+
runId: z.string().regex(/^[a-z0-9][a-z0-9-]{2,62}$/),
|
|
39
|
+
repository: repositoryRefSchema,
|
|
40
|
+
provenance: artifactRefSchema,
|
|
41
|
+
candidateCounts: candidateCountsSchema,
|
|
42
|
+
authoring: z.object({
|
|
43
|
+
provider: z.literal("openai-codex"),
|
|
44
|
+
model: z.string().min(1),
|
|
45
|
+
reasoningEffort: z.literal("high"),
|
|
46
|
+
}),
|
|
47
|
+
version: z.object({
|
|
48
|
+
selfbenchCommit: commitSchema,
|
|
49
|
+
executionBackend: z.enum(["docker", "modal"]),
|
|
50
|
+
sandboxImage: z.string().min(1),
|
|
51
|
+
schema: z.literal(1),
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export type RunRequest = z.infer<typeof runRequestSchema>;
|
|
56
|
+
|
|
57
|
+
export const candidateSchema = z.object({
|
|
58
|
+
candidateId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
59
|
+
difficulty: difficultySchema,
|
|
60
|
+
sourcePr: z.number().int().positive(),
|
|
61
|
+
sourceUrl: z.string().url(),
|
|
62
|
+
baseCommit: commitSchema,
|
|
63
|
+
completedCommit: commitSchema,
|
|
64
|
+
request: z.string().min(1),
|
|
65
|
+
provenance: artifactRefSchema,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export type Candidate = z.infer<typeof candidateSchema>;
|
|
69
|
+
|
|
70
|
+
export const taskDefinitionSchema = z.object({
|
|
71
|
+
schemaVersion: z.literal(1),
|
|
72
|
+
difficulty: difficultySchema,
|
|
73
|
+
taskId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
74
|
+
repo: z.string().min(1),
|
|
75
|
+
baseCommit: commitSchema,
|
|
76
|
+
workdir: z.string().min(1),
|
|
77
|
+
setupCommand: z.string().min(1),
|
|
78
|
+
testCommand: z.string().refine((value) => value.includes("{tests}"), {
|
|
79
|
+
message: 'testCommand must contain "{tests}"',
|
|
80
|
+
}),
|
|
81
|
+
failToPass: z.array(z.string().min(1)).min(1),
|
|
82
|
+
passToPass: z.array(z.string().min(1)),
|
|
83
|
+
testPaths: z.array(z.string().min(1)).min(1),
|
|
84
|
+
toolchains: z.array(z.enum(["uv", "bun", "go", "node", "python", "rust"])).min(1),
|
|
85
|
+
sourcePr: z.number().int().positive(),
|
|
86
|
+
sourceUrl: z.string().url(),
|
|
87
|
+
prompt: z.string().min(1),
|
|
88
|
+
timeouts: z.object({
|
|
89
|
+
setupSeconds: z.number().int().positive(),
|
|
90
|
+
agentSeconds: z.number().int().positive(),
|
|
91
|
+
testsSeconds: z.number().int().positive(),
|
|
92
|
+
}),
|
|
93
|
+
resources: z.object({
|
|
94
|
+
cpus: z.number().positive(),
|
|
95
|
+
memoryMb: z.number().int().positive(),
|
|
96
|
+
storageMb: z.number().int().positive(),
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export type TaskDefinition = z.infer<typeof taskDefinitionSchema>;
|
|
101
|
+
|
|
102
|
+
export const authoredTaskSchema = z.object({
|
|
103
|
+
candidateId: z.string().min(1),
|
|
104
|
+
taskId: z.string().min(1),
|
|
105
|
+
definition: artifactRefSchema,
|
|
106
|
+
bundle: artifactRefSchema,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export type AuthoredTask = z.infer<typeof authoredTaskSchema>;
|
|
110
|
+
|
|
111
|
+
export const validationResultSchema = z.object({
|
|
112
|
+
taskId: z.string().min(1),
|
|
113
|
+
accepted: z.boolean(),
|
|
114
|
+
nop: z.object({
|
|
115
|
+
passed: z.boolean(),
|
|
116
|
+
result: artifactRefSchema,
|
|
117
|
+
output: artifactRefSchema.optional(),
|
|
118
|
+
}),
|
|
119
|
+
oracle: z.object({
|
|
120
|
+
passed: z.boolean(),
|
|
121
|
+
result: artifactRefSchema,
|
|
122
|
+
output: artifactRefSchema.optional(),
|
|
123
|
+
}),
|
|
124
|
+
reason: z.string().optional(),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
export type ValidationResult = z.infer<typeof validationResultSchema>;
|
|
128
|
+
|
|
129
|
+
export type RunPhase =
|
|
130
|
+
| "queued"
|
|
131
|
+
| "discovering"
|
|
132
|
+
| "authoring"
|
|
133
|
+
| "validating"
|
|
134
|
+
| "reviewing"
|
|
135
|
+
| "repairing"
|
|
136
|
+
| "auditing"
|
|
137
|
+
| "exporting"
|
|
138
|
+
| "complete"
|
|
139
|
+
| "blocked"
|
|
140
|
+
| "failed"
|
|
141
|
+
| "cancelling"
|
|
142
|
+
| "cancelled";
|
|
143
|
+
|
|
144
|
+
export interface TaskProgress {
|
|
145
|
+
taskId: string;
|
|
146
|
+
candidateId: string;
|
|
147
|
+
difficulty: Difficulty;
|
|
148
|
+
status:
|
|
149
|
+
| "authoring"
|
|
150
|
+
| "auditing"
|
|
151
|
+
| "validating"
|
|
152
|
+
| "reviewing"
|
|
153
|
+
| "repairing"
|
|
154
|
+
| "infrastructure_failed"
|
|
155
|
+
| "rejected"
|
|
156
|
+
| "accepted";
|
|
157
|
+
reason?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface DiscoveryProgress {
|
|
161
|
+
readonly wave: number;
|
|
162
|
+
readonly totalShards: number;
|
|
163
|
+
readonly completedShards: number;
|
|
164
|
+
readonly failedShards: number;
|
|
165
|
+
readonly candidates: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface RunStatus {
|
|
169
|
+
readonly runId: string;
|
|
170
|
+
readonly phase: RunPhase;
|
|
171
|
+
readonly requested: number;
|
|
172
|
+
readonly requestedByDifficulty: Readonly<Record<Difficulty, number>>;
|
|
173
|
+
readonly discovered: number;
|
|
174
|
+
readonly accepted: number;
|
|
175
|
+
readonly rejected: number;
|
|
176
|
+
readonly tasks: readonly TaskProgress[];
|
|
177
|
+
readonly discovery?: DiscoveryProgress;
|
|
178
|
+
readonly export?: ArtifactRef;
|
|
179
|
+
readonly error?: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface RunResult {
|
|
183
|
+
readonly runId: string;
|
|
184
|
+
readonly export: ArtifactRef;
|
|
185
|
+
readonly acceptedTaskIds: readonly string[];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface DiscoveryResult {
|
|
189
|
+
readonly candidates: readonly Candidate[];
|
|
190
|
+
readonly report: ArtifactRef;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export type AuthorOutcome =
|
|
194
|
+
| { readonly kind: "authored"; readonly task: AuthoredTask }
|
|
195
|
+
| { readonly kind: "rejected"; readonly candidateId: string; readonly reason: string };
|
|
196
|
+
|
|
197
|
+
export interface ReviewResult {
|
|
198
|
+
readonly taskId: string;
|
|
199
|
+
readonly accepted: boolean;
|
|
200
|
+
readonly report: ArtifactRef;
|
|
201
|
+
readonly reason?: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface AuditResult {
|
|
205
|
+
readonly taskId: string;
|
|
206
|
+
readonly accepted: boolean;
|
|
207
|
+
readonly report: ArtifactRef;
|
|
208
|
+
readonly reason?: string;
|
|
209
|
+
}
|
package/src/coupling.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { runCommand } from "./process.js";
|
|
4
|
+
|
|
5
|
+
export type ContractArtifactCategory =
|
|
6
|
+
| "endpoint_path"
|
|
7
|
+
| "field_name"
|
|
8
|
+
| "header_name"
|
|
9
|
+
| "media_type";
|
|
10
|
+
|
|
11
|
+
export interface ContractArtifactEvidence {
|
|
12
|
+
readonly artifact: string;
|
|
13
|
+
readonly category: ContractArtifactCategory;
|
|
14
|
+
readonly testLocations: readonly string[];
|
|
15
|
+
readonly introducedByGold: boolean;
|
|
16
|
+
readonly presentInPrompt: boolean;
|
|
17
|
+
readonly presentInBase: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CouplingEvidence {
|
|
21
|
+
readonly schemaVersion: 1;
|
|
22
|
+
readonly artifacts: readonly ContractArtifactEvidence[];
|
|
23
|
+
readonly blockers: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CouplingReviewInput {
|
|
27
|
+
readonly verdict: "clean" | "coupled";
|
|
28
|
+
readonly reason: string;
|
|
29
|
+
readonly findings: readonly {
|
|
30
|
+
readonly artifact: string;
|
|
31
|
+
readonly disposition:
|
|
32
|
+
| "base_contract"
|
|
33
|
+
| "prompt_contract"
|
|
34
|
+
| "external_contract"
|
|
35
|
+
| "gold_only"
|
|
36
|
+
| "not_contract";
|
|
37
|
+
}[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CouplingReviewResolution {
|
|
41
|
+
readonly verdict: "clean" | "coupled";
|
|
42
|
+
readonly reason: string;
|
|
43
|
+
readonly missingArtifacts: readonly string[];
|
|
44
|
+
readonly goldOnlyArtifacts: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface AddedLine {
|
|
48
|
+
readonly location: string;
|
|
49
|
+
readonly text: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ContractArtifactCandidate {
|
|
53
|
+
readonly artifact: string;
|
|
54
|
+
readonly category: ContractArtifactCategory;
|
|
55
|
+
readonly testLocations: readonly string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function discoverContractArtifacts(testPatch: string): readonly ContractArtifactCandidate[] {
|
|
59
|
+
const found = new Map<string, { category: ContractArtifactCategory; locations: Set<string> }>();
|
|
60
|
+
for (const line of addedLines(testPatch)) {
|
|
61
|
+
for (const candidate of contractArtifacts(line.text)) {
|
|
62
|
+
const key = `${candidate.category}\u0000${candidate.artifact}`;
|
|
63
|
+
const existing = found.get(key);
|
|
64
|
+
if (existing) {
|
|
65
|
+
existing.locations.add(line.location);
|
|
66
|
+
} else {
|
|
67
|
+
found.set(key, {
|
|
68
|
+
category: candidate.category,
|
|
69
|
+
locations: new Set([line.location]),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return [...found.entries()]
|
|
75
|
+
.map(([key, value]) => ({
|
|
76
|
+
artifact: key.slice(key.indexOf("\u0000") + 1),
|
|
77
|
+
category: value.category,
|
|
78
|
+
testLocations: [...value.locations].sort(),
|
|
79
|
+
}))
|
|
80
|
+
.sort((left, right) =>
|
|
81
|
+
left.category === right.category
|
|
82
|
+
? left.artifact.localeCompare(right.artifact)
|
|
83
|
+
: left.category.localeCompare(right.category),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function buildCouplingEvidence(input: {
|
|
88
|
+
readonly prompt: string;
|
|
89
|
+
readonly testPatch: string;
|
|
90
|
+
readonly goldPatch: string;
|
|
91
|
+
readonly baseArtifacts: ReadonlySet<string>;
|
|
92
|
+
}): CouplingEvidence {
|
|
93
|
+
const gold = addedLines(input.goldPatch)
|
|
94
|
+
.map((line) => line.text)
|
|
95
|
+
.join("\n");
|
|
96
|
+
const artifacts = discoverContractArtifacts(input.testPatch)
|
|
97
|
+
.filter((candidate) => containsArtifact(gold, candidate.artifact, candidate.category))
|
|
98
|
+
.map(
|
|
99
|
+
(candidate): ContractArtifactEvidence => ({
|
|
100
|
+
...candidate,
|
|
101
|
+
introducedByGold: true,
|
|
102
|
+
presentInPrompt: containsArtifact(input.prompt, candidate.artifact, candidate.category),
|
|
103
|
+
presentInBase: input.baseArtifacts.has(candidate.artifact),
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
const blockers = artifacts
|
|
107
|
+
.filter((artifact) => !artifact.presentInPrompt && !artifact.presentInBase)
|
|
108
|
+
.map(
|
|
109
|
+
(artifact) =>
|
|
110
|
+
`held-out tests assert gold-only ${artifact.category} ${JSON.stringify(artifact.artifact)} at ${artifact.testLocations.join(", ")}`,
|
|
111
|
+
);
|
|
112
|
+
return { schemaVersion: 1, artifacts, blockers };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function resolveCouplingReview(
|
|
116
|
+
evidence: CouplingEvidence,
|
|
117
|
+
review: CouplingReviewInput,
|
|
118
|
+
): CouplingReviewResolution {
|
|
119
|
+
const unresolvedArtifacts = evidence.artifacts
|
|
120
|
+
.filter((artifact) => !artifact.presentInBase && !artifact.presentInPrompt)
|
|
121
|
+
.map((artifact) => artifact.artifact);
|
|
122
|
+
const reviewedArtifacts = new Set(review.findings.map((finding) => finding.artifact));
|
|
123
|
+
const missingArtifacts = unresolvedArtifacts.filter(
|
|
124
|
+
(artifact) => !reviewedArtifacts.has(artifact),
|
|
125
|
+
);
|
|
126
|
+
const goldOnlyArtifacts = review.findings
|
|
127
|
+
.filter((finding) => finding.disposition === "gold_only")
|
|
128
|
+
.map((finding) => finding.artifact);
|
|
129
|
+
const verdict =
|
|
130
|
+
review.verdict === "coupled" || missingArtifacts.length > 0 || goldOnlyArtifacts.length > 0
|
|
131
|
+
? "coupled"
|
|
132
|
+
: "clean";
|
|
133
|
+
const reason =
|
|
134
|
+
missingArtifacts.length > 0
|
|
135
|
+
? `review did not resolve deterministic coupling evidence for: ${missingArtifacts.join(", ")}`
|
|
136
|
+
: goldOnlyArtifacts.length > 0 && review.verdict === "clean"
|
|
137
|
+
? `review identified gold-only artifacts: ${goldOnlyArtifacts.join(", ")}`
|
|
138
|
+
: review.reason;
|
|
139
|
+
return { verdict, reason, missingArtifacts, goldOnlyArtifacts };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function scanBaseContractArtifacts(
|
|
143
|
+
baseDirectory: string,
|
|
144
|
+
scratchDirectory: string,
|
|
145
|
+
artifacts: readonly { readonly artifact: string }[],
|
|
146
|
+
): Promise<ReadonlySet<string>> {
|
|
147
|
+
const values = [...new Set(artifacts.map((artifact) => artifact.artifact))].filter(
|
|
148
|
+
(artifact) => !artifact.includes("\n") && artifact.length > 0,
|
|
149
|
+
);
|
|
150
|
+
if (values.length === 0) {
|
|
151
|
+
return new Set();
|
|
152
|
+
}
|
|
153
|
+
const patterns = join(scratchDirectory, "coupling-patterns.txt");
|
|
154
|
+
await writeFile(patterns, `${values.join("\n")}\n`);
|
|
155
|
+
const result = await runCommand(
|
|
156
|
+
"rg",
|
|
157
|
+
[
|
|
158
|
+
"--fixed-strings",
|
|
159
|
+
"--only-matching",
|
|
160
|
+
"--no-filename",
|
|
161
|
+
"--hidden",
|
|
162
|
+
"--glob",
|
|
163
|
+
"!.git/**",
|
|
164
|
+
"--file",
|
|
165
|
+
patterns,
|
|
166
|
+
baseDirectory,
|
|
167
|
+
],
|
|
168
|
+
{ allowFailure: true },
|
|
169
|
+
);
|
|
170
|
+
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
|
171
|
+
throw new Error(`failed to scan base repository for contract artifacts: ${result.stderr}`);
|
|
172
|
+
}
|
|
173
|
+
return new Set(result.stdout.split("\n").filter((value) => value.length > 0));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function addedLines(patch: string): readonly AddedLine[] {
|
|
177
|
+
const lines: AddedLine[] = [];
|
|
178
|
+
let path = "unknown";
|
|
179
|
+
for (const [index, line] of patch.split("\n").entries()) {
|
|
180
|
+
if (line.startsWith("diff --git a/")) {
|
|
181
|
+
const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
|
|
182
|
+
path = match?.[2] ?? "unknown";
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
186
|
+
lines.push({ location: `${path}:patch-line-${index + 1}`, text: line.slice(1) });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return lines;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function contractArtifacts(
|
|
193
|
+
line: string,
|
|
194
|
+
): readonly { artifact: string; category: ContractArtifactCategory }[] {
|
|
195
|
+
const artifacts: { artifact: string; category: ContractArtifactCategory }[] = [];
|
|
196
|
+
const quoted = /(["'`])((?:\\.|.)*?)\1/g;
|
|
197
|
+
for (const match of line.matchAll(quoted)) {
|
|
198
|
+
const artifact = unescapeLiteral(match[2] ?? "");
|
|
199
|
+
const start = match.index ?? 0;
|
|
200
|
+
const prefix = line.slice(0, start);
|
|
201
|
+
const suffix = line.slice(start + match[0].length);
|
|
202
|
+
const category = classifyQuotedArtifact(artifact, prefix, suffix, line);
|
|
203
|
+
if (category) {
|
|
204
|
+
artifacts.push({ artifact, category });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const bareKey = /(?:^\s*|[{,]\s*)([A-Za-z][A-Za-z0-9_]{2,63})\s*:/g;
|
|
208
|
+
for (const match of line.matchAll(bareKey)) {
|
|
209
|
+
if (match[1]) {
|
|
210
|
+
artifacts.push({ artifact: match[1], category: "field_name" });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const member = /\.([A-Za-z][A-Za-z0-9_]{2,63})\b/g;
|
|
214
|
+
for (const match of line.matchAll(member)) {
|
|
215
|
+
if (match[1]) {
|
|
216
|
+
artifacts.push({ artifact: match[1], category: "field_name" });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return artifacts;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function classifyQuotedArtifact(
|
|
223
|
+
artifact: string,
|
|
224
|
+
prefix: string,
|
|
225
|
+
suffix: string,
|
|
226
|
+
line: string,
|
|
227
|
+
): ContractArtifactCategory | undefined {
|
|
228
|
+
if (/^\/[A-Za-z0-9]/.test(artifact) && !/\s/.test(artifact)) {
|
|
229
|
+
return "endpoint_path";
|
|
230
|
+
}
|
|
231
|
+
if (/^[a-z][a-z0-9.+-]*\/[a-z0-9.+-]+(?:;.*)?$/i.test(artifact)) {
|
|
232
|
+
return "media_type";
|
|
233
|
+
}
|
|
234
|
+
if (/^[A-Za-z][A-Za-z0-9_-]{2,63}$/.test(artifact)) {
|
|
235
|
+
if (/^\s*:/.test(suffix) || (/\[\s*$/.test(prefix) && /^\s*\]/.test(suffix))) {
|
|
236
|
+
return "field_name";
|
|
237
|
+
}
|
|
238
|
+
if (/headers?|content-type/i.test(line) && artifact.includes("-")) {
|
|
239
|
+
return "header_name";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function unescapeLiteral(value: string): string {
|
|
246
|
+
return value.replace(/\\([\\"'`])/g, "$1");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function containsArtifact(
|
|
250
|
+
text: string,
|
|
251
|
+
artifact: string,
|
|
252
|
+
category: ContractArtifactCategory,
|
|
253
|
+
): boolean {
|
|
254
|
+
if (category !== "field_name") {
|
|
255
|
+
return text.includes(artifact);
|
|
256
|
+
}
|
|
257
|
+
const escaped = artifact.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
258
|
+
return new RegExp(`(^|[^A-Za-z0-9_])${escaped}(?=$|[^A-Za-z0-9_])`).test(text);
|
|
259
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import type { SelfBenchConfig } from "./config.js";
|
|
5
|
+
import { runCommand } from "./process.js";
|
|
6
|
+
import type {
|
|
7
|
+
SandboxExecutor,
|
|
8
|
+
SandboxRequest,
|
|
9
|
+
SandboxResult,
|
|
10
|
+
SandboxRunOptions,
|
|
11
|
+
} from "./sandbox.js";
|
|
12
|
+
|
|
13
|
+
export class DockerSandboxExecutor implements SandboxExecutor {
|
|
14
|
+
readonly #config: Extract<SelfBenchConfig["execution"], { kind: "docker" }>;
|
|
15
|
+
|
|
16
|
+
constructor(config: Extract<SelfBenchConfig["execution"], { kind: "docker" }>) {
|
|
17
|
+
this.#config = config;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async run(request: SandboxRequest, options: SandboxRunOptions = {}): Promise<SandboxResult> {
|
|
21
|
+
const root = await mkdtemp(join(tmpdir(), "selfbench-docker-"));
|
|
22
|
+
const sandboxId = sandboxName(request.runId, request.stage);
|
|
23
|
+
try {
|
|
24
|
+
for (const file of request.files ?? []) {
|
|
25
|
+
const destination = hostPath(root, file.path);
|
|
26
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
27
|
+
await writeFile(destination, file.contents);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const environment = { ...request.environment, ...request.secrets };
|
|
31
|
+
const args = [
|
|
32
|
+
"create",
|
|
33
|
+
"--name",
|
|
34
|
+
sandboxId,
|
|
35
|
+
"--cpus",
|
|
36
|
+
String(request.cpu ?? 4),
|
|
37
|
+
"--memory",
|
|
38
|
+
`${request.memoryMiB ?? 8192}m`,
|
|
39
|
+
"--volume",
|
|
40
|
+
`${sandboxId}:/work`,
|
|
41
|
+
"--workdir",
|
|
42
|
+
"/work",
|
|
43
|
+
];
|
|
44
|
+
for (const key of Object.keys(environment)) {
|
|
45
|
+
args.push("--env", key);
|
|
46
|
+
}
|
|
47
|
+
args.push(this.#config.image, ...request.command);
|
|
48
|
+
|
|
49
|
+
await runCommand("docker", ["volume", "create", sandboxId]);
|
|
50
|
+
await runCommand("docker", args, { env: { ...process.env, ...environment } });
|
|
51
|
+
await runCommand("docker", ["cp", `${root}/.`, `${sandboxId}:/work/`]);
|
|
52
|
+
|
|
53
|
+
const abort = () => {
|
|
54
|
+
void runCommand("docker", ["rm", "--force", sandboxId], { allowFailure: true });
|
|
55
|
+
};
|
|
56
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
57
|
+
try {
|
|
58
|
+
const result = await runCommand("docker", ["start", "--attach", sandboxId], {
|
|
59
|
+
allowFailure: true,
|
|
60
|
+
timeoutMs: request.timeoutMs,
|
|
61
|
+
...(request.inactivityTimeoutMs
|
|
62
|
+
? { inactivityTimeoutMs: request.inactivityTimeoutMs }
|
|
63
|
+
: {}),
|
|
64
|
+
onOutput: (stream, chunk) => options.onProgress?.({ stream, bytes: chunk.byteLength }),
|
|
65
|
+
});
|
|
66
|
+
const outputs: Record<string, Uint8Array> = {};
|
|
67
|
+
for (const path of request.outputPaths ?? []) {
|
|
68
|
+
const destination = hostPath(root, path);
|
|
69
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
70
|
+
const copied = await runCommand("docker", ["cp", `${sandboxId}:${path}`, destination], {
|
|
71
|
+
allowFailure: true,
|
|
72
|
+
});
|
|
73
|
+
if (copied.exitCode === 0) {
|
|
74
|
+
outputs[path] = await readFile(destination);
|
|
75
|
+
} else if (result.exitCode === 0) {
|
|
76
|
+
throw new Error(`sandbox ${sandboxId} exited successfully without output ${path}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { sandboxId, ...result, outputs };
|
|
80
|
+
} finally {
|
|
81
|
+
options.signal?.removeEventListener("abort", abort);
|
|
82
|
+
await runCommand("docker", ["rm", "--force", sandboxId], { allowFailure: true });
|
|
83
|
+
await runCommand("docker", ["volume", "rm", "--force", sandboxId], {
|
|
84
|
+
allowFailure: true,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
await runCommand("docker", ["rm", "--force", sandboxId], { allowFailure: true });
|
|
89
|
+
await runCommand("docker", ["volume", "rm", "--force", sandboxId], {
|
|
90
|
+
allowFailure: true,
|
|
91
|
+
});
|
|
92
|
+
await rm(root, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
close(): void {}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hostPath(root: string, containerPath: string): string {
|
|
100
|
+
if (!containerPath.startsWith("/work/")) {
|
|
101
|
+
throw new Error(`sandbox path must be beneath /work: ${containerPath}`);
|
|
102
|
+
}
|
|
103
|
+
const path = resolve(root, relative("/work", containerPath));
|
|
104
|
+
if (path !== root && !path.startsWith(`${root}/`)) {
|
|
105
|
+
throw new Error(`sandbox path escapes /work: ${containerPath}`);
|
|
106
|
+
}
|
|
107
|
+
return path;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sandboxName(runId: string, stage: string): string {
|
|
111
|
+
const suffix = crypto.randomUUID().slice(0, 8);
|
|
112
|
+
return `selfbench-${runId.slice(0, 20)}-${stage.slice(0, 16)}-${suffix}`
|
|
113
|
+
.toLowerCase()
|
|
114
|
+
.replace(/[^a-z0-9_.-]/g, "-");
|
|
115
|
+
}
|
package/src/eval-main.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { MATRIX_MODELS, type MatrixModel, runMatrix } from "./evaluate.js";
|
|
5
|
+
|
|
6
|
+
const parsed = parseArgs({
|
|
7
|
+
options: {
|
|
8
|
+
export: { type: "string" },
|
|
9
|
+
tasks: { type: "string" },
|
|
10
|
+
jobs: { type: "string" },
|
|
11
|
+
harbor: { type: "string" },
|
|
12
|
+
environment: { type: "string", default: "modal" },
|
|
13
|
+
concurrency: { type: "string", default: "3" },
|
|
14
|
+
auth: { type: "string" },
|
|
15
|
+
model: { type: "string", multiple: true },
|
|
16
|
+
help: { type: "boolean", short: "h" },
|
|
17
|
+
},
|
|
18
|
+
strict: true,
|
|
19
|
+
});
|
|
20
|
+
if (parsed.values.help) {
|
|
21
|
+
console.log(`Run Harbor tasks through the fixed Codex subscription model matrix.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
self-bench-eval (--export FILE.tar.gz | --tasks DIRECTORY) --jobs DIRECTORY [options]
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
--tasks DIRECTORY Expanded Harbor tasks (one or more)
|
|
28
|
+
--harbor PATH Harbor executable (default: harbor)
|
|
29
|
+
--environment docker|modal Execution environment (default: modal)
|
|
30
|
+
--concurrency N Concurrent trials (default: 3)
|
|
31
|
+
--model MODEL Run only this model; may be repeated
|
|
32
|
+
--auth FILE Codex ChatGPT auth.json path
|
|
33
|
+
-h, --help Show this help`);
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
const environment = parsed.values.environment;
|
|
37
|
+
if (environment !== "docker" && environment !== "modal") {
|
|
38
|
+
throw new Error("--environment must be docker or modal");
|
|
39
|
+
}
|
|
40
|
+
const concurrency = positiveInteger(parsed.values.concurrency, "--concurrency");
|
|
41
|
+
const models = evaluationModels(parsed.values.model);
|
|
42
|
+
const summaries = await runMatrix({
|
|
43
|
+
...(parsed.values.export ? { exportPath: parsed.values.export } : {}),
|
|
44
|
+
...(parsed.values.tasks ? { tasksPath: parsed.values.tasks } : {}),
|
|
45
|
+
jobsDirectory: parsed.values.jobs ?? fail("--jobs is required"),
|
|
46
|
+
environment,
|
|
47
|
+
concurrency,
|
|
48
|
+
...(parsed.values.harbor ? { harborPath: parsed.values.harbor } : {}),
|
|
49
|
+
...(parsed.values.auth ? { authPath: parsed.values.auth } : {}),
|
|
50
|
+
...(models ? { models } : {}),
|
|
51
|
+
onTrialComplete: (summary, completed, total) => {
|
|
52
|
+
const status = summary.passed ? "passed" : summary.exception ? "error" : "failed";
|
|
53
|
+
console.error(`[${completed}/${total}] ${summary.model} ${summary.taskId}: ${status}`);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
console.log(
|
|
57
|
+
JSON.stringify(
|
|
58
|
+
{
|
|
59
|
+
trials: summaries.length,
|
|
60
|
+
passed: summaries.filter((summary) => summary.passed).length,
|
|
61
|
+
failed: summaries.filter((summary) => !summary.passed).length,
|
|
62
|
+
},
|
|
63
|
+
null,
|
|
64
|
+
2,
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
function fail(message: string): never {
|
|
69
|
+
throw new Error(message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function evaluationModels(values: string[] | undefined): readonly MatrixModel[] | undefined {
|
|
73
|
+
if (!values) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
const allowed = new Set<string>(MATRIX_MODELS);
|
|
77
|
+
const invalid = values.filter((value) => !allowed.has(value));
|
|
78
|
+
if (invalid.length > 0) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`--model must be one of ${MATRIX_MODELS.join(", ")}; got ${invalid.join(", ")}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return [...new Set(values)] as MatrixModel[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function positiveInteger(value: string | undefined, label: string): number {
|
|
87
|
+
const parsed = Number(value);
|
|
88
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
89
|
+
throw new Error(`${label} must be a positive integer`);
|
|
90
|
+
}
|
|
91
|
+
return parsed;
|
|
92
|
+
}
|