@wjarka/cezarion 0.15.1 → 0.16.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/README.md +167 -46
- package/dist/agent-config/catalog.d.ts +6 -2
- package/dist/agent-config/catalog.js +78 -3
- package/dist/agent-config/catalog.js.map +1 -1
- package/dist/agent-config/model-settings/shared.js +6 -3
- package/dist/agent-config/model-settings/shared.js.map +1 -1
- package/dist/agent-config/models.js +4 -0
- package/dist/agent-config/models.js.map +1 -1
- package/dist/agent-config/validate.js +6 -0
- package/dist/agent-config/validate.js.map +1 -1
- package/dist/artifacts/cli.js +10 -2
- package/dist/artifacts/cli.js.map +1 -1
- package/dist/automations/coordinator.d.ts +7 -0
- package/dist/automations/coordinator.js +22 -2
- package/dist/automations/coordinator.js.map +1 -1
- package/dist/automations/github-poller.d.ts +4 -4
- package/dist/automations/github-poller.js.map +1 -1
- package/dist/automations/schedule-runner.d.ts +130 -0
- package/dist/automations/schedule-runner.js +326 -0
- package/dist/automations/schedule-runner.js.map +1 -0
- package/dist/automations/scheduler.d.ts +38 -7
- package/dist/automations/scheduler.js +90 -22
- package/dist/automations/scheduler.js.map +1 -1
- package/dist/automations/store.d.ts +80 -3
- package/dist/automations/store.js +208 -16
- package/dist/automations/store.js.map +1 -1
- package/dist/automations/task-template.d.ts +42 -6
- package/dist/automations/task-template.js +152 -24
- package/dist/automations/task-template.js.map +1 -1
- package/dist/automations/types.d.ts +142 -6
- package/dist/automations/types.js +64 -6
- package/dist/automations/types.js.map +1 -1
- package/dist/autosave-holders.js +8 -12
- package/dist/autosave-holders.js.map +1 -1
- package/dist/ci-wait/client.d.ts +8 -1
- package/dist/ci-wait/client.js +43 -1
- package/dist/ci-wait/client.js.map +1 -1
- package/dist/ci-wait/controller.d.ts +83 -4
- package/dist/ci-wait/controller.js +76 -11
- package/dist/ci-wait/controller.js.map +1 -1
- package/dist/ci-wait/errors.d.ts +7 -1
- package/dist/ci-wait/errors.js +36 -0
- package/dist/ci-wait/errors.js.map +1 -1
- package/dist/ci-wait/github.d.ts +2 -0
- package/dist/ci-wait/github.js +3 -1
- package/dist/ci-wait/github.js.map +1 -1
- package/dist/ci-wait/mcp.d.ts +2 -98
- package/dist/ci-wait/mcp.js +11 -25
- package/dist/ci-wait/mcp.js.map +1 -1
- package/dist/ci-wait/supervisor.d.ts +2 -1
- package/dist/ci-wait/supervisor.js +2 -1
- package/dist/ci-wait/supervisor.js.map +1 -1
- package/dist/ci-wait/tools.d.ts +307 -0
- package/dist/ci-wait/tools.js +63 -0
- package/dist/ci-wait/tools.js.map +1 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +7 -0
- package/dist/config.js.map +1 -1
- package/dist/contract/agent-config.d.ts +5 -0
- package/dist/contract/agent-profiles.d.ts +15 -0
- package/dist/contract/automation-schedule.d.ts +60 -0
- package/dist/contract/automations.d.ts +208 -36
- package/dist/contract/delegation.d.ts +96 -0
- package/dist/contract/discovery.d.ts +7 -0
- package/dist/contract/github.d.ts +1 -0
- package/dist/contract/health.d.ts +6 -0
- package/dist/contract/index.d.ts +3 -0
- package/dist/contract/index.js +1445 -745
- package/dist/contract/preview.d.ts +508 -0
- package/dist/contract/runs.d.ts +716 -15
- package/dist/contract/skills.d.ts +39 -0
- package/dist/contract/workflows.d.ts +6 -0
- package/dist/contract/workspace.d.ts +65 -0
- package/dist/contract/zoned-time.d.ts +35 -0
- package/dist/core/agent-env.d.ts +11 -0
- package/dist/core/agent-env.js +27 -5
- package/dist/core/agent-env.js.map +1 -1
- package/dist/core/agent-profiles.js +7 -0
- package/dist/core/agent-profiles.js.map +1 -1
- package/dist/core/agent-runner.d.ts +49 -1
- package/dist/core/agent-runner.js +2 -1
- package/dist/core/agent-runner.js.map +1 -1
- package/dist/core/backend-detect.d.ts +3 -2
- package/dist/core/backend-detect.js +27 -1
- package/dist/core/backend-detect.js.map +1 -1
- package/dist/core/claude-cli-runner.d.ts +1 -0
- package/dist/core/claude-cli-runner.js +69 -19
- package/dist/core/claude-cli-runner.js.map +1 -1
- package/dist/core/codex-app-server-runner.d.ts +1 -0
- package/dist/core/codex-app-server-runner.js +200 -25
- package/dist/core/codex-app-server-runner.js.map +1 -1
- package/dist/core/codex-app-server-transport.d.ts +7 -3
- package/dist/core/codex-app-server-transport.js +11 -9
- package/dist/core/codex-app-server-transport.js.map +1 -1
- package/dist/core/cursor-acp-runner.d.ts +1 -0
- package/dist/core/cursor-acp-runner.js +24 -9
- package/dist/core/cursor-acp-runner.js.map +1 -1
- package/dist/core/host-model-catalog.js +2 -0
- package/dist/core/host-model-catalog.js.map +1 -1
- package/dist/core/managed-session.js +2 -0
- package/dist/core/managed-session.js.map +1 -1
- package/dist/core/model-identity.js +2 -0
- package/dist/core/model-identity.js.map +1 -1
- package/dist/core/model-presets.js +3 -0
- package/dist/core/model-presets.js.map +1 -1
- package/dist/core/omp-model-catalog.d.ts +43 -0
- package/dist/core/omp-model-catalog.js +205 -0
- package/dist/core/omp-model-catalog.js.map +1 -0
- package/dist/core/omp-runner.d.ts +73 -0
- package/dist/core/omp-runner.js +990 -0
- package/dist/core/omp-runner.js.map +1 -0
- package/dist/core/omp-ui-mapper.d.ts +106 -0
- package/dist/core/omp-ui-mapper.js +882 -0
- package/dist/core/omp-ui-mapper.js.map +1 -0
- package/dist/core/opencode-server-runner.d.ts +18 -4
- package/dist/core/opencode-server-runner.js +337 -96
- package/dist/core/opencode-server-runner.js.map +1 -1
- package/dist/core/opencode-session-error.d.ts +11 -0
- package/dist/core/opencode-session-error.js +26 -0
- package/dist/core/opencode-session-error.js.map +1 -0
- package/dist/core/opencode-ui-mapper.js +4 -36
- package/dist/core/opencode-ui-mapper.js.map +1 -1
- package/dist/core/pi-runner.d.ts +1 -0
- package/dist/core/pi-runner.js +75 -12
- package/dist/core/pi-runner.js.map +1 -1
- package/dist/core/provider-auth.d.ts +9 -2
- package/dist/core/provider-auth.js +43 -6
- package/dist/core/provider-auth.js.map +1 -1
- package/dist/core/runner-factory.js +3 -0
- package/dist/core/runner-factory.js.map +1 -1
- package/dist/core/runner-model-catalog.js +1 -0
- package/dist/core/runner-model-catalog.js.map +1 -1
- package/dist/core/runner-stderr.d.ts +2 -0
- package/dist/core/runner-stderr.js +20 -0
- package/dist/core/runner-stderr.js.map +1 -0
- package/dist/core/session-process.d.ts +22 -0
- package/dist/core/session-process.js +202 -0
- package/dist/core/session-process.js.map +1 -0
- package/dist/core/ui-events.d.ts +1 -1
- package/dist/core/usage-limit.js +3 -60
- package/dist/core/usage-limit.js.map +1 -1
- package/dist/data-gitignore.d.ts +6 -0
- package/dist/data-gitignore.js +57 -0
- package/dist/data-gitignore.js.map +1 -0
- package/dist/delegation/capacity.d.ts +19 -0
- package/dist/delegation/capacity.js +34 -0
- package/dist/delegation/capacity.js.map +1 -0
- package/dist/delegation/cli.js +13 -4
- package/dist/delegation/cli.js.map +1 -1
- package/dist/delegation/conversations.d.ts +4 -2
- package/dist/delegation/conversations.js +8 -12
- package/dist/delegation/conversations.js.map +1 -1
- package/dist/delegation/credentials.d.ts +2 -0
- package/dist/delegation/credentials.js +10 -0
- package/dist/delegation/credentials.js.map +1 -1
- package/dist/delegation/destroy-observation.d.ts +42 -0
- package/dist/delegation/destroy-observation.js +115 -0
- package/dist/delegation/destroy-observation.js.map +1 -0
- package/dist/delegation/execution-identity.d.ts +6 -0
- package/dist/delegation/execution-identity.js +1 -1
- package/dist/delegation/execution-identity.js.map +1 -1
- package/dist/delegation/policy.d.ts +3 -2
- package/dist/delegation/policy.js +7 -5
- package/dist/delegation/policy.js.map +1 -1
- package/dist/delegation/process-liveness.d.ts +57 -20
- package/dist/delegation/process-liveness.js +114 -92
- package/dist/delegation/process-liveness.js.map +1 -1
- package/dist/delegation/provision.js +6 -6
- package/dist/delegation/provision.js.map +1 -1
- package/dist/delegation/results.js +4 -2
- package/dist/delegation/results.js.map +1 -1
- package/dist/delegation/retry-backoff.d.ts +23 -0
- package/dist/delegation/retry-backoff.js +18 -0
- package/dist/delegation/retry-backoff.js.map +1 -0
- package/dist/delegation/routes.d.ts +64 -58
- package/dist/delegation/scratch-cleanup.d.ts +29 -0
- package/dist/delegation/scratch-cleanup.js +143 -0
- package/dist/delegation/scratch-cleanup.js.map +1 -0
- package/dist/delegation/service.d.ts +20 -1
- package/dist/delegation/service.js +233 -70
- package/dist/delegation/service.js.map +1 -1
- package/dist/delegation/transport.d.ts +12 -6
- package/dist/delegation/workspace.d.ts +23 -4
- package/dist/delegation/workspace.js +147 -31
- package/dist/delegation/workspace.js.map +1 -1
- package/dist/git-worktree-lock-helper.js +8 -3
- package/dist/git-worktree-lock-helper.js.map +1 -1
- package/dist/git-worktree-lock.d.ts +6 -1
- package/dist/git-worktree-lock.js +10 -4
- package/dist/git-worktree-lock.js.map +1 -1
- package/dist/git-worktree-release.d.ts +20 -0
- package/dist/git-worktree-release.js +27 -0
- package/dist/git-worktree-release.js.map +1 -0
- package/dist/git-worktree.d.ts +8 -2
- package/dist/git-worktree.js +19 -2
- package/dist/git-worktree.js.map +1 -1
- package/dist/handoff.d.ts +2 -2
- package/dist/handoff.js +1 -1
- package/dist/index.js +109 -76
- package/dist/index.js.map +1 -1
- package/dist/paths.d.ts +1 -1
- package/dist/paths.js +5 -1
- package/dist/paths.js.map +1 -1
- package/dist/preview/cdp.d.ts +13 -0
- package/dist/preview/cdp.js +69 -0
- package/dist/preview/cdp.js.map +1 -0
- package/dist/preview/chromium.d.ts +75 -0
- package/dist/preview/chromium.js +394 -0
- package/dist/preview/chromium.js.map +1 -0
- package/dist/preview/dev-server.d.ts +68 -0
- package/dist/preview/dev-server.js +341 -0
- package/dist/preview/dev-server.js.map +1 -0
- package/dist/preview/host.d.ts +154 -0
- package/dist/preview/host.js +681 -0
- package/dist/preview/host.js.map +1 -0
- package/dist/preview/registration.d.ts +63 -0
- package/dist/preview/registration.js +109 -0
- package/dist/preview/registration.js.map +1 -0
- package/dist/preview/session.d.ts +73 -0
- package/dist/preview/session.js +347 -0
- package/dist/preview/session.js.map +1 -0
- package/dist/preview/url.d.ts +6 -0
- package/dist/preview/url.js +17 -0
- package/dist/preview/url.js.map +1 -0
- package/dist/runs/agent-tmpdir-removal.d.ts +15 -0
- package/dist/runs/agent-tmpdir-removal.js +60 -0
- package/dist/runs/agent-tmpdir-removal.js.map +1 -0
- package/dist/runs/agent-tmpdir.d.ts +12 -1
- package/dist/runs/agent-tmpdir.js +123 -34
- package/dist/runs/agent-tmpdir.js.map +1 -1
- package/dist/runs/delegation-state.d.ts +5 -1
- package/dist/runs/delegation-state.js +9 -0
- package/dist/runs/delegation-state.js.map +1 -1
- package/dist/runs/event-history.d.ts +4 -4
- package/dist/runs/event-history.js +97 -117
- package/dist/runs/event-history.js.map +1 -1
- package/dist/runs/history-compressor.d.ts +29 -0
- package/dist/runs/history-compressor.js +116 -0
- package/dist/runs/history-compressor.js.map +1 -0
- package/dist/runs/history-file.d.ts +36 -0
- package/dist/runs/history-file.js +463 -0
- package/dist/runs/history-file.js.map +1 -0
- package/dist/runs/human-ask-summary.js +18 -13
- package/dist/runs/human-ask-summary.js.map +1 -1
- package/dist/runs/legacy-index.d.ts +62 -0
- package/dist/runs/legacy-index.js +168 -0
- package/dist/runs/legacy-index.js.map +1 -0
- package/dist/runs/raw-record.d.ts +52 -0
- package/dist/runs/raw-record.js +145 -0
- package/dist/runs/raw-record.js.map +1 -0
- package/dist/runs/retention.d.ts +13 -6
- package/dist/runs/retention.js +42 -13
- package/dist/runs/retention.js.map +1 -1
- package/dist/runs/run-claims.d.ts +23 -0
- package/dist/runs/run-claims.js +24 -0
- package/dist/runs/run-claims.js.map +1 -0
- package/dist/runs/run-database.d.ts +385 -0
- package/dist/runs/run-database.js +930 -0
- package/dist/runs/run-database.js.map +1 -0
- package/dist/runs/run-index.d.ts +35 -9
- package/dist/runs/run-index.js +200 -25
- package/dist/runs/run-index.js.map +1 -1
- package/dist/runs/run-row.d.ts +44 -0
- package/dist/runs/run-row.js +84 -0
- package/dist/runs/run-row.js.map +1 -0
- package/dist/runs/run-search.d.ts +29 -0
- package/dist/runs/run-search.js +63 -0
- package/dist/runs/run-search.js.map +1 -0
- package/dist/runs/store-open-error.d.ts +24 -0
- package/dist/runs/store-open-error.js +61 -0
- package/dist/runs/store-open-error.js.map +1 -0
- package/dist/runs/store.d.ts +667 -32
- package/dist/runs/store.js +2153 -324
- package/dist/runs/store.js.map +1 -1
- package/dist/runs/transcript-facts.d.ts +77 -0
- package/dist/runs/transcript-facts.js +302 -0
- package/dist/runs/transcript-facts.js.map +1 -0
- package/dist/runs/webhook.js +5 -4
- package/dist/runs/webhook.js.map +1 -1
- package/dist/runs/worker-execution.d.ts +22 -0
- package/dist/runs/worker-execution.js +30 -0
- package/dist/runs/worker-execution.js.map +1 -0
- package/dist/server/capabilities.d.ts +1 -0
- package/dist/server/capabilities.js +2 -0
- package/dist/server/capabilities.js.map +1 -1
- package/dist/server/cockpit-ownership.d.ts +8 -0
- package/dist/server/cockpit-ownership.js +19 -0
- package/dist/server/cockpit-ownership.js.map +1 -1
- package/dist/server/forge/github.d.ts +1 -4
- package/dist/server/forge/github.js +47 -23
- package/dist/server/forge/github.js.map +1 -1
- package/dist/server/git.js +1 -1
- package/dist/server/git.js.map +1 -1
- package/dist/server/open-in-app.js +1 -0
- package/dist/server/open-in-app.js.map +1 -1
- package/dist/server/preview-socket.d.ts +36 -0
- package/dist/server/preview-socket.js +126 -0
- package/dist/server/preview-socket.js.map +1 -0
- package/dist/server/project-context.d.ts +31 -4
- package/dist/server/project-context.js +73 -9
- package/dist/server/project-context.js.map +1 -1
- package/dist/server/provider-action-gate.js +1 -0
- package/dist/server/provider-action-gate.js.map +1 -1
- package/dist/server/repo-branches.d.ts +4 -3
- package/dist/server/repo-branches.js +5 -4
- package/dist/server/repo-branches.js.map +1 -1
- package/dist/server/server.d.ts +4500 -2727
- package/dist/server/server.js +651 -202
- package/dist/server/server.js.map +1 -1
- package/dist/server/upgrade-router.d.ts +23 -0
- package/dist/server/upgrade-router.js +53 -0
- package/dist/server/upgrade-router.js.map +1 -0
- package/dist/server/validators.d.ts +6 -1
- package/dist/server/validators.js +4 -4
- package/dist/server/validators.js.map +1 -1
- package/dist/server/ws.d.ts +16 -16
- package/dist/server/ws.js +56 -54
- package/dist/server/ws.js.map +1 -1
- package/dist/skills-remote.d.ts +14 -0
- package/dist/skills-remote.js +91 -15
- package/dist/skills-remote.js.map +1 -1
- package/dist/task-cli/cli.js +40 -16
- package/dist/task-cli/cli.js.map +1 -1
- package/dist/task-cli/discovery.d.ts +4 -3
- package/dist/task-cli/discovery.js +4 -3
- package/dist/task-cli/discovery.js.map +1 -1
- package/dist/task-cli/http.d.ts +2 -0
- package/dist/task-cli/http.js +10 -4
- package/dist/task-cli/http.js.map +1 -1
- package/dist/task-cli/projections.d.ts +5 -2
- package/dist/task-cli/projections.js +13 -0
- package/dist/task-cli/projections.js.map +1 -1
- package/dist/task-cli/watch.d.ts +15 -2
- package/dist/task-cli/watch.js +31 -9
- package/dist/task-cli/watch.js.map +1 -1
- package/dist/ui-state.d.ts +4 -3
- package/dist/ui-state.js +8 -3
- package/dist/ui-state.js.map +1 -1
- package/dist/workflows/grouped-command.d.ts +31 -0
- package/dist/workflows/grouped-command.js +143 -0
- package/dist/workflows/grouped-command.js.map +1 -0
- package/dist/workflows/run.d.ts +223 -42
- package/dist/workflows/run.js +1316 -398
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +12 -0
- package/dist/workflows/types.js +20 -0
- package/dist/workflows/types.js.map +1 -1
- package/dist/workspace/agent-accounts.d.ts +5 -1
- package/dist/workspace/agent-accounts.js +1 -0
- package/dist/workspace/agent-accounts.js.map +1 -1
- package/dist/workspace/agent-profiles.js +1 -1
- package/dist/workspace/agent-profiles.js.map +1 -1
- package/dist/workspace/config.d.ts +3 -1
- package/dist/workspace/config.js +1 -0
- package/dist/workspace/config.js.map +1 -1
- package/dist/worktree-setup.d.ts +85 -0
- package/dist/worktree-setup.js +164 -0
- package/dist/worktree-setup.js.map +1 -0
- package/package.json +7 -5
- package/scripts/benchmark-run-store-fixture.ts +428 -0
- package/scripts/benchmark-run-store.ts +743 -0
- package/scripts/benchmark-stuck-destroys.ts +143 -0
- package/scripts/mock-autonomous.mjs +11 -0
- package/scripts/mock-ci-tool.mjs +20 -5
- package/scripts/mock-claude.mjs +110 -1
- package/scripts/mock-codex-app-server.mjs +117 -2
- package/scripts/mock-codex-coalesce-steer.mjs +59 -0
- package/scripts/mock-codex-reverse-steer.mjs +130 -0
- package/scripts/mock-cursor-acp.mjs +75 -1
- package/scripts/mock-omp-rpc.mjs +605 -0
- package/scripts/mock-opencode-serve.mjs +312 -22
- package/scripts/mock-pi-rpc.mjs +109 -3
- package/scripts/mock-runner-crash.mjs +42 -0
- package/scripts/mock-silent-tail.mjs +78 -0
- package/scripts/mock-turn-messages.mjs +5 -0
- package/scripts/omp-ci-wait.mjs +29 -0
- package/scripts/omp-restrict-delegation.yml +4 -0
- package/scripts/pi-ci-wait.mjs +12 -9
- package/scripts/seed-archive-profile.ts +3 -3
- package/web/dist/assets/agent-accounts-CFOBMVHc.js +1 -0
- package/web/dist/assets/alert-dialog-BrpNUwMt.js +1 -0
- package/web/dist/assets/app-window-CSHwDX9N.js +1 -0
- package/web/dist/assets/button-C_rd7CPp.js +43 -0
- package/web/dist/assets/compare-variants-D6WXhvXA.js +1 -0
- package/web/dist/assets/diff-DrEARXTV.js +3 -0
- package/web/dist/assets/diff-controls-B1-j7nFg.js +1 -0
- package/web/dist/assets/{diff-view-DPn6bQ5W.js → diff-view-DvGLjdTk.js} +3 -3
- package/web/dist/assets/dropdown-menu-BegOZFrj.js +1 -0
- package/web/dist/assets/format-DpNe3S0A.js +1 -0
- package/web/dist/assets/git-toolbar-Cw4ljmhB.js +1 -0
- package/web/dist/assets/github-CfV5sqL0.js +1 -0
- package/web/dist/assets/{image-preview-DJ8cgb1Z.js → image-preview-FOEfqpZP.js} +1 -1
- package/web/dist/assets/index-DgWWlOLy.js +13 -0
- package/web/dist/assets/index-fBFnuvxu.css +1 -0
- package/web/dist/assets/{nav-row-styles-3j64kH5s.js → nav-row-styles-vp2JbhuJ.js} +1 -1
- package/web/dist/assets/play-CmVAj_fu.js +1 -0
- package/web/dist/assets/popover-BohP6WTx.js +1 -0
- package/web/dist/assets/preview-pane-connected-qTA5KNoJ.js +3 -0
- package/web/dist/assets/preview-state-vuMviq6q.js +1 -0
- package/web/dist/assets/prompt-templates-Cubdut_C.js +15 -0
- package/web/dist/assets/repo-git-Cf4t5PD0.js +1 -0
- package/web/dist/assets/{run-diff-5-RRdcVz.js → run-diff-BpkUW37Z.js} +3 -3
- package/web/dist/assets/run-header-68WHf9wV.js +1 -0
- package/web/dist/assets/skills-DiVIgKeU.js +1 -0
- package/web/dist/assets/square-terminal-B6RYYieR.js +1 -0
- package/web/dist/assets/status-dot-CqTJHSP9.js +1 -0
- package/web/dist/assets/{tab-link-K0hjzEXR.js → tab-link-B07Hu4QX.js} +1 -1
- package/web/dist/assets/task-changes--lQF6EYf.js +1 -0
- package/web/dist/assets/task-commits-Cx0qy4DN.js +1 -0
- package/web/dist/assets/task-files-D50s-gKW.js +2 -0
- package/web/dist/assets/task-github-item-DaqmSwYT.js +1 -0
- package/web/dist/assets/task-thread-BPQ64lui.js +7 -0
- package/web/dist/assets/{task-thread-Bw_nQuTx.css → task-thread-zNE2Q9E-.css} +1 -1
- package/web/dist/assets/textarea-D20O4Pw0.js +1 -0
- package/web/dist/assets/thread-state-DWcFMrvb.js +4 -0
- package/web/dist/assets/use-hand-to-agent-state-BLIGmwbd.css +1 -0
- package/web/dist/assets/use-hand-to-agent-state-Cy9eyyfL.js +1 -0
- package/web/dist/assets/{utils-fzNfbPzA.js → utils-JsL3ZsJf.js} +1 -1
- package/web/dist/assets/workflows-jwG7uxqi.js +11 -0
- package/web/dist/assets/{zoomable-image-CDmZ_Rtl.js → zoomable-image-BJJ64KGc.js} +1 -1
- package/web/dist/cezarion-lockup-dark.svg +1 -13
- package/web/dist/cezarion-lockup-light.svg +1 -13
- package/web/dist/cezarion-mark-dark.svg +1 -12
- package/web/dist/cezarion-mark-light.svg +1 -12
- package/web/dist/index.html +16 -21
- package/web/dist/assets/agent-accounts-CU2ZDv24.js +0 -1
- package/web/dist/assets/alert-dialog-C-xYE_dg.js +0 -43
- package/web/dist/assets/chevron-right-BENZ-1ZN.js +0 -1
- package/web/dist/assets/collapsible-DD9J91Iv.js +0 -1
- package/web/dist/assets/compare-variants-5FmqpFSN.js +0 -1
- package/web/dist/assets/diff-controls-Bj0YrioE.js +0 -1
- package/web/dist/assets/diff-rE8rm75J2.js +0 -3
- package/web/dist/assets/diff-stat-BKDCSVfJ.js +0 -1
- package/web/dist/assets/dropdown-menu-Ch5BYQh_.js +0 -1
- package/web/dist/assets/format-dBiNDNVe.js +0 -1
- package/web/dist/assets/git-toolbar-ClVvf5iF.js +0 -1
- package/web/dist/assets/github-BkNOyour.css +0 -1
- package/web/dist/assets/github-DeZMeMwJ.js +0 -1
- package/web/dist/assets/github-item-detail-W5F6GVjF.js +0 -1
- package/web/dist/assets/github-sidebar-model-DcMu-IK2.js +0 -1
- package/web/dist/assets/highlighter-CdHqIwFr.js +0 -3
- package/web/dist/assets/index-CatemBEL.js +0 -8
- package/web/dist/assets/index-MvfD8bZJ.css +0 -1
- package/web/dist/assets/markdown-zdKeLIYA.js +0 -2
- package/web/dist/assets/mobile-run-bar-BCfmFi8V.js +0 -1
- package/web/dist/assets/prompt-templates-CBPp39IU.js +0 -15
- package/web/dist/assets/reference-chip-D6owncJT.js +0 -1
- package/web/dist/assets/repo-git-CtdkZFey.js +0 -1
- package/web/dist/assets/run-header-BcjoDi0W.js +0 -1
- package/web/dist/assets/sheet-ChnYVrp1.js +0 -1
- package/web/dist/assets/skills-C8YKp2vr.js +0 -1
- package/web/dist/assets/task-changes-BW8YdWG8.js +0 -1
- package/web/dist/assets/task-commits-q2kB2y6L.js +0 -1
- package/web/dist/assets/task-files-pPL6_3Cr.js +0 -2
- package/web/dist/assets/task-github-item-BXF5R52U.js +0 -1
- package/web/dist/assets/task-thread-DtkFL3EC.js +0 -10
- package/web/dist/assets/textarea-DxlkE-Yd.js +0 -1
- package/web/dist/assets/token-metrics-DIAjrPlM.js +0 -1
- package/web/dist/assets/use-desktop-CrDny2uG.js +0 -1
- package/web/dist/assets/use-submit-shortcut-KWJsC906.js +0 -1
- package/web/dist/assets/workflows-TmcPEj2K.js +0 -11
- /package/web/dist/assets/{lib-BQXq3kEf2.js → lib-BQXq3kEf.js} +0 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-store benchmark (#779): what the run index costs as it grows, and whether saving it is the
|
|
3
|
+
* cost worth moving to SQLite. Rerun it unchanged after the migration.
|
|
4
|
+
*
|
|
5
|
+
* npm run bench:run-store -w @wjarka/cezarion # full run, about 4 hours
|
|
6
|
+
* npm run bench:run-store -w @wjarka/cezarion -- --quick # smoke, a few minutes
|
|
7
|
+
* node --import tsx packages/cezar/scripts/benchmark-run-store.ts --sizes 100,5000 --profiles legacy
|
|
8
|
+
*
|
|
9
|
+
* Flags: --sizes (100,500,1000,2000,5000) --profiles (legacy,post-778) --processes (5)
|
|
10
|
+
* --samples (100) --budget-seconds (60) --duration (30, seconds of active simulation) --seed (779)
|
|
11
|
+
* --metrics (all: open,coldRead,save,commit,runs,runsIndex,runSummaries,runSummariesRecent,archivedPage,runsSearch,active,heap,getRun)
|
|
12
|
+
* --out-dir (a new /tmp directory) --resume (reuse child results already in --out-dir, run the rest) --quick (sizes 100,1000, 2 processes, 10 samples, 10 s budget,
|
|
13
|
+
* 3 s simulation). Results land in <out-dir>/results.{md,json}; the Markdown also goes to stdout.
|
|
14
|
+
* `--metrics heap,getRun,runSummaries` is the pass #779's second amendment added; a plain run
|
|
15
|
+
* measures everything.
|
|
16
|
+
*
|
|
17
|
+
* Method:
|
|
18
|
+
* - Fixtures come from `benchmark-run-store-fixture.ts` (seeded, two profiles; see its header for
|
|
19
|
+
* how #778 is modeled, and the live share: max(3, 1% of runs) queued/running/waiting records on
|
|
20
|
+
* top of the finished ones, which keep the bytes the first table measured) and are generated
|
|
21
|
+
* before any timer starts. `seedRunStore` is the ONLY place that knows the storage format: since
|
|
22
|
+
* #779 it writes `runs.db` through the tests' shared seeder, so "open" reads the database and
|
|
23
|
+
* "cold read" reads its summaries, the way a migrated project does. Every store opens with
|
|
24
|
+
* `keepLive`, as `serve` does, so live records load as themselves.
|
|
25
|
+
* - The parent spawns one fresh process per size x profile x process index, in shuffled order,
|
|
26
|
+
* one at a time. Each process measures every operation after 3 untimed warm-up calls, taking up
|
|
27
|
+
* to --samples samples and stopping early once it has 20 and --budget-seconds have passed.
|
|
28
|
+
* The table pools every process's samples per cell (median, p95, n). A failed child is
|
|
29
|
+
* reported and the rest still aggregate; the parent stops launching children when less than
|
|
30
|
+
* 1 GB is free, and exits 1 after writing what it has. `--resume` reruns only missing children.
|
|
31
|
+
* - Everything goes through public APIs: `RunStore.open`, `getRun`, `updateRun` + `flush`,
|
|
32
|
+
* `updateStep`, `commitDelegation`, `readRunIndexFromDisk`, and the Hono app from `createApp`.
|
|
33
|
+
* - "heap after open" is `heapUsed` after `RunStore.open` and a forced gc, minus `heapUsed` after a
|
|
34
|
+
* forced gc just before it, in MB; 3 samples per process, measured first, before anything else
|
|
35
|
+
* has run in the process.
|
|
36
|
+
* - "getRun" is per-call time in microseconds: each sample cycles `getRun` through a fixed id set
|
|
37
|
+
* until 2 ms have passed, then divides. "live" is every live record; "finished" is 49 evenly
|
|
38
|
+
* spaced finished records plus the largest one (50 ids, or 49 when the largest is among them).
|
|
39
|
+
* - Synchronous calls are timed with `performance.now()` around the call.
|
|
40
|
+
* - "save (flush)" is `updateRun` + `flush()`: `flush` runs the same `saveNow` the 300 ms debounce
|
|
41
|
+
* timer calls, synchronously, so it is the debounced write without the timer. Both save rows
|
|
42
|
+
* write the finished record closest to the median finished size (`pickSaveTarget`), and the
|
|
43
|
+
* results table prints that size per cell.
|
|
44
|
+
* - "save (debounced)" is the real path: `updateRun`, then the timer fires on its own. It is
|
|
45
|
+
* measured as the longest gap a `setImmediate` loop sees while waiting for the save (stop after
|
|
46
|
+
* a gap of at least half the flush median once 300 ms have passed, or at a timeout).
|
|
47
|
+
* - "commit (1 row)" / "commit (10 rows)" are `commitDelegation` with each row's own delegation,
|
|
48
|
+
* the durable `commitIndex` path.
|
|
49
|
+
* - The list routes (`/runs`, `/run-summaries`, `/workspace/runs-index`, and since #864 the windowed
|
|
50
|
+
* `/run-summaries?archived=recent`, the first `/run-summaries/archived` page and
|
|
51
|
+
* `/workspace/runs-search?q=the`) run in-process through
|
|
52
|
+
* `app.request` (no socket), against a second store
|
|
53
|
+
* opened on the same files, so app listeners never touch the store-under-test. "wall" is call
|
|
54
|
+
* to fully read body; "block" is the longest synchronous stretch inside it, from the same
|
|
55
|
+
* `setImmediate` gap detector. `/workspace/runs-index` reads a second, registered project cold
|
|
56
|
+
* from disk; the boot project is not registered, so the index measures the cold reader only.
|
|
57
|
+
* - "open" and "cold read" repeat on the same files, so the OS page cache is warm: they measure
|
|
58
|
+
* parse and validation, not disk.
|
|
59
|
+
* - The active simulation touches one unarchived run every 5 ms (`updateStep`, fixed-width token
|
|
60
|
+
* counts) for --duration seconds while `monitorEventLoopDelay` (1 ms resolution) records.
|
|
61
|
+
*/
|
|
62
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
63
|
+
import { randomInt } from 'node:crypto';
|
|
64
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, statfsSync, writeFileSync } from 'node:fs';
|
|
65
|
+
import { cpus, totalmem, tmpdir, type as osType, release, arch } from 'node:os';
|
|
66
|
+
import { join } from 'node:path';
|
|
67
|
+
import { fileURLToPath } from 'node:url';
|
|
68
|
+
import { parseArgs } from 'node:util';
|
|
69
|
+
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
|
|
70
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
71
|
+
import type { RunRecord, RunStore as RunStoreInstance } from '../src/runs/store.ts';
|
|
72
|
+
import { DEFAULT_FIXTURE_SEED, FIXTURE_PROFILES, generateRunFixture, isLiveRun, pickSaveTarget, type FixtureProfile } from './benchmark-run-store-fixture.ts';
|
|
73
|
+
|
|
74
|
+
const SCRIPT = fileURLToPath(import.meta.url);
|
|
75
|
+
const WARM_UP = 3;
|
|
76
|
+
const MIN_SAMPLES = 20;
|
|
77
|
+
const DEBOUNCE_MS = 300;
|
|
78
|
+
const TOUCH_EVERY_MS = 5;
|
|
79
|
+
const MULTI_ROWS = 10;
|
|
80
|
+
const MIN_FREE_BYTES = 1e9;
|
|
81
|
+
const HEAP_SAMPLES = 3;
|
|
82
|
+
const GET_RUN_MIN_MS = 2;
|
|
83
|
+
const GET_RUN_FINISHED_IDS = 50;
|
|
84
|
+
|
|
85
|
+
const METRICS = ['open', 'coldRead', 'save', 'commit', 'runs', 'runsIndex', 'runSummaries', 'runSummariesRecent', 'archivedPage', 'runsSearch', 'active', 'heap', 'getRun'] as const;
|
|
86
|
+
type Metric = typeof METRICS[number];
|
|
87
|
+
/** What a child result measured before `--metrics` existed. */
|
|
88
|
+
const FIRST_TABLE_METRICS: readonly Metric[] = ['open', 'coldRead', 'save', 'commit', 'runs', 'runsIndex', 'active'];
|
|
89
|
+
const SYNC_OPS = ['open', 'cold read', 'save (flush)', 'save (debounced)', 'commit (1 row)', 'commit (10 rows)', 'commit (held run)', 'commit (cold run)'] as const;
|
|
90
|
+
type SyncOp = typeof SYNC_OPS[number];
|
|
91
|
+
const SYNC_OP_METRIC: Record<SyncOp, Metric> = {
|
|
92
|
+
open: 'open', 'cold read': 'coldRead', 'save (flush)': 'save', 'save (debounced)': 'save', 'commit (1 row)': 'commit', 'commit (10 rows)': 'commit',
|
|
93
|
+
'commit (held run)': 'commit', 'commit (cold run)': 'commit',
|
|
94
|
+
};
|
|
95
|
+
/** The same one-row commit split by claim state (#779, plan step 3): on a run this store already
|
|
96
|
+
* holds and claims (the fence alone), and on a run it must claim first. Reported, never gated:
|
|
97
|
+
* `commit (1 row)` is the gate's commit and the first table's. */
|
|
98
|
+
const CLAIM_OPS: readonly SyncOp[] = ['commit (held run)', 'commit (cold run)'];
|
|
99
|
+
const ROUTE_PATHS = {
|
|
100
|
+
'GET /runs': { metric: 'runs', path: '/api/v1/runs' },
|
|
101
|
+
'GET /run-summaries': { metric: 'runSummaries', path: '/api/v1/run-summaries' },
|
|
102
|
+
'GET /workspace/runs-index': { metric: 'runsIndex', path: '/api/v1/workspace/runs-index' },
|
|
103
|
+
// The bounded lists (#864): the cockpit's windowed list, the first archived page past it, and
|
|
104
|
+
// the workspace search over a common fixture word (the cold project, as the index reads it).
|
|
105
|
+
'GET /run-summaries?archived=recent': { metric: 'runSummariesRecent', path: '/api/v1/run-summaries?archived=recent' },
|
|
106
|
+
'GET /run-summaries/archived': { metric: 'archivedPage', path: '/api/v1/run-summaries/archived?limit=50' },
|
|
107
|
+
'GET /workspace/runs-search': { metric: 'runsSearch', path: '/api/v1/workspace/runs-search?q=the' },
|
|
108
|
+
} as const satisfies Record<string, { metric: Metric; path: string }>;
|
|
109
|
+
type Route = keyof typeof ROUTE_PATHS;
|
|
110
|
+
const ROUTES = Object.keys(ROUTE_PATHS) as Route[];
|
|
111
|
+
|
|
112
|
+
interface ChildOptions { size: number; profile: FixtureProfile; process: number; samples: number; budgetSeconds: number; duration: number; seed: number; metrics: Metric[] }
|
|
113
|
+
interface ChildResult {
|
|
114
|
+
size: number; profile: FixtureProfile; process: number; fixtureBytes: number; records: number;
|
|
115
|
+
/** Live records among `records`; absent on results written before the fixture had any. */
|
|
116
|
+
liveRecords?: number;
|
|
117
|
+
/** Serialized size of the record the save rows write; absent on results written before the target was a median-size record. */
|
|
118
|
+
saveTargetBytes?: number;
|
|
119
|
+
/** Absent on results written before `--metrics` existed: those measured `FIRST_TABLE_METRICS`. */
|
|
120
|
+
metrics?: Metric[];
|
|
121
|
+
/** Added by the parent; absent on results written before `--resume` existed. */
|
|
122
|
+
revision?: string;
|
|
123
|
+
sync: Partial<Record<SyncOp, number[]>>;
|
|
124
|
+
routes: Partial<Record<Route, { wall: number[]; block: number[]; responseBytes: number }>>;
|
|
125
|
+
loop?: { p50: number; p99: number; max: number; mean: number; touches: number; expectedTouches: number };
|
|
126
|
+
/** MB per sample. */
|
|
127
|
+
heap?: number[];
|
|
128
|
+
/** Microseconds per call, per sample. */
|
|
129
|
+
getRun?: { live: number[]; finished: number[]; liveIds: number; finishedIds: number };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- measurement helpers ------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/** Times `fn` per sample; when `fn` returns a number, that number is the sample instead. */
|
|
135
|
+
async function sampleSync(options: ChildOptions, fn: (iteration: number) => number | void): Promise<number[]> {
|
|
136
|
+
for (let i = 0; i < WARM_UP; i++) { fn(-1 - i); await yieldLoop(); }
|
|
137
|
+
const samples: number[] = [];
|
|
138
|
+
const started = performance.now();
|
|
139
|
+
while (samples.length < options.samples &&
|
|
140
|
+
!(samples.length >= Math.min(MIN_SAMPLES, options.samples) && performance.now() - started > options.budgetSeconds * 1000)) {
|
|
141
|
+
const t0 = performance.now();
|
|
142
|
+
const value = fn(samples.length);
|
|
143
|
+
const elapsed = performance.now() - t0;
|
|
144
|
+
samples.push(value ?? elapsed);
|
|
145
|
+
await yieldLoop();
|
|
146
|
+
}
|
|
147
|
+
return samples;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function sampleAsync(options: ChildOptions, fn: (iteration: number) => Promise<void>): Promise<void> {
|
|
151
|
+
for (let i = 0; i < WARM_UP; i++) await fn(-1 - i);
|
|
152
|
+
const started = performance.now();
|
|
153
|
+
let taken = 0;
|
|
154
|
+
while (taken < options.samples &&
|
|
155
|
+
!(taken >= Math.min(MIN_SAMPLES, options.samples) && performance.now() - started > options.budgetSeconds * 1000)) {
|
|
156
|
+
await fn(taken++);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const yieldLoop = () => new Promise<void>((resolve) => setImmediate(resolve));
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The longest synchronous stretch while `body` runs: a `setImmediate` loop records the gap
|
|
164
|
+
* between its turns, and a gap is time the loop could not run. Without `until` the window ends
|
|
165
|
+
* when `body` resolves; with it, watching continues until `until` returns true.
|
|
166
|
+
*/
|
|
167
|
+
async function watchBlocking(body: () => Promise<unknown>, until?: (elapsed: number, maxGap: number) => boolean): Promise<{ wall: number; block: number }> {
|
|
168
|
+
let last = performance.now();
|
|
169
|
+
let maxGap = 0;
|
|
170
|
+
let stop = false;
|
|
171
|
+
const start = last;
|
|
172
|
+
const tick = () => {
|
|
173
|
+
const now = performance.now();
|
|
174
|
+
maxGap = Math.max(maxGap, now - last);
|
|
175
|
+
last = now;
|
|
176
|
+
if (!stop) setImmediate(tick);
|
|
177
|
+
};
|
|
178
|
+
setImmediate(tick);
|
|
179
|
+
await body();
|
|
180
|
+
const end = performance.now();
|
|
181
|
+
let block = Math.max(maxGap, end - last);
|
|
182
|
+
if (until) {
|
|
183
|
+
while (!until(performance.now() - start, maxGap)) await sleep(1);
|
|
184
|
+
block = Math.max(maxGap, performance.now() - last);
|
|
185
|
+
}
|
|
186
|
+
stop = true;
|
|
187
|
+
await yieldLoop();
|
|
188
|
+
return { wall: end - start, block };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const isoAt = (iteration: number) => new Date(Date.UTC(2026, 9, 2) + (iteration + 10) * 1000).toISOString();
|
|
192
|
+
|
|
193
|
+
function quantile(values: readonly number[], q: number): number {
|
|
194
|
+
if (values.length === 0) return Number.NaN;
|
|
195
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
196
|
+
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1));
|
|
197
|
+
return sorted[index]!;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- child: one size x profile in a fresh process --------------------------------------------
|
|
201
|
+
|
|
202
|
+
async function runChild(options: ChildOptions, resultPath: string): Promise<void> {
|
|
203
|
+
const root = mkdtempSync(join(tmpdir(), 'cez-bench-'));
|
|
204
|
+
process.env.CEZ_HOME = join(root, 'home');
|
|
205
|
+
process.env.CEZ_DRY_RUN = '1';
|
|
206
|
+
try {
|
|
207
|
+
const { RunStore } = await import('../src/runs/store.ts');
|
|
208
|
+
const { seedRuns } = await import('../src/runs/run-store.testkit.ts');
|
|
209
|
+
// ---- fixture seeding: the one storage-format-specific step ----
|
|
210
|
+
/** Put `records` where `RunStore.open(dataDir)` finds them and return the bytes stored. */
|
|
211
|
+
const seedRunStore = (dataDir: string, records: readonly RunRecord[]): number => {
|
|
212
|
+
seedRuns(dataDir, records);
|
|
213
|
+
return statSync(join(dataDir, 'runs.db')).size;
|
|
214
|
+
};
|
|
215
|
+
const { readRunIndexFromDisk } = await import('../src/runs/run-index.ts');
|
|
216
|
+
const { createApp } = await import('../src/server/server.ts');
|
|
217
|
+
const { registerProject } = await import('../src/workspace/projects.ts');
|
|
218
|
+
const gc = (globalThis as { gc?: () => void }).gc ?? (() => {});
|
|
219
|
+
|
|
220
|
+
const records = generateRunFixture({ runs: options.size, profile: options.profile, seed: options.seed });
|
|
221
|
+
const bootDir = join(root, 'boot/.ai/cezar');
|
|
222
|
+
const coldRoot = join(root, 'cold');
|
|
223
|
+
const fixtureBytes = seedRunStore(bootDir, records);
|
|
224
|
+
seedRunStore(join(coldRoot, '.ai/cezar'), records);
|
|
225
|
+
await registerProject(coldRoot);
|
|
226
|
+
const has = (metric: Metric) => options.metrics.includes(metric);
|
|
227
|
+
// Each open stands for a freshly started process, so the store opened before it is closed
|
|
228
|
+
// first, untimed: left open, its claims (#779, plan step 3) would keep the new one off every
|
|
229
|
+
// live run.
|
|
230
|
+
let opened: RunStoreInstance | undefined;
|
|
231
|
+
const closePrevious = () => { opened?.close(); opened = undefined; };
|
|
232
|
+
const open = () => (opened = RunStore.open(bootDir, { keepLive: true }));
|
|
233
|
+
|
|
234
|
+
// Targets are picked from the fixture data, never from store internals. The first table's
|
|
235
|
+
// targets come from the finished records, which are that table's whole fixture.
|
|
236
|
+
const live = records.filter(isLiveRun);
|
|
237
|
+
const finished = records.filter((run) => !isLiveRun(run));
|
|
238
|
+
const roots = finished.filter((run) => run.delegation?.role === 'root');
|
|
239
|
+
const singleTarget = roots.find((run) => run.delegation?.role === 'root' && run.delegation.conversation)?.id ?? roots[0]!.id;
|
|
240
|
+
const multiTargets = finished.filter((run) => run.delegation && run.delegation.role !== 'invalid').slice(0, MULTI_ROWS).map((run) => run.id);
|
|
241
|
+
const active = finished.find((run) => !run.archived)!;
|
|
242
|
+
// A median-size record, not `finished[length / 2]` (see `pickSaveTarget`): save cost tracks the
|
|
243
|
+
// changed record's size, and that position was 231 KB at 2,000 runs.
|
|
244
|
+
const saveRecord = pickSaveTarget(finished);
|
|
245
|
+
const saveTarget = saveRecord.id;
|
|
246
|
+
const bytes = finished.map((run) => JSON.stringify(run).length);
|
|
247
|
+
const largest = finished[bytes.indexOf(Math.max(...bytes))]!;
|
|
248
|
+
const spaced = Math.min(finished.length, GET_RUN_FINISHED_IDS - 1);
|
|
249
|
+
const finishedIds = [...new Set([largest.id,
|
|
250
|
+
...Array.from({ length: spaced }, (_, i) => finished[Math.floor((i * finished.length) / spaced)]!.id)])];
|
|
251
|
+
|
|
252
|
+
const result: ChildResult = {
|
|
253
|
+
size: options.size, profile: options.profile, process: options.process, fixtureBytes, records: records.length,
|
|
254
|
+
liveRecords: live.length, saveTargetBytes: JSON.stringify(saveRecord).length, metrics: options.metrics, sync: {}, routes: {},
|
|
255
|
+
};
|
|
256
|
+
const sync = result.sync;
|
|
257
|
+
let store: RunStoreInstance | undefined;
|
|
258
|
+
if (has('heap')) {
|
|
259
|
+
const heap: number[] = [];
|
|
260
|
+
for (let i = 0; i < HEAP_SAMPLES; i++) {
|
|
261
|
+
closePrevious();
|
|
262
|
+
store = undefined;
|
|
263
|
+
gc();
|
|
264
|
+
const before = process.memoryUsage().heapUsed;
|
|
265
|
+
store = open();
|
|
266
|
+
gc();
|
|
267
|
+
heap.push((process.memoryUsage().heapUsed - before) / 1e6);
|
|
268
|
+
}
|
|
269
|
+
result.heap = heap;
|
|
270
|
+
}
|
|
271
|
+
gc();
|
|
272
|
+
if (has('open')) {
|
|
273
|
+
sync.open = await sampleSync(options, () => {
|
|
274
|
+
closePrevious();
|
|
275
|
+
const t0 = performance.now();
|
|
276
|
+
store = open();
|
|
277
|
+
return performance.now() - t0;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
store ??= open();
|
|
281
|
+
gc();
|
|
282
|
+
if (has('getRun')) {
|
|
283
|
+
const s = store;
|
|
284
|
+
/** Microseconds per `getRun`, cycling through `ids` until GET_RUN_MIN_MS have passed. */
|
|
285
|
+
const perCall = (ids: readonly string[]) => {
|
|
286
|
+
let calls = 0;
|
|
287
|
+
let found = 0;
|
|
288
|
+
let elapsed = 0;
|
|
289
|
+
const t0 = performance.now();
|
|
290
|
+
do {
|
|
291
|
+
for (const id of ids) if (s.getRun(id)) found++;
|
|
292
|
+
calls += ids.length;
|
|
293
|
+
elapsed = performance.now() - t0;
|
|
294
|
+
} while (elapsed < GET_RUN_MIN_MS);
|
|
295
|
+
if (found !== calls) throw new Error('getRun missed a fixture id');
|
|
296
|
+
return (elapsed * 1000) / calls;
|
|
297
|
+
};
|
|
298
|
+
const liveIds = live.map((run) => run.id);
|
|
299
|
+
const liveSamples = await sampleSync(options, () => perCall(liveIds));
|
|
300
|
+
gc();
|
|
301
|
+
const finishedSamples = await sampleSync(options, () => perCall(finishedIds));
|
|
302
|
+
result.getRun = { live: liveSamples, finished: finishedSamples, liveIds: liveIds.length, finishedIds: finishedIds.length };
|
|
303
|
+
gc();
|
|
304
|
+
}
|
|
305
|
+
if (has('coldRead')) {
|
|
306
|
+
// The limit `/workspace/runs-index` reads with.
|
|
307
|
+
sync['cold read'] = await sampleSync(options, () => { readRunIndexFromDisk(join(coldRoot, '.ai/cezar'), { archivedWindow: 200 }); });
|
|
308
|
+
gc();
|
|
309
|
+
}
|
|
310
|
+
if (has('save')) {
|
|
311
|
+
const s = store;
|
|
312
|
+
sync['save (flush)'] = await sampleSync(options, (iteration) => {
|
|
313
|
+
s.updateRun(saveTarget, { seenAt: isoAt(iteration) });
|
|
314
|
+
s.flush();
|
|
315
|
+
});
|
|
316
|
+
gc();
|
|
317
|
+
const flushMedian = quantile(sync['save (flush)'], 0.5);
|
|
318
|
+
const flushP95 = quantile(sync['save (flush)'], 0.95);
|
|
319
|
+
const debounced: number[] = [];
|
|
320
|
+
await sampleAsync(options, async (iteration) => {
|
|
321
|
+
const { block } = await watchBlocking(async () => { s.updateRun(saveTarget, { seenAt: isoAt(iteration) }); },
|
|
322
|
+
(elapsed, maxGap) => (elapsed > DEBOUNCE_MS && maxGap >= Math.max(2, flushMedian / 2)) ||
|
|
323
|
+
elapsed > DEBOUNCE_MS + 3 * flushP95 + 200);
|
|
324
|
+
if (iteration >= 0) debounced.push(block);
|
|
325
|
+
await sleep(20);
|
|
326
|
+
});
|
|
327
|
+
sync['save (debounced)'] = debounced;
|
|
328
|
+
gc();
|
|
329
|
+
}
|
|
330
|
+
if (has('commit')) {
|
|
331
|
+
const s = store;
|
|
332
|
+
const commit = (ids: readonly string[]) => s.commitDelegation(ids.map((id) => ({ id, delegation: s.getRun(id)!.delegation! })));
|
|
333
|
+
sync['commit (1 row)'] = await sampleSync(options, () => { commit([singleTarget]); });
|
|
334
|
+
gc();
|
|
335
|
+
sync['commit (10 rows)'] = await sampleSync(options, () => { commit(multiTargets); });
|
|
336
|
+
gc();
|
|
337
|
+
// #779 plan step 3: every durable commit is fenced (claim and revision checked in its
|
|
338
|
+
// transaction). A run already held and claimed pays the fence alone; a run that left memory
|
|
339
|
+
// is claimed first. Its claim is released when it leaves again — the untimed flush.
|
|
340
|
+
s.pin(singleTarget, 'cleanup');
|
|
341
|
+
sync['commit (held run)'] = await sampleSync(options, () => { commit([singleTarget]); });
|
|
342
|
+
s.unpin(singleTarget, 'cleanup');
|
|
343
|
+
gc();
|
|
344
|
+
sync['commit (cold run)'] = await sampleSync(options, () => {
|
|
345
|
+
s.flush();
|
|
346
|
+
const t0 = performance.now();
|
|
347
|
+
commit([singleTarget]);
|
|
348
|
+
return performance.now() - t0;
|
|
349
|
+
});
|
|
350
|
+
gc();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const routes = ROUTES.filter((route) => has(ROUTE_PATHS[route].metric));
|
|
354
|
+
if (routes.length > 0) {
|
|
355
|
+
closePrevious();
|
|
356
|
+
const routeStore = open();
|
|
357
|
+
const app = createApp({
|
|
358
|
+
repoRoot: join(root, 'boot'), store: routeStore, version: '0.0.0-bench',
|
|
359
|
+
manager: { finishBlockedReason: () => null } as unknown as Parameters<typeof createApp>[0]['manager'],
|
|
360
|
+
});
|
|
361
|
+
for (const route of routes) {
|
|
362
|
+
const wall: number[] = [];
|
|
363
|
+
const block: number[] = [];
|
|
364
|
+
let responseBytes = 0;
|
|
365
|
+
await sampleAsync(options, async (iteration) => {
|
|
366
|
+
const measured = await watchBlocking(async () => {
|
|
367
|
+
const response = await app.request(ROUTE_PATHS[route].path, { headers: { host: '127.0.0.1:4321' } });
|
|
368
|
+
if (response.status !== 200) throw new Error(`${route} answered ${response.status}`);
|
|
369
|
+
responseBytes = (await response.text()).length;
|
|
370
|
+
});
|
|
371
|
+
if (iteration >= 0) { wall.push(measured.wall); block.push(measured.block); }
|
|
372
|
+
});
|
|
373
|
+
result.routes[route] = { wall, block, responseBytes };
|
|
374
|
+
gc();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (has('active')) {
|
|
379
|
+
if (opened !== store) { closePrevious(); store = open(); }
|
|
380
|
+
const s = store;
|
|
381
|
+
const histogram = monitorEventLoopDelay({ resolution: 1 });
|
|
382
|
+
let touches = 0;
|
|
383
|
+
histogram.enable();
|
|
384
|
+
const timer = setInterval(() => {
|
|
385
|
+
s.updateStep(active.id, 'task', { tokensUsed: 100_000 + (touches++ % 900_000) });
|
|
386
|
+
}, TOUCH_EVERY_MS);
|
|
387
|
+
await sleep(options.duration * 1000);
|
|
388
|
+
clearInterval(timer);
|
|
389
|
+
histogram.disable();
|
|
390
|
+
s.flush();
|
|
391
|
+
const ms = (ns: number) => ns / 1e6;
|
|
392
|
+
result.loop = {
|
|
393
|
+
p50: ms(histogram.percentile(50)), p99: ms(histogram.percentile(99)), max: ms(histogram.max), mean: ms(histogram.mean),
|
|
394
|
+
touches, expectedTouches: Math.floor((options.duration * 1000) / TOUCH_EVERY_MS),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
writeFileSync(resultPath, JSON.stringify(result));
|
|
399
|
+
} finally {
|
|
400
|
+
rmSync(root, { recursive: true, force: true });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---- parent: orchestrate, aggregate, report -------------------------------------------------
|
|
405
|
+
|
|
406
|
+
interface ParentOptions { sizes: number[]; profiles: FixtureProfile[]; processes: number; samples: number; budgetSeconds: number; duration: number; seed: number; metrics: Metric[]; outDir: string; resume: boolean }
|
|
407
|
+
|
|
408
|
+
function runChildProcess(options: ChildOptions, resultPath: string): Promise<void> {
|
|
409
|
+
const env = { ...process.env };
|
|
410
|
+
for (const key of Object.keys(env)) if (key.startsWith('CEZ_')) delete env[key];
|
|
411
|
+
const args = [...process.execArgv, '--expose-gc', '--max-old-space-size=8192', SCRIPT, '--child',
|
|
412
|
+
'--size', String(options.size), '--profile', options.profile, '--process', String(options.process),
|
|
413
|
+
'--samples', String(options.samples), '--budget-seconds', String(options.budgetSeconds),
|
|
414
|
+
'--duration', String(options.duration), '--seed', String(options.seed),
|
|
415
|
+
'--metrics', options.metrics.join(','), '--result', resultPath];
|
|
416
|
+
return new Promise((resolve, reject) => {
|
|
417
|
+
const child = spawn(process.execPath, args, { env, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
418
|
+
child.on('error', reject);
|
|
419
|
+
child.on('exit', (code, signal) => code === 0 ? resolve() : reject(new Error(`child ${options.size}/${options.profile}/${options.process} exited ${code ?? signal}`)));
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function environment() {
|
|
424
|
+
const git = (...args: string[]) => { try { return execFileSync('git', args, { cwd: join(SCRIPT, '..'), encoding: 'utf8' }).trim(); } catch { return 'unknown'; } };
|
|
425
|
+
const cpu = cpus();
|
|
426
|
+
return {
|
|
427
|
+
node: process.version, os: `${osType()} ${release()} ${arch()}`, cpu: `${cpu[0]?.model ?? 'unknown'} x${cpu.length}`,
|
|
428
|
+
memoryGb: Math.round(totalmem() / 1e9), revision: git('rev-parse', 'HEAD'), dirty: git('status', '--porcelain', '--untracked-files=no') !== '',
|
|
429
|
+
date: new Date().toISOString(),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
interface Cell { median: number; p95: number; n: number; spread: [number, number] }
|
|
434
|
+
const cell = (perProcess: number[][]): Cell => {
|
|
435
|
+
const pooled = perProcess.flat();
|
|
436
|
+
const medians = perProcess.map((values) => quantile(values, 0.5));
|
|
437
|
+
return { median: quantile(pooled, 0.5), p95: quantile(pooled, 0.95), n: pooled.length, spread: [Math.min(...medians), Math.max(...medians)] };
|
|
438
|
+
};
|
|
439
|
+
/** A cell over the processes that measured `pick`, or nothing when none did. */
|
|
440
|
+
const cellOf = (group: readonly ChildResult[], pick: (result: ChildResult) => number[] | undefined): Cell | undefined => {
|
|
441
|
+
const perProcess = group.map(pick).filter((values): values is number[] => values !== undefined);
|
|
442
|
+
return perProcess.length > 0 ? cell(perProcess) : undefined;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
interface SizeAggregate {
|
|
446
|
+
metrics: Record<string, Cell>; fixtureBytes: number; records: number; liveRecords: number; saveTargetBytes?: number;
|
|
447
|
+
responseBytes: Partial<Record<Route, number>>; getRunIds?: { live: number; finished: number };
|
|
448
|
+
loop?: Record<'p50' | 'p99' | 'max' | 'touchRate', Cell>;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function aggregate(results: ChildResult[], options: ParentOptions) {
|
|
452
|
+
const out: Record<string, Record<number, SizeAggregate>> = {};
|
|
453
|
+
for (const profile of options.profiles) {
|
|
454
|
+
out[profile] = {};
|
|
455
|
+
for (const size of options.sizes) {
|
|
456
|
+
const group = results.filter((result) => result.profile === profile && result.size === size);
|
|
457
|
+
if (group.length === 0) continue;
|
|
458
|
+
const metrics: Record<string, Cell> = {};
|
|
459
|
+
const put = (key: string, value: Cell | undefined) => { if (value) metrics[key] = value; };
|
|
460
|
+
const selected = (metric: Metric) => options.metrics.includes(metric);
|
|
461
|
+
if (selected('heap')) put('heap', cellOf(group, (result) => result.heap));
|
|
462
|
+
if (selected('getRun')) {
|
|
463
|
+
put('getRun live', cellOf(group, (result) => result.getRun?.live));
|
|
464
|
+
put('getRun finished', cellOf(group, (result) => result.getRun?.finished));
|
|
465
|
+
}
|
|
466
|
+
for (const op of SYNC_OPS) if (selected(SYNC_OP_METRIC[op])) put(op, cellOf(group, (result) => result.sync[op]));
|
|
467
|
+
const responseBytes: SizeAggregate['responseBytes'] = {};
|
|
468
|
+
for (const route of ROUTES.filter((route) => selected(ROUTE_PATHS[route].metric))) {
|
|
469
|
+
put(`${route} block`, cellOf(group, (result) => result.routes[route]?.block));
|
|
470
|
+
put(`${route} wall`, cellOf(group, (result) => result.routes[route]?.wall));
|
|
471
|
+
const measured = group.find((result) => result.routes[route]);
|
|
472
|
+
if (measured) responseBytes[route] = measured.routes[route]!.responseBytes;
|
|
473
|
+
}
|
|
474
|
+
const loops = selected('active') ? group.flatMap((result) => result.loop ? [result.loop] : []) : [];
|
|
475
|
+
const withGetRun = group.find((result) => result.getRun);
|
|
476
|
+
out[profile]![size] = {
|
|
477
|
+
metrics,
|
|
478
|
+
fixtureBytes: group[0]!.fixtureBytes,
|
|
479
|
+
records: group[0]!.records,
|
|
480
|
+
liveRecords: group[0]!.liveRecords ?? 0,
|
|
481
|
+
...(group[0]!.saveTargetBytes !== undefined ? { saveTargetBytes: group[0]!.saveTargetBytes } : {}),
|
|
482
|
+
responseBytes,
|
|
483
|
+
...(withGetRun ? { getRunIds: { live: withGetRun.getRun!.liveIds, finished: withGetRun.getRun!.finishedIds } } : {}),
|
|
484
|
+
...(loops.length > 0 ? {
|
|
485
|
+
loop: {
|
|
486
|
+
p50: cell(loops.map((loop) => [loop.p50])), p99: cell(loops.map((loop) => [loop.p99])),
|
|
487
|
+
max: cell(loops.map((loop) => [loop.max])),
|
|
488
|
+
touchRate: cell(loops.map((loop) => [loop.touches / loop.expectedTouches])),
|
|
489
|
+
},
|
|
490
|
+
} : {}),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const fmt = (value: number) => value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value >= 0.01 ? value.toFixed(2) : value.toPrecision(2);
|
|
498
|
+
const mb = (bytes: number) => `${(bytes / 1e6).toFixed(1)} MB`;
|
|
499
|
+
const SAVE_METRICS = ['save (flush)', 'save (debounced)', 'commit (1 row)', 'commit (10 rows)'];
|
|
500
|
+
/** The gate's "largest block" candidates. `GET /runs` is left out: since #817 the cockpit lists
|
|
501
|
+
* through `GET /run-summaries`, and `GET /runs` serves only older clients. */
|
|
502
|
+
const BLOCKING_METRICS = [...SYNC_OPS.filter((op) => !CLAIM_OPS.includes(op)), 'GET /workspace/runs-index block', 'GET /run-summaries block'];
|
|
503
|
+
const BLOCKING_INPUTS: Record<string, Metric> = {
|
|
504
|
+
...SYNC_OP_METRIC, 'GET /workspace/runs-index block': 'runsIndex', 'GET /run-summaries block': 'runSummaries',
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
interface RunOutcome { failures: { job: string; error: string }[]; missing: string[]; stopped?: string }
|
|
508
|
+
|
|
509
|
+
function report(aggregated: ReturnType<typeof aggregate>, options: ParentOptions, env: ReturnType<typeof environment>, order: string[],
|
|
510
|
+
results: readonly ChildResult[], outcome: RunOutcome): { markdown: string; gate: unknown } {
|
|
511
|
+
const lines: string[] = ['# Run-store benchmark (#779)', ''];
|
|
512
|
+
const revisions = new Map<string, number>();
|
|
513
|
+
for (const result of results) revisions.set(result.revision ?? 'unrecorded', (revisions.get(result.revision ?? 'unrecorded') ?? 0) + 1);
|
|
514
|
+
lines.push(`- Revision: \`${env.revision}\`${env.dirty ? ' (uncommitted changes)' : ''}`,
|
|
515
|
+
`- Child results by revision: ${[...revisions].map(([revision, count]) => `\`${revision}\` x${count}`).join(', ')}`, `- Node ${env.node}, ${env.os}, ${env.cpu}, ${env.memoryGb} GB RAM`,
|
|
516
|
+
`- Date: ${env.date}`,
|
|
517
|
+
`- Metrics: ${options.metrics.length === METRICS.length ? 'all' : options.metrics.join(', ')}`,
|
|
518
|
+
`- ${options.processes} fresh processes per size x profile, shuffled; up to ${options.samples} samples per operation after ${WARM_UP} warm-ups, stopping after ${options.budgetSeconds} s once ${MIN_SAMPLES} are taken; ${options.duration} s active simulation; fixture seed ${options.seed}`,
|
|
519
|
+
'- Cells: median / p95 over all processes\' samples, in ms unless the row says otherwise; `n` when the time budget cut sampling short', '');
|
|
520
|
+
for (const [profile, bySize] of Object.entries(aggregated)) {
|
|
521
|
+
const sizes = Object.keys(bySize).map(Number);
|
|
522
|
+
if (sizes.length === 0) continue;
|
|
523
|
+
lines.push(`## ${profile}`, '', `| Metric | ${sizes.map((size) => `${size} runs`).join(' | ')} |`, `| --- | ${sizes.map(() => '---:').join(' | ')} |`);
|
|
524
|
+
lines.push(`| Records (live) | ${sizes.map((size) => `${bySize[size]!.records} (${bySize[size]!.liveRecords})`).join(' | ')} |`);
|
|
525
|
+
lines.push(`| Fixture bytes | ${sizes.map((size) => mb(bySize[size]!.fixtureBytes)).join(' | ')} |`);
|
|
526
|
+
if (sizes.some((size) => bySize[size]!.saveTargetBytes !== undefined)) {
|
|
527
|
+
lines.push(`| Save target bytes (median-size finished record) | ${sizes.map((size) => bySize[size]!.saveTargetBytes?.toLocaleString('en-US') ?? '-').join(' | ')} |`);
|
|
528
|
+
}
|
|
529
|
+
const row = (label: string, key: string, expectedN = options.processes * options.samples) => {
|
|
530
|
+
if (!sizes.some((size) => bySize[size]!.metrics[key])) return;
|
|
531
|
+
lines.push(`| ${label} | ${sizes.map((size) => {
|
|
532
|
+
const value = bySize[size]!.metrics[key];
|
|
533
|
+
if (!value) return '-';
|
|
534
|
+
return `${fmt(value.median)} / ${fmt(value.p95)}${value.n < expectedN ? ` (n=${value.n})` : ''}`;
|
|
535
|
+
}).join(' | ')} |`);
|
|
536
|
+
};
|
|
537
|
+
row('Heap after open (MB, `heapUsed` delta after gc)', 'heap', options.processes * HEAP_SAMPLES);
|
|
538
|
+
row('`getRun`, live records (µs per call)', 'getRun live');
|
|
539
|
+
row('`getRun`, finished records incl. the largest (µs per call)', 'getRun finished');
|
|
540
|
+
row('Open, sync', 'open');
|
|
541
|
+
row('Cold read (`readRunIndexFromDisk`), sync', 'cold read');
|
|
542
|
+
row('Save (`updateRun` + `flush`), sync', 'save (flush)');
|
|
543
|
+
row('Save (real 300 ms debounce), longest block', 'save (debounced)');
|
|
544
|
+
row('Commit 1 row (`commitDelegation`), sync', 'commit (1 row)');
|
|
545
|
+
row(`Commit ${MULTI_ROWS} rows (\`commitDelegation\`), sync`, 'commit (10 rows)');
|
|
546
|
+
row('Commit 1 row, run held and claimed (fence only), sync', 'commit (held run)');
|
|
547
|
+
row('Commit 1 row, run claimed first (claim + fence), sync', 'commit (cold run)');
|
|
548
|
+
for (const route of ROUTES) {
|
|
549
|
+
const label = route === 'GET /runs' ? 'GET /runs (older clients)' : route;
|
|
550
|
+
row(`${label}, longest block`, `${route} block`);
|
|
551
|
+
row(`${label}, wall (in-process)`, `${route} wall`);
|
|
552
|
+
}
|
|
553
|
+
for (const route of ROUTES) {
|
|
554
|
+
if (!sizes.some((size) => bySize[size]!.responseBytes[route] !== undefined)) continue;
|
|
555
|
+
lines.push(`| ${route} response | ${sizes.map((size) => {
|
|
556
|
+
const bytes = bySize[size]!.responseBytes[route];
|
|
557
|
+
return bytes === undefined ? '-' : bytes < 1e6 ? `${(bytes / 1e3).toFixed(0)} KB` : mb(bytes);
|
|
558
|
+
}).join(' | ')} |`);
|
|
559
|
+
}
|
|
560
|
+
if (sizes.some((size) => bySize[size]!.loop)) {
|
|
561
|
+
const loopCell = (size: number, render: (loop: NonNullable<SizeAggregate['loop']>) => string) => {
|
|
562
|
+
const loop = bySize[size]!.loop;
|
|
563
|
+
return loop ? render(loop) : '-';
|
|
564
|
+
};
|
|
565
|
+
lines.push(`| Active run: loop delay p50 / p99 / max | ${sizes.map((size) => loopCell(size, (loop) =>
|
|
566
|
+
`${fmt(loop.p50.median)} / ${fmt(loop.p99.median)} / ${fmt(loop.max.median)}`)).join(' | ')} |`);
|
|
567
|
+
lines.push(`| Active run: touches done / scheduled | ${sizes.map((size) => loopCell(size, (loop) =>
|
|
568
|
+
`${(loop.touchRate.median * 100).toFixed(0)}%`)).join(' | ')} |`);
|
|
569
|
+
}
|
|
570
|
+
lines.push('');
|
|
571
|
+
}
|
|
572
|
+
if (options.metrics.includes('active')) lines.push('Loop-delay cells are the median across processes of each process\'s p50, p99 and max.', '');
|
|
573
|
+
if (options.metrics.includes('getRun')) {
|
|
574
|
+
lines.push(`\`getRun\` ids: every live record; finished = up to ${GET_RUN_FINISHED_IDS - 1} evenly spaced finished records plus the largest (${
|
|
575
|
+
Object.values(aggregated).flatMap((bySize) => Object.entries(bySize)).filter(([, entry]) => entry.getRunIds)
|
|
576
|
+
.map(([size, entry]) => `${size} runs: ${entry.getRunIds!.live} live, ${entry.getRunIds!.finished} finished`)
|
|
577
|
+
.filter((value, index, all) => all.indexOf(value) === index).join('; ')}).`, '');
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Gate: does single-run save blocking grow with run count beyond noise, and is it the largest?
|
|
581
|
+
const gate: Record<string, unknown> = {};
|
|
582
|
+
lines.push('## Gate', '', '`GET /runs` is left out of the comparison: since #817 the cockpit lists through `GET /run-summaries`, and `GET /runs` serves older clients only.', '');
|
|
583
|
+
const missingInputs = [...new Set(Object.values(BLOCKING_INPUTS))].filter((metric) => !options.metrics.includes(metric));
|
|
584
|
+
for (const [profile, bySize] of Object.entries(aggregated)) {
|
|
585
|
+
const sizes = Object.keys(bySize).map(Number).sort((a, b) => a - b);
|
|
586
|
+
if (sizes.length < 2) { lines.push(`- ${profile}: needs at least two sizes.`); continue; }
|
|
587
|
+
const small = sizes[0]!;
|
|
588
|
+
const large = sizes[sizes.length - 1]!;
|
|
589
|
+
const absent = [...new Set([...missingInputs.map((metric) => `\`${metric}\` (not selected)`),
|
|
590
|
+
...(missingInputs.includes('save') || bySize[small]!.metrics['save (flush)'] ? [] : [`save (flush) at ${small} runs`]),
|
|
591
|
+
...BLOCKING_METRICS.filter((metric) => !missingInputs.includes(BLOCKING_INPUTS[metric]!) && !bySize[large]!.metrics[metric])
|
|
592
|
+
.map((metric) => `${metric} at ${large} runs`)])];
|
|
593
|
+
if (absent.length > 0) {
|
|
594
|
+
gate[profile] = { small, large, verdict: null, missingInputs: absent };
|
|
595
|
+
lines.push(`### ${profile}`, '', `- No verdict: this pass is missing gate inputs: ${absent.join(', ')}. Rerun with every metric (the default) for one.`, '');
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const save = (size: number) => bySize[size]!.metrics['save (flush)']!;
|
|
599
|
+
const grows = save(large).median > save(small).p95;
|
|
600
|
+
const threshold = save(small).median + Math.max(0.25 * save(small).median, 1);
|
|
601
|
+
const ranking = BLOCKING_METRICS.map((metric) => ({ metric, median: bySize[large]!.metrics[metric]!.median }))
|
|
602
|
+
.sort((a, b) => b.median - a.median);
|
|
603
|
+
const recurring = ranking.filter((entry) => entry.metric !== 'open');
|
|
604
|
+
const saveLargest = SAVE_METRICS.includes(ranking[0]!.metric);
|
|
605
|
+
const saveLargestRecurring = SAVE_METRICS.includes(recurring[0]!.metric);
|
|
606
|
+
gate[profile] = { small, large, saveSmall: save(small), saveLarge: save(large), grows, threshold, ranking, saveLargest, saveLargestRecurring };
|
|
607
|
+
lines.push(`### ${profile}`, '',
|
|
608
|
+
`- Single-run save (\`updateRun\` + \`flush\`): median ${fmt(save(small).median)} ms (p95 ${fmt(save(small).p95)}) at ${small} runs, ${fmt(save(large).median)} ms (p95 ${fmt(save(large).p95)}) at ${large} runs. ` +
|
|
609
|
+
(grows ? `**It grows beyond noise**: the ${large}-run median is above the ${small}-run p95.` : `**It does not grow beyond noise**: the ${large}-run median is within the ${small}-run p95.`),
|
|
610
|
+
`- For reference, the post-migration criterion x_N <= x_${small} + max(0.25 x_${small}, 1 ms) allows ${fmt(threshold)} ms; today's ${large}-run median ${save(large).median <= threshold ? 'meets' : 'misses'} it.`,
|
|
611
|
+
`- Largest synchronous block at ${large} runs: ${ranking.slice(0, 4).map((entry) => `${entry.metric} ${fmt(entry.median)} ms`).join(', ')}. ` +
|
|
612
|
+
(saveLargest ? '**A save is the largest component.**' : `**A save is not the largest component**; ${ranking[0]!.metric} is.`) +
|
|
613
|
+
(ranking[0]!.metric === 'open' ? ` Excluding open (once per boot), the largest is ${recurring[0]!.metric}${saveLargestRecurring ? ', a save' : ''}.` : ''),
|
|
614
|
+
`- Verdict: ${grows && saveLargest ? 'the evidence gate passes' : grows && saveLargestRecurring ? 'save grows beyond noise and is the largest recurring block, but open is larger' : 'the evidence gate does not pass'} for ${profile}.`, '');
|
|
615
|
+
}
|
|
616
|
+
if (outcome.failures.length > 0 || outcome.missing.length > 0 || outcome.stopped) {
|
|
617
|
+
lines.push('## Incomplete run', '', 'Cells aggregate the children that finished.', '');
|
|
618
|
+
if (outcome.stopped) lines.push(`- ${outcome.stopped}`);
|
|
619
|
+
for (const failure of outcome.failures) lines.push(`- ${failure.job} failed: ${failure.error}`);
|
|
620
|
+
if (outcome.missing.length > 0) lines.push(`- Not run: ${outcome.missing.join(', ')}`);
|
|
621
|
+
lines.push('');
|
|
622
|
+
}
|
|
623
|
+
lines.push('## Run order', '', order.join(', '), '');
|
|
624
|
+
return { markdown: lines.join('\n'), gate };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Bytes free to this user on the file systems the children write to. */
|
|
628
|
+
function freeBytes(paths: readonly string[]): number {
|
|
629
|
+
return Math.min(...paths.map((path) => { const fs = statfsSync(path); return fs.bavail * fs.bsize; }));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function runParent(options: ParentOptions): Promise<void> {
|
|
633
|
+
mkdirSync(options.outDir, { recursive: true });
|
|
634
|
+
const jobs: ChildOptions[] = [];
|
|
635
|
+
for (const size of options.sizes) for (const profile of options.profiles) for (let p = 0; p < options.processes; p++) {
|
|
636
|
+
jobs.push({ size, profile, process: p, samples: options.samples, budgetSeconds: options.budgetSeconds, duration: options.duration, seed: options.seed, metrics: options.metrics });
|
|
637
|
+
}
|
|
638
|
+
for (let i = jobs.length - 1; i > 0; i--) { const j = randomInt(i + 1); [jobs[i], jobs[j]] = [jobs[j]!, jobs[i]!]; }
|
|
639
|
+
const env = environment();
|
|
640
|
+
const results: ChildResult[] = [];
|
|
641
|
+
const outcome: RunOutcome = { failures: [], missing: [] };
|
|
642
|
+
const status = new Map<ChildOptions, string>();
|
|
643
|
+
const started = performance.now();
|
|
644
|
+
for (const [index, job] of jobs.entries()) {
|
|
645
|
+
const name = `${job.size}/${job.profile}/${job.process}`;
|
|
646
|
+
const resultPath = join(options.outDir, `child-${job.size}-${job.profile}-${job.process}.json`);
|
|
647
|
+
if (options.resume && existsSync(resultPath)) {
|
|
648
|
+
try {
|
|
649
|
+
const saved = JSON.parse(readFileSync(resultPath, 'utf8')) as ChildResult;
|
|
650
|
+
const measured = saved.metrics ?? FIRST_TABLE_METRICS;
|
|
651
|
+
const lacking = options.metrics.filter((metric) => !measured.includes(metric));
|
|
652
|
+
if (lacking.length === 0) {
|
|
653
|
+
results.push(saved);
|
|
654
|
+
status.set(job, 'reused');
|
|
655
|
+
process.stderr.write(`[bench] ${index + 1}/${jobs.length} ${name} reused\n`);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
process.stderr.write(`[bench] ${name}: saved result lacks ${lacking.join(', ')}, running it again\n`);
|
|
659
|
+
} catch {
|
|
660
|
+
process.stderr.write(`[bench] ${name}: saved result unreadable, running it again\n`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const free = freeBytes([options.outDir, tmpdir()]);
|
|
664
|
+
if (free < MIN_FREE_BYTES) {
|
|
665
|
+
outcome.stopped = `stopped before ${name}: ${(free / 1e9).toFixed(2)} GB free, ${MIN_FREE_BYTES / 1e9} GB required`;
|
|
666
|
+
process.stderr.write(`[bench] ${outcome.stopped}\n`);
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
process.stderr.write(`[bench] ${index + 1}/${jobs.length} ${name} (${((performance.now() - started) / 60_000).toFixed(1)} min elapsed)\n`);
|
|
670
|
+
try {
|
|
671
|
+
await runChildProcess(job, resultPath);
|
|
672
|
+
// Tag the result with the revision that produced it, so a resumed run can name every revision.
|
|
673
|
+
const result = { ...(JSON.parse(readFileSync(resultPath, 'utf8')) as ChildResult), revision: env.revision };
|
|
674
|
+
writeFileSync(resultPath, JSON.stringify(result));
|
|
675
|
+
results.push(result);
|
|
676
|
+
status.set(job, 'ran');
|
|
677
|
+
} catch (err) {
|
|
678
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
679
|
+
outcome.failures.push({ job: name, error });
|
|
680
|
+
status.set(job, 'failed');
|
|
681
|
+
process.stderr.write(`[bench] ${name} failed: ${error}\n`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
outcome.missing = jobs.filter((job) => !status.has(job)).map((job) => `${job.size}/${job.profile}/${job.process}`);
|
|
685
|
+
const aggregated = aggregate(results, options);
|
|
686
|
+
const order = jobs.map((job) => `${job.size}/${job.profile}/${job.process}${status.get(job) === 'ran' ? '' : ` (${status.get(job) ?? 'not run'})`}`);
|
|
687
|
+
const { markdown, gate } = report(aggregated, options, env, order, results, outcome);
|
|
688
|
+
writeFileSync(join(options.outDir, 'results.md'), markdown);
|
|
689
|
+
writeFileSync(join(options.outDir, 'results.json'), JSON.stringify({ environment: env, options, order, outcome, aggregated, gate, results }, null, 2));
|
|
690
|
+
process.stdout.write(`${markdown}\n`);
|
|
691
|
+
process.stderr.write(`[bench] wrote ${join(options.outDir, 'results.md')} and results.json\n`);
|
|
692
|
+
if (outcome.failures.length > 0 || outcome.stopped) process.exitCode = 1;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ---- entry ------------------------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
const { values } = parseArgs({
|
|
698
|
+
options: {
|
|
699
|
+
child: { type: 'boolean', default: false }, quick: { type: 'boolean', default: false }, resume: { type: 'boolean', default: false },
|
|
700
|
+
sizes: { type: 'string' }, profiles: { type: 'string' }, processes: { type: 'string' }, samples: { type: 'string' },
|
|
701
|
+
'budget-seconds': { type: 'string' }, duration: { type: 'string' }, seed: { type: 'string' }, metrics: { type: 'string' }, 'out-dir': { type: 'string' },
|
|
702
|
+
size: { type: 'string' }, profile: { type: 'string' }, process: { type: 'string' }, result: { type: 'string' },
|
|
703
|
+
},
|
|
704
|
+
});
|
|
705
|
+
const positive = (raw: string | undefined, fallback: number, name: string) => {
|
|
706
|
+
const value = raw === undefined ? fallback : Number(raw);
|
|
707
|
+
if (!Number.isFinite(value) || value <= 0) throw new Error(`--${name} must be a positive number`);
|
|
708
|
+
return value;
|
|
709
|
+
};
|
|
710
|
+
const profileOf = (raw: string): FixtureProfile => {
|
|
711
|
+
if (!(FIXTURE_PROFILES as readonly string[]).includes(raw)) throw new Error(`unknown profile ${raw}; use ${FIXTURE_PROFILES.join(', ')}`);
|
|
712
|
+
return raw as FixtureProfile;
|
|
713
|
+
};
|
|
714
|
+
const metricOf = (raw: string): Metric => {
|
|
715
|
+
if (!(METRICS as readonly string[]).includes(raw)) throw new Error(`unknown metric ${raw}; use ${METRICS.join(', ')}`);
|
|
716
|
+
return raw as Metric;
|
|
717
|
+
};
|
|
718
|
+
const quick = values.quick;
|
|
719
|
+
const shared = {
|
|
720
|
+
samples: positive(values.samples, quick ? 10 : 100, 'samples'),
|
|
721
|
+
budgetSeconds: positive(values['budget-seconds'], quick ? 10 : 60, 'budget-seconds'),
|
|
722
|
+
duration: positive(values.duration, quick ? 3 : 30, 'duration'),
|
|
723
|
+
seed: positive(values.seed, DEFAULT_FIXTURE_SEED, 'seed'),
|
|
724
|
+
// In METRICS order, so a child's list compares and prints the same however it was typed.
|
|
725
|
+
metrics: (() => {
|
|
726
|
+
const selected = new Set((values.metrics ?? METRICS.join(',')).split(',').map(metricOf));
|
|
727
|
+
return METRICS.filter((metric) => selected.has(metric));
|
|
728
|
+
})(),
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
if (values.child) {
|
|
732
|
+
await runChild({ ...shared, size: positive(values.size, 0, 'size'), profile: profileOf(values.profile ?? ''), process: Number(values.process ?? 0) }, values.result!);
|
|
733
|
+
process.exit(0);
|
|
734
|
+
} else {
|
|
735
|
+
await runParent({
|
|
736
|
+
...shared,
|
|
737
|
+
sizes: (values.sizes ?? (quick ? '100,1000' : '100,500,1000,2000,5000')).split(',').map((size) => positive(size, 0, 'sizes')),
|
|
738
|
+
profiles: (values.profiles ?? FIXTURE_PROFILES.join(',')).split(',').map(profileOf),
|
|
739
|
+
processes: positive(values.processes, quick ? 2 : 5, 'processes'),
|
|
740
|
+
outDir: values['out-dir'] ?? mkdtempSync(join(tmpdir(), 'cezar-run-store-bench-')),
|
|
741
|
+
resume: values.resume,
|
|
742
|
+
});
|
|
743
|
+
}
|