@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.
Files changed (243) hide show
  1. package/CONTRIBUTING.md +170 -0
  2. package/LICENSE +63 -136
  3. package/README.md +83 -66
  4. package/docs/README.md +44 -0
  5. package/docs/acknowledgements.md +14 -0
  6. package/docs/architecture.md +146 -0
  7. package/docs/assets/README.md +27 -0
  8. package/docs/assets/sevro-logo.png +0 -0
  9. package/docs/assets/sevro-symbol.svg +1 -0
  10. package/docs/assets/sevro-wordmark.svg +1 -0
  11. package/docs/builtin-graders-v1.md +41 -13
  12. package/docs/codex-host.md +184 -52
  13. package/docs/contracts.md +19 -0
  14. package/docs/creating-cases.md +139 -0
  15. package/docs/development-cli.md +190 -30
  16. package/docs/documentation-quality.md +61 -0
  17. package/docs/evaluation-workflow.md +110 -0
  18. package/docs/evidence/code-mode-controls-71.md +64 -0
  19. package/docs/evidence/code-mode-programs-85.md +64 -0
  20. package/docs/evidence/codex-state-73/README.md +204 -0
  21. package/docs/evidence/codex-state-73/darrow-migration.patch +182 -0
  22. package/docs/evidence/codex-state-73/results.json +661 -0
  23. package/docs/evidence/local-http-74.md +104 -0
  24. package/docs/evidence/runtime-startup-30/README.md +185 -0
  25. package/docs/evidence/runtime-startup-30/after.json +319 -0
  26. package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +2 -0
  27. package/docs/evidence/runtime-startup-30/before.json +403 -0
  28. package/docs/evidence/runtime-startup-30/consumer-smoke.json +63 -0
  29. package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +3 -0
  30. package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +3 -0
  31. package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +20 -0
  32. package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +2 -0
  33. package/docs/evidence/skill-reads-70/results.json +823 -0
  34. package/docs/evidence.md +21 -0
  35. package/docs/extension-protocol-v1.md +50 -16
  36. package/docs/fixture-cleanup-validation.md +3 -0
  37. package/docs/getting-started.md +102 -0
  38. package/docs/guide-evaluation.md +89 -0
  39. package/docs/guide-verification.json +239 -0
  40. package/docs/identity-v1.md +35 -1
  41. package/docs/installing.md +88 -0
  42. package/docs/licenses/BUSL-1.1-0.1.0-rc.2.txt +159 -0
  43. package/docs/licensing.md +42 -0
  44. package/docs/native-controls.md +76 -5
  45. package/docs/native-hosts.md +228 -0
  46. package/docs/reading-results.md +141 -0
  47. package/docs/recipes.md +489 -0
  48. package/docs/releases.md +81 -40
  49. package/docs/report-v1.md +29 -1
  50. package/docs/results-v1.md +180 -0
  51. package/docs/run-settings-v1.md +147 -0
  52. package/docs/running-evaluations.md +31 -0
  53. package/docs/runtime-v1.md +614 -0
  54. package/docs/specs/repository-guide.md +58 -0
  55. package/docs/troubleshooting.md +30 -0
  56. package/docs/typescript-quality.md +234 -0
  57. package/docs/usability-study.md +114 -0
  58. package/examples/basic/host.ts +2 -2
  59. package/examples/recipes/candidate.ts +26 -0
  60. package/examples/recipes/cases/coding-change.json +21 -0
  61. package/examples/recipes/cases/host-tools.json +21 -0
  62. package/examples/recipes/cases/mise.json +21 -0
  63. package/examples/recipes/cases/node.json +21 -0
  64. package/examples/recipes/cases/python-uv.json +21 -0
  65. package/examples/recipes/cases/semantic.json +17 -0
  66. package/examples/recipes/coding-change/check.ts +23 -0
  67. package/examples/recipes/extension-artifacts.ts +65 -0
  68. package/examples/recipes/extension-methods.ts +174 -0
  69. package/examples/recipes/extension.ts +37 -0
  70. package/examples/recipes/repository-skill/.agents/skills/startup-guide/SKILL.md +7 -0
  71. package/examples/recipes/repository-skill/.claude/skills/startup-guide/SKILL.md +7 -0
  72. package/examples/recipes/repository-skill/README.md +3 -0
  73. package/examples/recipes/run-inputs.ts +188 -0
  74. package/examples/recipes/run-options.ts +131 -0
  75. package/examples/recipes/run.ts +63 -0
  76. package/examples/recipes/runtime/empty.json +1 -0
  77. package/examples/recipes/runtime/goals.json +1 -0
  78. package/examples/recipes/runtime/mise.json +6 -0
  79. package/examples/recipes/runtime/path.json +1 -0
  80. package/examples/recipes/runtime/plugins.json +4 -0
  81. package/examples/recipes/runtime/python-uv.json +10 -0
  82. package/examples/recipes/semantic-judge.ts +29 -0
  83. package/examples/templates/coding/adapter.ts +17 -0
  84. package/examples/templates/coding/case.json +15 -0
  85. package/examples/templates/repository-skill/.agents/skills/startup-guide/SKILL.md +6 -0
  86. package/examples/templates/repository-skill/extension-data.ts +64 -0
  87. package/examples/templates/repository-skill/extension.ts +31 -0
  88. package/examples/templates/response/adapter.ts +12 -0
  89. package/examples/templates/response/case.json +13 -0
  90. package/package.json +39 -6
  91. package/schemas/report-v1.schema.json +9 -0
  92. package/schemas/run-evidence-v1.schema.json +2 -1
  93. package/schemas/runtime-v1.schema.json +193 -0
  94. package/src/advisory.ts +10 -5
  95. package/src/candidate-artifacts.ts +208 -0
  96. package/src/candidate-codex-state.ts +185 -0
  97. package/src/case-validation.ts +105 -0
  98. package/src/cli/cancellation.ts +20 -0
  99. package/src/cli/command.ts +81 -0
  100. package/src/cli/completion.ts +15 -0
  101. package/src/cli/demo.ts +59 -0
  102. package/src/cli/evaluation.ts +55 -0
  103. package/src/cli/extension.ts +79 -0
  104. package/src/cli/help.ts +222 -0
  105. package/src/cli/hosts.ts +59 -0
  106. package/src/cli/init-settings.ts +30 -0
  107. package/src/cli/init.ts +111 -0
  108. package/src/cli/invocation.ts +627 -0
  109. package/src/cli/loading.ts +151 -0
  110. package/src/cli/preparation.ts +163 -0
  111. package/src/cli/presentation.ts +91 -0
  112. package/src/cli/progress.ts +6 -0
  113. package/src/cli/run-manifest.ts +133 -0
  114. package/src/cli/run-options.ts +101 -0
  115. package/src/cli/run-overrides.ts +83 -0
  116. package/src/cli/settings.ts +160 -0
  117. package/src/cli/summary.ts +43 -0
  118. package/src/cli.ts +13 -843
  119. package/src/contract-types.ts +94 -0
  120. package/src/engine.ts +19 -2373
  121. package/src/evaluation/advisory.ts +150 -0
  122. package/src/evaluation/check-plan.ts +510 -0
  123. package/src/evaluation/collection.ts +88 -0
  124. package/src/evaluation/context.ts +10 -0
  125. package/src/evaluation/grading-host.ts +39 -0
  126. package/src/evaluation/grading.ts +258 -0
  127. package/src/evaluation/invocation-plan.ts +548 -0
  128. package/src/evaluation/plan.ts +736 -0
  129. package/src/evaluation/progress.ts +19 -0
  130. package/src/evaluation/resources.ts +241 -0
  131. package/src/evaluation/run.ts +206 -0
  132. package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +3 -1
  133. package/src/evaluation/semantic.ts +312 -0
  134. package/src/evaluation/trial.ts +866 -0
  135. package/src/evaluation/types.ts +207 -0
  136. package/src/evidence/host-results.ts +92 -0
  137. package/src/evidence/retained-artifacts.ts +18 -0
  138. package/src/evidence/trial-writer.ts +203 -0
  139. package/src/evidence/usage.ts +17 -0
  140. package/src/extension-client.ts +228 -148
  141. package/src/extension-session.ts +326 -195
  142. package/src/fixtures/advisory.ts +338 -0
  143. package/src/fixtures/bin.ts +57 -0
  144. package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
  145. package/src/fixtures/create.ts +187 -0
  146. package/src/fixtures/generated.ts +300 -0
  147. package/src/fixtures/git-hooks.ts +24 -0
  148. package/src/fixtures/preparation.ts +270 -0
  149. package/src/{repository-fixture.ts → fixtures/repository.ts} +207 -85
  150. package/src/fixtures/setup.ts +224 -0
  151. package/src/fixtures/tools.ts +88 -0
  152. package/src/fixtures/workspace-state.ts +212 -0
  153. package/src/generated/cli-result.cjs +2 -0
  154. package/src/generated/cli-result.d.cts +3 -0
  155. package/src/generated/extension.cjs +2 -0
  156. package/src/generated/extension.d.cts +3 -0
  157. package/src/generated/report.cjs +2 -0
  158. package/src/generated/report.d.cts +3 -0
  159. package/src/generated/run-evidence.cjs +2 -0
  160. package/src/generated/run-evidence.d.cts +3 -0
  161. package/src/generated/runtime.cjs +2 -0
  162. package/src/generated/runtime.d.cts +3 -0
  163. package/src/graders/artifact.ts +56 -23
  164. package/src/graders/git-head.ts +79 -75
  165. package/src/graders/output.ts +222 -140
  166. package/src/graders/semantic.ts +102 -43
  167. package/src/graders/shell.ts +379 -207
  168. package/src/host-diagnostic-redaction.ts +52 -0
  169. package/src/host-diagnostics.ts +319 -0
  170. package/src/hosts/claude-continuation.ts +63 -18
  171. package/src/hosts/claude-credential.ts +43 -62
  172. package/src/hosts/claude-events.ts +86 -51
  173. package/src/hosts/claude-keychain.ts +59 -0
  174. package/src/hosts/claude-native-goal.ts +57 -33
  175. package/src/hosts/claude-process.ts +111 -0
  176. package/src/hosts/claude-repository-invocation.ts +208 -117
  177. package/src/hosts/claude-settings.ts +240 -21
  178. package/src/hosts/claude-skill-activations.ts +166 -0
  179. package/src/hosts/claude-state.ts +403 -0
  180. package/src/hosts/claude-tool-calls.ts +171 -106
  181. package/src/hosts/claude.ts +561 -451
  182. package/src/hosts/codex-app-server.ts +326 -178
  183. package/src/hosts/codex-configuration.ts +44 -18
  184. package/src/hosts/codex-events.ts +97 -60
  185. package/src/hosts/codex-exec-controls.ts +516 -0
  186. package/src/hosts/codex-invocation.ts +70 -0
  187. package/src/hosts/codex-marketplace.ts +283 -0
  188. package/src/hosts/codex-native-calls.ts +546 -500
  189. package/src/hosts/codex-process.ts +202 -0
  190. package/src/hosts/codex-profile.ts +257 -28
  191. package/src/hosts/codex-session-files.ts +106 -0
  192. package/src/hosts/codex-state.ts +492 -0
  193. package/src/hosts/codex.ts +783 -832
  194. package/src/hosts/isolation-common.ts +94 -0
  195. package/src/hosts/isolation-roots.ts +54 -27
  196. package/src/hosts/isolation.ts +10 -0
  197. package/src/hosts/linux-sandbox.ts +159 -0
  198. package/src/hosts/mac-sandbox.ts +187 -61
  199. package/src/hosts/native-controls.ts +152 -62
  200. package/src/hosts/process-lifecycle.ts +37 -0
  201. package/src/hosts/protected-worktrees.ts +51 -19
  202. package/src/hosts/skill-hook-catalog.ts +131 -0
  203. package/src/hosts/skill-hook-handler.ts +374 -0
  204. package/src/hosts/skill-hooks.ts +580 -0
  205. package/src/hosts/workspace-fingerprint.ts +55 -35
  206. package/src/identity.ts +102 -48
  207. package/src/instrumentation.ts +109 -48
  208. package/src/native-transcript-state.ts +131 -0
  209. package/src/native-transcript-view.ts +90 -0
  210. package/src/native-transcripts.ts +286 -0
  211. package/src/process-lifecycle.ts +150 -0
  212. package/src/provenance.ts +161 -81
  213. package/src/prune.ts +215 -0
  214. package/src/report.ts +172 -140
  215. package/src/resolved-case.ts +33 -0
  216. package/src/results-lock.ts +29 -0
  217. package/src/results.ts +133 -86
  218. package/src/run-owner.ts +154 -63
  219. package/src/runtime/config.ts +456 -0
  220. package/src/runtime/discovery-apple.ts +90 -0
  221. package/src/runtime/discovery-providers.ts +63 -0
  222. package/src/runtime/discovery.ts +70 -0
  223. package/src/runtime/hook-processes.ts +97 -0
  224. package/src/runtime/hooks.ts +476 -0
  225. package/src/runtime/paths.ts +313 -0
  226. package/src/runtime/query.ts +85 -0
  227. package/src/runtime/seeds.ts +134 -0
  228. package/src/runtime/shared-cache.ts +143 -0
  229. package/src/runtime/state.ts +143 -0
  230. package/src/schema-types.ts +198 -0
  231. package/src/schema.ts +15 -19
  232. package/src/transcript-retention.ts +146 -0
  233. package/src/value-guards.ts +20 -0
  234. package/src/advisory-fixture.ts +0 -282
  235. package/src/fixture-bin.ts +0 -87
  236. package/src/fixture-setup.ts +0 -163
  237. package/src/generated-fixture.ts +0 -220
  238. package/src/git-hooks.ts +0 -63
  239. package/src/hosts/claude-nested-skills.ts +0 -218
  240. package/src/hosts/codex-command-output.ts +0 -399
  241. package/src/hosts/codex-executor-command.ts +0 -195
  242. package/src/hosts/codex-skill-reads.ts +0 -391
  243. package/src/preparation.ts +0 -211
@@ -0,0 +1,286 @@
1
+ import { lstat, readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { gzipSync } from "node:zlib";
4
+ import { sha256 } from "./identity";
5
+ import { HostExecutionFailure, type HostResult } from "./evaluation/types";
6
+ import type { RuntimeRole } from "./runtime/state";
7
+ import type { RuntimePolicy } from "./runtime/config";
8
+ import { isRecord } from "./value-guards";
9
+ import {
10
+ rethrowDiagnosticPersistenceError,
11
+ type HostDiagnostics,
12
+ } from "./host-diagnostics";
13
+
14
+ export function candidateTranscriptRoots(root: string | undefined): string[] {
15
+ return root ? [root] : [];
16
+ }
17
+
18
+ export function candidateTranscriptEnvironment(
19
+ root: string | undefined,
20
+ ): Record<string, string> {
21
+ return root ? { SEVRO_CANDIDATE_TRANSCRIPTS: root } : {};
22
+ }
23
+
24
+ export async function retainEnabledNativeTranscripts(
25
+ result: HostResult,
26
+ policy: RuntimePolicy | undefined,
27
+ root: string,
28
+ host: "codex" | "claude",
29
+ role?: RuntimeRole,
30
+ rootSessionId?: string,
31
+ ): Promise<void> {
32
+ if (policy?.nativeTranscripts === true)
33
+ await retainNativeTranscripts(
34
+ result,
35
+ root,
36
+ host,
37
+ role,
38
+ rootSessionId ?? observedRootSession(result),
39
+ );
40
+ }
41
+
42
+ function observedRootSession(result: HostResult): string | undefined {
43
+ const observation = result.observations?.find(
44
+ (item) => item.id === "sevro.host.native-goal",
45
+ );
46
+ const session = observation?.data.threadId;
47
+ return typeof session === "string" ? session : undefined;
48
+ }
49
+
50
+ export async function failedNativeTranscripts(
51
+ cause: unknown,
52
+ policy: RuntimePolicy | undefined,
53
+ root: string,
54
+ host: "codex" | "claude",
55
+ role?: RuntimeRole,
56
+ diagnostics?: HostDiagnostics,
57
+ ): Promise<HostResult> {
58
+ rethrowDiagnosticPersistenceError(cause);
59
+ requireFailureCapture(cause, policy, diagnostics);
60
+ const result: HostResult =
61
+ cause instanceof HostExecutionFailure
62
+ ? cause.result
63
+ : {
64
+ finalMessage: null,
65
+ complete: false,
66
+ executionFailed: true,
67
+ actualCondition: "passive",
68
+ };
69
+ await diagnostics?.retain(result, cause);
70
+ await retainEnabledNativeTranscripts(result, policy, root, host, role);
71
+ return result;
72
+ }
73
+
74
+ function requireFailureCapture(
75
+ cause: unknown,
76
+ policy: RuntimePolicy | undefined,
77
+ diagnostics: HostDiagnostics | undefined,
78
+ ): void {
79
+ if (policy?.nativeTranscripts !== true && !diagnostics) throw cause;
80
+ }
81
+
82
+ const MAX_FILES = 4096;
83
+ const MAX_BYTES = 5 * 1024 * 1024;
84
+ const MAX_ENCODED_FILES_BYTES = 7 * 1024 * 1024;
85
+ type NativeFile = { path: string; sha256: string; gzipBase64: string };
86
+ type Capture = {
87
+ files: NativeFile[];
88
+ bytes: number;
89
+ entries: number;
90
+ encodedBytes: number;
91
+ issues: string[];
92
+ };
93
+
94
+ /** Capture only a fresh role's transcript tree; never follow native source links. */
95
+ export async function retainNativeTranscripts(
96
+ result: HostResult,
97
+ root: string,
98
+ host: "codex" | "claude",
99
+ role: RuntimeRole = "candidate",
100
+ rootSessionId: string | null = null,
101
+ ): Promise<void> {
102
+ const capture: Capture = {
103
+ files: [],
104
+ bytes: 0,
105
+ entries: 0,
106
+ encodedBytes: 0,
107
+ issues: [],
108
+ };
109
+ try {
110
+ await captureDirectory(root, "", capture);
111
+ } catch {
112
+ capture.issues.push("source_unavailable");
113
+ }
114
+ observeCaptureRoot(capture, rootSessionId, host);
115
+ const completeness = captureCompleteness(capture, result);
116
+ const bundle = {
117
+ format: "sevro.native-transcripts.v2",
118
+ host,
119
+ role,
120
+ rootSessionId,
121
+ completeness,
122
+ issues: capture.issues,
123
+ files: capture.files,
124
+ };
125
+ const lists = nativeResultLists(result);
126
+ lists.artifacts.push({
127
+ id: "sevro.native-transcripts.bundle",
128
+ bytes: Buffer.from(JSON.stringify(bundle)),
129
+ });
130
+ lists.observations.push({
131
+ id: "sevro.host.native-transcripts",
132
+ completeness,
133
+ data: {
134
+ method: "native_files",
135
+ host,
136
+ role,
137
+ rootSessionId,
138
+ artifactId: "sevro.native-transcripts.bundle",
139
+ issues: capture.issues,
140
+ files: capture.files.map(({ path, sha256 }) => ({ path, sha256 })),
141
+ },
142
+ });
143
+ }
144
+
145
+ function nativeResultLists(result: HostResult) {
146
+ result.artifacts ??= [];
147
+ result.observations ??= [];
148
+ return { artifacts: result.artifacts, observations: result.observations };
149
+ }
150
+
151
+ function observeCaptureRoot(
152
+ capture: Capture,
153
+ session: string | null,
154
+ host: string,
155
+ ): void {
156
+ if (session === null) {
157
+ capture.issues.push("root_session_unknown");
158
+ return;
159
+ }
160
+ const suffix = host === "codex" ? `-${session}.jsonl` : `/${session}.jsonl`;
161
+ if (!capture.files.some((file) => file.path.endsWith(suffix)))
162
+ capture.issues.push("root_session_missing");
163
+ }
164
+
165
+ function captureCompleteness(capture: Capture, result: HostResult) {
166
+ if (!capture.files.some((file) => file.path.endsWith(".jsonl")))
167
+ return "unavailable" as const;
168
+ if (capture.issues.length || !result.complete || result.executionFailed)
169
+ return "partial" as const;
170
+ return "complete" as const;
171
+ }
172
+
173
+ async function captureDirectory(
174
+ root: string,
175
+ path: string,
176
+ capture: Capture,
177
+ ): Promise<void> {
178
+ const directory = join(root, path);
179
+ if (!(await lstat(directory)).isDirectory())
180
+ throw new Error("invalid source directory");
181
+ for (const name of (await readdir(directory)).sort()) {
182
+ capture.entries++;
183
+ if (capture.entries > MAX_FILES) {
184
+ capture.issues.push("entry_limit");
185
+ return;
186
+ }
187
+ await captureEntry(root, join(path, name), capture);
188
+ }
189
+ }
190
+
191
+ async function captureEntry(
192
+ root: string,
193
+ path: string,
194
+ capture: Capture,
195
+ ): Promise<void> {
196
+ try {
197
+ const info = await lstat(join(root, path));
198
+ if (info.isDirectory()) {
199
+ await captureDirectory(root, path, capture);
200
+ return;
201
+ }
202
+ if (!info.isFile()) {
203
+ capture.issues.push("unsafe_entry");
204
+ return;
205
+ }
206
+ await captureFile(root, path, info.size, capture);
207
+ } catch {
208
+ capture.issues.push("entry_unavailable");
209
+ }
210
+ }
211
+
212
+ async function captureFile(
213
+ root: string,
214
+ path: string,
215
+ size: number,
216
+ capture: Capture,
217
+ ): Promise<void> {
218
+ if (capture.bytes + size > MAX_BYTES) {
219
+ capture.issues.push("byte_limit");
220
+ return;
221
+ }
222
+ const bytes = await readFile(join(root, path));
223
+ if (bytes.length !== size) {
224
+ capture.issues.push("source_changed");
225
+ return;
226
+ }
227
+ const file = {
228
+ path,
229
+ sha256: sha256(bytes),
230
+ gzipBase64: gzipSync(bytes).toString("base64"),
231
+ };
232
+ const issue = nativeFileIssue(path, bytes);
233
+ if (issue && !capture.issues.includes(issue)) capture.issues.push(issue);
234
+ retainCaptureFile(file, bytes.length, capture);
235
+ }
236
+
237
+ function nativeFileIssue(path: string, bytes: Buffer): string | null {
238
+ if (path.endsWith(".json")) return nativeSidecarIssue(bytes);
239
+ if (!path.endsWith(".jsonl")) return null;
240
+ return nativeTranscriptIssue(bytes);
241
+ }
242
+
243
+ function nativeTranscriptIssue(bytes: Buffer): string | null {
244
+ try {
245
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
246
+ if (!text.trim()) return "empty_transcript";
247
+ const records = text.replace(/\r?\n$/, "").split("\n");
248
+ if (!records.every(validNativeRecord)) return "malformed_transcript";
249
+ return null;
250
+ } catch {
251
+ return "malformed_transcript";
252
+ }
253
+ }
254
+
255
+ function nativeSidecarIssue(bytes: Buffer): string | null {
256
+ try {
257
+ JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
258
+ return null;
259
+ } catch {
260
+ return "malformed_sidecar";
261
+ }
262
+ }
263
+
264
+ function validNativeRecord(line: string): boolean {
265
+ const record: unknown = JSON.parse(line);
266
+ return isRecord(record) && nativeRecordType(record.type);
267
+ }
268
+
269
+ function nativeRecordType(type: unknown): boolean {
270
+ return typeof type === "string" && type.trim().length > 0;
271
+ }
272
+
273
+ function retainCaptureFile(
274
+ file: NativeFile,
275
+ size: number,
276
+ capture: Capture,
277
+ ): void {
278
+ const encoded = Buffer.byteLength(JSON.stringify(file));
279
+ if (capture.encodedBytes + encoded > MAX_ENCODED_FILES_BYTES) {
280
+ capture.issues.push("bundle_limit");
281
+ return;
282
+ }
283
+ capture.bytes += size;
284
+ capture.encodedBytes += encoded;
285
+ capture.files.push(file);
286
+ }
@@ -0,0 +1,150 @@
1
+ export type ProcessInterruptionReason = "timeout" | "cancelled";
2
+
3
+ interface DeadlineOptions {
4
+ timeoutMs: number;
5
+ signal?: AbortSignal;
6
+ interruption: (reason: ProcessInterruptionReason) => Error;
7
+ }
8
+
9
+ /** Keep the deadline active until the caller has finished failure cleanup. */
10
+ export class ProcessDeadline {
11
+ private timer: ReturnType<typeof setTimeout> | undefined;
12
+ private cancel: (() => void) | undefined;
13
+
14
+ constructor(private readonly options: DeadlineOptions) {}
15
+
16
+ wait<T>(completed: Promise<T> | (() => Promise<T>)): Promise<T> {
17
+ const timeout = this.timeout();
18
+ const aborted = this.aborted();
19
+ const completion =
20
+ typeof completed === "function" ? completed() : completed;
21
+ return Promise.race([completion, timeout, aborted]);
22
+ }
23
+
24
+ private timeout(): Promise<never> {
25
+ return new Promise((_resolve, reject) => {
26
+ this.timer = setTimeout(() => {
27
+ reject(this.options.interruption("timeout"));
28
+ }, this.options.timeoutMs);
29
+ });
30
+ }
31
+
32
+ private aborted(): Promise<never> {
33
+ return new Promise((_resolve, reject) => {
34
+ const signal = this.options.signal;
35
+ if (!signal) return;
36
+ this.cancel = () => {
37
+ reject(this.options.interruption("cancelled"));
38
+ };
39
+ if (signal.aborted) this.cancel();
40
+ else signal.addEventListener("abort", this.cancel, { once: true });
41
+ });
42
+ }
43
+
44
+ dispose(): void {
45
+ if (this.timer) clearTimeout(this.timer);
46
+ if (this.cancel)
47
+ this.options.signal?.removeEventListener("abort", this.cancel);
48
+ }
49
+ }
50
+
51
+ interface OwnedProcess {
52
+ pid: number;
53
+ kill: (signal: "SIGKILL") => unknown;
54
+ }
55
+
56
+ interface TerminationOptions {
57
+ alwaysGroup?: boolean;
58
+ fallbackError?: "ignore" | "propagate";
59
+ }
60
+
61
+ function killGroup(pid: number): boolean {
62
+ try {
63
+ process.kill(-pid, "SIGKILL");
64
+ return true;
65
+ } catch {
66
+ // The owned process group may already be gone.
67
+ return false;
68
+ }
69
+ }
70
+
71
+ function useProcessGroup(options: TerminationOptions): boolean {
72
+ return options.alwaysGroup === true || process.platform !== "win32";
73
+ }
74
+
75
+ function killOwnedProcess(
76
+ proc: OwnedProcess,
77
+ options: TerminationOptions,
78
+ ): void {
79
+ if (options.fallbackError === "propagate") {
80
+ proc.kill("SIGKILL");
81
+ return;
82
+ }
83
+ try {
84
+ proc.kill("SIGKILL");
85
+ } catch {
86
+ // The owned process may already have exited.
87
+ }
88
+ }
89
+
90
+ /** Only use for children spawned in a new, runner-owned process group. */
91
+ export function terminateOwnedProcessGroup(
92
+ proc: OwnedProcess,
93
+ options: TerminationOptions = {},
94
+ ): void {
95
+ if (useProcessGroup(options) && killGroup(proc.pid)) return;
96
+ killOwnedProcess(proc, options);
97
+ }
98
+
99
+ type RejectedCapture = {
100
+ overflow: "reject";
101
+ exceeded: () => Error;
102
+ };
103
+
104
+ interface CaptureOptions {
105
+ limit: number;
106
+ observe?: (bytes: Uint8Array) => void;
107
+ cancelOnError?: boolean;
108
+ }
109
+
110
+ type ByteCaptureOptions = CaptureOptions &
111
+ (RejectedCapture | { overflow: "truncate" });
112
+
113
+ function retainedChunk(
114
+ bytes: Uint8Array,
115
+ size: number,
116
+ options: ByteCaptureOptions,
117
+ ): Uint8Array {
118
+ if (options.overflow === "reject" && size + bytes.byteLength > options.limit)
119
+ throw options.exceeded();
120
+ return bytes.subarray(0, Math.max(0, options.limit - size));
121
+ }
122
+
123
+ /** Truncation retains a byte prefix while still draining the entire stream. */
124
+ export async function captureBoundedBytes(
125
+ stream: ReadableStream<Uint8Array>,
126
+ options: ByteCaptureOptions,
127
+ ): Promise<{ bytes: Uint8Array; truncated: boolean }> {
128
+ const chunks: Uint8Array[] = [];
129
+ let size = 0;
130
+ let truncated = false;
131
+ for await (const bytes of stream.values({
132
+ preventCancel: !options.cancelOnError,
133
+ })) {
134
+ options.observe?.(bytes);
135
+ const retained = retainedChunk(bytes, size, options);
136
+ if (retained.byteLength) chunks.push(retained);
137
+ size += retained.byteLength;
138
+ truncated ||= retained.byteLength !== bytes.byteLength;
139
+ }
140
+ return { bytes: Buffer.concat(chunks), truncated };
141
+ }
142
+
143
+ /** Reject oversize output before strictly decoding the complete UTF-8 stream. */
144
+ export async function captureBoundedText(
145
+ stream: ReadableStream<Uint8Array>,
146
+ options: CaptureOptions & RejectedCapture,
147
+ ): Promise<string> {
148
+ const { bytes } = await captureBoundedBytes(stream, options);
149
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
150
+ }