niceeval 0.6.1 → 0.7.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/INDEX.md +23 -23
- package/README.zh.md +6 -6
- package/dist/agents/types.d.ts +69 -7
- package/dist/context/types.d.ts +32 -12
- package/dist/i18n/en.d.ts +54 -0
- package/dist/i18n/zh-CN.d.ts +57 -3
- package/dist/o11y/types.d.ts +16 -2
- package/dist/report/aggregate.d.ts +32 -24
- package/dist/report/aggregate.js +158 -50
- package/dist/report/built-in/index.d.ts +2 -0
- package/dist/report/built-in/index.js +8 -0
- package/dist/report/components.d.ts +93 -160
- package/dist/report/components.js +377 -114
- package/dist/report/compute.d.ts +87 -81
- package/dist/report/compute.js +597 -417
- package/dist/report/flag.d.ts +32 -6
- package/dist/report/flag.js +92 -4
- package/dist/report/format.d.ts +19 -11
- package/dist/report/format.js +30 -13
- package/dist/report/index.d.ts +16 -16
- package/dist/report/index.js +20 -21
- package/dist/report/load.js +3 -2
- package/dist/report/locale.d.ts +57 -33
- package/dist/report/locale.js +122 -56
- package/dist/report/metrics.d.ts +23 -4
- package/dist/report/metrics.js +110 -25
- package/dist/report/primitives.d.ts +48 -15
- package/dist/report/primitives.js +135 -26
- package/dist/report/react/AttemptList.d.ts +9 -7
- package/dist/report/react/AttemptList.js +17 -10
- package/dist/report/react/DeltaTable.js +19 -18
- package/dist/report/react/EvalList.d.ts +4 -4
- package/dist/report/react/EvalList.js +0 -0
- package/dist/report/react/ExperimentComparison.d.ts +10 -0
- package/dist/report/react/ExperimentComparison.js +12 -0
- package/dist/report/react/ExperimentList.d.ts +4 -3
- package/dist/report/react/ExperimentList.js +17 -18
- package/dist/report/react/MetricBars.js +5 -4
- package/dist/report/react/MetricLine.js +12 -5
- package/dist/report/react/MetricMatrix.js +1 -1
- package/dist/report/react/MetricScatter.js +59 -28
- package/dist/report/react/MetricTable.js +2 -12
- package/dist/report/react/ScopeSummary.d.ts +10 -0
- package/dist/report/react/ScopeSummary.js +17 -0
- package/dist/report/react/Scoreboard.js +6 -6
- package/dist/report/react/cell.js +2 -2
- package/dist/report/react/chart-math.d.ts +23 -6
- package/dist/report/react/chart-math.js +71 -19
- package/dist/report/react/fixtures.d.ts +5 -9
- package/dist/report/react/fixtures.js +110 -147
- package/dist/report/react/index.d.ts +15 -5
- package/dist/report/react/index.js +18 -7
- package/dist/report/report.d.ts +137 -16
- package/dist/report/report.js +259 -28
- package/dist/report/text/faces.d.ts +17 -19
- package/dist/report/text/faces.js +253 -184
- package/dist/report/text/plot.js +1 -1
- package/dist/report/text/table.js +38 -7
- package/dist/report/tree.d.ts +90 -40
- package/dist/report/tree.js +252 -94
- package/dist/report/types.d.ts +247 -284
- package/dist/report/types.js +4 -3
- package/dist/report/web.d.ts +21 -5
- package/dist/report/web.js +42 -16
- package/dist/results/select.d.ts +38 -16
- package/dist/results/select.js +73 -25
- package/dist/results/types.d.ts +49 -14
- package/dist/runner/feedback/sink.d.ts +110 -0
- package/dist/runner/types.d.ts +513 -22
- package/dist/sandbox/docker.d.ts +23 -2
- package/dist/sandbox/e2b.d.ts +15 -1
- package/dist/sandbox/errors.d.ts +30 -3
- package/dist/sandbox/io-retry.d.ts +17 -0
- package/dist/sandbox/registry.d.ts +2 -0
- package/dist/sandbox/resolve.d.ts +18 -5
- package/dist/sandbox/retry.d.ts +11 -1
- package/dist/sandbox/types.d.ts +39 -5
- package/dist/sandbox/vercel.d.ts +7 -1
- package/dist/scoring/coverage.d.ts +30 -0
- package/dist/scoring/display.d.ts +21 -0
- package/dist/scoring/display.js +120 -0
- package/dist/scoring/types.d.ts +103 -20
- package/dist/shared/aggregate.d.ts +4 -2
- package/dist/shared/aggregate.js +8 -7
- package/dist/shared/types.d.ts +28 -0
- package/dist/tty-line.d.ts +0 -4
- package/dist/util.d.ts +23 -0
- package/docs-site/zh/README.md +44 -0
- package/docs-site/zh/examples/ai-agent-application.mdx +63 -0
- package/docs-site/zh/examples/coding-agent-extensions.mdx +57 -0
- package/docs-site/zh/examples/index.mdx +50 -0
- package/docs-site/zh/{concepts → explanation}/adapter.mdx +31 -13
- package/docs-site/zh/{concepts → explanation}/assert.mdx +7 -7
- package/docs-site/zh/{concepts → explanation}/drive.mdx +8 -8
- package/docs-site/zh/{concepts → explanation}/evals.mdx +4 -4
- package/docs-site/zh/{concepts → explanation}/experiment.mdx +8 -8
- package/docs-site/zh/{concepts → explanation}/hitl.mdx +8 -8
- package/docs-site/zh/{concepts → explanation}/judge.mdx +5 -5
- package/docs-site/zh/{concepts → explanation}/overview.mdx +11 -11
- package/docs-site/zh/{guides → explanation}/runner.mdx +18 -8
- package/docs-site/zh/{concepts → explanation}/tier.mdx +6 -6
- package/docs-site/zh/{guides → how-to}/agent-feedback-loop.mdx +35 -33
- package/docs-site/zh/{guides → how-to}/authoring.mdx +35 -2
- package/docs-site/zh/{guides → how-to}/ci-integration.mdx +23 -12
- package/docs-site/zh/{guides → how-to}/connect-otel.mdx +6 -6
- package/docs-site/zh/{guides → how-to}/connect-your-agent.mdx +47 -21
- package/docs-site/zh/{guides → how-to}/custom-reports.mdx +34 -39
- package/docs-site/zh/{guides → how-to}/dataset-fanout.mdx +25 -3
- package/docs-site/zh/{guides → how-to}/experiments.mdx +12 -5
- package/docs-site/zh/how-to/publish-report.mdx +105 -0
- package/docs-site/zh/{guides → how-to}/reporters.mdx +2 -2
- package/docs-site/zh/{guides → how-to}/sandbox-agent.mdx +56 -7
- package/docs-site/zh/how-to/sandbox-providers.mdx +350 -0
- package/docs-site/zh/{guides → how-to}/scoring-guide.mdx +4 -4
- package/docs-site/zh/{guides → how-to}/viewing-results.mdx +82 -39
- package/docs-site/zh/{guides → how-to}/write-experiment.mdx +6 -4
- package/docs-site/zh/{guides → how-to}/write-send.mdx +30 -14
- package/docs-site/zh/index.mdx +24 -26
- package/docs-site/zh/introduction.mdx +8 -8
- package/docs-site/zh/reference/builtin-agents.mdx +32 -5
- package/docs-site/zh/reference/capabilities.mdx +8 -8
- package/docs-site/zh/reference/cli.mdx +40 -12
- package/docs-site/zh/reference/define-agent.mdx +58 -5
- package/docs-site/zh/reference/define-config.mdx +1 -1
- package/docs-site/zh/reference/define-eval.mdx +42 -9
- package/docs-site/zh/reference/events.mdx +3 -3
- package/docs-site/zh/reference/expect.mdx +26 -1
- package/docs-site/zh/{guides → reference}/official-adapters.mdx +32 -8
- package/docs-site/zh/{guides → reference}/report-components.mdx +45 -33
- package/docs-site/zh/{guides → reference}/results-data.mdx +21 -13
- package/docs-site/zh/troubleshooting/debug-sandbox.mdx +57 -0
- package/docs-site/zh/troubleshooting/debugging.mdx +212 -0
- package/docs-site/zh/{quickstart.mdx → tutorials/quickstart.mdx} +5 -17
- package/package.json +10 -2
- package/src/agents/ai-sdk-otel.test.ts +1 -0
- package/src/agents/ai-sdk.test.ts +3 -0
- package/src/agents/ai-sdk.ts +3 -0
- package/src/agents/bub-install-spec.test.ts +34 -0
- package/src/agents/bub-install-spec.ts +32 -0
- package/src/agents/bub.ts +31 -32
- package/src/agents/claude-code.test.ts +130 -9
- package/src/agents/claude-code.ts +76 -4
- package/src/agents/codex.test.ts +189 -40
- package/src/agents/codex.ts +155 -14
- package/src/agents/coding-cli-versions.test.ts +15 -0
- package/src/agents/coding-cli-versions.ts +3 -0
- package/src/agents/index.ts +13 -2
- package/src/agents/langgraph.test.ts +204 -0
- package/src/agents/langgraph.ts +495 -0
- package/src/agents/marketplace.ts +85 -0
- package/src/agents/native-config.test.ts +179 -0
- package/src/agents/native-config.ts +267 -0
- package/src/agents/openai-compat.test.ts +1 -0
- package/src/agents/openai-compat.ts +1 -1
- package/src/agents/openclaw.test.ts +31 -0
- package/src/agents/openclaw.ts +171 -0
- package/src/agents/plugin-config.test.ts +1 -0
- package/src/agents/sdk-streams.test.ts +79 -0
- package/src/agents/sdk-streams.ts +55 -10
- package/src/agents/skills.test.ts +1 -0
- package/src/agents/streaming.test.ts +3 -9
- package/src/agents/streaming.ts +2 -2
- package/src/agents/types.ts +71 -8
- package/src/agents/ui-message-stream.test.ts +3 -0
- package/src/cli.ts +446 -124
- package/src/context/context.test.ts +51 -12
- package/src/context/context.ts +162 -30
- package/src/context/session.test.ts +2 -1
- package/src/context/session.ts +115 -7
- package/src/context/types.ts +30 -12
- package/src/define.test.ts +13 -8
- package/src/define.ts +25 -4
- package/src/expect/index.ts +53 -23
- package/src/i18n/en.ts +81 -17
- package/src/i18n/zh-CN.ts +80 -17
- package/src/o11y/cost.test.ts +1 -0
- package/src/o11y/execution-tree.test.ts +1 -20
- package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
- package/src/o11y/otlp/parse.test.ts +1 -0
- package/src/o11y/otlp/turn-otel.test.ts +1 -0
- package/src/o11y/parsers/bub.test.ts +1 -0
- package/src/o11y/parsers/claude-code.test.ts +1 -34
- package/src/o11y/parsers/openclaw.test.ts +154 -0
- package/src/o11y/parsers/openclaw.ts +310 -0
- package/src/o11y/prices.json +746 -311
- package/src/o11y/tool-names.test.ts +1 -0
- package/src/o11y/types.ts +16 -2
- package/src/report/aggregate.ts +178 -61
- package/src/report/built-in/index.tsx +9 -0
- package/src/report/components.tsx +625 -279
- package/src/report/compute.ts +723 -491
- package/src/report/dual-render.test.tsx +741 -1024
- package/src/report/flag.ts +104 -12
- package/src/report/format.ts +32 -12
- package/src/report/index.ts +119 -46
- package/src/report/load.ts +3 -2
- package/src/report/locale.ts +136 -65
- package/src/report/metrics.ts +108 -25
- package/src/report/primitives.tsx +196 -45
- package/src/report/react/AttemptList.tsx +30 -43
- package/src/report/react/DeltaTable.tsx +63 -45
- package/src/report/react/EvalList.tsx +0 -0
- package/src/report/react/ExperimentComparison.tsx +73 -0
- package/src/report/react/ExperimentList.tsx +50 -32
- package/src/report/react/MetricBars.tsx +5 -4
- package/src/report/react/MetricLine.tsx +13 -8
- package/src/report/react/MetricMatrix.tsx +2 -2
- package/src/report/react/MetricScatter.tsx +86 -34
- package/src/report/react/MetricTable.tsx +4 -76
- package/src/report/react/ScopeSummary.tsx +86 -0
- package/src/report/react/Scoreboard.tsx +28 -10
- package/src/report/react/cell.tsx +2 -2
- package/src/report/react/chart-math.test.ts +85 -0
- package/src/report/react/chart-math.ts +101 -22
- package/src/report/react/enhance.js +89 -5
- package/src/report/react/fixtures.ts +114 -154
- package/src/report/react/index.tsx +24 -39
- package/src/report/react/render.test.tsx +138 -158
- package/src/report/react/styles.css +243 -82
- package/src/report/report.test.ts +779 -841
- package/src/report/report.ts +423 -41
- package/src/report/text/faces.ts +290 -193
- package/src/report/text/plot.ts +1 -1
- package/src/report/text/table.ts +44 -7
- package/src/report/tree.ts +362 -104
- package/src/report/types.ts +261 -271
- package/src/report/web.ts +63 -20
- package/src/results/annotated-source.test.ts +62 -9
- package/src/results/annotated-source.ts +64 -6
- package/src/results/attempt-evidence.test.ts +13 -11
- package/src/results/attempt-evidence.ts +20 -13
- package/src/results/attempt-source.ts +6 -3
- package/src/results/copy.ts +150 -60
- package/src/results/host-equivalence.test.ts +34 -20
- package/src/results/index.ts +12 -4
- package/src/results/locator.test.ts +1 -22
- package/src/results/open.ts +15 -5
- package/src/results/publish.ts +149 -0
- package/src/results/results.test.ts +89 -54
- package/src/results/select.ts +104 -34
- package/src/results/truncate.ts +90 -0
- package/src/results/types.ts +43 -14
- package/src/results/writer.ts +31 -13
- package/src/runner/attempt.test.ts +138 -7
- package/src/runner/attempt.ts +603 -104
- package/src/runner/discover.test.ts +47 -0
- package/src/runner/discover.ts +36 -2
- package/src/runner/eval-source.test.ts +1 -27
- package/src/runner/feedback/agent.test.ts +504 -0
- package/src/runner/feedback/agent.ts +409 -0
- package/src/runner/feedback/ci.test.ts +562 -0
- package/src/runner/feedback/ci.ts +401 -0
- package/src/runner/feedback/coordinator.test.ts +317 -0
- package/src/runner/feedback/coordinator.ts +397 -0
- package/src/runner/feedback/failure.ts +40 -0
- package/src/runner/feedback/human.test.ts +616 -0
- package/src/runner/feedback/human.ts +535 -0
- package/src/runner/feedback/index.ts +66 -0
- package/src/runner/feedback/io.ts +78 -0
- package/src/runner/feedback/profile.test.ts +50 -0
- package/src/runner/feedback/profile.ts +58 -0
- package/src/runner/feedback/reducer.test.ts +395 -0
- package/src/runner/feedback/reducer.ts +260 -0
- package/src/runner/feedback/renderer.ts +82 -0
- package/src/runner/feedback/sink.ts +203 -0
- package/src/runner/feedback/testing.ts +106 -0
- package/src/runner/ledger.test.ts +230 -0
- package/src/runner/ledger.ts +329 -0
- package/src/runner/report.test.ts +128 -3
- package/src/runner/report.ts +33 -9
- package/src/runner/reporters/artifacts.ts +8 -2
- package/src/runner/reporters/braintrust.test.ts +8 -7
- package/src/runner/reporters/braintrust.ts +9 -2
- package/src/runner/reporters/index.ts +2 -2
- package/src/runner/reporters/json.test.ts +162 -0
- package/src/runner/reporters/json.ts +35 -8
- package/src/runner/reporters/shared.ts +1 -5
- package/src/runner/run.test.ts +760 -3
- package/src/runner/run.ts +243 -37
- package/src/runner/sandbox-prep.ts +3 -42
- package/src/runner/timing.ts +158 -0
- package/src/runner/types.ts +518 -22
- package/src/sandbox/checkpoint.test.ts +55 -0
- package/src/sandbox/checkpoint.ts +29 -8
- package/src/sandbox/cli-commands.ts +407 -0
- package/src/sandbox/docker.ts +115 -16
- package/src/sandbox/e2b-agent-template.test.ts +56 -0
- package/src/sandbox/e2b-agent-template.ts +94 -0
- package/src/sandbox/e2b.ts +74 -9
- package/src/sandbox/errors.ts +111 -4
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/io-retry.test.ts +58 -0
- package/src/sandbox/io-retry.ts +45 -0
- package/src/sandbox/keep-registry.test.ts +86 -0
- package/src/sandbox/keep-registry.ts +142 -0
- package/src/sandbox/keep.ts +178 -0
- package/src/sandbox/paths.test.ts +1 -0
- package/src/sandbox/paths.ts +19 -8
- package/src/sandbox/registry.ts +20 -3
- package/src/sandbox/resolve.ts +76 -11
- package/src/sandbox/retry.test.ts +70 -0
- package/src/sandbox/retry.ts +46 -4
- package/src/sandbox/types.ts +44 -6
- package/src/sandbox/vercel.ts +43 -20
- package/src/scoring/collector.ts +60 -17
- package/src/scoring/coverage.ts +95 -0
- package/src/scoring/diff.ts +81 -0
- package/src/scoring/display.test.ts +121 -0
- package/src/scoring/display.ts +133 -0
- package/src/scoring/evidence.test.ts +189 -0
- package/src/scoring/judge.test.ts +142 -0
- package/src/scoring/judge.ts +15 -18
- package/src/scoring/scoped.ts +217 -50
- package/src/scoring/types.ts +117 -20
- package/src/scoring/verdict.ts +16 -4
- package/src/shared/aggregate.ts +8 -6
- package/src/shared/types.ts +31 -0
- package/src/show/compose.ts +50 -67
- package/src/show/index.ts +127 -56
- package/src/show/render.ts +662 -131
- package/src/show/report-host.test.ts +188 -0
- package/src/show/report-host.ts +375 -0
- package/src/show/show.test.ts +320 -54
- package/src/tty-line.ts +8 -26
- package/src/util.test.ts +1 -0
- package/src/util.ts +41 -0
- package/src/view/app/App.test.tsx +69 -0
- package/src/view/app/App.tsx +144 -48
- package/src/view/app/components/AttemptModal.tsx +423 -11
- package/src/view/app/components/CodeView.tsx +41 -14
- package/src/view/app/components/CopyControls.tsx +2 -2
- package/src/view/app/i18n.ts +37 -17
- package/src/view/app/lib/attempt-route.test.ts +1 -0
- package/src/view/app/lib/verdict.ts +7 -9
- package/src/view/app/main.tsx +13 -8
- package/src/view/app/pages/{RunsPage.tsx → AttemptsPage.tsx} +6 -6
- package/src/view/app/types.ts +4 -1
- package/src/view/artifact-serving.test.ts +2 -1
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +17 -17
- package/src/view/data.test.ts +10 -3
- package/src/view/data.ts +155 -49
- package/src/view/index.ts +56 -41
- package/src/view/server.ts +37 -15
- package/src/view/shared/types.ts +34 -5
- package/src/view/styles.css +227 -0
- package/src/view/view-report.test.ts +167 -62
- package/dist/report/built-ins/experiment-comparison.d.ts +0 -1
- package/dist/report/built-ins/experiment-comparison.js +0 -13
- package/dist/report/built-ins/index.d.ts +0 -1
- package/dist/report/built-ins/index.js +0 -2
- package/dist/report/react/GroupSummary.d.ts +0 -8
- package/dist/report/react/GroupSummary.js +0 -8
- package/dist/report/react/RunOverview.d.ts +0 -8
- package/dist/report/react/RunOverview.js +0 -12
- package/docs-site/zh/example/ai-agent-application.mdx +0 -152
- package/docs-site/zh/example/claude-code-codex-plugin.mdx +0 -167
- package/docs-site/zh/example/claude-code-codex-skill.mdx +0 -152
- package/docs-site/zh/example/showcase.mdx +0 -39
- package/docs-site/zh/guides/publish-report.mdx +0 -91
- package/docs-site/zh/guides/sandbox-providers.mdx +0 -102
- package/src/report/built-in-user-parity.test.tsx +0 -640
- package/src/report/built-ins/experiment-comparison.tsx +0 -19
- package/src/report/built-ins/index.ts +0 -2
- package/src/report/react/GroupSummary.tsx +0 -66
- package/src/report/react/RunOverview.tsx +0 -109
- package/src/runner/reporters/console.ts +0 -70
- package/src/runner/reporters/live.test.ts +0 -56
- package/src/runner/reporters/live.ts +0 -247
- package/src/runner/reporters/quiet.test.ts +0 -66
- package/src/runner/reporters/quiet.ts +0 -49
- package/src/runner/reporters/table.ts +0 -277
- /package/docs-site/zh/{example/tier1-ai-sdk-v7.mdx → examples/integrations/ai-sdk-v7.mdx} +0 -0
- /package/docs-site/zh/{example/tier1-claude-sdk.mdx → examples/integrations/claude-sdk.mdx} +0 -0
- /package/docs-site/zh/{example/tier1-codex-sdk.mdx → examples/integrations/codex-sdk.mdx} +0 -0
- /package/docs-site/zh/{example/tier1-langgraph.mdx → examples/integrations/langgraph.mdx} +0 -0
- /package/docs-site/zh/{example/tier1-pi-sdk.mdx → examples/integrations/pi-sdk.mdx} +0 -0
- /package/docs-site/zh/{guides → how-to}/fixtures.mdx +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// cases: docs/engineering/unit-tests/sandbox/cases.md
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { classifySandboxIoError } from "./errors.ts";
|
|
4
|
+
import { withSandboxIoRetry } from "./io-retry.ts";
|
|
5
|
+
|
|
6
|
+
describe("classifySandboxIoError", () => {
|
|
7
|
+
it("recognizes the nested fetch failure shape emitted by E2B uploads", () => {
|
|
8
|
+
const error = new TypeError("fetch failed", { cause: Object.assign(new Error("reset"), { code: "ECONNRESET" }) });
|
|
9
|
+
expect(classifySandboxIoError(error)).toBe("network");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("recognizes status-bearing rate limits and service outages", () => {
|
|
13
|
+
expect(classifySandboxIoError({ response: { status: 429 } })).toBe("rate_limit");
|
|
14
|
+
expect(classifySandboxIoError({ statusCode: 503 })).toBe("service_unavailable");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("does not retry cancellation, sandbox termination, or deterministic file errors", () => {
|
|
18
|
+
expect(classifySandboxIoError(Object.assign(new Error("aborted"), { name: "AbortError" }))).toBe("unknown");
|
|
19
|
+
expect(classifySandboxIoError(new Error("Sandbox was terminated"))).toBe("unknown");
|
|
20
|
+
expect(classifySandboxIoError(new Error("ENOENT: no such file"))).toBe("unknown");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("withSandboxIoRetry", () => {
|
|
25
|
+
it("retries a transient failure with bounded exponential backoff", async () => {
|
|
26
|
+
const sleep = vi.fn(async () => {});
|
|
27
|
+
const onRetry = vi.fn();
|
|
28
|
+
let attempts = 0;
|
|
29
|
+
const result = await withSandboxIoRetry(async () => {
|
|
30
|
+
attempts += 1;
|
|
31
|
+
if (attempts < 3) throw new TypeError("fetch failed");
|
|
32
|
+
return "ok";
|
|
33
|
+
}, { baseDelayMs: 100, random: () => 0.5, sleep, onRetry });
|
|
34
|
+
|
|
35
|
+
expect(result).toBe("ok");
|
|
36
|
+
expect(attempts).toBe(3);
|
|
37
|
+
expect(sleep.mock.calls).toEqual([[100], [200]]);
|
|
38
|
+
expect(onRetry.mock.calls.map(([event]) => event.kind)).toEqual(["network", "network"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("preserves the original error after the retry limit", async () => {
|
|
42
|
+
const error = new TypeError("fetch failed");
|
|
43
|
+
await expect(withSandboxIoRetry(
|
|
44
|
+
async () => { throw error; },
|
|
45
|
+
{ maxAttempts: 2, baseDelayMs: 0, sleep: async () => {} },
|
|
46
|
+
)).rejects.toBe(error);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("does not retry deterministic errors", async () => {
|
|
50
|
+
let attempts = 0;
|
|
51
|
+
const error = new Error("ENOENT: missing");
|
|
52
|
+
await expect(withSandboxIoRetry(async () => {
|
|
53
|
+
attempts += 1;
|
|
54
|
+
throw error;
|
|
55
|
+
})).rejects.toBe(error);
|
|
56
|
+
expect(attempts).toBe(1);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// 已创建 Sandbox 的幂等文件 IO 重试。命令执行、appendLog、stop 不经过这里:
|
|
2
|
+
// 它们可能有不可重复的副作用,框架不能在调用者不知情时重跑。
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
classifySandboxIoError,
|
|
6
|
+
isRetryableSandboxIoError,
|
|
7
|
+
type SandboxIoErrorKind,
|
|
8
|
+
} from "./errors.ts";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
11
|
+
const DEFAULT_BASE_DELAY_MS = 250;
|
|
12
|
+
|
|
13
|
+
export interface SandboxIoRetryOptions {
|
|
14
|
+
maxAttempts?: number;
|
|
15
|
+
baseDelayMs?: number;
|
|
16
|
+
classify?: (error: unknown) => SandboxIoErrorKind;
|
|
17
|
+
/** 内部可观察挂点;runner feedback 接入后由统一包装层注入。 */
|
|
18
|
+
onRetry?: (event: { attempt: number; delayMs: number; kind: SandboxIoErrorKind; error: unknown }) => void;
|
|
19
|
+
/** 仅供确定性单测注入。 */
|
|
20
|
+
random?: () => number;
|
|
21
|
+
sleep?: (ms: number) => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function withSandboxIoRetry<T>(
|
|
25
|
+
operation: () => Promise<T>,
|
|
26
|
+
options: SandboxIoRetryOptions = {},
|
|
27
|
+
): Promise<T> {
|
|
28
|
+
const maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
29
|
+
const baseDelayMs = Math.max(0, options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS);
|
|
30
|
+
const classify = options.classify ?? classifySandboxIoError;
|
|
31
|
+
const random = options.random ?? Math.random;
|
|
32
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
33
|
+
|
|
34
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
35
|
+
try {
|
|
36
|
+
return await operation();
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const kind = classify(error);
|
|
39
|
+
if (!isRetryableSandboxIoError(kind) || attempt >= maxAttempts) throw error;
|
|
40
|
+
const delayMs = baseDelayMs * 2 ** (attempt - 1) * (0.5 + random());
|
|
41
|
+
options.onRetry?.({ attempt, delayMs, kind, error });
|
|
42
|
+
await sleep(delayMs);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// cases: docs/engineering/unit-tests/sandbox/cases.md
|
|
2
|
+
// 留存注册表的单测:逐条目文件、原子写、发现规则、更新与删除(见 docs/feature/sandbox/architecture.md)。
|
|
3
|
+
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
findNiceevalRoot,
|
|
10
|
+
keptEntryId,
|
|
11
|
+
readKeptEntries,
|
|
12
|
+
removeKeptEntry,
|
|
13
|
+
updateKeptEntry,
|
|
14
|
+
writeKeptEntry,
|
|
15
|
+
type KeptSandboxEntry,
|
|
16
|
+
} from "./keep-registry.ts";
|
|
17
|
+
|
|
18
|
+
let roots: string[] = [];
|
|
19
|
+
async function makeRoot(): Promise<string> {
|
|
20
|
+
const dir = await mkdtemp(join(tmpdir(), "niceeval-keep-"));
|
|
21
|
+
roots.push(dir);
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await Promise.all(roots.map((r) => rm(r, { recursive: true, force: true })));
|
|
26
|
+
roots = [];
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function entry(over: Partial<KeptSandboxEntry> = {}): KeptSandboxEntry {
|
|
30
|
+
return {
|
|
31
|
+
sandboxId: "a3f9c2d1",
|
|
32
|
+
provider: "docker",
|
|
33
|
+
evalId: "onboarding/tool-first",
|
|
34
|
+
attempt: 1,
|
|
35
|
+
experimentId: "local/tool-first",
|
|
36
|
+
locator: "@1x7f3q9k",
|
|
37
|
+
verdict: "errored",
|
|
38
|
+
keptAt: "2026-07-14T15:02:00.000Z",
|
|
39
|
+
workdir: "/workspace",
|
|
40
|
+
state: "alive",
|
|
41
|
+
...over,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("kept-sandbox registry", () => {
|
|
46
|
+
it("逐条目文件:写入 → 读回 → 更新 state → 删除并同步目录", async () => {
|
|
47
|
+
const root = await makeRoot();
|
|
48
|
+
const niceeval = join(root, ".niceeval");
|
|
49
|
+
await writeKeptEntry(niceeval, entry());
|
|
50
|
+
const id = keptEntryId("docker", "a3f9c2d1");
|
|
51
|
+
|
|
52
|
+
const { entries } = await readKeptEntries(niceeval);
|
|
53
|
+
expect(entries).toHaveLength(1);
|
|
54
|
+
expect(entries[0]!.id).toBe(id);
|
|
55
|
+
expect(entries[0]!.entry.locator).toBe("@1x7f3q9k");
|
|
56
|
+
expect(entries[0]!.entry.state).toBe("alive");
|
|
57
|
+
|
|
58
|
+
expect(await updateKeptEntry(niceeval, id, { state: "dormant" })).toBe(true);
|
|
59
|
+
expect((await readKeptEntries(niceeval)).entries[0]!.entry.state).toBe("dormant");
|
|
60
|
+
|
|
61
|
+
await removeKeptEntry(niceeval, id);
|
|
62
|
+
expect((await readKeptEntries(niceeval)).entries).toHaveLength(0);
|
|
63
|
+
// 临时文件不残留
|
|
64
|
+
expect((await readdir(join(niceeval, "sandboxes"))).filter((f) => f.endsWith(".tmp"))).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("不同 provider+sandboxId 各自成条目,互不覆盖", async () => {
|
|
68
|
+
const root = await makeRoot();
|
|
69
|
+
const niceeval = join(root, ".niceeval");
|
|
70
|
+
await writeKeptEntry(niceeval, entry());
|
|
71
|
+
await writeKeptEntry(niceeval, entry({ sandboxId: "b81e07aa", verdict: "failed" }));
|
|
72
|
+
const { entries } = await readKeptEntries(niceeval);
|
|
73
|
+
expect(entries).toHaveLength(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("注册表发现:从子目录向上找最近的 .niceeval;找不到返回 undefined", async () => {
|
|
77
|
+
const root = await makeRoot();
|
|
78
|
+
const niceeval = join(root, ".niceeval");
|
|
79
|
+
await mkdir(join(niceeval, "sandboxes"), { recursive: true });
|
|
80
|
+
const deep = join(root, "a", "b", "c");
|
|
81
|
+
await mkdir(deep, { recursive: true });
|
|
82
|
+
expect(await findNiceevalRoot(deep)).toBe(niceeval);
|
|
83
|
+
const outside = await makeRoot();
|
|
84
|
+
expect(await findNiceevalRoot(outside)).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// 留存沙箱的持久注册表:`.niceeval/sandboxes/` 下的逐条目文件(不是多个 attempt 竞争改写的
|
|
2
|
+
// 一份 JSON)。entry id 由 provider + sandboxId 做稳定散列;每条先写同目录临时文件、fsync 文件后
|
|
3
|
+
// rename 成 <entry-id>.json,再 fsync 目录——不同 attempt 与不同 niceeval 进程不会覆盖彼此。
|
|
4
|
+
// 契约见 docs/feature/sandbox/architecture.md「留存(keep)与注册表」。
|
|
5
|
+
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { mkdir, open, readdir, readFile, rename, rm } from "node:fs/promises";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import type { Verdict } from "../types.ts";
|
|
10
|
+
|
|
11
|
+
/** 一条留存登记项(逐条目文件的 JSON 形状)。 */
|
|
12
|
+
export interface KeptSandboxEntry {
|
|
13
|
+
sandboxId: string;
|
|
14
|
+
provider: string;
|
|
15
|
+
evalId: string;
|
|
16
|
+
attempt: number;
|
|
17
|
+
experimentId?: string;
|
|
18
|
+
locator: string;
|
|
19
|
+
verdict: Verdict;
|
|
20
|
+
keptAt: string;
|
|
21
|
+
workdir: string;
|
|
22
|
+
/** provider 原生的进入命令(直连与审计用);日常入口是 `niceeval sandbox enter`。 */
|
|
23
|
+
enter?: string;
|
|
24
|
+
/** 现场可找回的截止时刻——provider 声明了保留期限才写(vercel 写,e2b pause 无限期保留则不写)。 */
|
|
25
|
+
expiresAt?: string;
|
|
26
|
+
/** alive = 实例在跑(suspend 失败或 --leave-running);dormant = 休眠可唤醒;expired = 现场已没了。 */
|
|
27
|
+
state: "alive" | "dormant" | "expired";
|
|
28
|
+
/** 事后命令的条目级互斥凭据(enter 持有;stop 与另一个 enter 对同一条目直接拒绝)。 */
|
|
29
|
+
lease?: { holder: string; op: string; acquiredAt: string; ttlMs: number };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** entry id:provider + sandboxId 的稳定散列(条目文件名)。 */
|
|
33
|
+
export function keptEntryId(provider: string, sandboxId: string): string {
|
|
34
|
+
return createHash("sha256").update(`${provider}\n${sandboxId}`).digest("hex").slice(0, 12);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function sandboxesDirOf(niceevalRoot: string): string {
|
|
38
|
+
return join(niceevalRoot, "sandboxes");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 注册表发现:从 cwd 向上找最近的 `.niceeval/`(与结果根发现同一规则)。
|
|
43
|
+
* 找不到返回 undefined,调用方报错并提示 `--run <结果根>`。
|
|
44
|
+
*/
|
|
45
|
+
export async function findNiceevalRoot(cwd: string): Promise<string | undefined> {
|
|
46
|
+
let current = resolve(cwd);
|
|
47
|
+
for (;;) {
|
|
48
|
+
const candidate = join(current, ".niceeval");
|
|
49
|
+
try {
|
|
50
|
+
const entries = await readdir(candidate);
|
|
51
|
+
void entries;
|
|
52
|
+
return candidate;
|
|
53
|
+
} catch {
|
|
54
|
+
// 不存在,继续向上
|
|
55
|
+
}
|
|
56
|
+
const parent = dirname(current);
|
|
57
|
+
if (parent === current) return undefined;
|
|
58
|
+
current = parent;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 原子写入一条登记项:临时文件 → fsync → rename → fsync 目录。 */
|
|
63
|
+
export async function writeKeptEntry(niceevalRoot: string, entry: KeptSandboxEntry): Promise<void> {
|
|
64
|
+
const dir = sandboxesDirOf(niceevalRoot);
|
|
65
|
+
await mkdir(dir, { recursive: true });
|
|
66
|
+
const id = keptEntryId(entry.provider, entry.sandboxId);
|
|
67
|
+
const tmpPath = join(dir, `.${id}.${process.pid}.tmp`);
|
|
68
|
+
const finalPath = join(dir, `${id}.json`);
|
|
69
|
+
const handle = await open(tmpPath, "w");
|
|
70
|
+
try {
|
|
71
|
+
await handle.writeFile(JSON.stringify(entry, null, 2), "utf-8");
|
|
72
|
+
await handle.sync();
|
|
73
|
+
} finally {
|
|
74
|
+
await handle.close();
|
|
75
|
+
}
|
|
76
|
+
await rename(tmpPath, finalPath);
|
|
77
|
+
await fsyncDir(dir);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 读全部登记项(坏条目跳过并记名,不整体失败)。 */
|
|
81
|
+
export async function readKeptEntries(
|
|
82
|
+
niceevalRoot: string,
|
|
83
|
+
): Promise<{ entries: { id: string; entry: KeptSandboxEntry }[]; malformed: string[] }> {
|
|
84
|
+
const dir = sandboxesDirOf(niceevalRoot);
|
|
85
|
+
let files: string[];
|
|
86
|
+
try {
|
|
87
|
+
files = await readdir(dir);
|
|
88
|
+
} catch {
|
|
89
|
+
return { entries: [], malformed: [] };
|
|
90
|
+
}
|
|
91
|
+
const entries: { id: string; entry: KeptSandboxEntry }[] = [];
|
|
92
|
+
const malformed: string[] = [];
|
|
93
|
+
for (const file of files) {
|
|
94
|
+
if (!file.endsWith(".json") || file.startsWith(".")) continue;
|
|
95
|
+
try {
|
|
96
|
+
const raw = await readFile(join(dir, file), "utf-8");
|
|
97
|
+
entries.push({ id: file.slice(0, -".json".length), entry: JSON.parse(raw) as KeptSandboxEntry });
|
|
98
|
+
} catch {
|
|
99
|
+
malformed.push(file);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
entries.sort((a, b) => a.entry.keptAt.localeCompare(b.entry.keptAt));
|
|
103
|
+
return { entries, malformed };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 更新一条登记项(读-改-原子写;字段浅合并)。条目不存在时静默返回 false。 */
|
|
107
|
+
export async function updateKeptEntry(
|
|
108
|
+
niceevalRoot: string,
|
|
109
|
+
id: string,
|
|
110
|
+
patch: Partial<KeptSandboxEntry> | ((entry: KeptSandboxEntry) => KeptSandboxEntry),
|
|
111
|
+
): Promise<boolean> {
|
|
112
|
+
const path = join(sandboxesDirOf(niceevalRoot), `${id}.json`);
|
|
113
|
+
let entry: KeptSandboxEntry;
|
|
114
|
+
try {
|
|
115
|
+
entry = JSON.parse(await readFile(path, "utf-8")) as KeptSandboxEntry;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
const next = typeof patch === "function" ? patch(entry) : { ...entry, ...patch };
|
|
120
|
+
await writeKeptEntry(niceevalRoot, next);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 删除一条登记项并同步目录(只在实例成功销毁或确认已不存在后调用)。 */
|
|
125
|
+
export async function removeKeptEntry(niceevalRoot: string, id: string): Promise<void> {
|
|
126
|
+
const dir = sandboxesDirOf(niceevalRoot);
|
|
127
|
+
await rm(join(dir, `${id}.json`), { force: true });
|
|
128
|
+
await fsyncDir(dir);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function fsyncDir(dir: string): Promise<void> {
|
|
132
|
+
try {
|
|
133
|
+
const handle = await open(dir, "r");
|
|
134
|
+
try {
|
|
135
|
+
await handle.sync();
|
|
136
|
+
} finally {
|
|
137
|
+
await handle.close();
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// 平台不支持目录 fsync(如 Windows)时静默降级;rename 本身已是原子替换。
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// 留存生命周期的 sandbox/ 域内路由:in-run 的 suspend(留存提交后转休眠)与事后命令
|
|
2
|
+
// (`niceeval sandbox list/enter/stop`)用的 detached 能力(不需要原来的 run 进程或 Sandbox
|
|
3
|
+
// 实例还活着)。provider 名的行为分支只允许出现在 sandbox/ 内(见 docs/architecture.md);
|
|
4
|
+
// 运行器与评分路径不感知 provider 名。
|
|
5
|
+
|
|
6
|
+
import type { Sandbox } from "../types.ts";
|
|
7
|
+
|
|
8
|
+
/** 有留存能力的 provider 实例都带一个非公开接口成员 suspend()(Sandbox 接口不因留存扩大)。 */
|
|
9
|
+
interface Suspendable {
|
|
10
|
+
suspend(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** provider 是否参与留存(defineSandbox 自定义 provider 不参与,创建前报错)。 */
|
|
14
|
+
export const KEEPABLE_PROVIDERS = new Set(["docker", "e2b", "vercel"]);
|
|
15
|
+
|
|
16
|
+
/** in-run 的休眠:留存提交成功后由 Scope release 调用(sandbox.suspend 阶段,有界计时)。 */
|
|
17
|
+
export async function suspendSandbox(sandbox: Sandbox): Promise<void> {
|
|
18
|
+
const suspend = (sandbox as unknown as Partial<Suspendable>).suspend;
|
|
19
|
+
if (typeof suspend !== "function") {
|
|
20
|
+
throw new Error(`sandbox provider has no suspend capability (sandboxId=${sandbox.sandboxId})`);
|
|
21
|
+
}
|
|
22
|
+
await suspend.call(sandbox);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** provider 原生的进入命令(记进注册表供直连与审计;日常入口是 `niceeval sandbox enter`)。 */
|
|
26
|
+
export function nativeEnterCommand(provider: string, sandboxId: string): string | undefined {
|
|
27
|
+
switch (provider) {
|
|
28
|
+
case "docker":
|
|
29
|
+
return `docker start ${sandboxId} && docker exec -it ${sandboxId} bash`;
|
|
30
|
+
case "e2b":
|
|
31
|
+
return `e2b sandbox connect ${sandboxId}`;
|
|
32
|
+
default:
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type DetachedState = "alive" | "dormant" | "expired";
|
|
38
|
+
|
|
39
|
+
/** 事后核对现场状态(docker 问本地 daemon;云 provider 按实例状态核对,查不到 = expired)。 */
|
|
40
|
+
export async function inspectDetached(provider: string, sandboxId: string): Promise<DetachedState> {
|
|
41
|
+
switch (provider) {
|
|
42
|
+
case "docker": {
|
|
43
|
+
try {
|
|
44
|
+
const { default: Docker } = await import("dockerode");
|
|
45
|
+
const info = await new Docker().getContainer(sandboxId).inspect();
|
|
46
|
+
return info.State?.Running ? "alive" : "dormant";
|
|
47
|
+
} catch {
|
|
48
|
+
return "expired";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
case "e2b": {
|
|
52
|
+
try {
|
|
53
|
+
const { Sandbox: E2BSdkSandbox } = await import("e2b");
|
|
54
|
+
const list = (E2BSdkSandbox as unknown as {
|
|
55
|
+
list?: (opts?: Record<string, unknown>) => Promise<Array<{ sandboxId: string; state?: string }>>;
|
|
56
|
+
}).list;
|
|
57
|
+
if (typeof list !== "function") return "dormant";
|
|
58
|
+
const sandboxes = await list({ apiKey: process.env.E2B_API_KEY });
|
|
59
|
+
const hit = sandboxes.find((s) => s.sandboxId === sandboxId || s.sandboxId.startsWith(sandboxId));
|
|
60
|
+
if (!hit) return "expired";
|
|
61
|
+
return hit.state === "running" ? "alive" : "dormant";
|
|
62
|
+
} catch {
|
|
63
|
+
return "expired";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
case "vercel": {
|
|
67
|
+
try {
|
|
68
|
+
const { Sandbox: VSandbox } = await import("@vercel/sandbox");
|
|
69
|
+
const get = (VSandbox as unknown as { get?: (opts: Record<string, unknown>) => Promise<unknown> }).get;
|
|
70
|
+
if (typeof get !== "function") return "dormant";
|
|
71
|
+
const found = await get({ sandboxId });
|
|
72
|
+
return found ? "dormant" : "expired";
|
|
73
|
+
} catch {
|
|
74
|
+
return "expired";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
default:
|
|
78
|
+
return "expired";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* detached 销毁:按注册表条目的 provider 名路由,不需要 Sandbox 实例。
|
|
84
|
+
* 返回 "stopped"(成功销毁)或 "already-gone"(实例已不存在,幂等);
|
|
85
|
+
* 其它错误上抛——调用方保留登记项并退出 1,不能把仍活着的资源从管理面隐藏掉。
|
|
86
|
+
*/
|
|
87
|
+
export async function destroyDetached(provider: string, sandboxId: string): Promise<"stopped" | "already-gone"> {
|
|
88
|
+
switch (provider) {
|
|
89
|
+
case "docker": {
|
|
90
|
+
const { default: Docker } = await import("dockerode");
|
|
91
|
+
const container = new Docker().getContainer(sandboxId);
|
|
92
|
+
try {
|
|
93
|
+
await container.remove({ force: true });
|
|
94
|
+
return "stopped";
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if ((e as { statusCode?: number }).statusCode === 404) return "already-gone";
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
case "e2b": {
|
|
101
|
+
const { Sandbox: E2BSdkSandbox } = await import("e2b");
|
|
102
|
+
const kill = (E2BSdkSandbox as unknown as {
|
|
103
|
+
kill?: (id: string, opts?: Record<string, unknown>) => Promise<boolean>;
|
|
104
|
+
}).kill;
|
|
105
|
+
if (typeof kill !== "function") throw new Error("this e2b SDK version has no detached kill capability");
|
|
106
|
+
const killed = await kill(sandboxId, { apiKey: process.env.E2B_API_KEY });
|
|
107
|
+
return killed ? "stopped" : "already-gone";
|
|
108
|
+
}
|
|
109
|
+
case "vercel": {
|
|
110
|
+
const { Sandbox: VSandbox } = await import("@vercel/sandbox");
|
|
111
|
+
const get = (VSandbox as unknown as {
|
|
112
|
+
get?: (opts: Record<string, unknown>) => Promise<{ stop(): Promise<void> } | null>;
|
|
113
|
+
}).get;
|
|
114
|
+
if (typeof get !== "function") throw new Error("this vercel SDK version has no detached get capability");
|
|
115
|
+
const found = await get({ sandboxId }).catch(() => null);
|
|
116
|
+
if (!found) return "already-gone";
|
|
117
|
+
await found.stop();
|
|
118
|
+
return "stopped";
|
|
119
|
+
}
|
|
120
|
+
default:
|
|
121
|
+
throw new Error(`provider "${provider}" has no detached stop channel`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 唤醒休眠现场(enter / history / diff 前);docker start,云 provider 按 SDK 恢复。 */
|
|
126
|
+
export async function wakeDetached(provider: string, sandboxId: string): Promise<void> {
|
|
127
|
+
switch (provider) {
|
|
128
|
+
case "docker": {
|
|
129
|
+
const { default: Docker } = await import("dockerode");
|
|
130
|
+
const container = new Docker().getContainer(sandboxId);
|
|
131
|
+
const info = await container.inspect();
|
|
132
|
+
if (!info.State?.Running) await container.start();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
case "e2b": {
|
|
136
|
+
const { Sandbox: E2BSdkSandbox } = await import("e2b");
|
|
137
|
+
const resume = (E2BSdkSandbox as unknown as {
|
|
138
|
+
resume?: (id: string, opts?: Record<string, unknown>) => Promise<unknown>;
|
|
139
|
+
connect?: (id: string, opts?: Record<string, unknown>) => Promise<unknown>;
|
|
140
|
+
});
|
|
141
|
+
if (typeof resume.resume === "function") {
|
|
142
|
+
await resume.resume(sandboxId, { apiKey: process.env.E2B_API_KEY });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (typeof resume.connect === "function") {
|
|
146
|
+
await resume.connect(sandboxId, { apiKey: process.env.E2B_API_KEY });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
throw new Error("this e2b SDK version has no resume capability");
|
|
150
|
+
}
|
|
151
|
+
default:
|
|
152
|
+
throw new Error(`provider "${provider}" has no wake channel`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 送回休眠(enter 退出后 / history、diff 读完后)。 */
|
|
157
|
+
export async function suspendDetached(provider: string, sandboxId: string): Promise<void> {
|
|
158
|
+
switch (provider) {
|
|
159
|
+
case "docker": {
|
|
160
|
+
const { default: Docker } = await import("dockerode");
|
|
161
|
+
await new Docker().getContainer(sandboxId).stop({ t: 5 });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
case "e2b": {
|
|
165
|
+
const { Sandbox: E2BSdkSandbox } = await import("e2b");
|
|
166
|
+
const pause = (E2BSdkSandbox as unknown as {
|
|
167
|
+
pause?: (id: string, opts?: Record<string, unknown>) => Promise<unknown>;
|
|
168
|
+
}).pause;
|
|
169
|
+
if (typeof pause === "function") {
|
|
170
|
+
await pause(sandboxId, { apiKey: process.env.E2B_API_KEY });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
throw new Error("this e2b SDK version has no detached pause capability");
|
|
174
|
+
}
|
|
175
|
+
default:
|
|
176
|
+
throw new Error(`provider "${provider}" has no suspend channel`);
|
|
177
|
+
}
|
|
178
|
+
}
|
package/src/sandbox/paths.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
2
|
import { isAbsolute, resolve } from "node:path";
|
|
3
3
|
import type { CommandOptions, Sandbox } from "../types.ts";
|
|
4
|
+
import { withSandboxIoRetry } from "./io-retry.ts";
|
|
4
5
|
|
|
5
6
|
export function resolveSandboxPath(workdir: string, path?: string): string {
|
|
6
7
|
if (!path || path === ".") return workdir;
|
|
@@ -30,16 +31,26 @@ export function normalizeSandboxPaths(sandbox: Sandbox): Sandbox {
|
|
|
30
31
|
},
|
|
31
32
|
runCommand: (cmd, args, opts) => sandbox.runCommand(cmd, args, resolveCommandOptions(sandbox.workdir, opts)),
|
|
32
33
|
runShell: (script, opts) => sandbox.runShell(script, resolveCommandOptions(sandbox.workdir, opts)),
|
|
33
|
-
readFile: (path) => sandbox.readFile(resolveSandboxPath(sandbox.workdir, path)),
|
|
34
|
-
fileExists: (path) => sandbox.fileExists(resolveSandboxPath(sandbox.workdir, path)),
|
|
35
|
-
readSourceFiles: (opts) => sandbox.readSourceFiles(opts),
|
|
36
|
-
writeFiles: (files, targetDir) =>
|
|
37
|
-
|
|
34
|
+
readFile: (path) => withSandboxIoRetry(() => sandbox.readFile(resolveSandboxPath(sandbox.workdir, path))),
|
|
35
|
+
fileExists: (path) => withSandboxIoRetry(() => sandbox.fileExists(resolveSandboxPath(sandbox.workdir, path))),
|
|
36
|
+
readSourceFiles: (opts) => withSandboxIoRetry(() => sandbox.readSourceFiles(opts)),
|
|
37
|
+
writeFiles: (files, targetDir) => withSandboxIoRetry(
|
|
38
|
+
() => sandbox.writeFiles(files, resolveSandboxPath(sandbox.workdir, targetDir)),
|
|
39
|
+
),
|
|
40
|
+
uploadFiles: (files, targetDir) => withSandboxIoRetry(
|
|
41
|
+
() => sandbox.uploadFiles(files, resolveSandboxPath(sandbox.workdir, targetDir)),
|
|
42
|
+
),
|
|
38
43
|
uploadDirectory: (localDir, targetDir, opts) =>
|
|
39
|
-
|
|
44
|
+
withSandboxIoRetry(
|
|
45
|
+
() => sandbox.uploadDirectory(localDir, resolveSandboxPath(sandbox.workdir, targetDir), opts),
|
|
46
|
+
),
|
|
40
47
|
stop: () => sandbox.stop(),
|
|
41
48
|
appendLog: sandbox.appendLog ? (line) => sandbox.appendLog!(line) : undefined,
|
|
42
|
-
downloadFile: (path) =>
|
|
43
|
-
|
|
49
|
+
downloadFile: (path) => withSandboxIoRetry(
|
|
50
|
+
() => sandbox.downloadFile(resolveSandboxPath(sandbox.workdir, path)),
|
|
51
|
+
),
|
|
52
|
+
uploadFile: (path, content) => withSandboxIoRetry(
|
|
53
|
+
() => sandbox.uploadFile(resolveSandboxPath(sandbox.workdir, path), content),
|
|
54
|
+
),
|
|
44
55
|
};
|
|
45
56
|
}
|
package/src/sandbox/registry.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { Sandbox } from "../types.ts";
|
|
12
12
|
import { t } from "../i18n/index.ts";
|
|
13
|
-
import {
|
|
13
|
+
import { reportDiagnostic } from "../runner/feedback/sink.ts";
|
|
14
14
|
|
|
15
15
|
const live = new Set<Sandbox>();
|
|
16
16
|
|
|
@@ -22,6 +22,11 @@ export function registerSandbox(sb: Sandbox): void {
|
|
|
22
22
|
live.add(sb);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** 留存提交成功后把沙箱移出本次 run 的内存强清集合(不 stop——现场归持久注册表管理)。 */
|
|
26
|
+
export function unregisterSandbox(sb: Sandbox): void {
|
|
27
|
+
live.delete(sb);
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
export function liveSandboxCount(): number {
|
|
26
31
|
return live.size;
|
|
27
32
|
}
|
|
@@ -41,7 +46,14 @@ export async function stopSandbox(sb: Sandbox, timeoutMs = DEFAULT_STOP_TIMEOUT_
|
|
|
41
46
|
]);
|
|
42
47
|
} catch (e) {
|
|
43
48
|
const msg = e instanceof Error ? e.message : String(e);
|
|
44
|
-
|
|
49
|
+
// 稳定 key 不含具体 sandbox id:同一类失败(如 provider 限流导致大批 stop 超时)去重折叠成
|
|
50
|
+
// 一条永久事件、count 累加,而不是刷屏出几十行几乎相同的诊断;具体是哪个沙箱失败进 data。
|
|
51
|
+
reportDiagnostic({
|
|
52
|
+
key: "sandbox-stop-failed",
|
|
53
|
+
severity: "warning",
|
|
54
|
+
message: t("sandbox.stopFailed", { id: sb.sandboxId, message: msg }).trimEnd(),
|
|
55
|
+
data: { sandboxId: sb.sandboxId, message: msg },
|
|
56
|
+
});
|
|
45
57
|
} finally {
|
|
46
58
|
if (timer) clearTimeout(timer);
|
|
47
59
|
live.delete(sb);
|
|
@@ -55,7 +67,12 @@ export async function stopSandbox(sb: Sandbox, timeoutMs = DEFAULT_STOP_TIMEOUT_
|
|
|
55
67
|
export async function stopAllSandboxes(timeoutMs = DEFAULT_STOP_TIMEOUT_MS): Promise<number> {
|
|
56
68
|
const all = [...live];
|
|
57
69
|
if (all.length === 0) return 0;
|
|
58
|
-
|
|
70
|
+
reportDiagnostic({
|
|
71
|
+
key: "sandbox-force-cleanup",
|
|
72
|
+
severity: "warning",
|
|
73
|
+
message: t("sandbox.forceCleanup", { count: all.length }).trimEnd(),
|
|
74
|
+
data: { count: all.length },
|
|
75
|
+
});
|
|
59
76
|
await Promise.allSettled(all.map((sb) => stopSandbox(sb, timeoutMs)));
|
|
60
77
|
return all.length;
|
|
61
78
|
}
|